@byline/host-tanstack-start 3.7.0 → 3.8.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.
@@ -11,7 +11,16 @@ import type { ContentLocaleOption } from './view-menu.js';
11
11
  export declare const HistoryView: ({ collectionDefinition, adminConfig, data, workflowStatuses, currentDocument, contentLocales, defaultContentLocale, }: {
12
12
  collectionDefinition: CollectionDefinition;
13
13
  adminConfig?: CollectionAdminConfig;
14
- data: AnyCollectionSchemaTypes["HistoryType"];
14
+ data: AnyCollectionSchemaTypes["HistoryType"] & {
15
+ /**
16
+ * Audit display labels (the acting user per version), resolved admin-side from each
17
+ * version's `createdBy` id (see docs/AUDIT.md — Workstream 1). Ids
18
+ * absent from the map belong to deleted users.
19
+ */
20
+ actors?: Record<string, {
21
+ label: string;
22
+ }>;
23
+ };
15
24
  workflowStatuses?: WorkflowStatus[];
16
25
  currentDocument?: Record<string, unknown> | null;
17
26
  contentLocales: ReadonlyArray<ContentLocaleOption>;
@@ -1,5 +1,5 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
- import { Suspense, lazy, useState } from "react";
2
+ import { Fragment as external_react_Fragment, Suspense, lazy, useState } from "react";
3
3
  import { useParams, useRouterState } from "@tanstack/react-router";
4
4
  import { StatusBadge, renderFormatted } from "@byline/admin/react";
5
5
  import { useBylineAdminServices } from "@byline/admin/services";
@@ -43,6 +43,13 @@ function padRows(value) {
43
43
  children: "\xa0"
44
44
  }, `empty-row-${index}`));
45
45
  }
