@startsimpli/ui 0.4.141 → 0.4.142

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.
Files changed (28) hide show
  1. package/package.json +3 -3
  2. package/src/components/activity/ActivityFeedPanel.tsx +96 -29
  3. package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
  4. package/src/components/activity/types.ts +10 -5
  5. package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
  6. package/src/components/collection/InlineReviewActions.tsx +3 -2
  7. package/src/components/collection/ReviewDrawer.tsx +35 -7
  8. package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
  9. package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
  10. package/src/components/collection/data.ts +16 -1
  11. package/src/components/collection/index.ts +2 -1
  12. package/src/components/collection/scoped-client.ts +4 -1
  13. package/src/components/collection/types.ts +19 -1
  14. package/src/components/history/FieldAttribution.tsx +308 -0
  15. package/src/components/history/RecordActivityPanel.tsx +344 -0
  16. package/src/components/history/RecordHistoryPanel.tsx +331 -15
  17. package/src/components/history/RestoreConfirmDialog.tsx +244 -0
  18. package/src/components/history/RevisionFields.tsx +69 -3
  19. package/src/components/history/StaleSaveDialog.tsx +28 -7
  20. package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
  21. package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
  22. package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
  23. package/src/components/history/__tests__/restore.test.ts +209 -0
  24. package/src/components/history/index.ts +72 -1
  25. package/src/components/history/restore.ts +295 -0
  26. package/src/components/history/revision-feed.ts +105 -0
  27. package/src/components/history/revision-history.ts +22 -0
  28. package/src/components/history/types.ts +153 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.141",
3
+ "version": "0.4.142",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -49,9 +49,9 @@
49
49
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
50
50
  "react": "^18.0.0 || ^19.0.0",
51
51
  "react-dom": "^18.0.0 || ^19.0.0",
52
- "@startsimpli/i18n": "^0.1.3",
52
+ "@startsimpli/api": "^0.5.66",
53
53
  "@startsimpli/auth": "^0.4.38",
54
- "@startsimpli/api": "^0.5.66"
54
+ "@startsimpli/i18n": "^0.1.3"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@startsimpli/api": {
@@ -17,12 +17,28 @@
17
17
  * exactly the rows the signed-in user may see; a row that arrives is rendered,
18
18
  * and an action string this build has never heard of renders with a humanised
