@byline/host-tanstack-start 3.13.3 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,15 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { ColumnDefinition, WorkflowStatus } from '@byline/core';
8
+ import type React from 'react';
9
+ import type { ColumnDefinition, ListActionComponentProps, WorkflowStatus } from '@byline/core';
9
10
  import type { AnyCollectionSchemaTypes } from '@byline/core/zod-schemas';
10
11
  type ReorderFn = (params: {
11
12
  documentId: string;
12
13
  beforeDocumentId: string | null;
13
14
  afterDocumentId: string | null;
14
15
  }) => Promise<unknown>;
15
- export declare const ListView: ({ data, columns, workflowStatuses, useAsTitle, orderable, onReorder, }: {
16
+ export declare const ListView: ({ data, columns, workflowStatuses, useAsTitle, orderable, onReorder, listActions, }: {
16
17
  data: AnyCollectionSchemaTypes["ListType"];
17
18
  columns: ColumnDefinition[];
18
19
  workflowStatuses?: WorkflowStatus[];
@@ -21,5 +22,7 @@ export declare const ListView: ({ data, columns, workflowStatuses, useAsTitle, o
21
22
  orderable?: boolean;
22
23
  /** Persists a single-row reorder via the host's reorder server fn. */
23
24
  onReorder?: ReorderFn;
24
- }) => import("react").JSX.Element;
25
+ /** Header action components (`CollectionAdminConfig.listActions`). */
26
+ listActions?: Array<(props: ListActionComponentProps) => React.ReactNode>;
27
+ }) => React.JSX.Element;
25
28
  export {};
@@ -70,7 +70,7 @@ function SortableTableRow({ id, disabled, t, children }) {
70
70
  ]
71
71
  });
72
72
  }