46
+ const AUDIT_ACTION_KEYS = {
47
+ create: 'collections.history.audit.actionCreate',
48
+ update: 'collections.history.audit.actionUpdate',
49
+ restore: 'collections.history.audit.actionRestore',
50
+ copy_to_locale: 'collections.history.audit.actionCopyToLocale',
51
+ delete_locale: 'collections.history.audit.actionDeleteLocale'
52
+ };
46
53
  const HistoryView = ({ collectionDefinition, adminConfig, data, workflowStatuses, currentDocument, contentLocales, defaultContentLocale })=>{
47
54
  const { id, collection } = useParams({
48
55
  from: '/_byline/admin/collections/$collection/$id/history'
@@ -52,6 +59,7 @@ const HistoryView = ({ collectionDefinition, adminConfig, data, workflowStatuses
52
59
  const { t } = useTranslation('byline-admin');
53
60
  const columns = adminConfig?.columns || [];
54
61
  const { labels } = collectionDefinition;
62
+ const titleFieldName = collectionDefinition.useAsTitle;
55
63
  const location = useRouterState({
56
64
  select: (s)=>s.location
57
65
  });
@@ -65,9 +73,10 @@ const HistoryView = ({ collectionDefinition, adminConfig, data, workflowStatuses
65
73
  delete params.page;
66
74
  params.page_size = Number.parseInt(value, 10);
67
75
  navigate({
68
- to: '/admin/collections/$collection',
76
+ to: '/admin/collections/$collection/$id/history',
69
77
  params: {
70
- collection
78
+ collection,
79
+ id
71
80
  },
72
81
  search: params
73
82
  });
@@ -138,7 +147,7 @@ const HistoryView = ({ collectionDefinition, adminConfig, data, workflowStatuses
138
147
  align: column.align,
139
148
  className: column.className
140
149
  }, String(column.fieldName));
141
- if ('title' === column.fieldName) return [
150
+ if (null != titleFieldName && column.fieldName === titleFieldName) return [
142
151
  cell,
143
152
  /*#__PURE__*/ jsx("th", {
144
153
  scope: "col",
@@ -157,75 +166,111 @@ const HistoryView = ({ collectionDefinition, adminConfig, data, workflowStatuses
157
166
  const versionId = document.versionId;
158
167
  const { total, page, pageSize, desc } = data.meta;
159
168
  const versionNumber = desc ? total - (page - 1) * pageSize - rowIndex : (page - 1) * pageSize + rowIndex + 1;
160
- return /*#__PURE__*/ jsxs(Table.Row, {
169
+ const actorLabel = document.createdBy ? data.actors?.[document.createdBy]?.label ?? t('collections.history.audit.formerUser') : t('collections.history.audit.unknown');
170
+ const actionKey = document.eventType ? AUDIT_ACTION_KEYS[document.eventType] : void 0;
171
+ const actionLabel = actionKey ? t(actionKey) : document.eventType ?? '';
172
+ const auditColSpan = columns.length + (null != titleFieldName && columns.some((c)=>c.fieldName === titleFieldName) ? 1 : 0);
173
+ return /*#__PURE__*/ jsxs(external_react_Fragment, {
161
174
  children: [
162
- /*#__PURE__*/ jsx(Table.Cell, {
163
- className: classnames('byline-coll-history-version-cell', history_module.versionCell),
164
- children: versionId && currentDocument ? /*#__PURE__*/ jsx(IconButton, {
165
- size: "xs",
166
- variant: "outlined",
167
- intent: "noeffect",
168
- "aria-label": t('collections.history.compareAriaLabel'),
169
- title: t('collections.history.compareTitle'),
170
- className: classnames('byline-coll-history-version-button', history_module.versionButton),
171
- onClick: ()=>setSelectedVersion({
172
- versionId,
173
- label: new Date(document.createdAt).toLocaleString()
174
- }),
175
- children: versionNumber
176
- }) : null
177
- }),
178
- columns.flatMap((column)=>{
179
- const dataCell = /*#__PURE__*/ jsx(Table.Cell, {
180
- className: classnames({
181
- 'byline-coll-history-cell-right': 'right' === column.align,
182
- [history_module.cellRight]: 'right' === column.align,
183
- 'byline-coll-history-cell-center': 'center' === column.align,
184
- [history_module.cellCenter]: 'center' === column.align
185
- }),
186
- children: 'title' === column.fieldName ? versionId && currentDocument ? /*#__PURE__*/ jsx("button", {
187
- type: "button",
188
- className: classnames('byline-coll-history-title-button', history_module.titleButton),
189
- onClick: ()=>setSelectedVersion({
190
- versionId,
191
- label: new Date(document.createdAt).toLocaleString()
192
- }),
193
- children: column.formatter ? renderFormatted(getColumnValue(document, column.fieldName), document, column.formatter) : resolveDisplayValue(getColumnValue(document, column.fieldName), locale, defaultContentLocale) || '------'
194
- }) : /*#__PURE__*/ jsx(Link, {
195
- to: '/admin/collections/$collection/$id',
196
- params: {
197
- collection,
198
- id: document.id
199
- },
200
- children: column.formatter ? renderFormatted(getColumnValue(document, column.fieldName), document, column.formatter) : resolveDisplayValue(getColumnValue(document, column.fieldName), locale, defaultContentLocale) || '------'
201
- }) : column.formatter ? renderFormatted(getColumnValue(document, column.fieldName), document, column.formatter) : 'status' === column.fieldName && workflowStatuses ? /*#__PURE__*/ jsx(StatusBadge, {
202
- status: document.status,
203
- workflowStatuses: workflowStatuses
204
- }) : resolveDisplayValue(getColumnValue(document, column.fieldName), locale, defaultContentLocale) || ''
205
- }, String(column.fieldName));
206
- if ('title' === column.fieldName) return [
207
- dataCell,
175
+ /*#__PURE__*/ jsxs(Table.Row, {
176
+ className: classnames('byline-coll-history-row', history_module.historyRow),
177
+ children: [
208
178
  /*#__PURE__*/ jsx(Table.Cell, {
209
- className: classnames('byline-coll-history-restore-cell', history_module.restoreCell),
210
- children: versionId && versionId !== currentVersionId ? /*#__PURE__*/ jsx(Button, {
211
- type: "button",
212
- variant: "outlined",
179
+ className: classnames('byline-coll-history-version-cell', history_module.versionCell),
180
+ children: versionId && currentDocument ? /*#__PURE__*/ jsx(IconButton, {
213
181
  size: "xs",
182
+ variant: "outlined",
214
183
  intent: "noeffect",
215
- onClick: ()=>setRestoreTarget({
184
+ "aria-label": t('collections.history.compareAriaLabel'),
185
+ title: t('collections.history.compareTitle'),
186
+ className: classnames('byline-coll-history-version-button', history_module.versionButton),
187
+ onClick: ()=>setSelectedVersion({
216
188
  versionId,
217
- label: new Date(document.createdAt).toLocaleString(),
218
- versionNumber
189
+ label: new Date(document.createdAt).toLocaleString()
219
190
  }),
220
- className: classnames('byline-coll-history-restore-button', history_module.restoreButton),
221
- title: t('collections.history.restoreButtonTitle'),
222
- children: t('collections.history.restoreButton')
191
+ children: versionNumber
223
192
  }) : null
224
- }, "__restore")
225
- ];
226
- return [
227
- dataCell
228
- ];
193
+ }),
194
+ columns.flatMap((column)=>{
195
+ const dataCell = /*#__PURE__*/ jsx(Table.Cell, {
196
+ className: classnames({
197
+ 'byline-coll-history-cell-right': 'right' === column.align,
198
+ [history_module.cellRight]: 'right' === column.align,
199
+ 'byline-coll-history-cell-center': 'center' === column.align,
200
+ [history_module.cellCenter]: 'center' === column.align
201
+ }),
202
+ children: null != titleFieldName && column.fieldName === titleFieldName ? versionId && currentDocument ? /*#__PURE__*/ jsx("button", {
203
+ type: "button",
204
+ className: classnames('byline-coll-history-title-button', history_module.titleButton),
205
+ onClick: ()=>setSelectedVersion({
206
+ versionId,
207
+ label: new Date(document.createdAt).toLocaleString()
208
+ }),
209
+ children: column.formatter ? renderFormatted(getColumnValue(document, column.fieldName), document, column.formatter) : resolveDisplayValue(getColumnValue(document, column.fieldName), locale, defaultContentLocale) || '------'
210
+ }) : /*#__PURE__*/ jsx(Link, {
211
+ to: '/admin/collections/$collection/$id',
212
+ params: {
213
+ collection,
214
+ id: document.id
215
+ },
216
+ children: column.formatter ? renderFormatted(getColumnValue(document, column.fieldName), document, column.formatter) : resolveDisplayValue(getColumnValue(document, column.fieldName), locale, defaultContentLocale) || '------'
217
+ }) : column.formatter ? renderFormatted(getColumnValue(document, column.fieldName), document, column.formatter) : 'status' === column.fieldName && workflowStatuses ? /*#__PURE__*/ jsx(StatusBadge, {
218
+ status: document.status,
219
+ workflowStatuses: workflowStatuses
220
+ }) : resolveDisplayValue(getColumnValue(document, column.fieldName), locale, defaultContentLocale) || ''
221
+ }, String(column.fieldName));
222
+ if (null != titleFieldName && column.fieldName === titleFieldName) return [
223
+ dataCell,
224
+ /*#__PURE__*/ jsx(Table.Cell, {
225
+ className: classnames('byline-coll-history-restore-cell', history_module.restoreCell),
226
+ children: versionId && versionId !== currentVersionId ? /*#__PURE__*/ jsx(Button, {
227
+ type: "button",
228
+ variant: "outlined",
229
+ size: "xs",
230
+ intent: "noeffect",
231
+ onClick: ()=>setRestoreTarget({
232
+ versionId,
233
+ label: new Date(document.createdAt).toLocaleString(),
234
+ versionNumber
235
+ }),
236
+ className: classnames('byline-coll-history-restore-button', history_module.restoreButton),
237
+ title: t('collections.history.restoreButtonTitle'),
238
+ children: t('collections.history.restoreButton')
239
+ }) : null
240
+ }, "__restore")
241
+ ];
242
+ return [
243
+ dataCell
244
+ ];
245
+ })
246
+ ]
247
+ }),
248
+ /*#__PURE__*/ jsxs(Table.Row, {
249
+ className: classnames('byline-coll-history-audit-row', history_module.auditRow),
250
+ "aria-label": t('collections.history.audit.createdBy', {
251
+ label: actorLabel
252
+ }),
253
+ children: [
254
+ /*#__PURE__*/ jsx(Table.Cell, {
255
+ className: classnames('byline-coll-history-audit-spacer-cell', history_module.auditSpacerCell)
256
+ }),
257
+ /*#__PURE__*/ jsx(Table.Cell, {
258
+ colSpan: auditColSpan,
259
+ className: classnames('byline-coll-history-audit-cell', history_module.auditCell),
260
+ children: /*#__PURE__*/ jsxs("span", {
261
+ className: classnames('byline-coll-history-audit', history_module.audit),
262
+ children: [
263
+ actionLabel,
264
+ ' · ',
265
+ t('collections.history.audit.createdBy', {
266
+ label: actorLabel
267
+ }),
268
+ ' · ',
269
+ new Date(document.createdAt).toLocaleString()
270
+ ]
271
+ })
272
+ })
273
+ ]
229
274
  })
230
275
  ]
231
276
  }, versionId ?? document.id);
@@ -15,6 +15,9 @@ const history_module = {
15
15
  titleButton: "titleButton-RPuvJm",
16
16
  colRestore: "colRestore-D3b2hI",
17
17
  restoreCell: "restoreCell-Exf1wn",
18
+ historyRow: "historyRow-P02Ptr",
19
+ auditRow: "auditRow-ZVH7hV",
20
+ audit: "audit-S9ZxPf",
18
21
  restoreButton: "restoreButton-axrnAL",
19
22
  restoreModal: "restoreModal-tNkjf6",
20
23
  restoreModalHead: "restoreModalHead-cJAc46",
@@ -81,6 +81,8 @@
81
81
 
82
82
  :is(.versionCell-Jc5W4g, .byline-coll-history-version-cell) {
83
83
  text-align: left;
84
+ padding-left: .375rem;
85
+ padding-right: .25rem;
84
86
  }
85
87
 
86
88
  :is(.colVersion-xGf5qV, .byline-coll-history-col-version) {
@@ -88,7 +90,9 @@
88
90
  }
89
91
 
90
92
  :is(.versionButton-DXCZwj, .byline-coll-history-version-button) {
91
- font-size: .75rem;
93
+ min-width: 20px;
94
+ min-height: 20px;
95
+ font-size: .6875rem;
92
96
  font-family: var(--font-family-mono);
93
97
  font-variant-numeric: tabular-nums;
94
98
  }
@@ -127,6 +131,38 @@
127
131
  white-space: nowrap;
128
132
  }
129
133
 
134
+ .historyRow-P02Ptr td {
135
+ border-bottom: none;
136
+ padding-top: .375rem;
137
+ padding-bottom: .25rem;
138
+ }
139
+
140
+ .byline-coll-history-row td {
141
+ border-bottom: none;
142
+ padding-top: .375rem;
143
+ padding-bottom: .25rem;
144
+ }
145
+
146
+ .auditRow-ZVH7hV td {
147
+ padding-top: 0;
148
+ padding-bottom: .375rem;
149
+ }
150
+
151
+ .byline-coll-history-audit-row td {
152
+ padding-top: 0;
153
+ padding-bottom: .375rem;
154
+ }
155
+
156
+ :is(.audit-S9ZxPf, .byline-coll-history-audit) {
157
+ color: var(--gray-500);
158
+ white-space: nowrap;
159
+ font-size: .75rem;
160
+ }
161
+
162
+ :is(:is([data-theme="dark"], .dark) .audit-S9ZxPf, :is([data-theme="dark"], .dark) .byline-coll-history-audit) {
163
+ color: var(--gray-400);
164
+ }
165
+
130
166
  :is(.restoreButton-axrnAL, .byline-coll-history-restore-button) {
131
167
  opacity: 0;
132
168
  transition: opacity .15s;
@@ -0,0 +1,17 @@
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
+ export type ActorLabelMap = Record<string, {
9
+ label: string;
10
+ }>;
11
+ /**
12
+ * Batch-resolve admin-user ids to display labels. Accepts the raw
13
+ * `createdBy` values straight off a page of shaped documents — nullish
14
+ * and duplicate entries are tolerated and deduplicated. Returns `{}`
15
+ * when there is nothing to resolve or no admin store is configured.
16
+ */
17
+ export declare function resolveActorLabels(ids: Iterable<string | null | undefined>): Promise<ActorLabelMap>;
@@ -0,0 +1,25 @@
1
+ import { bylineCore } from "../../integrations/byline-core.js";
2
+ function labelFor(row) {
3
+ const name = [
4
+ row.given_name,
5
+ row.family_name
6
+ ].filter(Boolean).join(' ');
7
+ return name || row.username || row.email;
8
+ }
9
+ async function resolveActorLabels(ids) {
10
+ const unique = [
11
+ ...new Set([
12
+ ...ids
13
+ ].filter((id)=>'string' == typeof id && id.length > 0))
14
+ ];
15
+ if (0 === unique.length) return {};
16
+ const store = bylineCore().adminStore;
17
+ if (null == store) return {};
18
+ const rows = await store.adminUsers.getByIds(unique);
19
+ const map = {};
20
+ for (const row of rows)map[row.id] = {
21
+ label: labelFor(row)
22
+ };
23
+ return map;
24
+ }
25
+ export { resolveActorLabels };
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { type ActorLabelMap } from './actors.js';
8
9
  export interface HistorySearchParams {
9
10
  page?: number;
10
11
  page_size?: number;
@@ -21,6 +22,7 @@ export declare const getCollectionDocumentHistory: import("@tanstack/react-start
21
22
  id: string;
22
23
  params: HistorySearchParams;
23
24
  }, Promise<{
25
+ actors: ActorLabelMap;
24
26
  docs: {
25
27
  fields: Record<string, any>;
26
28
  id: string;
@@ -31,6 +33,8 @@ export declare const getCollectionDocumentHistory: import("@tanstack/react-start
31
33
  path?: string | undefined;
32
34
  sourceLocale?: string | undefined;
33
35
  hasPublishedVersion?: boolean | undefined;
36
+ createdBy?: string | undefined;
37
+ eventType?: string | undefined;
34
38
  }[];
35
39
  meta: {
36
40
  page: number;
@@ -2,6 +2,7 @@ import { createServerFn } from "@tanstack/react-start";
2
2
  import { ERR_NOT_FOUND, getCollectionSchemasForPath, getLogger } from "@byline/core";
3
3
  import { ensureCollection } from "../../integrations/api-utils.js";
4
4
  import { getAdminBylineClient } from "../../integrations/byline-client.js";
5
+ import { resolveActorLabels } from "./actors.js";
5
6
  import { serialise } from "./utils.js";
6
7
  const getCollectionDocumentHistory = createServerFn({
7
8
  method: 'GET'
@@ -23,7 +24,14 @@ const getCollectionDocumentHistory = createServerFn({
23
24
  });
24
25
  const serialised = serialise(result);
25
26
  const { history } = getCollectionSchemasForPath(path);
26
- if ('all' === params.locale) return serialised;
27
- return history.parse(serialised);
27
+ const actors = await resolveActorLabels(result.docs.map((d)=>d.createdBy));
28
+ if ('all' === params.locale) return {
29
+ ...serialised,
30
+ actors
31
+ };
32
+ return {
33
+ ...history.parse(serialised),
34
+ actors
35
+ };
28
36
  });
29
37
  export { getCollectionDocumentHistory };
@@ -32,6 +32,8 @@ export declare const getCollectionDocuments: import("@tanstack/react-start").Req
32
32
  path?: string | undefined;
33
33
  sourceLocale?: string | undefined;
34
34
  hasPublishedVersion?: boolean | undefined;
35
+ createdBy?: string | undefined;
36
+ eventType?: string | undefined;
35
37
  }[];
36
38
  meta: {
37
39
  page: number;
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.7.0",
6
+ "version": "3.8.0",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -115,13 +115,13 @@
115
115
  "react-swipeable": "^7.0.2",
116
116
  "uuid": "^14.0.0",
117
117
  "zod": "^4.4.3",
118
- "@byline/client": "3.7.0",
119
- "@byline/core": "3.7.0",
120
- "@byline/auth": "3.7.0",
121
- "@byline/i18n": "3.7.0",
122
- "@byline/admin": "3.7.0",
123
- "@byline/ai": "3.7.0",
124
- "@byline/ui": "3.7.0"
118
+ "@byline/admin": "3.8.0",
119
+ "@byline/auth": "3.8.0",
120
+ "@byline/core": "3.8.0",
121
+ "@byline/i18n": "3.8.0",
122
+ "@byline/ui": "3.8.0",
123
+ "@byline/ai": "3.8.0",
124
+ "@byline/client": "3.8.0"
125
125
  },
126
126
  "peerDependencies": {
127
127
  "@tanstack/react-router": "^1.167.0",
@@ -13,6 +13,11 @@
13
13
  * .byline-coll-history-version-button — number-stamped version button
14
14
  * .byline-coll-history-title-button — clickable title cell button
15
15
  * .byline-coll-history-page-size — page-size select alignment
16
+ * .byline-coll-history-row — content row (strip sibling above)
17
+ * .byline-coll-history-audit-row — audit strip row (acting user + action + time)
18
+ * .byline-coll-history-audit-spacer-cell — empty cell under the version column
19
+ * .byline-coll-history-audit-cell — colspan cell carrying the strip
20
+ * .byline-coll-history-audit — the muted strip line itself
16
21
  */
17
22
 
18
23
  .head,
@@ -111,6 +116,10 @@
111
116
  .versionCell,
112
117
  :global(.byline-coll-history-version-cell) {
113
118
  text-align: left;
119
+ /* The column is width: 1% (shrink-to-content), so the horizontal padding
120
+ is what actually sets its width around the 20px stamp. */
121
+ padding-left: 0.375rem;
122
+ padding-right: 0.25rem;
114
123
  }
115
124
 
116
125
  .colVersion,
@@ -120,7 +129,11 @@
120
129
 
121
130
  .versionButton,
122
131
  :global(.byline-coll-history-version-button) {
123
- font-size: 0.75rem;
132
+ /* Shrink the xs round IconButton (26px) down to a 20px stamp so the
133
+ content row can sit tighter. */
134
+ min-height: 20px;
135
+ min-width: 20px;
136
+ font-size: 0.6875rem;
124
137
  font-family: var(--font-family-mono);
125
138
  font-variant-numeric: tabular-nums;
126
139
  }
@@ -160,6 +173,46 @@
160
173
  white-space: nowrap;
161
174
  }
162
175
 
176
+ /* Audit strip (docs/AUDIT.md — W1). The content row's bottom
177
+ border is suppressed so the strip reads as part of the same entry; the
178
+ strip row carries the row divider instead. Vertical padding is tightened
179
+ against the table default (--spacing-8) to keep the two-row entry compact. */
180
+ .historyRow td,
181
+ :global(.byline-coll-history-row td) {
182
+ border-bottom: none;
183
+ padding-top: 0.375rem;
184
+ padding-bottom: 0.25rem;
185
+ }
186
+
187
+ .auditRow td,
188
+ :global(.byline-coll-history-audit-row td) {
189
+ padding-top: 0;
190
+ padding-bottom: 0.375rem;
191
+ }
192
+
193
+ .auditSpacerCell,
194
+ :global(.byline-coll-history-audit-spacer-cell) {
195
+ /* empty cell under the version-number column; the strip starts on the
196
+ second column */
197
+ }
198
+
199
+ .auditCell,
200
+ :global(.byline-coll-history-audit-cell) {
201
+ /* override handle */
202
+ }
203
+
204
+ .audit,
205
+ :global(.byline-coll-history-audit) {
206
+ font-size: 0.75rem;
207
+ color: var(--gray-500);
208
+ white-space: nowrap;
209
+ }
210
+
211
+ :is([data-theme="dark"], :global(.dark)) .audit,
212
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-coll-history-audit) {
213
+ color: var(--gray-400);
214
+ }
215
+
163
216
  .restoreButton,
164
217
  :global(.byline-coll-history-restore-button) {
165
218
  opacity: 0;
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- import { lazy, Suspense, useState } from 'react'
9
+ import { Fragment, lazy, Suspense, useState } from 'react'
10
10
  import { useParams, useRouterState } from '@tanstack/react-router'
11
11
 
12
12
  import { renderFormatted, StatusBadge } from '@byline/admin/react'
@@ -94,6 +94,18 @@ function padRows(value: number) {
94
94
  ))
95
95
  }
96
96
 
97
+ /**
98
+ * Maps `event_type` values to audit-strip action labels. Unknown event
99
+ * types fall back to the raw value rather than a missing-key warning.
100
+ */
101
+ const AUDIT_ACTION_KEYS: Record<string, string> = {
102
+ create: 'collections.history.audit.actionCreate',
103
+ update: 'collections.history.audit.actionUpdate',
104
+ restore: 'collections.history.audit.actionRestore',
105
+ copy_to_locale: 'collections.history.audit.actionCopyToLocale',
106
+ delete_locale: 'collections.history.audit.actionDeleteLocale',
107
+ }
108
+
97
109
  export const HistoryView = ({
98
110
  collectionDefinition,
99
111
  adminConfig,
@@ -105,7 +117,14 @@ export const HistoryView = ({
105
117
  }: {
106
118
  collectionDefinition: CollectionDefinition
107
119
  adminConfig?: CollectionAdminConfig
108
- data: AnyCollectionSchemaTypes['HistoryType']
120
+ data: AnyCollectionSchemaTypes['HistoryType'] & {
121
+ /**
122
+ * Audit display labels (the acting user per version), resolved admin-side from each
123
+ * version's `createdBy` id (see docs/AUDIT.md — Workstream 1). Ids
124
+ * absent from the map belong to deleted users.
125
+ */
126
+ actors?: Record<string, { label: string }>
127
+ }
109
128
  workflowStatuses?: WorkflowStatus[]
110
129
  currentDocument?: Record<string, unknown> | null
111
130
  contentLocales: ReadonlyArray<ContentLocaleOption>
@@ -119,6 +138,10 @@ export const HistoryView = ({
119
138
  const { t } = useTranslation('byline-admin')
120
139
  const columns = adminConfig?.columns || []
121
140
  const { labels } = collectionDefinition
141
+ // The identity column — drives the clickable compare cell and the
142
+ // injected restore column, mirroring list.tsx. `useAsTitle` is optional;
143
+ // when absent, those affordances (and the strip colspan's +1) turn off.
144
+ const titleFieldName = collectionDefinition.useAsTitle
122
145
  const location = useRouterState({ select: (s) => s.location })
123
146
  const locale = (location.search as { locale?: string }).locale
124
147
  const [selectedVersion, setSelectedVersion] = useState<{
@@ -141,8 +164,8 @@ export const HistoryView = ({
141
164
  delete params.page
142
165
  params.page_size = Number.parseInt(value, 10)
143
166
  navigate({
144
- to: '/admin/collections/$collection' as never,
145
- params: { collection },
167
+ to: '/admin/collections/$collection/$id/history' as never,
168
+ params: { collection, id } as never,
146
169
  search: params,
147
170
  })
148
171
  }
@@ -199,7 +222,7 @@ export const HistoryView = ({
199
222
  className={column.className}
200
223
  />
201
224
  )
202
- if (column.fieldName === 'title') {
225
+ if (titleFieldName != null && column.fieldName === titleFieldName) {
203
226
  return [
204
227
  cell,
205
228
  <th
@@ -221,147 +244,198 @@ export const HistoryView = ({
221
244
  const versionNumber = desc
222
245
  ? total - (page - 1) * pageSize - rowIndex
223
246
  : (page - 1) * pageSize + rowIndex + 1
247
+ // Audit strip (docs/AUDIT.md — W1): who created this
248
+ // version, via which action. A present-but-unresolved id
249
+ // is a deleted user; an absent id is a row written before
250
+ // audit wiring or an internal-tooling write.
251
+ const actorLabel = document.createdBy
252
+ ? (data.actors?.[document.createdBy]?.label ??
253
+ t('collections.history.audit.formerUser'))
254
+ : t('collections.history.audit.unknown')
255
+ const actionKey = document.eventType
256
+ ? AUDIT_ACTION_KEYS[document.eventType]
257
+ : undefined
258
+ const actionLabel = actionKey ? t(actionKey) : (document.eventType ?? '')
259
+ // The strip starts on the second column — an empty spacer
260
+ // cell sits under the version-number column, then one cell
261
+ // spans the data columns plus the restore cell appended
262
+ // after the identity column when present.
263
+ const auditColSpan =
264
+ columns.length +
265
+ (titleFieldName != null && columns.some((c) => c.fieldName === titleFieldName)
266
+ ? 1
267
+ : 0)
224
268
  return (
225
- <Table.Row key={versionId ?? document.id}>
226
- <Table.Cell
227
- className={cx('byline-coll-history-version-cell', styles.versionCell)}
228
- >
229
- {versionId && currentDocument ? (
230
- <IconButton
231
- size="xs"
232
- variant="outlined"
233
- intent="noeffect"
234
- aria-label={t('collections.history.compareAriaLabel')}
235
- title={t('collections.history.compareTitle')}
236
- className={cx(
237
- 'byline-coll-history-version-button',
238
- styles.versionButton
239
- )}
240
- onClick={() =>
241
- setSelectedVersion({
242
- versionId,
243
- label: new Date(document.createdAt).toLocaleString(),
244
- })
245
- }
246
- >
247
- {versionNumber}
248
- </IconButton>
249
- ) : null}
250
- </Table.Cell>
251
- {columns.flatMap((column) => {
252
- const dataCell = (
253
- <Table.Cell
254
- key={String(column.fieldName)}
255
- className={cx({
256
- 'byline-coll-history-cell-right': column.align === 'right',
257
- [styles.cellRight]: column.align === 'right',
258
- 'byline-coll-history-cell-center': column.align === 'center',
259
- [styles.cellCenter]: column.align === 'center',
260
- })}
261
- >
262
- {column.fieldName === 'title' ? (
263
- versionId && currentDocument ? (
264
- <button
265
- type="button"
266
- className={cx(
267
- 'byline-coll-history-title-button',
268
- styles.titleButton
269
- )}
270
- onClick={() =>
271
- setSelectedVersion({
272
- versionId,
273
- label: new Date(document.createdAt).toLocaleString(),
274
- })
275
- }
276
- >
277
- {column.formatter
278
- ? renderFormatted(
279
- getColumnValue(document, column.fieldName as string),
280
- document,
281
- column.formatter
282
- )
283
- : resolveDisplayValue(
284
- getColumnValue(document, column.fieldName as string),
285
- locale,
286
- defaultContentLocale
287
- ) || '------'}
288
- </button>
289
- ) : (
290
- <Link
291
- to={'/admin/collections/$collection/$id' as never}
292
- params={{
293
- collection,
294
- id: document.id,
295
- }}
296
- >
297
- {column.formatter
298
- ? renderFormatted(
299
- getColumnValue(document, column.fieldName as string),
300
- document,
301
- column.formatter
302
- )
303
- : resolveDisplayValue(
304
- getColumnValue(document, column.fieldName as string),
305
- locale,
306
- defaultContentLocale
307
- ) || '------'}
308
- </Link>
309
- )
310
- ) : column.formatter ? (
311
- renderFormatted(
312
- getColumnValue(document, column.fieldName as string),
313
- document,
314
- column.formatter
315
- )
316
- ) : column.fieldName === 'status' && workflowStatuses ? (
317
- <StatusBadge
318
- status={document.status}
319
- workflowStatuses={workflowStatuses}
320
- />
321
- ) : (
322
- resolveDisplayValue(
323
- getColumnValue(document, column.fieldName as string),
324
- locale,
325
- defaultContentLocale
326
- ) || ''
327
- )}
328
- </Table.Cell>
329
- )
330
- if (column.fieldName === 'title') {
331
- return [
332
- dataCell,
269
+ <Fragment key={versionId ?? document.id}>
270
+ <Table.Row className={cx('byline-coll-history-row', styles.historyRow)}>
271
+ <Table.Cell
272
+ className={cx('byline-coll-history-version-cell', styles.versionCell)}
273
+ >
274
+ {versionId && currentDocument ? (
275
+ <IconButton
276
+ size="xs"
277
+ variant="outlined"
278
+ intent="noeffect"
279
+ aria-label={t('collections.history.compareAriaLabel')}
280
+ title={t('collections.history.compareTitle')}
281
+ className={cx(
282
+ 'byline-coll-history-version-button',
283
+ styles.versionButton
284
+ )}
285
+ onClick={() =>
286
+ setSelectedVersion({
287
+ versionId,
288
+ label: new Date(document.createdAt).toLocaleString(),
289
+ })
290
+ }
291
+ >
292
+ {versionNumber}
293
+ </IconButton>
294
+ ) : null}
295
+ </Table.Cell>
296
+ {columns.flatMap((column) => {
297
+ const dataCell = (
333
298
  <Table.Cell
334
- key="__restore"
335
- className={cx('byline-coll-history-restore-cell', styles.restoreCell)}
299
+ key={String(column.fieldName)}
300
+ className={cx({
301
+ 'byline-coll-history-cell-right': column.align === 'right',
302
+ [styles.cellRight]: column.align === 'right',
303
+ 'byline-coll-history-cell-center': column.align === 'center',
304
+ [styles.cellCenter]: column.align === 'center',
305
+ })}
336
306
  >
337
- {versionId && versionId !== currentVersionId ? (
338
- <Button
339
- type="button"
340
- variant="outlined"
341
- size="xs"
342
- intent="noeffect"
343
- onClick={() =>
344
- setRestoreTarget({
345
- versionId,
346
- label: new Date(document.createdAt).toLocaleString(),
347
- versionNumber,
348
- })
349
- }
350
- className={cx(
351
- 'byline-coll-history-restore-button',
352
- styles.restoreButton
353
- )}
354
- title={t('collections.history.restoreButtonTitle')}
355
- >
356
- {t('collections.history.restoreButton')}
357
- </Button>
358
- ) : null}
359
- </Table.Cell>,
360
- ]
361
- }
362
- return [dataCell]
363
- })}
364
- </Table.Row>
307
+ {titleFieldName != null && column.fieldName === titleFieldName ? (
308
+ versionId && currentDocument ? (
309
+ <button
310
+ type="button"
311
+ className={cx(
312
+ 'byline-coll-history-title-button',
313
+ styles.titleButton
314
+ )}
315
+ onClick={() =>
316
+ setSelectedVersion({
317
+ versionId,
318
+ label: new Date(document.createdAt).toLocaleString(),
319
+ })
320
+ }
321
+ >
322
+ {column.formatter
323
+ ? renderFormatted(
324
+ getColumnValue(document, column.fieldName as string),
325
+ document,
326
+ column.formatter
327
+ )
328
+ : resolveDisplayValue(
329
+ getColumnValue(document, column.fieldName as string),
330
+ locale,
331
+ defaultContentLocale
332
+ ) || '------'}
333
+ </button>
334
+ ) : (
335
+ <Link
336
+ to={'/admin/collections/$collection/$id' as never}
337
+ params={{
338
+ collection,
339
+ id: document.id,
340
+ }}
341
+ >
342
+ {column.formatter
343
+ ? renderFormatted(
344
+ getColumnValue(document, column.fieldName as string),
345
+ document,
346
+ column.formatter
347
+ )
348
+ : resolveDisplayValue(
349
+ getColumnValue(document, column.fieldName as string),
350
+ locale,
351
+ defaultContentLocale
352
+ ) || '------'}
353
+ </Link>
354
+ )
355
+ ) : column.formatter ? (
356
+ renderFormatted(
357
+ getColumnValue(document, column.fieldName as string),
358
+ document,
359
+ column.formatter
360
+ )
361
+ ) : column.fieldName === 'status' && workflowStatuses ? (
362
+ <StatusBadge
363
+ status={document.status}
364
+ workflowStatuses={workflowStatuses}
365
+ />
366
+ ) : (
367
+ resolveDisplayValue(
368
+ getColumnValue(document, column.fieldName as string),
369
+ locale,
370
+ defaultContentLocale
371
+ ) || ''
372
+ )}
373
+ </Table.Cell>
374
+ )
375
+ if (titleFieldName != null && column.fieldName === titleFieldName) {
376
+ return [
377
+ dataCell,
378
+ <Table.Cell
379
+ key="__restore"
380
+ className={cx(
381
+ 'byline-coll-history-restore-cell',
382
+ styles.restoreCell
383
+ )}
384
+ >
385
+ {versionId && versionId !== currentVersionId ? (
386
+ <Button
387
+ type="button"
388
+ variant="outlined"
389
+ size="xs"
390
+ intent="noeffect"
391
+ onClick={() =>
392
+ setRestoreTarget({
393
+ versionId,
394
+ label: new Date(document.createdAt).toLocaleString(),
395
+ versionNumber,
396
+ })
397
+ }
398
+ className={cx(
399
+ 'byline-coll-history-restore-button',
400
+ styles.restoreButton
401
+ )}
402
+ title={t('collections.history.restoreButtonTitle')}
403
+ >
404
+ {t('collections.history.restoreButton')}
405
+ </Button>
406
+ ) : null}
407
+ </Table.Cell>,
408
+ ]
409
+ }
410
+ return [dataCell]
411
+ })}
412
+ </Table.Row>
413
+ <Table.Row
414
+ className={cx('byline-coll-history-audit-row', styles.auditRow)}
415
+ aria-label={t('collections.history.audit.createdBy', {
416
+ label: actorLabel,
417
+ })}
418
+ >
419
+ <Table.Cell
420
+ className={cx(
421
+ 'byline-coll-history-audit-spacer-cell',
422
+ styles.auditSpacerCell
423
+ )}
424
+ />
425
+ <Table.Cell
426
+ colSpan={auditColSpan}
427
+ className={cx('byline-coll-history-audit-cell', styles.auditCell)}
428
+ >
429
+ <span className={cx('byline-coll-history-audit', styles.audit)}>
430
+ {actionLabel}
431
+ {' · '}
432
+ {t('collections.history.audit.createdBy', { label: actorLabel })}
433
+ {' · '}
434
+ {new Date(document.createdAt).toLocaleString()}
435
+ </span>
436
+ </Table.Cell>
437
+ </Table.Row>
438
+ </Fragment>
365
439
  )
366
440
  })}
367
441
  </Table.Body>
@@ -0,0 +1,60 @@
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
+ * Audit actor-label resolution (docs/AUDIT.md — Workstream 1).
11
+ *
12
+ * Shaped documents carry only the raw `createdBy` uuid; turning ids into
13
+ * display labels is an **admin-realm** concern, so it happens here — in
14
+ * the admin server-fn layer, which already holds the admin actor and the
15
+ * `AdminStore` — never as a JOIN inside the document storage adapter
16
+ * (which must stay ignorant of admin-realm tables for the future
17
+ * `UserAuth` writer realm).
18
+ *
19
+ * Responses attach the result as an `actors` map alongside the page of
20
+ * documents; the UI joins by id. Ids with no matching admin-user row
21
+ * (deleted users) are absent from the map — consumers render a tombstone
22
+ * label.
23
+ */
24
+
25
+ import type { AdminUserRow } from '@byline/admin/admin-users'
26
+
27
+ import { bylineCore } from '../../integrations/byline-core.js'
28
+
29
+ export type ActorLabelMap = Record<string, { label: string }>
30
+
31
+ /** Display label: full name → username → email. */
32
+ function labelFor(row: AdminUserRow): string {
33
+ const name = [row.given_name, row.family_name].filter(Boolean).join(' ')
34
+ return name || row.username || row.email
35
+ }
36
+
37
+ /**
38
+ * Batch-resolve admin-user ids to display labels. Accepts the raw
39
+ * `createdBy` values straight off a page of shaped documents — nullish
40
+ * and duplicate entries are tolerated and deduplicated. Returns `{}`
41
+ * when there is nothing to resolve or no admin store is configured.
42
+ */
43
+ export async function resolveActorLabels(
44
+ ids: Iterable<string | null | undefined>
45
+ ): Promise<ActorLabelMap> {
46
+ const unique = [
47
+ ...new Set([...ids].filter((id): id is string => typeof id === 'string' && id.length > 0)),
48
+ ]
49
+ if (unique.length === 0) return {}
50
+
51
+ const store = bylineCore().adminStore
52
+ if (store == null) return {}
53
+
54
+ const rows = await store.adminUsers.getByIds(unique)
55
+ const map: ActorLabelMap = {}
56
+ for (const row of rows) {
57
+ map[row.id] = { label: labelFor(row) }
58
+ }
59
+ return map
60
+ }
@@ -12,6 +12,7 @@ import { ERR_NOT_FOUND, getCollectionSchemasForPath, getLogger } from '@byline/c
12
12
 
13
13
  import { ensureCollection } from '../../integrations/api-utils.js'
14
14
  import { getAdminBylineClient } from '../../integrations/byline-client.js'
15
+ import { type ActorLabelMap, resolveActorLabels } from './actors.js'
15
16
  import { serialise } from './utils'
16
17
 
17
18
  // ---------------------------------------------------------------------------
@@ -60,14 +61,22 @@ export const getCollectionDocumentHistory = createServerFn({ method: 'GET' })
60
61
  const serialised = serialise(result)
61
62
  const { history } = getCollectionSchemasForPath(path)
62
63
 
64
+ // Acting-user labels for the audit strip (docs/AUDIT.md — W1).
65
+ // Resolved here, in the admin realm, from the page's raw `createdBy`
66
+ // ids; the UI joins by id. Deleted users are absent from the map.
67
+ const actors: ActorLabelMap = await resolveActorLabels(result.docs.map((d) => d.createdBy))
68
+
63
69
  // When locale is 'all' the storage layer returns localized fields as
64
70
  // locale-keyed objects which don't conform to the typed Zod schema — skip
65
71
  // validation in that case, same as getCollectionDocument. Cast through
66
72
  // the inferred Zod type so both branches share one return shape; the
67
73
  // runtime contents are structurally compatible.
68
74
  if (params.locale === 'all') {
69
- return serialised as unknown as ReturnType<typeof history.parse>
75
+ return {
76
+ ...(serialised as unknown as ReturnType<typeof history.parse>),
77
+ actors,
78
+ }
70
79
  }
71
80
 
72
- return history.parse(serialised)
81
+ return { ...history.parse(serialised), actors }
73
82
  })