@startsimpli/ui 0.4.140 → 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.
- package/package.json +1 -1
- package/src/components/ActivityTimeline.tsx +17 -12
- package/src/components/ActorIdentity.tsx +15 -2
- package/src/components/activity/ActivityFeedPanel.tsx +96 -29
- package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
- package/src/components/activity/types.ts +10 -5
- package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
- package/src/components/collection/InlineReviewActions.tsx +3 -2
- package/src/components/collection/ReviewDrawer.tsx +35 -7
- package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
- package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
- package/src/components/collection/data.ts +16 -1
- package/src/components/collection/index.ts +2 -1
- package/src/components/collection/scoped-client.ts +4 -1
- package/src/components/collection/types.ts +19 -1
- package/src/components/history/FieldAttribution.tsx +308 -0
- package/src/components/history/RecordActivityPanel.tsx +344 -0
- package/src/components/history/RecordHistoryPanel.tsx +381 -29
- package/src/components/history/RestoreConfirmDialog.tsx +244 -0
- package/src/components/history/RevisionFields.tsx +78 -6
- package/src/components/history/StaleSaveDialog.tsx +28 -7
- package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
- package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
- package/src/components/history/__tests__/record-history-panel.test.tsx +157 -9
- package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
- package/src/components/history/__tests__/restore.test.ts +209 -0
- package/src/components/history/index.ts +72 -1
- package/src/components/history/record-history-panel-default-class-names.ts +1 -1
- package/src/components/history/restore.ts +295 -0
- package/src/components/history/revision-feed.ts +105 -0
- package/src/components/history/revision-history.ts +22 -0
- package/src/components/history/types.ts +153 -1
package/package.json
CHANGED
|
@@ -197,24 +197,29 @@ function TimelineEntry({
|
|
|
197
197
|
{activity.description && (
|
|
198
198
|
<p className="mt-1 text-sm text-muted-foreground">{activity.description}</p>
|
|
199
199
|
)}
|
|
200
|
-
{activity.detail && (
|
|
201
|
-
// A DIV, NOT A P. `detail` is typed ReactNode and the record
|
|
202
|
-
// history panel passes a field LIST into it — and a <ul> or a
|
|
203
|
-
// <div> inside a <p> is invalid HTML, which React resolves by
|
|
204
|
-
// reparenting during hydration. That produces a hydration
|
|
205
|
-
// mismatch on every row, and neither tsc nor jsdom reports it:
|
|
206
|
-
// only a real browser does (bd startsim-g4rwo). The classes are
|
|
207
|
-
// unchanged, so every existing caller renders identically.
|
|
208
|
-
<div className={`mt-1 break-words text-xs ${TONE_TEXT[activity.detailTone ?? 'muted']}`}>
|
|
209
|
-
{activity.detail}
|
|
210
|
-
</div>
|
|
211
|
-
)}
|
|
212
200
|
</div>
|
|
213
201
|
<div className="ml-4 whitespace-nowrap text-xs text-muted-foreground">
|
|
214
202
|
{formatTime(activity.occurredAt)}
|
|
215
203
|
</div>
|
|
216
204
|
</div>
|
|
217
205
|
|
|
206
|
+
{activity.detail && (
|
|
207
|
+
// A DIV, NOT A P. `detail` is typed ReactNode and the record history
|
|
208
|
+
// panel passes a field LIST into it — and a <ul> or a <div> inside a
|
|
209
|
+
// <p> is invalid HTML, which React resolves by reparenting during
|
|
210
|
+
// hydration. That produces a hydration mismatch on every row, and
|
|
211
|
+
// neither tsc nor jsdom reports it: only a real browser does
|
|
212
|
+
// (bd startsim-g4rwo).
|
|
213
|
+
//
|
|
214
|
+
// BELOW the title/timestamp row, not inside its left column: there it
|
|
215
|
+
// shared the line with the timestamp and lost ~100px of a 366px
|
|
216
|
+
// review rail to it, which is what left a field's before/after a
|
|
217
|
+
// column of single characters (bd startsim-jkkn7.12).
|
|
218
|
+
<div className={`-mt-1 mb-2 break-words text-xs ${TONE_TEXT[activity.detailTone ?? 'muted']}`}>
|
|
219
|
+
{activity.detail}
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
|
|
218
223
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
|
219
224
|
<ActorIdentity
|
|
220
225
|
actor={activity.actor}
|
|
@@ -113,8 +113,17 @@ export function actorHandle(actor?: ActorIdentityValue): string {
|
|
|
113
113
|
* panel instead, and only when some loaded row is
|
|
114
114
|
* actually unknown.
|
|
115
115
|
*
|
|
116
|
-
* The truth is never *lost* in the quiet cases
|
|
117
|
-
*
|
|
116
|
+
* The truth is never *lost* in the quiet cases. Whenever a kind is passed, the
|
|
117
|
+
* WRAPPER carries {@link actorKindTitle} as its `title`, on every row and not
|
|
118
|
+
* only on the marker, so hover and the accessible description report it for an
|
|
119
|
+
* unknown or claimed-person row too (bd startsim-jkkn7.15). It is a title and
|
|
120
|
+
* NOT sr-only text on purpose: this chip sits inside sentences
|
|
121
|
+
* ("<actor> changed this record while you were editing"), and a hidden
|
|
122
|
+
* sentence spliced into the middle of one reads worse than no sentence.
|
|
123
|
+
*
|
|
124
|
+
* `person`/`inferred` is unreachable today — the server's inference ladder
|
|
125
|
+
* floors at `unknown` and a believed claim always records `claim` — so its
|
|
126
|
+
* branch below is dead code kept on purpose, as the guard it is.
|
|
118
127
|
*/
|
|
119
128
|
function kindMarker(
|
|
120
129
|
kind: ActorKind | undefined,
|
|
@@ -185,12 +194,16 @@ export function ActorIdentity({
|
|
|
185
194
|
if (!label && !marker) return null;
|
|
186
195
|
const initials = showInitials && label ? actorInitials(actor) : '';
|
|
187
196
|
const handle = actorHandle(actor);
|
|
197
|
+
const kindTitle = kind ? actorKindTitle(kind, kindSource) : undefined;
|
|
188
198
|
|
|
189
199
|
return (
|
|
190
200
|
<span
|
|
191
201
|
data-testid="actor-identity"
|
|
192
202
|
data-actor-kind={kind ?? undefined}
|
|
193
203
|
data-actor-kind-source={kindSource ?? undefined}
|
|
204
|
+
// Only when a kind was passed: every surface that predates the actor axis
|
|
205
|
+
// must render exactly as it did (see the `kind` prop).
|
|
206
|
+
title={kindTitle}
|
|
194
207
|
className={className}
|
|
195
208
|
>
|
|
196
209
|
{initials && <span className={initialsClassName}>{initials}</span>}
|
|
@@ -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:
|
|
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 [
|
|
126
|
-
const
|
|
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
|
-
|
|
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 }) =>
|
|
138
|
-
|
|
139
|
-
|
|
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(
|
|
148
|
-
|
|
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.
|
|
151
|
-
|
|
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
|
-
|
|
170
|
-
value
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.');
|