73
- const ListView = ({ data, columns, workflowStatuses, useAsTitle, orderable = false, onReorder })=>{
73
+ const ListView = ({ data, columns, workflowStatuses, useAsTitle, orderable = false, onReorder, listActions })=>{
74
74
  const navigate = useNavigate();
75
75
  const router = useRouter();
76
76
  const toastManager = useToastManager();
@@ -215,6 +215,9 @@ const ListView = ({ data, columns, workflowStatuses, useAsTitle, orderable = fal
215
215
  /*#__PURE__*/ jsx(Stats, {
216
216
  total: data?.meta.total
217
217
  }),
218
+ listActions?.map((Action, i)=>/*#__PURE__*/ jsx(Action, {
219
+ collectionPath: data.included.collection.path
220
+ }, i)),
218
221
  /*#__PURE__*/ jsx(IconButton, {
219
222
  "aria-label": t('collections.list.createAriaLabel'),
220
223
  render: /*#__PURE__*/ jsx(Link, {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ /**
9
+ * A `CollectionAdminConfig.listActions` component that rebuilds a collection's
10
+ * search index. Register it on a searchable collection's admin config:
11
+ *
12
+ * ```ts
13
+ * import { ReindexButton } from '@byline/host-tanstack-start/admin-shell/collections/reindex-button'
14
+ * // CollectionAdminConfig:
15
+ * listActions: [ReindexButton],
16
+ * ```
17
+ *
18
+ * Permission-gated both ends: hidden unless the actor holds
19
+ * `collections.<path>.reindex` (the same ability the server fn re-asserts).
20
+ * Synchronous — fine for small/medium collections; a large corpus wants a
21
+ * backgrounded job (see docs/05-reading-and-delivery/07-search.md).
22
+ */
23
+ import type React from 'react';
24
+ import type { ListActionComponentProps } from '@byline/core';
25
+ export declare function ReindexButton({ collectionPath, }: ListActionComponentProps): React.JSX.Element | null;
@@ -0,0 +1,55 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { useTranslation } from "@byline/i18n/react";
4
+ import { Button, useToastManager } from "@byline/ui/react";
5
+ import { useAbility } from "../../integrations/abilities.js";
6
+ import { reindexCollection } from "../../server-fns/collections/reindex.js";
7
+ function ReindexButton({ collectionPath }) {
8
+ const { t } = useTranslation('byline-admin');
9
+ const toastManager = useToastManager();
10
+ const canReindex = useAbility(`collections.${collectionPath}.reindex`);
11
+ const [pending, setPending] = useState(false);
12
+ if (!canReindex) return null;
13
+ const onClick = async ()=>{
14
+ setPending(true);
15
+ try {
16
+ const report = await reindexCollection({
17
+ data: {
18
+ collection: collectionPath
19
+ }
20
+ });
21
+ toastManager.add({
22
+ title: t('collections.list.reindexDoneTitle'),
23
+ description: t("collections.list.reindexDoneDescription", {
24
+ count: report.documents
25
+ }),
26
+ data: {
27
+ intent: 'success',
28
+ iconType: 'success',
29
+ icon: true,
30
+ close: true
31
+ }
32
+ });
33
+ } catch {
34
+ toastManager.add({
35
+ title: t('collections.list.reindexFailedTitle'),
36
+ data: {
37
+ intent: 'danger',
38
+ iconType: 'danger',
39
+ icon: true,
40
+ close: true
41
+ }
42
+ });
43
+ } finally{
44
+ setPending(false);
45
+ }
46
+ };
47
+ return /*#__PURE__*/ jsx(Button, {
48
+ size: "sm",
49
+ variant: "outlined",
50
+ disabled: pending,
51
+ onClick: onClick,
52
+ children: pending ? t('collections.list.reindexPending') : t('collections.list.reindexLabel')
53
+ });
54
+ }
55
+ export { ReindexButton };
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type React from 'react';
9
- import type { ColumnDefinition, WorkflowStatus } from '@byline/core';
9
+ import type { ColumnDefinition, ListActionComponentProps, WorkflowStatus } from '@byline/core';
10
10
  import type { CollectionTreeRow } from '../../server-fns/collections/tree.js';
11
11
  export type TreeMoveFn = (params: {
12
12
  documentId: string;
@@ -14,7 +14,7 @@ export type TreeMoveFn = (params: {
14
14
  beforeDocumentId: string | null;
15
15
  afterDocumentId: string | null;
16
16
  }) => Promise<unknown>;
17
- export declare const TreeListView: ({ rows, columns, workflowStatuses, useAsTitle, collection, collectionLabels, onMove, }: {
17
+ export declare const TreeListView: ({ rows, columns, workflowStatuses, useAsTitle, collection, collectionLabels, onMove, listActions, }: {
18
18
  rows: CollectionTreeRow[];
19
19
  columns: ColumnDefinition[];
20
20
  workflowStatuses?: WorkflowStatus[];
@@ -26,4 +26,6 @@ export declare const TreeListView: ({ rows, columns, workflowStatuses, useAsTitl
26
26
  };
27
27
  /** When provided, the placed tree becomes drag-to-reorder / re-parent. */
28
28
  onMove?: TreeMoveFn;
29
+ /** Header action components (`CollectionAdminConfig.listActions`). */
30
+ listActions?: Array<(props: ListActionComponentProps) => React.ReactNode>;
29
31
  }) => React.JSX.Element;
@@ -50,7 +50,7 @@ function SortableTreeRow({ id, depth, dragging, dragHandleLabel, children }) {
50
50
  ]
51
51
  });
52
52
  }
53
- const TreeListView = ({ rows, columns, workflowStatuses, useAsTitle, collection, collectionLabels, onMove })=>{
53
+ const TreeListView = ({ rows, columns, workflowStatuses, useAsTitle, collection, collectionLabels, onMove, listActions })=>{
54
54
  const { t } = useTranslation('byline-admin');
55
55
  const router = useRouter();
56
56
  const toastManager = useToastManager();
@@ -177,6 +177,9 @@ const TreeListView = ({ rows, columns, workflowStatuses, useAsTitle, collection,
177
177
  className: classnames('byline-coll-list-stats', tree_list_module.stats),
178
178
  children: formatNumber(rows.length, 0)
179
179
  }),
180
+ listActions?.map((Action, i)=>/*#__PURE__*/ jsx(Action, {
181
+ collectionPath: collection
182
+ }, i)),
180
183
  /*#__PURE__*/ jsx(IconButton, {
181
184
  "aria-label": t('collections.list.createAriaLabel'),
182
185
  render: /*#__PURE__*/ jsx(Link, {
@@ -5,17 +5,20 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { type BylineClient } from '@byline/client';
9
+ export declare function getAdminBylineClient(): BylineClient;
8
10
  /**
9
- * The admin webapp's `BylineClient` singleton.
11
+ * A `BylineClient` bound to an explicit super-admin context — for
12
+ * **system / background** work that is not scoped to an HTTP request:
13
+ * lifecycle-hook search indexing, maintenance scripts, seeds, migrations.
10
14
  *
11
- * Configured with a `requestContext` factory that resolves the
12
- * authenticated admin actor per call via `getAdminRequestContext`. Every
13
- * admin server fn that reads documents goes through this client, so the
14
- * full read pipeline (`beforeRead` `findDocuments` `populate`
15
- * `afterRead`) is uniform between admin and any future external
16
- * client. The collection runtime, db adapter, and storage provider are
17
- * sourced from the shared `getServerConfig()` so we don't duplicate
18
- * adapter wiring.
15
+ * Unlike {@link getAdminBylineClient}, this does **not** read session
16
+ * cookies, so it works outside the TanStack Start server runtime (a bare
17
+ * `tsx` script, a seed, a test). Reaching for the request-scoped client in
18
+ * a lifecycle hook couples that hook to the live server and throws
19
+ * `No StartEvent found in AsyncLocalStorage` from any out-of-band write
20
+ * path. Indexing is maintenance, not a user action it reads the published
21
+ * view and bypasses `beforeRead` so the super-admin context is both
22
+ * correct and runtime-agnostic. The context is auditable by its stable id.
19
23
  */
20
- import { type BylineClient } from '@byline/client';
21
- export declare function getAdminBylineClient(): BylineClient;
24
+ export declare function getSystemBylineClient(): BylineClient;
@@ -1,3 +1,4 @@
1
+ import { createSuperAdminContext } from "@byline/auth";
1
2
  import { createBylineClient } from "@byline/client";
2
3
  import { getServerConfig } from "@byline/core";
3
4
  import { getAdminRequestContext } from "../auth/auth-context.js";
@@ -10,4 +11,15 @@ function getAdminBylineClient() {
10
11
  });
11
12
  return cachedClient;
12
13
  }
13
- export { getAdminBylineClient };
14
+ let cachedSystemClient;
15
+ function getSystemBylineClient() {
16
+ if (cachedSystemClient) return cachedSystemClient;
17
+ cachedSystemClient = createBylineClient({
18
+ config: getServerConfig(),
19
+ requestContext: createSuperAdminContext({
20
+ id: 'byline-system-client'
21
+ })
22
+ });
23
+ return cachedSystemClient;
24
+ }
25
+ export { getAdminBylineClient, getSystemBylineClient };
@@ -1,6 +1,7 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef } from "react";
3
3
  import { createFileRoute, notFound } from "@tanstack/react-router";
4
+ import { applyRelationColumnFormatters } from "@byline/admin/react";
4
5
  import { getCollectionAdminConfig, getCollectionDefinition, getWorkflowStatuses } from "@byline/core";
5
6
  import { useTranslation } from "@byline/i18n/react";
6
7
  import { useToastManager } from "@byline/ui/react";
@@ -71,7 +72,7 @@ function createCollectionListRoute(path) {
71
72
  const navigate = useNavigate();
72
73
  const collectionDef = getCollectionDefinition(collection);
73
74
  const adminConfig = getCollectionAdminConfig(collection);
74
- const columns = adminConfig?.columns || [];
75
+ const columns = applyRelationColumnFormatters(adminConfig?.columns || [], collectionDef);
75
76
  const workflowStatuses = getWorkflowStatuses(collectionDef);
76
77
  const createdToastFiredRef = useRef(false);
77
78
  useEffect(()=>{
@@ -132,6 +133,7 @@ function createCollectionListRoute(path) {
132
133
  useAsTitle: collectionDef.useAsTitle,
133
134
  collection: collection,
134
135
  collectionLabels: data.included.collection.labels,
136
+ listActions: adminConfig?.listActions,
135
137
  onMove: async ({ documentId, parentDocumentId, beforeDocumentId, afterDocumentId })=>{
136
138
  await placeTreeNode({
137
139
  data: {
@@ -152,6 +154,7 @@ function createCollectionListRoute(path) {
152
154
  workflowStatuses: workflowStatuses,
153
155
  useAsTitle: collectionDef.useAsTitle,
154
156
  orderable: true === collectionDef.orderable,
157
+ listActions: adminConfig?.listActions,
155
158
  onReorder: async ({ documentId, beforeDocumentId, afterDocumentId })=>{
156
159
  await reorderCollectionDocument({
157
160
  data: {
@@ -13,6 +13,7 @@ export * from './duplicate';
13
13
  export * from './get';
14
14
  export * from './history';
15
15
  export * from './list';
16
+ export * from './reindex';
16
17
  export * from './reorder';
17
18
  export * from './restore-version';
18
19
  export * from './stats';
@@ -7,6 +7,7 @@ export * from "./duplicate.js";
7
7
  export * from "./get.js";
8
8
  export * from "./history.js";
9
9
  export * from "./list.js";
10
+ export * from "./reindex.js";
10
11
  export * from "./reorder.js";
11
12
  export * from "./restore-version.js";
12
13
  export * from "./stats.js";
@@ -1,5 +1,5 @@
1
1
  import { createServerFn } from "@tanstack/react-start";
2
- import { ERR_NOT_FOUND, getCollectionSchemasForPath, getLogger, getServerConfig } from "@byline/core";
2
+ import { ERR_NOT_FOUND, buildRelationSummaryPopulateMap, getCollectionAdminConfig, getCollectionDefinition, getCollectionSchemasForPath, getLogger, getServerConfig } from "@byline/core";
3
3
  import { ensureCollection } from "../../integrations/api-utils.js";
4
4
  import { getAdminBylineClient } from "../../integrations/byline-client.js";
5
5
  import { serialise } from "./utils.js";
@@ -26,6 +26,12 @@ const getCollectionDocuments = createServerFn({
26
26
  const sortSpec = params.order ? {
27
27
  [params.order]: false === params.desc ? 'asc' : 'desc'
28
28
  } : defaultSort;
29
+ const populateMap = buildRelationSummaryPopulateMap(config.definition.fields, (targetPath)=>({
30
+ def: getCollectionDefinition(targetPath),
31
+ admin: getCollectionAdminConfig(targetPath)
32
+ }));
33
+ const hasRelations = Object.keys(populateMap).length > 0;
34
+ const populate = hasRelations ? populateMap : void 0;
29
35
  const result = await handle.find({
30
36
  where: Object.keys(where).length > 0 ? where : void 0,
31
37
  sort: sortSpec,
@@ -33,6 +39,8 @@ const getCollectionDocuments = createServerFn({
33
39
  page: params.page,
34
40
  pageSize,
35
41
  select: params.fields,
42
+ populate,
43
+ depth: hasRelations ? 1 : void 0,
36
44
  status: 'any',
37
45
  onMissingLocale: 'empty'
38
46
  });
@@ -65,6 +73,7 @@ const getCollectionDocuments = createServerFn({
65
73
  };
66
74
  const serialised = serialise(response);
67
75
  const { list } = getCollectionSchemasForPath(path);
76
+ if (hasRelations) return serialised;
68
77
  return list.parse(serialised);
69
78
  });
70
79
  export { getCollectionDocuments };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { ReindexResult } from '@byline/client';
9
+ export type { ReindexResult };
10
+ export declare const reindexCollection: import("@tanstack/react-start").RequiredFetcher<undefined, (input: {
11
+ collection: string;
12
+ }) => {
13
+ collection: string;
14
+ }, Promise<ReindexResult>>;
@@ -0,0 +1,18 @@
1
+ import { createServerFn } from "@tanstack/react-start";
2
+ import { ERR_NOT_FOUND, getLogger } from "@byline/core";
3
+ import { ensureCollection } from "../../integrations/api-utils.js";
4
+ import { getAdminBylineClient } from "../../integrations/byline-client.js";
5
+ const reindexCollection = createServerFn({
6
+ method: 'POST'
7
+ }).validator((input)=>input).handler(async ({ data })=>{
8
+ const { collection } = data;
9
+ const config = await ensureCollection(collection);
10
+ if (!config) throw ERR_NOT_FOUND({
11
+ message: 'Collection not found',
12
+ details: {
13
+ collectionPath: collection
14
+ }
15
+ }).log(getLogger());
16
+ return getAdminBylineClient().collection(collection).reindex();
17
+ });
18
+ export { reindexCollection };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "3.13.3",
6
+ "version": "3.15.0",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -119,13 +119,13 @@
119
119
  "react-swipeable": "^7.0.2",
120
120
  "uuid": "^14.0.0",
121
121
  "zod": "^4.4.3",
122
- "@byline/i18n": "3.13.3",
123
- "@byline/ai": "3.13.3",
124
- "@byline/admin": "3.13.3",
125
- "@byline/client": "3.13.3",
126
- "@byline/auth": "3.13.3",
127
- "@byline/ui": "3.13.3",
128
- "@byline/core": "3.13.3"
122
+ "@byline/admin": "3.15.0",
123
+ "@byline/auth": "3.15.0",
124
+ "@byline/ai": "3.15.0",
125
+ "@byline/i18n": "3.15.0",
126
+ "@byline/client": "3.15.0",
127
+ "@byline/core": "3.15.0",
128
+ "@byline/ui": "3.15.0"
129
129
  },
130
130
  "peerDependencies": {
131
131
  "@tanstack/react-router": "^1.167.0",
@@ -6,11 +6,12 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
+ import type React from 'react'
9
10
  import { useEffect, useMemo, useState } from 'react'
10
11
  import { useRouter, useRouterState } from '@tanstack/react-router'
11
12
 
12
13
  import { renderFormatted, StatusBadge } from '@byline/admin/react'
13
- import type { ColumnDefinition, WorkflowStatus } from '@byline/core'
14
+ import type { ColumnDefinition, ListActionComponentProps, WorkflowStatus } from '@byline/core'
14
15
  import type { AnyCollectionSchemaTypes } from '@byline/core/zod-schemas'
15
16
  import type { UseTranslationReturn } from '@byline/i18n/react'
16
17
  import { useTranslation } from '@byline/i18n/react'
@@ -158,6 +159,7 @@ export const ListView = ({
158
159
  useAsTitle,
159
160
  orderable = false,
160
161
  onReorder,
162
+ listActions,
161
163
  }: {
162
164
  data: AnyCollectionSchemaTypes['ListType']
163
165
  columns: ColumnDefinition[]
@@ -167,6 +169,8 @@ export const ListView = ({
167
169
  orderable?: boolean
168
170
  /** Persists a single-row reorder via the host's reorder server fn. */
169
171
  onReorder?: ReorderFn
172
+ /** Header action components (`CollectionAdminConfig.listActions`). */
173
+ listActions?: Array<(props: ListActionComponentProps) => React.ReactNode>
170
174
  }) => {
171
175
  const navigate = useNavigate()
172
176
  const router = useRouter()
@@ -316,6 +320,10 @@ export const ListView = ({
316
320
  {data.included.collection.labels.plural as string}
317
321
  </h1>
318
322
  <Stats total={data?.meta.total} />
323
+ {listActions?.map((Action, i) => (
324
+ // biome-ignore lint/suspicious/noArrayIndexKey: static config order
325
+ <Action key={i} collectionPath={data.included.collection.path as string} />
326
+ ))}
319
327
  <IconButton
320
328
  aria-label={t('collections.list.createAriaLabel')}
321
329
  render={
@@ -0,0 +1,69 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * A `CollectionAdminConfig.listActions` component that rebuilds a collection's
11
+ * search index. Register it on a searchable collection's admin config:
12
+ *
13
+ * ```ts
14
+ * import { ReindexButton } from '@byline/host-tanstack-start/admin-shell/collections/reindex-button'
15
+ * // CollectionAdminConfig:
16
+ * listActions: [ReindexButton],
17
+ * ```
18
+ *
19
+ * Permission-gated both ends: hidden unless the actor holds
20
+ * `collections.<path>.reindex` (the same ability the server fn re-asserts).
21
+ * Synchronous — fine for small/medium collections; a large corpus wants a
22
+ * backgrounded job (see docs/05-reading-and-delivery/07-search.md).
23
+ */
24
+
25
+ import type React from 'react'
26
+ import { useState } from 'react'
27
+
28
+ import type { ListActionComponentProps } from '@byline/core'
29
+ import { useTranslation } from '@byline/i18n/react'
30
+ import { Button, useToastManager } from '@byline/ui/react'
31
+
32
+ import { useAbility } from '../../integrations/abilities.js'
33
+ import { reindexCollection } from '../../server-fns/collections/reindex.js'
34
+
35
+ export function ReindexButton({
36
+ collectionPath,
37
+ }: ListActionComponentProps): React.JSX.Element | null {
38
+ const { t } = useTranslation('byline-admin')
39
+ const toastManager = useToastManager()
40
+ const canReindex = useAbility(`collections.${collectionPath}.reindex`)
41
+ const [pending, setPending] = useState(false)
42
+
43
+ if (!canReindex) return null
44
+
45
+ const onClick = async (): Promise<void> => {
46
+ setPending(true)
47
+ try {
48
+ const report = await reindexCollection({ data: { collection: collectionPath } })
49
+ toastManager.add({
50
+ title: t('collections.list.reindexDoneTitle'),
51
+ description: t('collections.list.reindexDoneDescription', { count: report.documents }),
52
+ data: { intent: 'success', iconType: 'success', icon: true, close: true },
53
+ })
54
+ } catch {
55
+ toastManager.add({
56
+ title: t('collections.list.reindexFailedTitle'),
57
+ data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
58
+ })
59
+ } finally {
60
+ setPending(false)
61
+ }
62
+ }
63
+
64
+ return (
65
+ <Button size="sm" variant="outlined" disabled={pending} onClick={onClick}>
66
+ {pending ? t('collections.list.reindexPending') : t('collections.list.reindexLabel')}
67
+ </Button>
68
+ )
69
+ }
@@ -11,7 +11,7 @@ import { useEffect, useMemo, useState } from 'react'
11
11
  import { useRouter } from '@tanstack/react-router'
12
12
 
13
13
  import { renderFormatted, StatusBadge } from '@byline/admin/react'
14
- import type { ColumnDefinition, WorkflowStatus } from '@byline/core'
14
+ import type { ColumnDefinition, ListActionComponentProps, WorkflowStatus } from '@byline/core'
15
15
  import { useTranslation } from '@byline/i18n/react'
16
16
  import {
17
17
  Container,
@@ -124,6 +124,7 @@ export const TreeListView = ({
124
124
  collection,
125
125
  collectionLabels,
126
126
  onMove,
127
+ listActions,
127
128
  }: {
128
129
  rows: CollectionTreeRow[]
129
130
  columns: ColumnDefinition[]
@@ -133,6 +134,8 @@ export const TreeListView = ({
133
134
  collectionLabels: { singular: string; plural: string }
134
135
  /** When provided, the placed tree becomes drag-to-reorder / re-parent. */
135
136
  onMove?: TreeMoveFn
137
+ /** Header action components (`CollectionAdminConfig.listActions`). */
138
+ listActions?: Array<(props: ListActionComponentProps) => React.ReactNode>
136
139
  }) => {
137
140
  const { t } = useTranslation('byline-admin')
138
141
  const router = useRouter()
@@ -267,6 +270,10 @@ export const TreeListView = ({
267
270
  <span className={cx('byline-coll-list-stats', styles.stats)}>
268
271
  {formatNumber(rows.length, 0)}
269
272
  </span>
273
+ {listActions?.map((Action, i) => (
274
+ // biome-ignore lint/suspicious/noArrayIndexKey: static config order
275
+ <Action key={i} collectionPath={collection} />
276
+ ))}
270
277
  <IconButton
271
278
  aria-label={t('collections.list.createAriaLabel')}
272
279
  render={
@@ -19,6 +19,7 @@
19
19
  * adapter wiring.
20
20
  */
21
21
 
22
+ import { createSuperAdminContext } from '@byline/auth'
22
23
  import { type BylineClient, createBylineClient } from '@byline/client'
23
24
  import { getServerConfig } from '@byline/core'
24
25
 
@@ -38,3 +39,28 @@ export function getAdminBylineClient(): BylineClient {
38
39
  })
39
40
  return cachedClient
40
41
  }
42
+
43
+ let cachedSystemClient: BylineClient | undefined
44
+
45
+ /**
46
+ * A `BylineClient` bound to an explicit super-admin context — for
47
+ * **system / background** work that is not scoped to an HTTP request:
48
+ * lifecycle-hook search indexing, maintenance scripts, seeds, migrations.
49
+ *
50
+ * Unlike {@link getAdminBylineClient}, this does **not** read session
51
+ * cookies, so it works outside the TanStack Start server runtime (a bare
52
+ * `tsx` script, a seed, a test). Reaching for the request-scoped client in
53
+ * a lifecycle hook couples that hook to the live server and throws
54
+ * `No StartEvent found in AsyncLocalStorage` from any out-of-band write
55
+ * path. Indexing is maintenance, not a user action — it reads the published
56
+ * view and bypasses `beforeRead` — so the super-admin context is both
57
+ * correct and runtime-agnostic. The context is auditable by its stable id.
58
+ */
59
+ export function getSystemBylineClient(): BylineClient {
60
+ if (cachedSystemClient) return cachedSystemClient
61
+ cachedSystemClient = createBylineClient({
62
+ config: getServerConfig(),
63
+ requestContext: createSuperAdminContext({ id: 'byline-system-client' }),
64
+ })
65
+ return cachedSystemClient
66
+ }
@@ -54,7 +54,7 @@ export function createCollectionEditRoute(path: string, opts: CollectionEditOpts
54
54
  // Auto-populate direct relation fields (depth 1) so the edit form's
55
55
  // relation-summary tiles render with target data (category name, media
56
56
  // thumbnail, etc.) on first paint. Projection is derived from each
57
- // target's `CollectionAdminConfig.picker` columns.
57
+ // target's `CollectionAdminConfig.itemView` columns.
58
58
  const data = await getCollectionDocument(
59
59
  params.collection,
60
60
  params.id,
@@ -9,6 +9,7 @@
9
9
  import { useEffect, useRef } from 'react'
10
10
  import { createFileRoute, notFound } from '@tanstack/react-router'
11
11
 
12
+ import { applyRelationColumnFormatters } from '@byline/admin/react'
12
13
  import type { CollectionDefinition } from '@byline/core'
13
14
  import {
14
15
  getCollectionAdminConfig,
@@ -118,7 +119,10 @@ export function createCollectionListRoute(path: string) {
118
119
  const navigate = useNavigate()
119
120
  const collectionDef = getCollectionDefinition(collection) as CollectionDefinition
120
121
  const adminConfig = getCollectionAdminConfig(collection)
121
- const columns = adminConfig?.columns || []
122
+ // Apply the built-in relation formatter to any relation column without an
123
+ // explicit one, so relation cells render the target's title (the list
124
+ // read populates relation columns to depth 1).
125
+ const columns = applyRelationColumnFormatters(adminConfig?.columns || [], collectionDef)
122
126
  const workflowStatuses = getWorkflowStatuses(collectionDef)
123
127
 
124
128
  // Ref-guarded so the post-create toast fires exactly once per arrival with
@@ -178,6 +182,7 @@ export function createCollectionListRoute(path: string) {
178
182
  useAsTitle={collectionDef.useAsTitle}
179
183
  collection={collection}
180
184
  collectionLabels={data.included.collection.labels}
185
+ listActions={adminConfig?.listActions}
181
186
  onMove={async ({
182
187
  documentId,
183
188
  parentDocumentId,
@@ -204,6 +209,7 @@ export function createCollectionListRoute(path: string) {
204
209
  workflowStatuses={workflowStatuses}
205
210
  useAsTitle={collectionDef.useAsTitle}
206
211
  orderable={collectionDef.orderable === true}
212
+ listActions={adminConfig?.listActions}
207
213
  onReorder={async ({ documentId, beforeDocumentId, afterDocumentId }) => {
208
214
  await reorderCollectionDocument({
209
215
  data: {
@@ -14,6 +14,7 @@ export * from './duplicate'
14
14
  export * from './get'
15
15
  export * from './history'
16
16
  export * from './list'
17
+ export * from './reindex'
17
18
  export * from './reorder'
18
19
  export * from './restore-version'
19
20
  export * from './stats'
@@ -9,10 +9,14 @@
9
9
  import { createServerFn } from '@tanstack/react-start'
10
10
 
11
11
  import {
12
+ buildRelationSummaryPopulateMap,
12
13
  ERR_NOT_FOUND,
14
+ getCollectionAdminConfig,
15
+ getCollectionDefinition,
13
16
  getCollectionSchemasForPath,
14
17
  getLogger,
15
18
  getServerConfig,
19
+ type PopulateSpec,
16
20
  type QueryPredicate,
17
21
  } from '@byline/core'
18
22
 
@@ -74,6 +78,19 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
74
78
  ? { [params.order]: params.desc === false ? 'asc' : 'desc' }
75
79
  : defaultSort
76
80
 
81
+ // Auto-populate relation columns (depth 1) so the list renders each
82
+ // target's title (via `relationColumnFormatter`) rather than a raw
83
+ // document id. Projection follows each target's `itemView` columns +
84
+ // `useAsTitle` — the same map the edit view uses. Populate only fires for
85
+ // relation fields actually loaded (i.e. selected as columns), so building
86
+ // the full map is harmless for collections whose relations aren't shown.
87
+ const populateMap = buildRelationSummaryPopulateMap(config.definition.fields, (targetPath) => ({
88
+ def: getCollectionDefinition(targetPath),
89
+ admin: getCollectionAdminConfig(targetPath),
90
+ }))
91
+ const hasRelations = Object.keys(populateMap).length > 0
92
+ const populate: PopulateSpec | undefined = hasRelations ? populateMap : undefined
93
+
77
94
  const result = await handle.find({
78
95
  where: Object.keys(where).length > 0 ? where : undefined,
79
96
  sort: sortSpec,
@@ -81,6 +98,8 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
81
98
  page: params.page,
82
99
  pageSize,
83
100
  select: params.fields,
101
+ populate,
102
+ depth: hasRelations ? 1 : undefined,
84
103
  status: 'any',
85
104
  // Admin list: show the raw per-locale state (untranslated docs render
86
105
  // empty in the active locale's columns) rather than falling back to the
@@ -131,5 +150,15 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
131
150
 
132
151
  // Validate with schema for runtime type safety and field normalisation.
133
152
  const { list } = getCollectionSchemasForPath(path)
153
+
154
+ // Skip the per-locale Zod parse when relations were populated — the tree
155
+ // then carries nested populated documents that don't match the raw
156
+ // relation-ref shape the list schema expects, and a strict parse would
157
+ // strip the `_resolved` / `document` envelope keys the relation formatter
158
+ // reads. Mirrors the edit-route (`get.ts`) populated-tree handling.
159
+ if (hasRelations) {
160
+ return serialised as unknown as ReturnType<typeof list.parse>
161
+ }
162
+
134
163
  return list.parse(serialised)
135
164
  })
@@ -0,0 +1,47 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Admin server fn: rebuild a collection's search index. Thin boundary over
11
+ * `CollectionHandle.reindex()` — which clears the index slice and re-indexes
12
+ * every published document. Routes through `getAdminBylineClient()` so the
13
+ * request's admin actor is used; `reindex()` asserts the
14
+ * `collections.<path>.reindex` ability, so authorization is enforced here even
15
+ * though the button is also permission-gated in the UI.
16
+ *
17
+ * Synchronous today (fine for small/medium collections). A large corpus wants
18
+ * this backgrounded with progress — see docs/05-reading-and-delivery/07-search.md
19
+ * ("reindex cost").
20
+ */
21
+
22
+ import { createServerFn } from '@tanstack/react-start'
23
+
24
+ import type { ReindexResult } from '@byline/client'
25
+ import { ERR_NOT_FOUND, getLogger } from '@byline/core'
26
+
27
+ import { ensureCollection } from '../../integrations/api-utils.js'
28
+ import { getAdminBylineClient } from '../../integrations/byline-client.js'
29
+
30
+ export type { ReindexResult }
31
+
32
+ export const reindexCollection = createServerFn({ method: 'POST' })
33
+ .validator((input: { collection: string }) => input)
34
+ .handler(async ({ data }): Promise<ReindexResult> => {
35
+ const { collection } = data
36
+ const config = await ensureCollection(collection)
37
+ if (!config) {
38
+ throw ERR_NOT_FOUND({
39
+ message: 'Collection not found',
40
+ details: { collectionPath: collection },
41
+ }).log(getLogger())
42
+ }
43
+
44
+ // `reindex()` asserts `collections.<collection>.reindex` against the
45
+ // request's admin actor before doing any work.
46
+ return getAdminBylineClient().collection(collection).reindex()
47
+ })