Skip to content
Open
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
67 changes: 53 additions & 14 deletions apps/docs/content/docs/dev/search.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ config change followed by a rebuild.
## Indexing content

<Callout type="info" title="Using the Content Engine?">
A content type only needs a
[`search` block](/docs/dev/content-engine/public-api-and-caching#2-full-text-search-indexing) - publishing, editing,
unpublishing and deleting a record then keep its document in step
automatically, and it joins the rebuild without any of the wiring below.
A content type only needs a [`search`
block](/docs/dev/content-engine/public-api-and-caching#2-full-text-search-indexing)
- publishing, editing, unpublishing and deleting a record then keep its
document in step automatically, and it joins the rebuild without any of the
wiring below.
</Callout>

Any API handler can (re)index or remove an item through `c.get("search")`. It is
Expand Down Expand Up @@ -93,8 +94,8 @@ agnostic** and match every locale, so single-language plugins need no changes.

<Callout type="info">
Postgres full-text ranking picks a text-search configuration per locale
(`polish` for `pl`, `german` for `de`, and so on), falling back to `simple` for
a locale with no bundled dictionary - and for a document with no
(`polish` for `pl`, `german` for `de`, and so on), falling back to `simple`
for a locale with no bundled dictionary - and for a document with no
`languageCode`, which matches every locale. Matching works across languages
either way; only stemming and stop-words differ.
</Callout>
Expand Down Expand Up @@ -146,8 +147,8 @@ documents (one per language) or none at all (a row that cannot be projected).
<Callout type="warn" title="Never page by document count">
Returning `documents.length` as `itemsRead`, or ending the loop on an empty
`documents` array, silently truncates the index: a page whose rows all fail to
project would stop the rebuild before the valid rows behind it. Report the rows
you read.
project would stop the rebuild before the valid rows behind it. Report the
rows you read.
</Callout>

### The older array result
Expand Down Expand Up @@ -230,18 +231,18 @@ A collection is **unmanaged by the rebuild system** when documents for its
`itemType` are in the index but no `SearchIndexer` is registered for it.

<Callout type="info" title="Unmanaged is not abandoned">
It does **not** prove the plugin is uninstalled or inactive. A live-only plugin
looks exactly the same from the index's point of view, and may be keeping the
collection completely up to date. All VitNode can tell is that it has no way to
rebuild it.
It does **not** prove the plugin is uninstalled or inactive. A live-only
plugin looks exactly the same from the index's point of view, and may be
keeping the collection completely up to date. All VitNode can tell is that it
has no way to rebuild it.
</Callout>

**AdminCP → Advanced → Search** labels those rows *Unmanaged*, shows the plugin
**AdminCP → Advanced → Search** labels those rows _Unmanaged_, shows the plugin
stored on their documents, and says no rebuild indexer is registered. Coverage is
left blank rather than calculated: with no indexer there is no source count, and
`11 / 11` would claim a collection nothing can rebuild is fully covered.

*Reindex* is replaced by **Remove documents**, behind a confirmation. It deletes
_Reindex_ is replaced by **Remove documents**, behind a confirmation. It deletes
what is currently indexed and rebuilds nothing - but it does not stop anything
either, so a live-writing plugin may recreate those documents on its next write.
It is a way to clear a stale indexed state, not a way to uninstall a collection.
Expand All @@ -255,6 +256,44 @@ Result cards look up an icon and label by `itemType`. Add an entry to the render
registry (`@vitnode/core/views/search/registry`); unknown types fall back to a
generic renderer, so nothing breaks if an entry is missing.

## Rendering search in your own app

The search UI ships in three framework-neutral pieces, so a Next.js page and a
TanStack Start route render the same components:

| Module | What it is |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `@vitnode/core/views/search/search-params` | Pure functions: normalise a term from a URL, pick the default sort, build the feed's parameters. |
| `@vitnode/core/views/search/search-feed-query` | The feed as one query definition - request, page size, cursor rule, response check, cache key. |
| `@vitnode/core/views/search/search-controls-content` | The search box, type filters, sort and results, ready to mount. |

Two things are injected, because they are the only two a shared component cannot
answer for itself: how a page is fetched, and how an internal link becomes a
navigation.

```tsx title="A search page, in any framework"
import { SearchControlsContent } from "@vitnode/core/views/search/search-controls-content";
import { searchFeedQueryOptions } from "@vitnode/core/views/search/search-feed-query";
import { searchFeedParamsFor } from "@vitnode/core/views/search/search-params";

<SearchControlsContent
defaultParams={searchFeedParamsFor({ search: termFromTheUrl })}
feedQuery={params => searchFeedQueryOptions({ locale, params })}
LinkComponent={MyLink}
variant="timeline"
/>;
```

<Callout type="info">
Warm the *same* `searchFeedQueryOptions` in your loader
(`ensureInfiniteQueryData`) and the first page is already in the cache when
the component mounts - no `initialData`, no second copy of the same bytes.
</Callout>

Only the term belongs in the URL. The sort and the type filters are controls the
visitor drives after the page loads, so they stay component state - and a
malformed `?search=` normalises to the browse feed rather than breaking the page.

## Choosing the engine

The engine is set in `vitnode.api.config.ts`, exactly like the storage and email
Expand Down
70 changes: 70 additions & 0 deletions apps/docs/content/docs/ui/data-table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,76 @@ That pruning is also what makes a partly-successful action readable: revalidate
so a run that partly succeeded can say so.
</Callout>

## URL State

Every control on the table is really a URL editor. Sorting writes `?orderBy=` and `?order=`, paging writes `?first=`/`?last=` and `?cursor=`, the search box writes `?search=`, and each filter writes its own parameter. Nothing is kept in React state, which is why a table link can be bookmarked, shared, or opened in a new tab and show the same rows.

The rules those controls follow live in one framework-free module, so you can reuse them - or test them - without a router:

```ts
import {
readTableOrder,
readTablePageSize,
readTableSearch,
toggleTableOrder,
withTableFilter,
withTableOrder,
withTablePage,
withTablePageSize,
withTableSearch,
} from "@vitnode/core/components/table/url-state";

withTableSearch("page=3&tab=media", "vitnode");
// → "page=3&tab=media&search=vitnode"
```

Each `with*` helper takes the current query string (or a `URLSearchParams`) and returns a new one. They never mutate what you hand them, they always keep parameters they don't own - your own `?tab=` survives a sort click - and they remove a parameter rather than leaving it empty. Filtering and changing the page size drop the pagination cursor, because the rows underneath it changed; sorting and searching leave it alone.

### Using the table outside Next.js

`DataTable` is the Next.js binding: it supplies the current search parameters and a locale-aware, scroll-free push, and every page in this documentation uses it. Under it sits the same table with that one decision taken as an argument, which is all another router needs to render it.

Give `DataTableNavigationProvider` where you are and how to move, and render `ContentDataTable` inside it:

```tsx
import { ContentDataTable } from "@vitnode/core/components/table/content";
import { DataTableNavigationProvider } from "@vitnode/core/components/table/navigation";

<DataTableNavigationProvider
value={{
navigate: nextSearch => router.navigate({ search: nextSearch }),
searchParams: new URLSearchParams(location.searchStr),
}}
>
<ContentDataTable
id="users-table"
columns={columns}
edges={data.edges}
pageInfo={data.pageInfo}
order={{ defaultOrder: { column: "createdAt", order: "desc" } }}
/>
</DataTableNavigationProvider>;
```

<TypeTable
type={{
navigate: {
description:
"Goes to a query string with no leading '?', exactly as URLSearchParams.toString() produces it. Must not scroll - somebody sorting the last column is looking at the header they clicked. Return the router's promise if it has one and the pending spinner will last as long as the navigation does.",
required: true,
type: "(nextSearch: string) => Promise<void> | void",
},
searchParams: {
description:
"The query string the table is currently rendering. Never mutated.",
required: true,
type: "URLSearchParams",
},
}}
/>

The types, and the `DataTableSkeleton` you render as a loading fallback, come from `@vitnode/core/components/table/data-table-content` - importing them from `data-table` would pull Next.js in behind them.

## Complete Example

Here's a complete example showing how to use the `DataTable` component in a page:
Expand Down
Loading
Loading