19
19
  * label. Filtering happens SERVER-SIDE (repo rule #8) and can only subtract.
20
+ *
21
+ * ── EXTENDED IN PLACE, NOT FORKED (bd startsim-ucg5u) ───────────────────────
22
+ *
23
+ * The record-edit feed (`/api/v1/revisions/`) is a different stream with a
24
+ * different paging contract: a CURSOR, no `count`, and `?page=` is a 400. It
25
+ * composes this panel through four optional props, each of which leaves the
26
+ * page-numbered foundry/account feeds rendering exactly as before:
27
+ *
28
+ * - `paging="cursor"` — sends `cursor` (never `page`), reads `next_cursor`, and
29
+ * prints no count the server did not send;
30
+ * - `filters` + `onFiltersChange` — CONTROLLED filters, so a host keeps them in
31
+ * the URL (a page, never a Next layout, which does not re-render on soft
32
+ * navigation);
33
+ * - `toParams` — the host maps filters to params, so a key a strict route does
34
+ * not know (it 400s) is never sent;
35
+ * - `toItems` / `renderFilters` — the host's own rows and filter bar.
20
36
  */
21
37
  import { ComponentType, ReactNode, useEffect, useMemo, useRef, useState } from 'react';
22
38
  import { useInfiniteQuery } from '@tanstack/react-query';
23
39
  import { Activity as ActivityIcon } from 'lucide-react';
24
40
 
25
- import { ActivityTimeline } from '../ActivityTimeline';
41
+ import { ActivityTimeline, type ActivityTimelineItem } from '../ActivityTimeline';
26
42
  import { ErrorState } from '../states';
27
43
  import { ActivityFilterBar } from './ActivityFilterBar';
28
44
  import {
@@ -40,8 +56,21 @@ import {
40
56
  type ActivityFeedPanelClassNames,
41
57
  } from './activity-feed-panel-default-class-names';
42
58
 
43
- export interface ActivityFeedPanelProps {
44
- client: ActivityClient;
59
+ export interface ActivityFeedPanelProps<E = ActivityEvent, F extends object = ActivityFilterValue> {
60
+ client: ActivityClient<E>;
61
+ /** `page` (default) pages by number and reads `count`; `cursor` sends the
62
+ * server's `next_cursor` and never sends `page`. */
63
+ paging?: 'page' | 'cursor';
64
+ /** CONTROLLED filters. With these the panel holds no filter state of its own. */
65
+ filters?: F;
66
+ onFiltersChange?: (next: F) => void;
67
+ /** Filters → query params. Defaults to the filters as they are (with `q`
68
+ * debounced). A host over a strict route drops anything it does not accept. */
69
+ toParams?: (filters: F) => Record<string, unknown>;
70
+ /** The host's rows. Defaults to the shared event-stream mapping. */
71
+ toItems?: (events: E[]) => ActivityTimelineItem[];
72
+ /** The host's filter bar, replacing the default one. */
73
+ renderFilters?: (args: { value: F; onChange: (next: F) => void; events: E[] }) => ReactNode;
45
74
  /** Cache namespace, e.g. `['foundry', slug, 'activity']`. The live filters
46
75
  * are appended, so invalidating the prefix refreshes every filtered view. */
47
76
  queryKey?: readonly unknown[];
@@ -53,7 +82,7 @@ export interface ActivityFeedPanelProps {
53
82
  headerAside?: ReactNode;
54
83
  /** Given every loaded row, so a host can surface an invariant (e.g. "the
55
84
  * newest status event disagrees with the state badge"). */
56
- renderFooter?: (events: ActivityEvent[]) => ReactNode;
85
+ renderFooter?: (events: E[]) => ReactNode;
57
86
  showFilters?: boolean;
58
87
  emptyTitle?: string;
59
88
  emptyDescription?: string;
@@ -95,8 +124,14 @@ function useStickyOptions(events: ActivityEvent[]) {
95
124
  }, [events]);
96
125
  }
97
126
 
98
- export function ActivityFeedPanel({
127
+ export function ActivityFeedPanel<E = ActivityEvent, F extends object = ActivityFilterValue>({
99
128
  client,
129
+ paging = 'page',
130
+ filters: controlledFilters,
131
+ onFiltersChange,
132
+ toParams,
133
+ toItems,
134
+ renderFilters,
100
135
  queryKey = ['activity'],
101
136
  pageSize = 25,
102
137
  refetchInterval = false,
@@ -119,36 +154,64 @@ export function ActivityFeedPanel({
119
154
  activityColors,
120
155
  defaultIcon = ActivityIcon,
121
156
  classNames = {},
122
- }: ActivityFeedPanelProps) {
157
+ }: ActivityFeedPanelProps<E, F>) {
123
158
  const cx = { ...ACTIVITY_FEED_PANEL_DEFAULTS, ...classNames };
124
159
 
125
- const [filters, setFilters] = useState<ActivityFilterValue>({});
126
- const debouncedQ = useDebounced(filters.q ?? '', 300);
160
+ const [ownFilters, setOwnFilters] = useState<F>({} as F);
161
+ const controlled = controlledFilters !== undefined;
162
+ const filters = controlled ? controlledFilters : ownFilters;
163
+ const setFilters = (next: F) => {
164
+ if (!controlled) setOwnFilters(next);
165
+ onFiltersChange?.(next);
166
+ };
167
+ const typedQ = (filters as { q?: unknown }).q;
168
+ const debouncedQ = useDebounced(typeof typedQ === 'string' ? typedQ : '', 300);
127
169
 
128
170
  // What actually goes to the server. `q` lags the input by a beat; everything
129
- // else applies immediately.
130
- const params = useMemo(() => {
131
- const { q: _typed, ...rest } = filters;
171
+ // else applies immediately. A host mapping (`toParams`) owns the whole shape.
172
+ const params = useMemo<Record<string, unknown>>(() => {
173
+ if (toParams) return toParams(filters);
174
+ const { q: _typed, ...rest } = filters as Record<string, unknown>;
132
175
  return debouncedQ ? { ...rest, q: debouncedQ } : rest;
133
- }, [filters, debouncedQ]);
176
+ }, [filters, debouncedQ, toParams]);
134
177
 
178
+ const cursorMode = paging === 'cursor';
135
179
  const query = useInfiniteQuery({
136
- queryKey: [...queryKey, params],
137
- queryFn: ({ pageParam }) => client.list({ ...params, page: pageParam as number, pageSize }),
138
- initialPageParam: 1,
139
- getNextPageParam: (last, all) => (last?.next ? all.length + 1 : undefined),
180
+ queryKey: [...queryKey, params, paging],
181
+ queryFn: ({ pageParam }) =>
182
+ client.list(
183
+ cursorMode
184
+ ? { ...params, ...(pageParam ? { cursor: pageParam as string } : {}), pageSize }
185
+ : { ...params, page: pageParam as number, pageSize },
186
+ ),
187
+ initialPageParam: (cursorMode ? null : 1) as number | string | null,
188
+ getNextPageParam: (last, all) => {
189
+ if (cursorMode) {
190
+ const cursor = last?.next_cursor ?? last?.nextCursor;
191
+ return cursor ? cursor : undefined;
192
+ }
193
+ return last?.next ? all.length + 1 : undefined;
194
+ },
140
195
  refetchInterval,
141
196
  });
142
197
 
143
- const events = useMemo(
198
+ const events = useMemo<E[]>(
144
199
  () => (query.data?.pages ?? []).flatMap((p) => p?.results ?? []),
145
200
  [query.data],
146
201
  );
147
- const items = useMemo(() => toActivityTimelineItems(events), [events]);
148
- const options = useStickyOptions(events);
202
+ const items = useMemo(
203
+ () => (toItems ? toItems(events) : toActivityTimelineItems(events as unknown as ActivityEvent[])),
204
+ [events, toItems],
205
+ );
206
+ const options = useStickyOptions(events as unknown as ActivityEvent[]);
149
207
 
150
- const isFiltered = Object.keys(filters).length > 0;
151
- const total = query.data?.pages?.[0]?.count ?? events.length;
208
+ const isFiltered = Object.values(filters as Record<string, unknown>).some(
209
+ (v) => typeof v === 'string' ? v.trim() !== '' : v !== undefined && v !== null,
210
+ );
211
+ // Only a count the SERVER sent. A cursor stream sends none, and "Showing 25 of
212
+ // 25" over a stream with more pages would be a confident lie.
213
+ const serverCount = query.data?.pages?.[0]?.count;
214
+ const total = typeof serverCount === 'number' ? serverCount : undefined;
152
215
 
153
216
  const loadMore = () => {
154
217
  if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage();
@@ -166,13 +229,17 @@ export function ActivityFeedPanel({
166
229
 
167
230
  {showFilters && (
168
231
  <div className={cx.filters}>
169
- <ActivityFilterBar
170
- value={filters}
171
- onChange={setFilters}
172
- actors={options.actors}
173
- actions={options.actions}
174
- classNames={cx.filterBar}
175
- />
232
+ {renderFilters ? (
233
+ renderFilters({ value: filters, onChange: setFilters, events })
234
+ ) : (
235
+ <ActivityFilterBar
236
+ value={filters as ActivityFilterValue}
237
+ onChange={(next) => setFilters(next as F)}
238
+ actors={options.actors}
239
+ actions={options.actions}
240
+ classNames={cx.filterBar}
241
+ />
242
+ )}
176
243
  </div>
177
244
  )}
178
245
 
@@ -201,7 +268,7 @@ export function ActivityFeedPanel({
201
268
  )}
202
269
  </div>
203
270
 
204
- {events.length > 0 && total > events.length && (
271
+ {events.length > 0 && total !== undefined && total > events.length && (
205
272
  <p className={cx.countLine}>
206
273
  Showing {events.length} of {total} events.
207
274
  </p>
@@ -0,0 +1,107 @@
1
+ /**
2
+ * RED for bd startsim-ucg5u — the shared ActivityFeedPanel, extended IN PLACE
3
+ * (not forked) so a cursor-paged stream with host-owned filters can compose it.
4
+ *
5
+ * `GET /api/v1/revisions/` returns `{next, next_cursor, results}`: no `count`,
6
+ * and `?page=` is a 400. So the panel must be able to page by cursor, never send
7
+ * `page`, never print a count it was not given, and take its filters from the
8
+ * host (the URL) rather than its own state. Page-number mode is untouched — the
9
+ * existing suite is the proof.
10
+ */
11
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
12
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
13
+
14
+ import { ActivityFeedPanel, type ActivityEvent } from '../index'
15
+
16
+ function mount(ui: React.ReactElement) {
17
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })
18
+ return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
19
+ }
20
+
21
+ const ev = (id: string): ActivityEvent => ({ id, action: 'member_invited', created_at: '2026-10-07T10:00:00Z' })
22
+
23
+ describe('ActivityFeedPanel — cursor paging', () => {
24
+ it('sends no page param, then the server cursor, and stops when there is none', async () => {
25
+ const list = jest
26
+ .fn()
27
+ .mockResolvedValueOnce({ next: 'http://x/?cursor=c2', next_cursor: 'c2', results: [ev('a')] })
28
+ .mockResolvedValueOnce({ next: null, next_cursor: null, results: [ev('b')] })
29
+ mount(<ActivityFeedPanel client={{ list }} paging="cursor" pageSize={1} />)
30
+ await screen.findByRole('heading', { name: /member invited/i })
31
+ const first = list.mock.calls[0][0]
32
+ expect(first).not.toHaveProperty('page')
33
+ expect(first).not.toHaveProperty('cursor')
34
+ expect(first).toMatchObject({ pageSize: 1 })
35
+
36
+ fireEvent.click(await screen.findByRole('button', { name: /load more/i }))
37
+ await waitFor(() => expect(list).toHaveBeenCalledTimes(2))
38
+ expect(list.mock.calls[1][0]).toMatchObject({ cursor: 'c2' })
39
+ expect(list.mock.calls[1][0]).not.toHaveProperty('page')
40
+ await waitFor(() => expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument())
41
+ })
42
+
43
+ it('reads a camelised nextCursor too', async () => {
44
+ const list = jest
45
+ .fn()
46
+ .mockResolvedValueOnce({ next: 'x', nextCursor: 'c9', results: [ev('a')] })
47
+ .mockResolvedValueOnce({ next: null, results: [] })
48
+ mount(<ActivityFeedPanel client={{ list }} paging="cursor" />)
49
+ fireEvent.click(await screen.findByRole('button', { name: /load more/i }))
50
+ await waitFor(() => expect(list.mock.calls[1]?.[0]).toMatchObject({ cursor: 'c9' }))
51
+ })
52
+
53
+ it('never prints a count the server did not send', async () => {
54
+ const list = jest.fn().mockResolvedValue({ next: 'x', next_cursor: 'c2', results: [ev('a')] })
55
+ mount(<ActivityFeedPanel client={{ list }} paging="cursor" />)
56
+ await screen.findByRole('heading', { name: /member invited/i })
57
+ expect(screen.queryByText(/showing .* of/i)).not.toBeInTheDocument()
58
+ })
59
+ })
60
+
61
+ describe('ActivityFeedPanel — host-owned filters and rows', () => {
62
+ it('takes filters from the host and reports changes instead of holding them', async () => {
63
+ const list = jest.fn().mockResolvedValue({ count: 0, next: null, results: [] })
64
+ const onFiltersChange = jest.fn()
65
+ mount(
66
+ <ActivityFeedPanel
67
+ client={{ list }}
68
+ filters={{ actor: 'u1' }}
69
+ onFiltersChange={onFiltersChange}
70
+ />,
71
+ )
72
+ await waitFor(() => expect(list).toHaveBeenCalled())
73
+ expect(list.mock.calls[0][0]).toMatchObject({ actor: 'u1' })
74
+ fireEvent.click(await screen.findByRole('button', { name: /clear all/i }))
75
+ expect(onFiltersChange).toHaveBeenCalledWith({})
76
+ })
77
+
78
+ it('maps filters to params through the host, so unknown keys never reach a strict route', async () => {
79
+ const list = jest.fn().mockResolvedValue({ next: null, results: [] })
80
+ mount(
81
+ <ActivityFeedPanel
82
+ client={{ list }}
83
+ paging="cursor"
84
+ filters={{ actor: '', q: 'ignored' }}
85
+ toParams={(f) => (f.actor ? { actor: f.actor } : {})}
86
+ />,
87
+ )
88
+ await waitFor(() => expect(list).toHaveBeenCalled())
89
+ expect(list.mock.calls[0][0]).toEqual({ pageSize: 25 })
90
+ })
91
+
92
+ it('renders rows and filters through the host when asked', async () => {
93
+ const list = jest.fn().mockResolvedValue({ next: null, results: [ev('a')] })
94
+ mount(
95
+ <ActivityFeedPanel
96
+ client={{ list }}
97
+ toItems={(events) =>
98
+ events.map((e) => ({ id: e.id, type: 'custom', title: `custom row ${e.id}`, occurredAt: e.created_at }))
99
+ }
100
+ renderFilters={() => <div data-testid="host-filters" />}
101
+ />,
102
+ )
103
+ expect(await screen.findByText('custom row a')).toBeInTheDocument()
104
+ expect(screen.getByTestId('host-filters')).toBeInTheDocument()
105
+ expect(screen.queryByTestId('activity-filter-bar')).not.toBeInTheDocument()
106
+ })
107
+ })
@@ -67,11 +67,16 @@ export interface ActivityQuery extends ActivityFilterValue {
67
67
  [key: string]: unknown;
68
68
  }
69
69
 
70
- export interface ActivityPage {
71
- count: number;
70
+ export interface ActivityPage<E = ActivityEvent> {
71
+ /** Absent on a CURSOR-paged stream, which computes no count (bd
72
+ * startsim-ucg5u) — the panel then prints none rather than inventing one. */
73
+ count?: number;
72
74
  next?: string | null;
73
75
  previous?: string | null;
74
- results: ActivityEvent[];
76
+ /** The cursor for the next page, on a cursor-paged stream. */
77
+ next_cursor?: string | null;
78
+ nextCursor?: string | null;
79
+ results: E[];
75
80
  }
76
81
 
77
82
  /**
@@ -79,8 +84,8 @@ export interface ActivityPage {
79
84
  * (`(p) => api.foundry.activity(slug, p)`), which is what keeps @startsimpli/api
80
85
  * out of packages/ui and the app wrapper under ten lines.
81
86
  */
82
- export interface ActivityClient {
83
- list(params: ActivityQuery): Promise<ActivityPage>;
87
+ export interface ActivityClient<E = ActivityEvent> {
88
+ list(params: ActivityQuery): Promise<ActivityPage<E>>;
84
89
  }
85
90
 
86
91
  export interface ActivityFilterOption {
@@ -17,7 +17,7 @@ import { Badge } from '../ui/badge';
17
17
  import { notify } from '../toast/use-notify';
18
18
  import { MarkdownRenderer } from '../blog/MarkdownRenderer';
19
19
  import type { CollectionClient, EntityRecord } from './types';
20
- import { declaredBlob, readData, writeData } from './data';
20
+ import { declaredBlob, readData, writeData, writeOptionsFor } from './data';
21
21
  import { scopeKey } from './scoped-client';
22
22
  import {
23
23
  groupDrafts,
@@ -134,7 +134,7 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
134
134
  let data: Record<string, unknown> = { ...record.data };
135
135
  for (const [k, v] of Object.entries(changes)) data = writeData(data, k, v);
136
136
  data = declaredBlob(data, (draftType?.attributes ?? []).map((a) => a.name));
137
- return client.updateEntity(record.id, { data });
137
+ return client.updateEntity(record.id, { data }, writeOptionsFor(record));
138
138
  }
139
139
 
140
140
  async function pick(group: DraftGroup, cand: EntityRecord) {
@@ -27,7 +27,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
27
27
  import { notify } from '../toast/use-notify';
28
28
  import { cn } from '../../lib/utils';
29
29
  import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
30
- import { writeData } from './data';
30
+ import { writeData, writeOptionsFor } from './data';
31
31
  import {
32
32
  activeDecisionKey,
33
33
  applyDecisionToData,
@@ -110,7 +110,8 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
110
110
  // rename `source_1` to `source1` (bd startsim-8hgmq.18, collection/data.ts).
111
111
  let data = applyDecisionToData(record.data, d, cfg, declaredNames);
112
112
  if (noteText !== undefined) data = writeData(data, cfg.noteAttr, noteText.trim());
113
- await client.updateEntity(record.id, { data });
113
+ // Asserts the version of the ROW it merged over (bd startsim-jkkn7.20).
114
+ await client.updateEntity(record.id, { data }, writeOptionsFor(record));
114
115
  onSaved?.();
115
116
  return true;
116
117
  } catch (err) {
@@ -18,7 +18,7 @@
18
18
  * queue (prev/next), and optional ReviewConfig / render-props. Defaults match the
19
19
  * OGMC content schema but everything is a prop — nothing here is tenant-specific.
20
20
  */
21
- import { useEffect, useMemo, useState, type ReactNode } from 'react';
21
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
22
22
  import {
23
23
  ChevronLeft,
24
24
  ChevronRight,
@@ -42,7 +42,7 @@ import {
42
42
  import { notify } from '../toast/use-notify';
43
43
  import { cn } from '../../lib/utils';
44
44
  import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
45
- import { declaredBlob, readData, writeData } from './data';
45
+ import { declaredBlob, isVersionNumber, readData, writeData } from './data';
46
46
  import { parseSources } from './drafts';
47
47
  import {
48
48
  activeDecisionKey,
@@ -77,8 +77,10 @@ export interface ReviewDrawerProps {
77
77
  type: EntityTypeDef;
78
78
  /** Return to review mode without saving. */
79
79
  back: () => void;
80
- /** Refetch the parent + return to review mode. */
81
- saved: () => void;
80
+ /** Refetch the parent + return to review mode. Pass the SAVED record and the
81
+ * drawer moves onto it — its data and its version — so the next decision
82
+ * asserts the version the editor just produced (bd startsim-jkkn7.20). */
83
+ saved: (record?: EntityRecord) => void;
82
84
  }) => ReactNode;
83
85
  /**
84
86
  * App-specific extra content under the record (e.g. a topic's drafts).
@@ -117,6 +119,15 @@ function ReviewDrawerInner({
117
119
  const [mode, setMode] = useState<'review' | 'edit'>('review');
118
120
  // Local optimistic copy so a click reflects instantly (the parent also refetches).
119
121
  const [data, setData] = useState<Record<string, unknown>>(() => ({ ...record.data }));
122
+ /**
123
+ * THE VERSION OF THE BLOB ABOVE, held beside it (bd startsim-jkkn7.20). `data`
124
+ * is seeded once per record id and every decision merges onto it, so the
125
+ * precondition must be the version THAT blob came from — never the parent's
126
+ * newer row after a list refetch, which would make a stale blob look current
127
+ * and erase whatever landed in between with a 200. Moves only when a write
128
+ * this drawer made succeeds, or the field editor hands back its saved record.
129
+ */
130
+ const versionRef = useRef<number | undefined>(isVersionNumber(record.version) ? record.version : undefined);
120
131
  const [busy, setBusy] = useState<string | null>(null);
121
132
  const [noteOpen, setNoteOpen] = useState(false);
122
133
  const [note, setNote] = useState(() => fieldStr(record.data, cfg.noteAttr));
@@ -169,14 +180,24 @@ function ReviewDrawerInner({
169
180
  let nextData: Record<string, unknown> = { ...data };
170
181
  for (const [k, v] of Object.entries(changes)) nextData = writeData(nextData, k, v);
171
182
  nextData = declaredBlob(nextData, declaredNames);
183
+ const before = data;
172
184
  setData(nextData); // optimistic
173
185
  setBusy(key);
174
186
  try {
175
- await client.updateEntity(record.id, { data: nextData });
187
+ const held = versionRef.current;
188
+ const saved = await client.updateEntity(
189
+ record.id,
190
+ { data: nextData },
191
+ held !== undefined ? { expectedVersion: held } : undefined,
192
+ );
193
+ // THE ONLY PLACE THE HELD VERSION ADVANCES: a write this drawer made.
194
+ if (isVersionNumber(saved?.version)) versionRef.current = saved.version;
176
195
  onSaved?.();
177
196
  if (advance && next) onNavigate(next);
178
197
  } catch (err) {
179
- setData({ ...record.data }); // roll back
198
+ // Back to what THIS drawer held, not the parent's row: after a refetch
199
+ // the prop can be a newer record than the blob the version belongs to.
200
+ setData(before); // roll back
180
201
  notify.error(err instanceof Error ? err.message : 'Could not save.');
181
202
  } finally {
182
203
  setBusy(null);
@@ -305,7 +326,14 @@ function ReviewDrawerInner({
305
326
  record,
306
327
  type,
307
328
  back: () => setMode('review'),
308
- saved: () => {
329
+ saved: (savedRecord?: EntityRecord) => {
330
+ if (savedRecord) {
331
+ setData({ ...savedRecord.data });
332
+ setNote(fieldStr(savedRecord.data, cfg.noteAttr));
333
+ versionRef.current = isVersionNumber(savedRecord.version)
334
+ ? savedRecord.version
335
+ : versionRef.current;
336
+ }
309
337
  onSaved?.();
310
338
  setMode('review');
311
339
  },
@@ -17,7 +17,7 @@ import { Textarea } from '../ui/textarea';
17
17
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
18
18
  import { notify } from '../toast/use-notify';
19
19
  import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
20
- import { declaredBlob, readData, writeData } from './data';
20
+ import { declaredBlob, readData, writeData, writeOptionsFor } from './data';
21
21
  import { scopeKey } from './scoped-client';
22
22
 
23
23
  const STATUSES = ['suggested', 'ready', 'rejected', 'written'] as const;
@@ -115,7 +115,7 @@ export function TopicReviewWorkspace({ client, markets = [], renderDetail }: Top
115
115
  // The FULL blob, spread from the record — so an attribute this workspace
116
116
  // never touches (the scope path among them) survives a PATCH that
117
117
  // REPLACES the blob rather than merging it. A write is never a move.
118
- await client.updateEntity(record.id, { data });
118
+ await client.updateEntity(record.id, { data }, writeOptionsFor(record));
119
119
  await qc.invalidateQueries({ queryKey: ['entities', 'topic'] });
120
120
  } catch (err) {
121
121
  notify.error(err instanceof Error ? err.message : 'Could not save.');