@startsimpli/ui 0.4.120 → 0.4.121
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 +5 -5
- package/src/components/collection/DraftReviewWorkspace.tsx +8 -3
- package/src/components/collection/InlineReviewActions.tsx +7 -13
- package/src/components/collection/ReviewDrawer.tsx +16 -13
- package/src/components/collection/TopicReviewWorkspace.tsx +8 -3
- package/src/components/collection/__tests__/blob-round-trip.test.tsx +248 -0
- package/src/components/collection/data.ts +132 -4
- package/src/components/collection/index.ts +1 -1
- package/src/components/collection/review.ts +12 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.121",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
48
48
|
"react": "^18.0.0 || ^19.0.0",
|
|
49
49
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
50
|
+
"@startsimpli/i18n": "^0.1.3",
|
|
50
51
|
"@startsimpli/api": "^0.5.58",
|
|
51
|
-
"@startsimpli/auth": "^0.4.35"
|
|
52
|
-
"@startsimpli/i18n": "^0.1.3"
|
|
52
|
+
"@startsimpli/auth": "^0.4.35"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@startsimpli/api": {
|
|
@@ -123,8 +123,8 @@
|
|
|
123
123
|
"tailwindcss": "^3.4.19",
|
|
124
124
|
"tsup": "^8.5.1",
|
|
125
125
|
"typescript": "^6.0.3",
|
|
126
|
-
"@startsimpli/
|
|
127
|
-
"@startsimpli/
|
|
126
|
+
"@startsimpli/i18n": "0.1.3",
|
|
127
|
+
"@startsimpli/realtime": "0.0.2"
|
|
128
128
|
},
|
|
129
129
|
"keywords": [
|
|
130
130
|
"ui",
|
|
@@ -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 { readData,
|
|
20
|
+
import { declaredBlob, readData, writeData } from './data';
|
|
21
21
|
import { scopeKey } from './scoped-client';
|
|
22
22
|
import {
|
|
23
23
|
groupDrafts,
|
|
@@ -127,8 +127,13 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
|
127
127
|
// The FULL blob, spread from the record: PATCH REPLACES `data`, so an
|
|
128
128
|
// attribute this board never touches — the scope path included — has to be
|
|
129
129
|
// sent back to survive. A write is never a move between scopes.
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
// Re-keyed to the type's DECLARED attribute names before it goes back:
|
|
131
|
+
// the client cannot rebuild an underscore that sat before a digit, so a
|
|
132
|
+
// whole-blob write would rename `source_1` to `source1` (bd
|
|
133
|
+
// startsim-8hgmq.18, collection/data.ts).
|
|
134
|
+
let data: Record<string, unknown> = { ...record.data };
|
|
135
|
+
for (const [k, v] of Object.entries(changes)) data = writeData(data, k, v);
|
|
136
|
+
data = declaredBlob(data, (draftType?.attributes ?? []).map((a) => a.name));
|
|
132
137
|
return client.updateEntity(record.id, { data });
|
|
133
138
|
}
|
|
134
139
|
|
|
@@ -24,7 +24,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
|
|
24
24
|
import { notify } from '../toast/use-notify';
|
|
25
25
|
import { cn } from '../../lib/utils';
|
|
26
26
|
import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
|
|
27
|
-
import {
|
|
27
|
+
import { writeData } from './data';
|
|
28
28
|
import {
|
|
29
29
|
applyDecisionToData,
|
|
30
30
|
fieldStr,
|
|
@@ -63,6 +63,8 @@ export interface InlineReviewActionsProps {
|
|
|
63
63
|
|
|
64
64
|
export function InlineReviewActions({ client, type, record, config, onSaved }: InlineReviewActionsProps) {
|
|
65
65
|
const cfg = useMemo(() => resolveReviewConfig(type, config), [type, config]);
|
|
66
|
+
/** The type's declared attribute names — the spellings a write must send back. */
|
|
67
|
+
const declaredNames = useMemo(() => (type.attributes ?? []).map((a) => a.name), [type]);
|
|
66
68
|
const decisions = useMemo(
|
|
67
69
|
() => [...reviewDecisions(cfg)].sort((a, b) => ORDER.indexOf(a.key) - ORDER.indexOf(b.key)),
|
|
68
70
|
[cfg],
|
|
@@ -89,18 +91,10 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
|
|
|
89
91
|
setAppliedKey(d.key); // optimistic
|
|
90
92
|
setBusy(d.key);
|
|
91
93
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (t) {
|
|
97
|
-
data[camel] = t;
|
|
98
|
-
if (camel !== cfg.noteAttr) delete data[cfg.noteAttr];
|
|
99
|
-
} else {
|
|
100
|
-
delete data[camel];
|
|
101
|
-
delete data[cfg.noteAttr];
|
|
102
|
-
}
|
|
103
|
-
}
|
|
94
|
+
// Declared names re-key the whole outgoing blob, so a decision here cannot
|
|
95
|
+
// rename `source_1` to `source1` (bd startsim-8hgmq.18, collection/data.ts).
|
|
96
|
+
let data = applyDecisionToData(record.data, d, cfg, declaredNames);
|
|
97
|
+
if (noteText !== undefined) data = writeData(data, cfg.noteAttr, noteText.trim());
|
|
104
98
|
await client.updateEntity(record.id, { data });
|
|
105
99
|
onSaved?.();
|
|
106
100
|
return true;
|
|
@@ -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 { readData,
|
|
45
|
+
import { declaredBlob, readData, writeData } from './data';
|
|
46
46
|
import { parseSources } from './drafts';
|
|
47
47
|
import {
|
|
48
48
|
resolveReviewConfig,
|
|
@@ -111,6 +111,8 @@ function ReviewDrawerInner({
|
|
|
111
111
|
}: ReviewDrawerProps & { record: EntityRecord }) {
|
|
112
112
|
const cfg = useMemo(() => resolveReviewConfig(type, config), [type, config]);
|
|
113
113
|
const decisions = useMemo(() => reviewDecisions(cfg), [cfg]);
|
|
114
|
+
/** The type's declared attribute names — the spellings a write must send back. */
|
|
115
|
+
const declaredNames = useMemo(() => (type.attributes ?? []).map((a) => a.name), [type]);
|
|
114
116
|
const [mode, setMode] = useState<'review' | 'edit'>('review');
|
|
115
117
|
// Local optimistic copy so a click reflects instantly (the parent also refetches).
|
|
116
118
|
const [data, setData] = useState<Record<string, unknown>>(() => ({ ...record.data }));
|
|
@@ -140,19 +142,20 @@ function ReviewDrawerInner({
|
|
|
140
142
|
*/
|
|
141
143
|
const decided = useMemo(() => ({ ...record, data }), [record, data]);
|
|
142
144
|
|
|
143
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Merge changes into the blob and PATCH; the backend REPLACES data.
|
|
147
|
+
*
|
|
148
|
+
* The whole blob goes back, so it is re-keyed to the type's DECLARED attribute
|
|
149
|
+
* names first (`declaredBlob`). Without that, every decision renames
|
|
150
|
+
* `source_1` to `source1` on the way out — the client cannot rebuild an
|
|
151
|
+
* underscore that sat before a digit. See collection/data.ts and bd
|
|
152
|
+
* startsim-8hgmq.18. It also REPAIRS: a row that already holds `source1` is
|
|
153
|
+
* written back as `source_1`.
|
|
154
|
+
*/
|
|
144
155
|
async function patch(changes: Record<string, unknown>, key: string, advance = false) {
|
|
145
|
-
|
|
146
|
-
for (const [k, v] of Object.entries(changes))
|
|
147
|
-
|
|
148
|
-
if (v === '' || v == null) {
|
|
149
|
-
delete nextData[camel];
|
|
150
|
-
delete nextData[k];
|
|
151
|
-
} else {
|
|
152
|
-
nextData[camel] = v;
|
|
153
|
-
if (camel !== k) delete nextData[k];
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
+
let nextData: Record<string, unknown> = { ...data };
|
|
157
|
+
for (const [k, v] of Object.entries(changes)) nextData = writeData(nextData, k, v);
|
|
158
|
+
nextData = declaredBlob(nextData, declaredNames);
|
|
156
159
|
setData(nextData); // optimistic
|
|
157
160
|
setBusy(key);
|
|
158
161
|
try {
|
|
@@ -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 { readData,
|
|
20
|
+
import { declaredBlob, readData, writeData } from './data';
|
|
21
21
|
import { scopeKey } from './scoped-client';
|
|
22
22
|
|
|
23
23
|
const STATUSES = ['suggested', 'ready', 'rejected', 'written'] as const;
|
|
@@ -104,8 +104,13 @@ export function TopicReviewWorkspace({ client, markets = [], renderDetail }: Top
|
|
|
104
104
|
}, [records]);
|
|
105
105
|
|
|
106
106
|
async function patch(record: EntityRecord, changes: Record<string, unknown>) {
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
// Re-keyed to the type's DECLARED attribute names before it goes back:
|
|
108
|
+
// the client cannot rebuild an underscore that sat before a digit, so a
|
|
109
|
+
// whole-blob write would rename `source_1` to `source1` (bd
|
|
110
|
+
// startsim-8hgmq.18, collection/data.ts).
|
|
111
|
+
let data: Record<string, unknown> = { ...record.data };
|
|
112
|
+
for (const [k, v] of Object.entries(changes)) data = writeData(data, k, v);
|
|
113
|
+
data = declaredBlob(data, (topicType?.attributes ?? []).map((a) => a.name));
|
|
109
114
|
try {
|
|
110
115
|
// The FULL blob, spread from the record — so an attribute this workspace
|
|
111
116
|
// never touches (the scope path among them) survives a PATCH that
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE WIRE ROUND TRIP: a declared attribute whose name has an underscore before
|
|
3
|
+
* a DIGIT must survive being read into a surface and written back out.
|
|
4
|
+
*
|
|
5
|
+
* bd startsim-8hgmq.18. The shared API client transforms keys in BOTH directions
|
|
6
|
+
* and the pair is not an involution:
|
|
7
|
+
*
|
|
8
|
+
* snakeToCamel: /_([a-z0-9])/ -> uppercase so source_1 -> source1
|
|
9
|
+
* camelToSnake: /[A-Z]/ -> _lowercase so source1 -> source1
|
|
10
|
+
*
|
|
11
|
+
* Uppercasing a digit is a no-op, so the underscore is destroyed on the way IN
|
|
12
|
+
* and cannot be reconstructed on the way OUT. `team_verdict` survives only
|
|
13
|
+
* because `teamVerdict` has a hump to split on. Any surface that reads the
|
|
14
|
+
* camelised blob and PATCHes the WHOLE thing back therefore RENAMES the declared
|
|
15
|
+
* attribute `source_1` to the undeclared `source1` — which the app still renders
|
|
16
|
+
* (readData tries both spellings) but which `attr.source_1=` server-side filters
|
|
17
|
+
* and `redenormalize_attributes` cannot see.
|
|
18
|
+
*
|
|
19
|
+
* WHY THE ASSERTIONS RUN ON THE WIRE, not on the surface's output object. The
|
|
20
|
+
* outgoing blob is camelCase by construction; a test that stops there passes
|
|
21
|
+
* while the wire is still wrong. It also cannot see the OTHER failure: two keys
|
|
22
|
+
* in the outgoing object that camelToSnake collapses onto one wire key, where
|
|
23
|
+
* only insertion order decides the survivor. So every case here goes
|
|
24
|
+
*
|
|
25
|
+
* stored (wire) -> snakeToCamel -> surface write path -> camelToSnake -> wire
|
|
26
|
+
*
|
|
27
|
+
* using the REAL transforms imported from @startsimpli/api, so the test cannot
|
|
28
|
+
* drift from the client it is describing.
|
|
29
|
+
*
|
|
30
|
+
* THE FIX UNDER TEST is not "camelise harder": it is to write declared
|
|
31
|
+
* attributes back under their DECLARED (snake) name. camelToSnake is the
|
|
32
|
+
* identity on a pure-snake key — `source_1` -> `source_1`, `team_verdict` ->
|
|
33
|
+
* `team_verdict` — so a blob keyed by declared names round-trips exactly, with
|
|
34
|
+
* no change to the shared client and no change for any other app.
|
|
35
|
+
*/
|
|
36
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
37
|
+
import userEvent from '@testing-library/user-event';
|
|
38
|
+
import { snakeToCamel, camelToSnake } from '@startsimpli/api';
|
|
39
|
+
|
|
40
|
+
import { declaredBlob, writeData } from '../data';
|
|
41
|
+
import { ReviewDrawer } from '../ReviewDrawer';
|
|
42
|
+
import type { CollectionClient, EntityRecord, EntityTypeDef } from '../types';
|
|
43
|
+
|
|
44
|
+
/** The blob as the tenant stores it — the n8n writer's spelling. */
|
|
45
|
+
const STORED: Record<string, unknown> = {
|
|
46
|
+
title: 'Gulf Conflict Risk Checklist',
|
|
47
|
+
status: 'suggested',
|
|
48
|
+
team_verdict: '',
|
|
49
|
+
source_1: 'https://example.com/a',
|
|
50
|
+
source_2: 'https://example.com/b',
|
|
51
|
+
source_3: 'https://example.com/c',
|
|
52
|
+
_origin: 'n8n',
|
|
53
|
+
source_meta: { verified: true },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** What the browser client hands a component after transforming the response. */
|
|
57
|
+
function asRead(stored: Record<string, unknown>): Record<string, unknown> {
|
|
58
|
+
return snakeToCamel(stored) as Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What the tenant actually stores after the client transforms the request. */
|
|
62
|
+
function asStored(outgoing: Record<string, unknown>): Record<string, unknown> {
|
|
63
|
+
return camelToSnake(outgoing) as Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const TOPIC_TYPE: EntityTypeDef = {
|
|
67
|
+
id: 'tt',
|
|
68
|
+
key: 'topic',
|
|
69
|
+
label: 'Topic',
|
|
70
|
+
attributes: [
|
|
71
|
+
{ id: '1', name: 'title', dataType: 'text', required: false, config: {} },
|
|
72
|
+
{
|
|
73
|
+
id: '2',
|
|
74
|
+
name: 'status',
|
|
75
|
+
dataType: 'enum',
|
|
76
|
+
required: false,
|
|
77
|
+
config: { choices: ['suggested', 'ready', 'rejected', 'written'] },
|
|
78
|
+
},
|
|
79
|
+
{ id: '3', name: 'team_verdict', dataType: 'text', required: false, config: {} },
|
|
80
|
+
{ id: '4', name: 'team_notes', dataType: 'longtext', required: false, config: {} },
|
|
81
|
+
{ id: '5', name: 'source_1', dataType: 'text', required: false, config: {} },
|
|
82
|
+
{ id: '6', name: 'source_2', dataType: 'text', required: false, config: {} },
|
|
83
|
+
{ id: '7', name: 'source_3', dataType: 'text', required: false, config: {} },
|
|
84
|
+
],
|
|
85
|
+
} as unknown as EntityTypeDef;
|
|
86
|
+
|
|
87
|
+
const DECLARED = TOPIC_TYPE.attributes.map((a) => a.name);
|
|
88
|
+
|
|
89
|
+
// ── the pure helpers ────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
describe('the transform pair is lossy, which is why this bug exists', () => {
|
|
92
|
+
it('destroys the underscore before a digit and cannot restore it', () => {
|
|
93
|
+
expect(snakeToCamel({ source_1: 'x' })).toEqual({ source1: 'x' });
|
|
94
|
+
expect(camelToSnake({ source1: 'x' })).toEqual({ source1: 'x' });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('is the identity on a declared snake key, which is what the fix relies on', () => {
|
|
98
|
+
for (const name of DECLARED) {
|
|
99
|
+
expect(camelToSnake({ [name]: 'x' })).toEqual({ [name]: 'x' });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('declaredBlob', () => {
|
|
105
|
+
it('renames a camel-collapsed key back to the declared attribute name', () => {
|
|
106
|
+
const out = declaredBlob(asRead(STORED), DECLARED);
|
|
107
|
+
expect(asStored(out)).toMatchObject({
|
|
108
|
+
source_1: 'https://example.com/a',
|
|
109
|
+
source_2: 'https://example.com/b',
|
|
110
|
+
source_3: 'https://example.com/c',
|
|
111
|
+
});
|
|
112
|
+
expect(asStored(out)).not.toHaveProperty('source1');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('leaves the camel spelling behind — both keys would collide on the wire', () => {
|
|
116
|
+
const out = declaredBlob(asRead(STORED), DECLARED);
|
|
117
|
+
expect(out).not.toHaveProperty('source1');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('repairs an ALREADY-corrupted row on the next write', () => {
|
|
121
|
+
const corrupted = { title: 'x', source1: 'https://example.com/a', status: 'ready' };
|
|
122
|
+
const out = declaredBlob(asRead(corrupted), DECLARED);
|
|
123
|
+
expect(asStored(out)).toEqual({
|
|
124
|
+
title: 'x',
|
|
125
|
+
source_1: 'https://example.com/a',
|
|
126
|
+
status: 'ready',
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('prefers the declared spelling when a row carries BOTH (JSONB orders the short key first, so the declared value is the one the client kept)', () => {
|
|
131
|
+
// The client collapses both onto `source1`; the later (declared) entry wins.
|
|
132
|
+
const both = { source1: 'stale', source_1: 'fresh' };
|
|
133
|
+
expect(asRead(both)).toEqual({ source1: 'fresh' });
|
|
134
|
+
expect(asStored(declaredBlob(asRead(both), DECLARED))).toEqual({ source_1: 'fresh' });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('does not touch undeclared blob keys, which already round-trip exactly', () => {
|
|
138
|
+
const out = asStored(declaredBlob(asRead(STORED), DECLARED));
|
|
139
|
+
expect(out).toMatchObject({ _origin: 'n8n', source_meta: { verified: true } });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('is idempotent', () => {
|
|
143
|
+
const once = declaredBlob(asRead(STORED), DECLARED);
|
|
144
|
+
expect(declaredBlob(once, DECLARED)).toEqual(once);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('drops a key nothing declared away only when it is a lossy alias of a declared one', () => {
|
|
148
|
+
const out = declaredBlob({ ...asRead(STORED), somethingElse: 1 }, DECLARED);
|
|
149
|
+
expect(out).toHaveProperty('somethingElse', 1);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('writeData', () => {
|
|
154
|
+
it('writes under the declared name and removes the camel alias', () => {
|
|
155
|
+
const blob = writeData(asRead(STORED), 'source_1', 'https://example.com/new');
|
|
156
|
+
expect(blob).not.toHaveProperty('source1');
|
|
157
|
+
expect(asStored(blob)).toMatchObject({ source_1: 'https://example.com/new' });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('deletes every spelling when the value is blank', () => {
|
|
161
|
+
const blob = writeData(asRead(STORED), 'source_1', '');
|
|
162
|
+
expect(blob).not.toHaveProperty('source1');
|
|
163
|
+
expect(blob).not.toHaveProperty('source_1');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('round-trips a hump-bearing declared name without minting a second key', () => {
|
|
167
|
+
const blob = writeData(asRead(STORED), 'team_verdict', 'approved');
|
|
168
|
+
expect(Object.keys(asStored(blob)).filter((k) => k.includes('verdict'))).toEqual([
|
|
169
|
+
'team_verdict',
|
|
170
|
+
]);
|
|
171
|
+
expect(asStored(blob).team_verdict).toBe('approved');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ── the surface: a drawer decision ──────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
function stubClient(saved: Record<string, unknown>[]): CollectionClient {
|
|
178
|
+
return {
|
|
179
|
+
listTypes: jest.fn(async () => ({ count: 1, next: null, previous: null, results: [TOPIC_TYPE] })),
|
|
180
|
+
listAllEntities: jest.fn(async () => []),
|
|
181
|
+
updateEntity: jest.fn(async (_id, input: { data?: Record<string, unknown> }) => {
|
|
182
|
+
saved.push(input.data ?? {});
|
|
183
|
+
return {} as EntityRecord;
|
|
184
|
+
}),
|
|
185
|
+
} as unknown as CollectionClient;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const RECORD: EntityRecord = {
|
|
189
|
+
id: 'topic-1',
|
|
190
|
+
entityType: 'topic',
|
|
191
|
+
name: 'Gulf Conflict Risk Checklist',
|
|
192
|
+
data: asRead(STORED),
|
|
193
|
+
} as unknown as EntityRecord;
|
|
194
|
+
|
|
195
|
+
describe('ReviewDrawer decision — the surface corrupting topics today', () => {
|
|
196
|
+
it('keeps source_1 on the wire after an Approve', async () => {
|
|
197
|
+
const saved: Record<string, unknown>[] = [];
|
|
198
|
+
render(
|
|
199
|
+
<ReviewDrawer
|
|
200
|
+
client={stubClient(saved)}
|
|
201
|
+
type={TOPIC_TYPE}
|
|
202
|
+
records={[RECORD]}
|
|
203
|
+
record={RECORD}
|
|
204
|
+
onClose={() => {}}
|
|
205
|
+
onNavigate={() => {}}
|
|
206
|
+
/>,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
await userEvent.click(await screen.findByRole('button', { name: /approve/i }));
|
|
210
|
+
await waitFor(() => expect(saved).toHaveLength(1));
|
|
211
|
+
|
|
212
|
+
const wire = asStored(saved[0]!);
|
|
213
|
+
expect(wire).toHaveProperty('source_1', 'https://example.com/a');
|
|
214
|
+
expect(wire).toHaveProperty('source_2', 'https://example.com/b');
|
|
215
|
+
expect(wire).toHaveProperty('source_3', 'https://example.com/c');
|
|
216
|
+
expect(wire).not.toHaveProperty('source1');
|
|
217
|
+
expect(wire).not.toHaveProperty('source2');
|
|
218
|
+
expect(wire).not.toHaveProperty('source3');
|
|
219
|
+
// the decision itself still lands, under its declared names
|
|
220
|
+
expect(wire.status).toBe('ready');
|
|
221
|
+
expect(wire.team_verdict).toBeTruthy();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('repairs an already-renamed row rather than re-minting the bad key', async () => {
|
|
225
|
+
const saved: Record<string, unknown>[] = [];
|
|
226
|
+
const corrupted = {
|
|
227
|
+
...RECORD,
|
|
228
|
+
data: asRead({ title: 't', status: 'written', source1: 'https://example.com/a' }),
|
|
229
|
+
} as EntityRecord;
|
|
230
|
+
render(
|
|
231
|
+
<ReviewDrawer
|
|
232
|
+
client={stubClient(saved)}
|
|
233
|
+
type={TOPIC_TYPE}
|
|
234
|
+
records={[corrupted]}
|
|
235
|
+
record={corrupted}
|
|
236
|
+
onClose={() => {}}
|
|
237
|
+
onNavigate={() => {}}
|
|
238
|
+
/>,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
await userEvent.click(await screen.findByRole('button', { name: /reject/i }));
|
|
242
|
+
await waitFor(() => expect(saved).toHaveLength(1));
|
|
243
|
+
|
|
244
|
+
const wire = asStored(saved[0]!);
|
|
245
|
+
expect(wire).toHaveProperty('source_1', 'https://example.com/a');
|
|
246
|
+
expect(wire).not.toHaveProperty('source1');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -1,17 +1,145 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* camelCase-aware read/write helpers for an Entity.data blob.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* camelCase-aware read/write helpers for an Entity.data blob.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY THIS MODULE EXISTS AT ALL ───────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The tenant API client transforms keys in BOTH directions, recursively, and
|
|
7
|
+
* that includes the `data` blob — which is not an API contract but whatever
|
|
8
|
+
* attribute names the tenant declared. So the declared attribute `team_verdict`
|
|
9
|
+
* reaches a component as `teamVerdict`. {@link readData} tries the camel form
|
|
10
|
+
* first and the raw name second, so a component reads either shape.
|
|
11
|
+
*
|
|
12
|
+
* ── THE TRAP THIS MODULE ALSO EXISTS TO CLOSE (bd startsim-8hgmq.18) ────────
|
|
13
|
+
*
|
|
14
|
+
* The transform pair is NOT an involution:
|
|
15
|
+
*
|
|
16
|
+
* snake -> camel: /_([a-z0-9])/ -> uppercase so source_1 -> source1
|
|
17
|
+
* camel -> snake: /[A-Z]/ -> _lowercase so source1 -> source1
|
|
18
|
+
*
|
|
19
|
+
* Uppercasing a DIGIT is a no-op, so the underscore is destroyed on the way in
|
|
20
|
+
* and there is no hump to split on on the way back out. `team_verdict` survives
|
|
21
|
+
* only because `teamVerdict` has one. Any surface that reads the camelised blob
|
|
22
|
+
* and PATCHes the WHOLE thing back therefore RENAMES the declared attribute
|
|
23
|
+
* `source_1` to the undeclared `source1`. Nothing looks broken — `readData`
|
|
24
|
+
* finds either spelling — but the rename is invisible to `attr.source_1=`
|
|
25
|
+
* server-side filters (which this tenant answers by matching NOTHING, silently),
|
|
26
|
+
* to `redenormalize_attributes`, and to anything reading the row outside the app.
|
|
27
|
+
*
|
|
28
|
+
* THE FIX IS NOT TO CAMELISE HARDER. camelToSnake is the IDENTITY on a pure
|
|
29
|
+
* snake_case key — `source_1` -> `source_1`, `team_verdict` -> `team_verdict` —
|
|
30
|
+
* so a blob written back under its DECLARED names round-trips exactly, with no
|
|
31
|
+
* change to the shared client and no change for any other app. That is what
|
|
32
|
+
* {@link declaredBlob} and {@link writeData} do; every surface that writes a
|
|
33
|
+
* whole blob back must run it through one of them.
|
|
34
|
+
*
|
|
35
|
+
* WHAT THIS CANNOT FIX, stated so it is not mistaken for coverage: a declared
|
|
36
|
+
* name that is not itself snake_case (`sourceMeta`, `URL`) is mangled by
|
|
37
|
+
* camelToSnake no matter what a surface writes, because the surface never sees a
|
|
38
|
+
* wire key it could restore. Only the client can protect those, by not
|
|
39
|
+
* transforming the blob at all. Measured 2026-09-09 on marketing-agents: zero
|
|
40
|
+
* such declared names exist, and the whole lossy set is {source_1, source_2,
|
|
41
|
+
* source_3} on the topic type.
|
|
42
|
+
*
|
|
43
|
+
* Pure + framework-free.
|
|
6
44
|
*/
|
|
7
45
|
import type { EntityRecord } from './types';
|
|
8
46
|
|
|
47
|
+
/** The spelling the shared client produces from a wire key. */
|
|
9
48
|
export function toCamelKey(name: string): string {
|
|
10
49
|
return name.replace(/_+([a-z0-9])/g, (_m, c: string) => c.toUpperCase());
|
|
11
50
|
}
|
|
12
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The spelling the shared client sends to the wire for a key.
|
|
54
|
+
*
|
|
55
|
+
* A local mirror of `@startsimpli/api` utils/case-transform so `collection/`
|
|
56
|
+
* stays free of a runtime dependency on the API package. It cannot drift
|
|
57
|
+
* unnoticed: __tests__/blob-round-trip.test.ts imports the REAL pair and pins
|
|
58
|
+
* these against it.
|
|
59
|
+
*/
|
|
60
|
+
function toWireKey(key: string): string {
|
|
61
|
+
return key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* True when a declared attribute name does NOT survive the client's
|
|
66
|
+
* snake -> camel -> snake round trip, i.e. its camel spelling is a LOSSY ALIAS
|
|
67
|
+
* that no longer names the attribute. Today that is exactly the names with an
|
|
68
|
+
* underscore before a digit (`source_1`).
|
|
69
|
+
*/
|
|
70
|
+
export function isLossyName(name: string): boolean {
|
|
71
|
+
return toWireKey(toCamelKey(name)) !== name;
|
|
72
|
+
}
|
|
73
|
+
|
|
13
74
|
export function readData(data: EntityRecord['data'] | undefined, name: string): unknown {
|
|
14
75
|
if (!data) return undefined;
|
|
15
76
|
const bag = data as Record<string, unknown>;
|
|
16
77
|
return bag[toCamelKey(name)] ?? bag[name];
|
|
17
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Set one declared attribute in a blob, under the spelling that survives the
|
|
82
|
+
* wire, and remove every other spelling of it.
|
|
83
|
+
*
|
|
84
|
+
* A blank value (`''`, null, undefined) DELETES the attribute — the behaviour
|
|
85
|
+
* every caller here already had.
|
|
86
|
+
*
|
|
87
|
+
* WHICH SPELLING IT WRITES. A lossy name goes in under its DECLARED (snake)
|
|
88
|
+
* name, which camelToSnake then passes through untouched. Any other name keeps
|
|
89
|
+
* the camel spelling the blob already uses, because that is what round-trips for
|
|
90
|
+
* it and because writing a second spelling alongside the spread blob leaves two
|
|
91
|
+
* keys that COLLIDE on the wire, with only key order deciding the survivor. That
|
|
92
|
+
* is why `_edit_history` is written as `EditHistory` and must stay that way.
|
|
93
|
+
*/
|
|
94
|
+
export function writeData(
|
|
95
|
+
data: Record<string, unknown> | undefined,
|
|
96
|
+
name: string,
|
|
97
|
+
value: unknown,
|
|
98
|
+
): Record<string, unknown> {
|
|
99
|
+
const out: Record<string, unknown> = { ...(data ?? {}) };
|
|
100
|
+
const camel = toCamelKey(name);
|
|
101
|
+
delete out[camel];
|
|
102
|
+
delete out[name];
|
|
103
|
+
if (value === '' || value == null) return out;
|
|
104
|
+
out[isLossyName(name) ? name : camel] = value;
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Re-key a whole outgoing blob so every DECLARED attribute goes back to the
|
|
110
|
+
* tenant under its declared name. Run it on any blob that is about to be PATCHed
|
|
111
|
+
* in full — the backend REPLACES `data`, so the spread carries every key the
|
|
112
|
+
* client mangled on the way in, not just the ones the surface touched.
|
|
113
|
+
*
|
|
114
|
+
* IT ALSO REPAIRS. A row already holding `source1` is written back as `source_1`,
|
|
115
|
+
* so the corruption drains as records are worked rather than needing a migration
|
|
116
|
+
* for rows anyone still touches.
|
|
117
|
+
*
|
|
118
|
+
* Only LOSSY names are re-keyed. Everything else already survives the round trip
|
|
119
|
+
* unchanged, so rewriting it would churn the blob for no gain — and undeclared
|
|
120
|
+
* keys (`_origin`, `review`, `notes`, `sourceMeta`) are left exactly as they
|
|
121
|
+
* arrived, because the surface has no way to know their wire spelling.
|
|
122
|
+
*
|
|
123
|
+
* @param names the declared attribute names, i.e. `type.attributes.map(a => a.name)`
|
|
124
|
+
*/
|
|
125
|
+
export function declaredBlob(
|
|
126
|
+
data: Record<string, unknown> | undefined,
|
|
127
|
+
names: Iterable<string>,
|
|
128
|
+
): Record<string, unknown> {
|
|
129
|
+
const out: Record<string, unknown> = { ...(data ?? {}) };
|
|
130
|
+
for (const name of names) {
|
|
131
|
+
if (!isLossyName(name)) continue;
|
|
132
|
+
const camel = toCamelKey(name);
|
|
133
|
+
const hasDeclared = Object.prototype.hasOwnProperty.call(out, name);
|
|
134
|
+
const hasCamel = Object.prototype.hasOwnProperty.call(out, camel);
|
|
135
|
+
if (!hasDeclared && !hasCamel) continue;
|
|
136
|
+
// The declared spelling wins a tie. It is also the one the client already
|
|
137
|
+
// kept: a row carrying both collapses onto the camel key on the way in and
|
|
138
|
+
// JSONB orders the SHORTER key first, so the declared value is the later
|
|
139
|
+
// entry and last-write-wins hands it to the app.
|
|
140
|
+
const value = hasDeclared ? out[name] : out[camel];
|
|
141
|
+
delete out[camel];
|
|
142
|
+
out[name] = value;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
@@ -50,7 +50,7 @@ export {
|
|
|
50
50
|
SCOPE_FILTER,
|
|
51
51
|
type ScopedCollectionClient,
|
|
52
52
|
} from './scoped-client';
|
|
53
|
-
export { readData, toCamelKey } from './data';
|
|
53
|
+
export { readData, toCamelKey, writeData, declaredBlob, isLossyName } from './data';
|
|
54
54
|
export {
|
|
55
55
|
parseSources,
|
|
56
56
|
normUrl,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Nothing here is tenant-specific — the defaults are conventions, not requirements.
|
|
16
16
|
*/
|
|
17
17
|
import type { AttributeDef, EntityTypeDef } from './types';
|
|
18
|
-
import { readData,
|
|
18
|
+
import { declaredBlob, readData, writeData } from './data';
|
|
19
19
|
|
|
20
20
|
// ---- keyboard guard (lifted so the drawer + any review surface share it) ----
|
|
21
21
|
|
|
@@ -368,17 +368,23 @@ export function applyDecisionToData(
|
|
|
368
368
|
data: Record<string, unknown> | undefined,
|
|
369
369
|
decision: ReviewDecision,
|
|
370
370
|
cfg: ResolvedReview,
|
|
371
|
+
/**
|
|
372
|
+
* The type's declared attribute names. Passing them re-keys the WHOLE outgoing
|
|
373
|
+
* blob to the declared spellings, which is what stops a decision renaming
|
|
374
|
+
* `source_1` to `source1` on the way back to the tenant (bd startsim-8hgmq.18,
|
|
375
|
+
* collection/data.ts). Optional only so an existing caller cannot break; every
|
|
376
|
+
* caller that has a type SHOULD pass them.
|
|
377
|
+
*/
|
|
378
|
+
declaredNames: Iterable<string> = [],
|
|
371
379
|
): Record<string, unknown> {
|
|
372
|
-
|
|
380
|
+
let next: Record<string, unknown> = { ...(data ?? {}) };
|
|
373
381
|
const set = (attr: string, value: string | null) => {
|
|
374
382
|
if (value == null || value === '') return;
|
|
375
|
-
|
|
376
|
-
next[camel] = value;
|
|
377
|
-
if (camel !== attr) delete next[attr];
|
|
383
|
+
next = writeData(next, attr, value);
|
|
378
384
|
};
|
|
379
385
|
set(cfg.statusName, decision.status);
|
|
380
386
|
set(cfg.verdictAttr, decision.verdict);
|
|
381
|
-
return next;
|
|
387
|
+
return declaredBlob(next, declaredNames);
|
|
382
388
|
}
|
|
383
389
|
|
|
384
390
|
// ---- small read helpers used by the component (kept here so they're tested) ----
|