Skip to content

Commit 43bea50

Browse files
authored
DX-2261: databrowser search 2 (#48)
* fix: display area badges scroll bug * feat: bump redis to rc.12 * feat: update readme file to include details about project internals * refactor: schema parser to support more errors * fix: update query types and remove $ne * fix: many minor fixes for redis search * fix: loading state of query wizard * fix: minor style fixes for search-empty-state
1 parent ab2e0e1 commit 43bea50

25 files changed

+1086
-455
lines changed

README.md

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Redis Browser for Upstash Redis - [Preview](https://react-redis-browser.vercel.app/)
2+
23
`@upstash/react-redis-browser` is a React component that provides a UI for browsing and editing data in your Upstash Redis instances.
34

45
<img src="https://github.com/user-attachments/assets/1b714616-310b-4250-9f92-cc28ed9881cd" width="640px" />
@@ -17,15 +18,11 @@ Here's a basic example of how to use the component:
1718

1819
```tsx
1920
import { RedisBrowser } from "@upstash/react-redis-browser"
21+
2022
import "@upstash/react-redis-browser/dist/index.css"
2123

2224
export default function Page() {
23-
return (
24-
<RedisBrowser
25-
url={UPSTASH_REDIS_REST_URL}
26-
token={UPSTASH_REDIS_REST_TOKEN}
27-
/>
28-
)
25+
return <RedisBrowser url={UPSTASH_REDIS_REST_URL} token={UPSTASH_REDIS_REST_TOKEN} />
2926
}
3027
```
3128

@@ -35,6 +32,7 @@ The state of the databrowser can be persisted using the `storage` property.
3532

3633
```tsx
3734
import { RedisBrowser } from "@upstash/react-redis-browser"
35+
3836
import "@upstash/react-redis-browser/dist/index.css"
3937

4038
export default function Page() {
@@ -50,3 +48,72 @@ export default function Page() {
5048
)
5149
}
5250
```
51+
52+
---
53+
54+
## Codebase Internals
55+
56+
### Query Data Flow (UI ↔ Code ↔ Store ↔ Redis)
57+
58+
The query is stored in the zustand store as a **JS object literal string** (not JSON — keys are unquoted like `$and:` instead of `"$and":`). This single string is the shared source of truth between both the UI query builder and the code editor.
59+
60+
**Store shape:** `ValuesSearchFilter.queries` is a `Record<string, string>` mapping `indexName → queryString`. Each index remembers its own query independently.
61+
62+
**Code editor flow (`query-builder.tsx`):** Prepends `"const query: Query = "` to the stored string for Monaco. On edit, strips that prefix and writes the raw string to the store. Monaco gets type definitions generated from the index schema, giving autocomplete.
63+
64+
**UI query builder flow:** The UI works with a tree (`QueryState` — groups and conditions with IDs). `useQueryStateSync` handles bidirectional sync:
65+
66+
- **Store → tree:** `parseQueryString` (`query-parser.ts`) parses the string into `QueryNode`s. Runs on mount and when the store query changes externally (e.g. user edited in code mode then switched to UI).
67+
- **Tree → store:** When the UI mutates the tree, `stringifyQueryState` (`query-stringify.ts`) converts back to a string and writes to the store.
68+
- **Sync guard:** `isOurUpdate` ref prevents the hook from re-parsing its own writes. Only external changes trigger re-parsing.
69+
70+
**Limitation:** `$must`/`$should` combination is not supported in the UI builder — `hasMustShouldCombination` check blocks switching to UI mode when this pattern exists. The UI normalizes `$must``$and` and `$should``$or`.
71+
72+
**Store → Redis:** `KeysProvider` (`use-keys.tsx`) reads `valuesSearch.query` from the store, parses it with `parseJSObjectLiteral` into a plain object, and passes it as the `filter` to `redis.search.index().query()`.
73+
74+
### Schema Data Flow
75+
76+
Schemas define the structure of a search index. Two formats exist:
77+
78+
- **API format** — flat, dot-notation keys: `{ "name": "TEXT", "contact.email": "TEXT" }`
79+
- **Editor format** — nested TypeScript builder DSL: `s.object({ name: s.string(), contact: s.object({ email: s.string() }) })`
80+
81+
`parseSchemaFromEditorValue` (`schema-parser.ts`) converts editor → API by recursively walking the builder calls, flattening nested `s.object()` calls into dot-separated keys, and mapping `s.string()``"TEXT"`, `s.number()``"F64"`, etc.
82+
83+
`schemaToEditorValue` converts API → editor by unflattening dot-notation keys into a nested object, then rendering back to the builder DSL.
84+
85+
`generateSchemaTypeDefinitions` produces TypeScript class declarations (the `s` builder, field builders, `Schema` type) that are injected into Monaco for autocomplete in the schema editor. The type definitions in `search-types-file.ts` mirror the `@upstash/redis` SDK types.
86+
87+
### UI Query Builder Mutations
88+
89+
All mutations go through `QueryBuilderUIProvider` (`query-builder-context.tsx`), which exposes four operations via context: `updateNode`, `deleteNode`, `addChildToGroup`, `moveNode`.
90+
91+
Every node in the tree has a unique `id`. All operations traverse the tree by ID, apply the change immutably, and return a new tree. The flow: component calls e.g. `updateNode(id, updates)``setQueryState` from `useQueryStateSync` receives a modifier → modifier gets a `structuredClone` of current state → tree is modified → result is stringified and written to the zustand store.
92+
93+
### Drag-and-Drop in the UI Query Builder
94+
95+
Uses `@dnd-kit/core`. Files: `dnd-context.tsx`, `draggable-item.tsx`, `drop-zone.tsx`, `drag-overlay.tsx`.
96+
97+
**Components and responsibilities:**
98+
99+
- **`DraggableItem`** — wraps each condition row and group row, provides the drag handle
100+
- **`DropIndicator`** (`drop-zone.tsx`) — a thin horizontal line rendered _between_ each child in a group. Each one is a droppable zone.
101+
- **`EmptyGroupDropZone`** (`drop-zone.tsx`) — rendered inside empty groups as a dashed "Add a condition" button that also accepts drops
102+
- **`QueryDragOverlay`** — portal overlay showing a preview of the dragged node while dragging
103+
- **`QueryDndProvider`** (`dnd-context.tsx`) — wraps the tree, owns the `@dnd-kit` `DndContext`, handles all drag events
104+
105+
**How drop zones work:** `QueryGroup` renders a `DropIndicator` before each child and one at the end. Each drop zone has an ID following the pattern `drop-{groupId}-{childId}` (insert before that child) or `drop-{groupId}-end` (append). This encoding lets `onDragEnd` know both _which group_ and _which position_ to insert at by just parsing the drop zone ID.
106+
107+
On drop, `dnd-context.tsx` resolves the source/target from the IDs, handles edge cases (no-op for same position, prevents dropping a group into its own descendants), and calls `moveNode` from the query builder context.
108+
109+
### Keys List (`useKeys`)
110+
111+
`KeysProvider` wraps `DatabrowserInstance` and provides fetched keys via `useKeys()`. It's at root level because multiple children need it: the **sidebar** renders the key list, and the **data display** uses `useKeyType(selectedKey)` (which looks up the type from the fetched keys array) to decide which editor to render for the selected key, and uses `query.isLoading` to show a loading state.
112+
113+
**Dual-mode fetching** based on `isValuesSearchSelected`:
114+
115+
1. **Standard Redis SCAN:** Sends `SCAN` commands with optional `MATCH` pattern and `TYPE` filter. When SCAN returns empty pages (sparse databases + type filters), retries with increasing `COUNT` (`100 → 300 → 500`) to reduce round trips.
116+
117+
2. **Redis Search:** Calls `redis.search.index().query()` with the parsed filter object from the store, offset-based pagination, and `select: {}` (returns only keys + scores).
118+
119+
Uses React Query's `useInfiniteQuery` for cursor-based pagination. Key types from the scan are cached individually so `useFetchKeyType` (used elsewhere) doesn't need extra `TYPE` commands. Results are deduplicated since Redis SCAN can return duplicates across pages.

bun.lockb

2 Bytes
Binary file not shown.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
"@tabler/icons-react": "^3.19.0",
5959
"@tanstack/react-query": "^5.32.0",
6060
"@types/bytes": "^3.1.4",
61-
"@upstash/redis": "1.37.0-rc.9",
61+
"@upstash/redis": "1.37.0-rc.12",
6262
"bytes": "^3.1.2",
6363
"cmdk": "^1.1.1",
6464
"react-hook-form": "^7.53.0",

src/components/common/infinite-scroll.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const InfiniteScroll = ({
5757
type="always"
5858
onScroll={handleScroll}
5959
{...props}
60-
className={cn("block h-full w-full overflow-visible transition-all", props.className)}
60+
className={cn("block h-full min-h-0 w-full overflow-hidden transition-all", props.className)}
6161
ref={scrollRef}
6262
>
6363
<div ref={contentRef}>

src/components/databrowser/components/databrowser-instance.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const QueryBuilderContent = () => {
5050
switchError ?? (query.error ? formatUpstashErrorMessage(query.error) : undefined)
5151

5252
return (
53-
<div className="relative h-full">
53+
<div className="relative flex h-full min-h-0 flex-col">
5454
<div className="absolute right-4 top-4 z-[2]">
5555
<Segmented
5656
options={[
@@ -94,7 +94,7 @@ export const DatabrowserInstance = ({
9494
tabType: TabType
9595
allowSearch: boolean
9696
}) => {
97-
const { isValuesSearchSelected, setIsValuesSearchSelected } = useTab()
97+
const { isValuesSearchSelected, queryBuilderMode, setIsValuesSearchSelected } = useTab()
9898
const { data: indexes, isLoading } = useFetchSearchIndexes({
9999
enabled: tabType === "search",
100100
})
@@ -129,9 +129,14 @@ export const DatabrowserInstance = ({
129129
<PanelGroup
130130
autoSaveId="search-layout"
131131
direction="vertical"
132-
className="h-full w-full text-sm antialiased"
132+
className="h-full w-full !overflow-visible text-sm antialiased"
133133
>
134-
<Panel defaultSize={30} minSize={15} maxSize={60}>
134+
<Panel
135+
defaultSize={30}
136+
minSize={15}
137+
maxSize={60}
138+
className={queryBuilderMode === "code" ? "!overflow-visible" : ""}
139+
>
135140
<SearchContent />
136141
</Panel>
137142
<ResizeHandle direction="vertical" />

src/components/databrowser/components/delete-key-modal.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,16 @@ export function DeleteKeyModal({
3434
const isPlural = count > 1
3535
const itemLabel = deletionType === "item" ? "Item" : "Key"
3636
const itemsLabel = deletionType === "item" ? "Items" : "Keys"
37+
const [internalOpen, setInternalOpen] = useState(false)
3738
const [reindex, setReindex] = useState(true)
3839
const [isPending, setIsPending] = useState(false)
3940

41+
const isControlled = open !== undefined
42+
const isOpen = isControlled ? open : internalOpen
43+
const setIsOpen = isControlled ? onOpenChange : setInternalOpen
44+
4045
return (
41-
<Dialog open={open} onOpenChange={onOpenChange}>
46+
<Dialog open={isOpen} onOpenChange={setIsOpen}>
4247
{children && <DialogTrigger asChild>{children}</DialogTrigger>}
4348

4449
<DialogContent>
@@ -72,7 +77,7 @@ export function DeleteKeyModal({
7277
type="button"
7378
variant="outline"
7479
disabled={isPending}
75-
onClick={() => onOpenChange?.(false)}
80+
onClick={() => setIsOpen?.(false)}
7681
>
7782
Cancel
7883
</Button>

src/components/databrowser/components/display/display-header.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type DataType } from "@/types"
33
import { IconPlus } from "@tabler/icons-react"
44

55
import { Button } from "@/components/ui/button"
6+
import { ScrollArea } from "@/components/ui/scroll-area"
67
import { SimpleTooltip } from "@/components/ui/tooltip"
78

89
import { TypeTag } from "../type-tag"
@@ -53,12 +54,14 @@ export const DisplayHeader = ({
5354

5455
{/* Key info badges */}
5556
{type === "search" && hideTypeTag ? undefined : (
56-
<div className="flex h-10 items-center gap-1.5 overflow-x-scroll">
57-
{!hideTypeTag && <TypeTag variant={type} type="badge" />}
58-
{type !== "search" && <SizeBadge dataKey={dataKey} />}
59-
{type !== "search" && <LengthBadge dataKey={dataKey} type={type} content={content} />}
60-
{type !== "search" && <HeaderTTLBadge dataKey={dataKey} />}
61-
</div>
57+
<ScrollArea orientation="horizontal" className="w-full whitespace-nowrap">
58+
<div className="flex w-max items-center gap-1.5 pb-2 pt-1">
59+
{!hideTypeTag && <TypeTag variant={type} type="badge" />}
60+
{type !== "search" && <SizeBadge dataKey={dataKey} />}
61+
{type !== "search" && <LengthBadge dataKey={dataKey} type={type} content={content} />}
62+
{type !== "search" && <HeaderTTLBadge dataKey={dataKey} />}
63+
</div>
64+
</ScrollArea>
6265
)}
6366
</div>
6467
)

src/components/databrowser/components/query-builder.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,17 @@ export const QueryBuilder = () => {
1414

1515
return (
1616
<div className="flex h-full flex-col rounded-lg border border-zinc-300 bg-white px-[6px]">
17-
<div className="min-h-0 flex-1">
18-
<QueryEditor
19-
value={editorValue}
20-
onChange={(value) => {
21-
const queryPart = value.slice(PREFIX.length)
22-
setValuesSearchQuery(queryPart)
23-
}}
24-
schema={indexDetails}
25-
/>
17+
<div className="relative min-h-0 flex-1">
18+
<div className="absolute inset-0">
19+
<QueryEditor
20+
value={editorValue}
21+
onChange={(value) => {
22+
const queryPart = value.slice(PREFIX.length)
23+
setValuesSearchQuery(queryPart)
24+
}}
25+
schema={indexDetails}
26+
/>
27+
</div>
2628
</div>
2729
</div>
2830
)

src/components/databrowser/components/query-wizard/query-wizard-popover.tsx

Lines changed: 50 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import { useRedis } from "@/redis-context"
33
import { useDatabrowserStore } from "@/store"
44
import { useTab } from "@/tab-provider"
55
import { IconChevronRight } from "@tabler/icons-react"
6+
import { useMutation } from "@tanstack/react-query"
67

78
import { scanKeys } from "@/lib/scan-keys"
89
import { toJsLiteral } from "@/lib/utils"
910
import { Label } from "@/components/ui/label"
1011
import { Spinner } from "@/components/ui/spinner"
1112

13+
import type { SearchIndex } from "../../hooks/use-fetch-search-index"
1214
import { useFetchSearchIndex } from "../../hooks/use-fetch-search-index"
15+
import { DocsLink } from "../docs-link"
1316
import { ConsentPrompt } from "./consent-prompt"
1417
import { useGenerateQuery } from "./use-query-wizard"
1518

@@ -25,42 +28,45 @@ export const QueryWizardPopover = ({ onClose }: { onClose?: () => void }) => {
2528
const { data: indexData, isLoading: isLoadingIndex } = useFetchSearchIndex(valuesSearch.index)
2629
const generateQuery = useGenerateQuery()
2730

31+
const fetchSampleKeys = useMutation({
32+
mutationFn: async (index: SearchIndex) => {
33+
const firstTenKeys = await scanKeys(redis, {
34+
match: `${index.prefixes?.[0]}*`,
35+
type: index.dataType,
36+
limit: 10,
37+
})
38+
39+
const dataPromises = firstTenKeys.map(async (key) => {
40+
try {
41+
if (index.dataType === "json") {
42+
const data = await redis.json.get(key)
43+
return { key, data }
44+
} else if (index.dataType === "hash") {
45+
const data = await redis.hgetall(key)
46+
return { key, data }
47+
} else {
48+
const data = await redis.get(key)
49+
return { key, data }
50+
}
51+
} catch {
52+
return null
53+
}
54+
})
55+
56+
const results = await Promise.all(dataPromises)
57+
const filtered = results.filter(Boolean)
58+
setSampleData(filtered)
59+
return filtered
60+
},
61+
})
62+
2863
const handleGenerate = async () => {
2964
if (!input.trim() || !valuesSearch.index) return
3065

3166
try {
3267
let samples = sampleData
3368
if (samples.length === 0 && indexData?.prefixes?.[0]) {
34-
try {
35-
const firstTenKeys = await scanKeys(redis, {
36-
match: `${indexData.prefixes[0]}*`,
37-
type: indexData.dataType,
38-
limit: 10,
39-
})
40-
41-
const dataPromises = firstTenKeys.map(async (key) => {
42-
try {
43-
if (indexData.dataType === "json") {
44-
const data = await redis.json.get(key)
45-
return { key, data }
46-
} else if (indexData.dataType === "hash") {
47-
const data = await redis.hgetall(key)
48-
return { key, data }
49-
} else {
50-
const data = await redis.get(key)
51-
return { key, data }
52-
}
53-
} catch {
54-
return null
55-
}
56-
})
57-
58-
const results = await Promise.all(dataPromises)
59-
samples = results.filter(Boolean)
60-
setSampleData(samples)
61-
} catch (error) {
62-
console.error("Error fetching sample data:", error)
63-
}
69+
samples = await fetchSampleKeys.mutateAsync(indexData)
6470
}
6571

6672
const result = await generateQuery.mutateAsync({
@@ -150,39 +156,37 @@ export const QueryWizardPopover = ({ onClose }: { onClose?: () => void }) => {
150156
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setInput(e.target.value)}
151157
placeholder=""
152158
className="h-[58px] w-full resize-none rounded-md border border-zinc-300 bg-white px-3 py-3 text-sm text-zinc-950 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500 disabled:cursor-not-allowed disabled:opacity-50"
153-
disabled={generateQuery.isPending}
159+
disabled={generateQuery.isPending || fetchSampleKeys.isPending}
154160
autoFocus
155161
/>
156-
<div className="flex flex-col gap-0.5 pt-0.5">
157-
<p className="text-xs text-zinc-500">
158-
Example: Find sports cars and trucks, exclude age &gt; 100, boost sports_car
159-
</p>
160-
<a
161-
href="https://upstash.com/docs/redis"
162-
target="_blank"
163-
rel="noopener noreferrer"
164-
className="text-xs text-zinc-500 underline hover:text-zinc-700"
165-
>
166-
View Docs →
167-
</a>
162+
<div>
163+
<span className="text-xs text-zinc-500">
164+
Example: Find people named "John", boost if older than 20.
165+
</span>
166+
<DocsLink href="https://upstash-search.mintlify.app/redis/search/query-operators/boolean-operators/overview" />
168167
</div>
169168
</div>
170169
</div>
171170

172171
<div className="flex items-center justify-end gap-2">
173172
<button
174173
onClick={onClose}
175-
disabled={generateQuery.isPending}
174+
disabled={generateQuery.isPending || fetchSampleKeys.isPending}
176175
className="flex h-8 items-center justify-center rounded-md border border-zinc-300 bg-white px-4 text-sm text-zinc-950 shadow-[0_1px_1px_rgba(0,0,0,0.05)] hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-50"
177176
>
178177
Cancel
179178
</button>
180179
<button
181180
onClick={handleGenerate}
182-
disabled={!input.trim() || generateQuery.isPending}
181+
disabled={!input.trim() || generateQuery.isPending || fetchSampleKeys.isPending}
183182
className="flex h-8 items-center justify-center gap-2 rounded-md bg-purple-500 px-4 text-sm text-white shadow-[0_1px_1px_rgba(0,0,0,0.05)] hover:bg-purple-600 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-purple-500"
184183
>
185-
{generateQuery.isPending ? (
184+
{fetchSampleKeys.isPending ? (
185+
<>
186+
<Spinner isLoading={true} />
187+
Sampling keys...
188+
</>
189+
) : generateQuery.isPending ? (
186190
<>
187191
<Spinner isLoading={true} />
188192
Generating...

0 commit comments

Comments
 (0)