@startsimpli/ui 0.4.104 → 0.4.106
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 +3 -2
- package/src/components/navigation/__tests__/breadcrumbs.test.tsx +16 -0
- package/src/components/navigation/breadcrumbs.tsx +39 -0
- package/src/components/navigation/index.ts +1 -0
- package/src/components/unified-table/UnifiedTable.tsx +30 -2
- package/src/components/unified-table/__tests__/integration/search-and-empty-state.test.tsx +210 -0
- package/src/components/unified-table/components/DataTableCore/index.tsx +6 -2
- package/src/components/unified-table/components/Toolbar/StandardTableToolbar.tsx +53 -3
- package/src/components/unified-table/utils/export.ts +5 -37
- package/src/foundry/LinkPanel.tsx +394 -0
- package/src/foundry/__tests__/LinkPanel.test.tsx +139 -0
- package/src/foundry/__tests__/links.test.ts +251 -0
- package/src/foundry/__tests__/list-query.test.ts +200 -0
- package/src/foundry/__tests__/publication.test.ts +227 -0
- package/src/foundry/__tests__/record-validation.test.ts +132 -0
- package/src/foundry/__tests__/types.test.ts +53 -0
- package/src/foundry/index.ts +21 -0
- package/src/foundry/links.ts +227 -0
- package/src/foundry/list-query.ts +241 -0
- package/src/foundry/publication.ts +260 -0
- package/src/foundry/record-validation.ts +109 -0
- package/src/foundry/system-attrs.ts +51 -0
- package/src/foundry/types.ts +137 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-now.ts +46 -0
- package/src/index.ts +1 -0
- package/src/utils/__tests__/csv.test.ts +67 -0
- package/src/utils/__tests__/download-csv.test.ts +40 -0
- package/src/utils/csv.ts +77 -0
- package/src/utils/index.ts +9 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import {
|
|
2
|
+
countLinksByRecord,
|
|
3
|
+
countPublicLinksByRecord,
|
|
4
|
+
edgeEndpoints,
|
|
5
|
+
edgesForSide,
|
|
6
|
+
farDirection,
|
|
7
|
+
farIdOf,
|
|
8
|
+
joinLinkRows,
|
|
9
|
+
linkCandidates,
|
|
10
|
+
linkSidesFor,
|
|
11
|
+
nearDirection,
|
|
12
|
+
recordLabel,
|
|
13
|
+
sameId,
|
|
14
|
+
} from '../links';
|
|
15
|
+
import type { LinkSide } from '../links';
|
|
16
|
+
import type {
|
|
17
|
+
EntityRecord,
|
|
18
|
+
EntityTypeDef,
|
|
19
|
+
RelationshipDef,
|
|
20
|
+
RelationshipInstance,
|
|
21
|
+
} from '../types';
|
|
22
|
+
|
|
23
|
+
const PRODUCT: EntityTypeDef = { id: 'T-product', key: 'product', label: 'Product', attributes: [] };
|
|
24
|
+
const PATENT: EntityTypeDef = { id: 'T-patent', key: 'patent', label: 'Patent', attributes: [] };
|
|
25
|
+
const CATEGORY: EntityTypeDef = { id: 'T-category', key: 'category', label: 'Category', attributes: [] };
|
|
26
|
+
const TYPES = [PRODUCT, PATENT, CATEGORY];
|
|
27
|
+
|
|
28
|
+
const MARKED_WITH: RelationshipDef = {
|
|
29
|
+
id: 'R1', key: 'marked_with', sourceType: 'T-product', targetType: 'T-patent',
|
|
30
|
+
};
|
|
31
|
+
const IN_CATEGORY: RelationshipDef = {
|
|
32
|
+
id: 'R2', key: 'in_category', sourceType: 'T-product', targetType: 'T-category',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function rec(id: string, name: string, externalId: string | null = null): EntityRecord {
|
|
36
|
+
return { id, entityType: 'x', externalId, name, data: {}, createdAt: '2026-08-01' };
|
|
37
|
+
}
|
|
38
|
+
function edge(id: string, relType: string, source: string, target: string): RelationshipInstance {
|
|
39
|
+
return { id, relType, source, target, createdAt: '2026-08-01' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('sameId', () => {
|
|
43
|
+
it('compares across the string/number union the wire and the fixtures disagree on', () => {
|
|
44
|
+
expect(sameId('7', 7)).toBe(true);
|
|
45
|
+
expect(sameId('a', 'a')).toBe(true);
|
|
46
|
+
expect(sameId('a', 'b')).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
it('never matches a missing id — null is not an id', () => {
|
|
49
|
+
expect(sameId(null, null)).toBe(false);
|
|
50
|
+
expect(sameId(undefined, '1')).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('linkSidesFor', () => {
|
|
55
|
+
it('gives a product both of its declared sides, pointing at the far types', () => {
|
|
56
|
+
const sides = linkSidesFor(PRODUCT, [MARKED_WITH, IN_CATEGORY], TYPES);
|
|
57
|
+
expect(sides).toEqual([
|
|
58
|
+
{ relType: 'marked_with', role: 'source', farTypeKey: 'patent', farTypeLabel: 'Patent' },
|
|
59
|
+
{ relType: 'in_category', role: 'source', farTypeKey: 'category', farTypeLabel: 'Category' },
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('gives a patent the REVERSE side of the same relationship — the join read from the other end', () => {
|
|
64
|
+
expect(linkSidesFor(PATENT, [MARKED_WITH, IN_CATEGORY], TYPES)).toEqual([
|
|
65
|
+
{ relType: 'marked_with', role: 'target', farTypeKey: 'product', farTypeLabel: 'Product' },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('yields BOTH sides of a self-referential relationship', () => {
|
|
70
|
+
const selfRef: RelationshipDef = {
|
|
71
|
+
id: 'R3', key: 'supersedes', sourceType: 'T-patent', targetType: 'T-patent',
|
|
72
|
+
};
|
|
73
|
+
const sides = linkSidesFor(PATENT, [selfRef], TYPES);
|
|
74
|
+
expect(sides.map((s) => s.role)).toEqual(['source', 'target']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('drops a def naming a type the caller cannot read, rather than rendering a headless panel', () => {
|
|
78
|
+
const dangling: RelationshipDef = {
|
|
79
|
+
id: 'R4', key: 'ghost', sourceType: 'T-product', targetType: 'T-missing',
|
|
80
|
+
};
|
|
81
|
+
expect(linkSidesFor(PRODUCT, [dangling], TYPES)).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('is empty for no type', () => {
|
|
85
|
+
expect(linkSidesFor(null, [MARKED_WITH], TYPES)).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('farDirection', () => {
|
|
90
|
+
// Measured against the live tenant: `related_direction` names the RESULT
|
|
91
|
+
// record's end of the edge. Getting it backwards returns 0 rows with
|
|
92
|
+
// applied_filters still reporting success, so it is pinned here.
|
|
93
|
+
it("asks for the edge's TARGET when this record is the source", () => {
|
|
94
|
+
expect(farDirection(linkSidesFor(PRODUCT, [MARKED_WITH], TYPES)[0])).toBe('in');
|
|
95
|
+
});
|
|
96
|
+
it("asks for the edge's SOURCE when this record is the target", () => {
|
|
97
|
+
expect(farDirection(linkSidesFor(PATENT, [MARKED_WITH], TYPES)[0])).toBe('out');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('edgeEndpoints', () => {
|
|
102
|
+
const fromProduct = linkSidesFor(PRODUCT, [MARKED_WITH], TYPES)[0];
|
|
103
|
+
const fromPatent = linkSidesFor(PATENT, [MARKED_WITH], TYPES)[0];
|
|
104
|
+
|
|
105
|
+
it('writes the declared direction when linking from the product', () => {
|
|
106
|
+
expect(edgeEndpoints(fromProduct, 'P1', 'X9')).toEqual({
|
|
107
|
+
relType: 'marked_with', source: 'P1', target: 'X9',
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('STILL writes product -> patent when linking from the patent side', () => {
|
|
112
|
+
// The screen the user is on does not change the declared direction; the
|
|
113
|
+
// tenant's _validate_edge_types refuses the flipped edge. This is the bug
|
|
114
|
+
// the reverse panel would otherwise ship with.
|
|
115
|
+
expect(edgeEndpoints(fromPatent, 'X9', 'P1')).toEqual({
|
|
116
|
+
relType: 'marked_with', source: 'P1', target: 'X9',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('edgesForSide / farIdOf / joinLinkRows', () => {
|
|
122
|
+
const fromProduct = linkSidesFor(PRODUCT, [MARKED_WITH, IN_CATEGORY], TYPES)[0];
|
|
123
|
+
const fromPatent = linkSidesFor(PATENT, [MARKED_WITH], TYPES)[0];
|
|
124
|
+
const edges = [
|
|
125
|
+
edge('E1', 'marked_with', 'P1', 'X1'),
|
|
126
|
+
edge('E2', 'marked_with', 'P1', 'X2'),
|
|
127
|
+
edge('E3', 'in_category', 'P1', 'C1'),
|
|
128
|
+
edge('E4', 'marked_with', 'P2', 'X1'),
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
it('keeps only the edges of that relationship on that end', () => {
|
|
132
|
+
expect(edgesForSide(edges, fromProduct, 'P1').map((e) => e.id)).toEqual(['E1', 'E2']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('reads the reverse end of the same edge set', () => {
|
|
136
|
+
expect(edgesForSide(edges, fromPatent, 'X1').map((e) => e.id)).toEqual(['E1', 'E4']);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('resolves the far end according to the side', () => {
|
|
140
|
+
expect(farIdOf(edges[0], fromProduct)).toBe('X1');
|
|
141
|
+
expect(farIdOf(edges[0], fromPatent)).toBe('P1');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('pairs every edge with its far record, keeping the edge id unlinking needs', () => {
|
|
145
|
+
const rows = joinLinkRows(edgesForSide(edges, fromProduct, 'P1'), fromProduct, [
|
|
146
|
+
rec('X1', 'Patent one'),
|
|
147
|
+
rec('X2', 'Patent two'),
|
|
148
|
+
]);
|
|
149
|
+
expect(rows.map((r) => [r.edge.id, r.record?.name])).toEqual([
|
|
150
|
+
['E1', 'Patent one'],
|
|
151
|
+
['E2', 'Patent two'],
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('keeps an edge whose far record is unreadable rather than hiding the association', () => {
|
|
156
|
+
const rows = joinLinkRows(edgesForSide(edges, fromProduct, 'P1'), fromProduct, [rec('X1', 'Patent one')]);
|
|
157
|
+
expect(rows).toHaveLength(2);
|
|
158
|
+
expect(rows[1].record).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('linkCandidates', () => {
|
|
163
|
+
it('excludes what is already linked — nothing below enforces cardinality (768w.16.17)', () => {
|
|
164
|
+
const pool = [rec('X1', 'one'), rec('X2', 'two'), rec('X3', 'three')];
|
|
165
|
+
expect(linkCandidates(pool, ['X2'], 'P1').map((r) => r.id)).toEqual(['X1', 'X3']);
|
|
166
|
+
});
|
|
167
|
+
it('never offers the record itself, which a self-referential relationship would', () => {
|
|
168
|
+
const pool = [rec('X1', 'one'), rec('P1', 'self')];
|
|
169
|
+
expect(linkCandidates(pool, [], 'P1').map((r) => r.id)).toEqual(['X1']);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('countLinksByRecord', () => {
|
|
174
|
+
const fromProduct = linkSidesFor(PRODUCT, [MARKED_WITH], TYPES)[0];
|
|
175
|
+
const fromPatent = linkSidesFor(PATENT, [MARKED_WITH], TYPES)[0];
|
|
176
|
+
const edges = [
|
|
177
|
+
edge('E1', 'marked_with', 'P1', 'X1'),
|
|
178
|
+
edge('E2', 'marked_with', 'P1', 'X2'),
|
|
179
|
+
edge('E3', 'marked_with', 'P2', 'X1'),
|
|
180
|
+
edge('E4', 'in_category', 'P1', 'C1'),
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
it('counts a page of products by their patents', () => {
|
|
184
|
+
const counts = countLinksByRecord(edges, fromProduct);
|
|
185
|
+
expect(counts.get('P1')).toBe(2);
|
|
186
|
+
expect(counts.get('P2')).toBe(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('counts a page of patents by the articles asserting them — the public page number', () => {
|
|
190
|
+
const counts = countLinksByRecord(edges, fromPatent);
|
|
191
|
+
expect(counts.get('X1')).toBe(2);
|
|
192
|
+
expect(counts.get('X2')).toBe(1);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('recordLabel', () => {
|
|
197
|
+
it('prefers the name', () => expect(recordLabel(rec('X1', 'Widget', 'SKU1'))).toBe('Widget'));
|
|
198
|
+
it('falls back to the natural key, then to a short id', () => {
|
|
199
|
+
expect(recordLabel(rec('X1', '', 'US1234'))).toBe('US1234');
|
|
200
|
+
expect(recordLabel(rec('abcdef1234', '', null))).toBe('#abcdef12');
|
|
201
|
+
});
|
|
202
|
+
it('names an unreadable far end by its id rather than pretending it is absent', () => {
|
|
203
|
+
expect(recordLabel(null, 'abcdef1234')).toBe('#abcdef12');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('countPublicLinksByRecord / nearDirection (bd startsim-vc3ss)', () => {
|
|
208
|
+
const side: LinkSide = {
|
|
209
|
+
relType: 'marked_with',
|
|
210
|
+
role: 'source',
|
|
211
|
+
farTypeKey: 'patent',
|
|
212
|
+
farTypeLabel: 'Patents',
|
|
213
|
+
};
|
|
214
|
+
const reverse: LinkSide = {
|
|
215
|
+
relType: 'marked_with',
|
|
216
|
+
role: 'target',
|
|
217
|
+
farTypeKey: 'product',
|
|
218
|
+
farTypeLabel: 'Products',
|
|
219
|
+
};
|
|
220
|
+
const edges = [
|
|
221
|
+
{ id: 'e1', relType: 'marked_with', source: 'prod', target: 'pat-pub', createdAt: '' },
|
|
222
|
+
{ id: 'e2', relType: 'marked_with', source: 'prod', target: 'pat-draft', createdAt: '' },
|
|
223
|
+
{ id: 'e3', relType: 'in_category', source: 'prod', target: 'cat', createdAt: '' },
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
it('counts every edge for the admin, and only both-ends-published for the public', () => {
|
|
227
|
+
expect(countLinksByRecord(edges, side).get('prod')).toBe(2);
|
|
228
|
+
const published = new Set(['prod', 'pat-pub']);
|
|
229
|
+
expect(countPublicLinksByRecord(edges, side, published).get('prod')).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('is zero when the record itself is not published — it has no public page at all', () => {
|
|
233
|
+
const published = new Set(['pat-pub', 'pat-draft']);
|
|
234
|
+
expect(countPublicLinksByRecord(edges, side, published).get('prod')).toBeUndefined();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('reads the reverse side from the far end', () => {
|
|
238
|
+
const published = new Set(['prod', 'pat-pub']);
|
|
239
|
+
expect(countPublicLinksByRecord(edges, reverse, published).get('pat-pub')).toBe(1);
|
|
240
|
+
expect(countPublicLinksByRecord(edges, reverse, published).get('pat-draft')).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('nearDirection is the OPPOSITE of farDirection — the trap, pinned', () => {
|
|
244
|
+
// farDirection returns the far records; nearDirection lists records OF THIS
|
|
245
|
+
// TYPE linked to a named far record ("products in this category").
|
|
246
|
+
expect(farDirection(side)).toBe('in');
|
|
247
|
+
expect(nearDirection(side)).toBe('out');
|
|
248
|
+
expect(farDirection(reverse)).toBe('out');
|
|
249
|
+
expect(nearDirection(reverse)).toBe('in');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ALL_VALUE,
|
|
3
|
+
attributeForColumn,
|
|
4
|
+
farOptionLabels,
|
|
5
|
+
columnFiltersFor,
|
|
6
|
+
columnIdForAttribute,
|
|
7
|
+
isSortableColumn,
|
|
8
|
+
listParams,
|
|
9
|
+
orderParam,
|
|
10
|
+
} from '../list-query';
|
|
11
|
+
import type { AttributeShape } from '../types';
|
|
12
|
+
|
|
13
|
+
function attr(name: string, dataType: AttributeShape['dataType'] = 'text'): AttributeShape {
|
|
14
|
+
return { id: `id-${name}`, name, dataType, required: false, config: {} };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The four 287a types, exactly as the live tenant declares them.
|
|
18
|
+
const PRODUCT = [attr('name'), attr('stock_no'), attr('description', 'longtext'), attr('legacy_uuid')];
|
|
19
|
+
const PATENT = [
|
|
20
|
+
attr('number'),
|
|
21
|
+
attr('title'),
|
|
22
|
+
attr('description', 'longtext'),
|
|
23
|
+
attr('expiration', 'date'),
|
|
24
|
+
attr('legacy_uuid'),
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
describe('column id <-> attribute mapping', () => {
|
|
28
|
+
it('namespaces an attribute that collides with a framing column', () => {
|
|
29
|
+
// product declares an attribute literally called `name`; the framing column
|
|
30
|
+
// already owns the id `name`, so the attribute column is `attr_name`.
|
|
31
|
+
expect(columnIdForAttribute(attr('name'))).toBe('attr_name');
|
|
32
|
+
expect(columnIdForAttribute(attr('stock_no'))).toBe('stock_no');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('maps the namespaced id back to the attribute, and the framing id to nothing', () => {
|
|
36
|
+
expect(attributeForColumn('attr_name', PRODUCT)?.name).toBe('name');
|
|
37
|
+
expect(attributeForColumn('name', PRODUCT)).toBeNull();
|
|
38
|
+
expect(attributeForColumn('createdAt', PRODUCT)).toBeNull();
|
|
39
|
+
expect(attributeForColumn('link_marked_with_source', PRODUCT)).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does not mistake an attribute whose own name starts with attr_', () => {
|
|
43
|
+
const odd = [attr('attr_name'), attr('name')];
|
|
44
|
+
// `attr_name` (the literal attribute) does not collide with a framing id, so
|
|
45
|
+
// it keeps its own name as the column id — and must map back to ITSELF.
|
|
46
|
+
expect(columnIdForAttribute(odd[0])).toBe('attr_name');
|
|
47
|
+
expect(attributeForColumn('attr_name', odd)?.name).toBe('attr_name');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('sortability', () => {
|
|
52
|
+
it('refuses the framing Name column (?order=name is a 400 server-side)', () => {
|
|
53
|
+
expect(isSortableColumn('name', PRODUCT)).toBe(false);
|
|
54
|
+
expect(orderParam({ columnId: 'name', direction: 'asc' }, PRODUCT)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('refuses derived columns (association counts have no server ordering)', () => {
|
|
58
|
+
expect(isSortableColumn('link_marked_with_source', PRODUCT)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('orders Created through the recent/oldest vocabulary, not attr.created_at', () => {
|
|
62
|
+
expect(orderParam({ columnId: 'createdAt', direction: 'desc' }, PRODUCT)).toBe('recent');
|
|
63
|
+
expect(orderParam({ columnId: 'createdAt', direction: 'asc' }, PRODUCT)).toBe('oldest');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('orders a declared attribute by its ATTRIBUTE name, never by its column id', () => {
|
|
67
|
+
expect(orderParam({ columnId: 'attr_name', direction: 'asc' }, PRODUCT)).toBe('attr.name');
|
|
68
|
+
expect(orderParam({ columnId: 'attr_name', direction: 'desc' }, PRODUCT)).toBe('-attr.name');
|
|
69
|
+
expect(orderParam({ columnId: 'number', direction: 'asc' }, PATENT)).toBe('attr.number');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('column filters', () => {
|
|
74
|
+
it('offers text filters for text/longtext and a window for a date', () => {
|
|
75
|
+
expect(columnFiltersFor(PATENT)).toEqual([
|
|
76
|
+
{ id: 'number', label: 'number', kind: 'text', attrName: 'number' },
|
|
77
|
+
{ id: 'title', label: 'title', kind: 'text', attrName: 'title' },
|
|
78
|
+
{ id: 'description', label: 'description', kind: 'text', attrName: 'description' },
|
|
79
|
+
{ id: 'expiration', label: 'expiration', kind: 'date-window', attrName: 'expiration' },
|
|
80
|
+
{ id: 'legacy_uuid', label: 'legacy uuid', kind: 'text', attrName: 'legacy_uuid' },
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('listParams', () => {
|
|
86
|
+
it('is just type+page when nothing is asked for', () => {
|
|
87
|
+
expect(listParams({ type: 'product', page: 2 }, PRODUCT)).toEqual({ type: 'product', page: 2 });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('sends search, order and an icontains filter under their RAW server names', () => {
|
|
91
|
+
expect(
|
|
92
|
+
listParams(
|
|
93
|
+
{
|
|
94
|
+
type: 'patent',
|
|
95
|
+
page: 1,
|
|
96
|
+
search: ' web ',
|
|
97
|
+
sort: { columnId: 'number', direction: 'desc' },
|
|
98
|
+
filters: { title: 'scraping' },
|
|
99
|
+
},
|
|
100
|
+
PATENT,
|
|
101
|
+
),
|
|
102
|
+
).toEqual({
|
|
103
|
+
type: 'patent',
|
|
104
|
+
page: 1,
|
|
105
|
+
search: 'web',
|
|
106
|
+
order: '-attr.number',
|
|
107
|
+
'attr.title__icontains': 'scraping',
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('drops blank and "all" filter values, and filters for columns this type lacks', () => {
|
|
112
|
+
expect(
|
|
113
|
+
listParams(
|
|
114
|
+
{ type: 'product', page: 1, filters: { stock_no: '', description: ALL_VALUE, nope: 'x' } },
|
|
115
|
+
PRODUCT,
|
|
116
|
+
),
|
|
117
|
+
).toEqual({ type: 'product', page: 1 });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('turns a date window into a plain YYYY-MM-DD bound', () => {
|
|
121
|
+
const today = new Date('2026-08-24T18:30:00.000Z');
|
|
122
|
+
expect(
|
|
123
|
+
listParams({ type: 'patent', page: 1, filters: { expiration: 'past' } }, PATENT, today),
|
|
124
|
+
).toEqual({ type: 'patent', page: 1, 'attr.expiration__lt': '2026-08-24' });
|
|
125
|
+
expect(
|
|
126
|
+
listParams({ type: 'patent', page: 1, filters: { expiration: 'future' } }, PATENT, today),
|
|
127
|
+
).toEqual({ type: 'patent', page: 1, 'attr.expiration__gte': '2026-08-24' });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('never sends __icontains against a date attribute (the server answers 400)', () => {
|
|
131
|
+
const params = listParams(
|
|
132
|
+
{ type: 'patent', page: 1, filters: { expiration: 'anything else' } },
|
|
133
|
+
PATENT,
|
|
134
|
+
);
|
|
135
|
+
expect(Object.keys(params)).toEqual(['type', 'page']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('carries a link-side filter as the related_* triple', () => {
|
|
139
|
+
expect(
|
|
140
|
+
listParams(
|
|
141
|
+
{
|
|
142
|
+
type: 'product',
|
|
143
|
+
page: 1,
|
|
144
|
+
related: { relatedTo: 'cat-1', via: 'in_category', direction: 'out' },
|
|
145
|
+
},
|
|
146
|
+
PRODUCT,
|
|
147
|
+
),
|
|
148
|
+
).toEqual({
|
|
149
|
+
type: 'product',
|
|
150
|
+
page: 1,
|
|
151
|
+
related_to: 'cat-1',
|
|
152
|
+
related_via: 'in_category',
|
|
153
|
+
related_direction: 'out',
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('farOptionLabels', () => {
|
|
159
|
+
it('uses the plain name when it is unique', () => {
|
|
160
|
+
const labels = farOptionLabels([
|
|
161
|
+
{ id: 'cat-1', name: 'Software as a Service' },
|
|
162
|
+
{ id: 'cat-2', name: 'Hardware' },
|
|
163
|
+
]);
|
|
164
|
+
expect([...labels.keys()]).toEqual(['Software as a Service', 'Hardware']);
|
|
165
|
+
expect(labels.get('Hardware')).toBe('cat-2');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('disambiguates a repeated name instead of collapsing two records into one', () => {
|
|
169
|
+
// `name` is free text on a category — only `code` is a business key — so two
|
|
170
|
+
// rows can share one. One option filtering by an arbitrary one of two ids is
|
|
171
|
+
// the silent-wrong-filter failure this module exists to avoid.
|
|
172
|
+
const labels = farOptionLabels([
|
|
173
|
+
{ id: 'add566f1-754f-4582', name: 'Widgets' },
|
|
174
|
+
{ id: 'bb112233-0000-0000', name: 'Widgets' },
|
|
175
|
+
{ id: 'cc445566-0000-0000', name: 'Gadgets' },
|
|
176
|
+
]);
|
|
177
|
+
expect([...labels.keys()]).toEqual([
|
|
178
|
+
'Widgets (#add566f1)',
|
|
179
|
+
'Widgets (#bb112233)',
|
|
180
|
+
'Gadgets',
|
|
181
|
+
]);
|
|
182
|
+
expect(labels.get('Widgets (#bb112233)')).toBe('bb112233-0000-0000');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('falls back to the id when a record has no name at all', () => {
|
|
186
|
+
expect([...farOptionLabels([{ id: 'x-1', name: '' }]).keys()]).toEqual(['x-1']);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('page size', () => {
|
|
191
|
+
it('SENDS page_size — the server default is 50 and the pager counts in 20s', () => {
|
|
192
|
+
// Without this the table renders 50 rows under a pager that computes its
|
|
193
|
+
// page count from 20: page 2 serves rows 51-100 and page 3 is out of range.
|
|
194
|
+
expect(listParams({ type: 'product', page: 2, pageSize: 20 }, PRODUCT)).toEqual({
|
|
195
|
+
type: 'product',
|
|
196
|
+
page: 2,
|
|
197
|
+
page_size: 20,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isLivePublic,
|
|
3
|
+
isPublished,
|
|
4
|
+
livePublicGrant,
|
|
5
|
+
dependentPublication,
|
|
6
|
+
publicGrantsFor,
|
|
7
|
+
publishOrder,
|
|
8
|
+
publishPlan,
|
|
9
|
+
publishedIds,
|
|
10
|
+
unpublishPlan,
|
|
11
|
+
withdrawOrder,
|
|
12
|
+
} from '../publication';
|
|
13
|
+
import type { EntityRecordShape, GrantShape, RelationshipEdgeShape } from '../types';
|
|
14
|
+
|
|
15
|
+
const NOW = Date.parse('2026-08-22T12:00:00Z');
|
|
16
|
+
const PAST = '2026-08-01T00:00:00Z';
|
|
17
|
+
const FUTURE = '2027-01-01T00:00:00Z';
|
|
18
|
+
|
|
19
|
+
function rec(id: string, name: string): EntityRecordShape {
|
|
20
|
+
return { id, externalId: null, name, data: {} };
|
|
21
|
+
}
|
|
22
|
+
function grant(
|
|
23
|
+
id: string,
|
|
24
|
+
entity: string,
|
|
25
|
+
expiresAt: string | null = null,
|
|
26
|
+
subjectType = 'public',
|
|
27
|
+
): GrantShape {
|
|
28
|
+
return { id, entity, subjectType, expiresAt };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('isLivePublic', () => {
|
|
32
|
+
it('treats a null expiry as "until revoked", matching the RLS predicate', () => {
|
|
33
|
+
expect(isLivePublic(grant('G', 'P1', null), NOW)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it('is live while the expiry is in the future and dead once it passes', () => {
|
|
36
|
+
expect(isLivePublic(grant('G', 'P1', FUTURE), NOW)).toBe(true);
|
|
37
|
+
expect(isLivePublic(grant('G', 'P1', PAST), NOW)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
it('ignores a grant that is not a PUBLIC one — a share with a person is not publication', () => {
|
|
40
|
+
expect(isLivePublic(grant('G', 'P1', null, 'user'), NOW)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('livePublicGrant / isPublished / publishedIds', () => {
|
|
45
|
+
const grants = [grant('G1', 'P1', PAST), grant('G2', 'P1', null), grant('G3', 'P2', PAST)];
|
|
46
|
+
|
|
47
|
+
it('picks the live grant past a lapsed one on the same row', () => {
|
|
48
|
+
expect(livePublicGrant(grants, 'P1', NOW)?.id).toBe('G2');
|
|
49
|
+
expect(isPublished(grants, 'P1', NOW)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
it('a row whose only grant has lapsed is not published', () => {
|
|
52
|
+
expect(isPublished(grants, 'P2', NOW)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
it('keeps the lapsed grant visible as history rather than dropping it', () => {
|
|
55
|
+
expect(publicGrantsFor(grants, 'P1').map((g) => g.id)).toEqual(['G1', 'G2']);
|
|
56
|
+
});
|
|
57
|
+
it('collects exactly the live set', () => {
|
|
58
|
+
expect([...publishedIds(grants, NOW)]).toEqual(['P1']);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('publishPlan', () => {
|
|
63
|
+
const primary = rec('P1', 'Web Crawling Service');
|
|
64
|
+
const x1 = rec('X1', 'Dependent one');
|
|
65
|
+
const x2 = rec('X2', 'Dependent two');
|
|
66
|
+
|
|
67
|
+
it('publishes the primary AND every unpublished dependent linked to it', () => {
|
|
68
|
+
// Both ends must be published or the public policy hides the EDGE, and the
|
|
69
|
+
// primary renders as linked to nothing.
|
|
70
|
+
const plan = publishPlan(primary, [x1, x2], [], NOW);
|
|
71
|
+
expect(plan.primary?.entity).toBe('P1');
|
|
72
|
+
expect(plan.dependents.map((a) => a.entity)).toEqual(['X1', 'X2']);
|
|
73
|
+
expect(plan.alreadyPublic).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('leaves an already-public dependent alone and still names it in the outcome', () => {
|
|
77
|
+
const plan = publishPlan(primary, [x1, x2], [grant('G', 'X1', null)], NOW);
|
|
78
|
+
expect(plan.dependents.map((a) => a.entity)).toEqual(['X2']);
|
|
79
|
+
expect(plan.alreadyPublic.map((r) => r.id)).toEqual(['X1']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('revives a lapsed grant instead of minting a second row saying the same thing', () => {
|
|
83
|
+
const plan = publishPlan(primary, [], [grant('G-old', 'P1', PAST)], NOW);
|
|
84
|
+
expect(plan.primary?.reinstate?.id).toBe('G-old');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('has nothing to do for a primary and dependents that are already public', () => {
|
|
88
|
+
const grants = [grant('G1', 'P1', null), grant('G2', 'X1', null)];
|
|
89
|
+
const plan = publishPlan(primary, [x1], grants, NOW);
|
|
90
|
+
expect(plan.primary).toBeNull();
|
|
91
|
+
expect(plan.dependents).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('unpublishPlan', () => {
|
|
96
|
+
const p1 = rec('P1', 'Primary one');
|
|
97
|
+
const x1 = rec('X1', 'Shared dependent');
|
|
98
|
+
const x2 = rec('X2', 'Exclusive dependent');
|
|
99
|
+
const edges: RelationshipEdgeShape[] = [
|
|
100
|
+
{ id: 'E1', relType: 'marked_with', source: 'P1', target: 'X1', createdAt: PAST },
|
|
101
|
+
{ id: 'E2', relType: 'marked_with', source: 'P1', target: 'X2', createdAt: PAST },
|
|
102
|
+
{ id: 'E3', relType: 'marked_with', source: 'P2', target: 'X1', createdAt: PAST },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
it('withdraws the primary and the dependents no other published primary asserts', () => {
|
|
106
|
+
const grants = [
|
|
107
|
+
grant('G-P1', 'P1', null),
|
|
108
|
+
grant('G-P2', 'P2', null), // another live primary
|
|
109
|
+
grant('G-X1', 'X1', null),
|
|
110
|
+
grant('G-X2', 'X2', null),
|
|
111
|
+
];
|
|
112
|
+
const plan = unpublishPlan(p1, [x1, x2], edges, grants, { now: NOW });
|
|
113
|
+
expect(plan.primaryGrants.map((g) => g.id)).toEqual(['G-P1']);
|
|
114
|
+
// X1 stays: P2 is still published and still links to it.
|
|
115
|
+
expect(plan.dependentsKept.map((r) => r.id)).toEqual(['X1']);
|
|
116
|
+
expect(plan.dependentsToWithdraw.map((p) => p.record.id)).toEqual(['X2']);
|
|
117
|
+
expect(plan.dependentsToWithdraw[0].grants.map((g) => g.id)).toEqual(['G-X2']);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('withdraws a shared dependent once the OTHER primary is no longer published', () => {
|
|
121
|
+
const grants = [grant('G-P1', 'P1', null), grant('G-X1', 'X1', null), grant('G-X2', 'X2', null)];
|
|
122
|
+
const plan = unpublishPlan(p1, [x1, x2], edges, grants, { now: NOW });
|
|
123
|
+
expect(plan.dependentsKept).toEqual([]);
|
|
124
|
+
expect(plan.dependentsToWithdraw.map((p) => p.record.id)).toEqual(['X1', 'X2']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('never counts the primary being withdrawn as its own justification', () => {
|
|
128
|
+
const onlyOwn: RelationshipEdgeShape[] = [edges[0]];
|
|
129
|
+
const grants = [grant('G-P1', 'P1', null), grant('G-X1', 'X1', null)];
|
|
130
|
+
const plan = unpublishPlan(p1, [x1], onlyOwn, grants, { now: NOW });
|
|
131
|
+
expect(plan.dependentsToWithdraw.map((p) => p.record.id)).toEqual(['X1']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('ignores a dependent that was never published', () => {
|
|
135
|
+
const plan = unpublishPlan(p1, [x1, x2], edges, [grant('G-P1', 'P1', null)], { now: NOW });
|
|
136
|
+
expect(plan.dependentsToWithdraw).toEqual([]);
|
|
137
|
+
expect(plan.dependentsKept).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('dependentPublication', () => {
|
|
142
|
+
const x1 = rec('X1', 'Dependent one');
|
|
143
|
+
const p1 = rec('P1', 'Primary one');
|
|
144
|
+
|
|
145
|
+
it('is not published with no grant', () => {
|
|
146
|
+
expect(dependentPublication(x1, [p1], [], NOW)).toEqual({ kind: 'not-published' });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('names the published primaries it is public THROUGH', () => {
|
|
150
|
+
const grants = [grant('G-X1', 'X1', null), grant('G-P1', 'P1', null)];
|
|
151
|
+
expect(dependentPublication(x1, [p1], grants, NOW)).toEqual({
|
|
152
|
+
kind: 'published', viaPrimaries: [p1],
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('flags a dependent that is public while no published primary links to it', () => {
|
|
157
|
+
// A public claim with nothing behind it — reachable only by publishing a
|
|
158
|
+
// dependent directly, which is why a dependent's screen offers no toggle.
|
|
159
|
+
expect(dependentPublication(x1, [p1], [grant('G-X1', 'X1', null)], NOW)).toEqual({
|
|
160
|
+
kind: 'published-orphan',
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('publishOrder / withdrawOrder', () => {
|
|
166
|
+
// These orderings are a safety property, not a preference: every plan is N
|
|
167
|
+
// untransacted writes, so the only choice is which PARTIAL result is
|
|
168
|
+
// reachable when one of them fails.
|
|
169
|
+
const primary = rec('P1', 'Primary');
|
|
170
|
+
const x1 = rec('X1', 'Dependent one');
|
|
171
|
+
const x2 = rec('X2', 'Dependent two');
|
|
172
|
+
|
|
173
|
+
it('publishes every dependent BEFORE the primary', () => {
|
|
174
|
+
const plan = publishPlan(primary, [x1, x2], [], NOW);
|
|
175
|
+
expect(publishOrder(plan).map((a) => a.entity)).toEqual(['X1', 'X2', 'P1']);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('still ends on the primary when some dependents are already public', () => {
|
|
179
|
+
const plan = publishPlan(primary, [x1, x2], [grant('G', 'X1', null)], NOW);
|
|
180
|
+
const order = publishOrder(plan).map((a) => a.entity);
|
|
181
|
+
expect(order[order.length - 1]).toBe('P1');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('withdraws the primary BEFORE the dependents it was asserting', () => {
|
|
185
|
+
const edges: RelationshipEdgeShape[] = [
|
|
186
|
+
{ id: 'E1', relType: 'marked_with', source: 'P1', target: 'X1', createdAt: PAST },
|
|
187
|
+
];
|
|
188
|
+
const grants = [grant('G-P1', 'P1', null), grant('G-X1', 'X1', null)];
|
|
189
|
+
const plan = unpublishPlan(primary, [x1], edges, grants, { now: NOW });
|
|
190
|
+
expect(withdrawOrder(plan).map((g) => g.id)).toEqual(['G-P1', 'G-X1']);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('a primary that sits at the TARGET end of the cascading edge', () => {
|
|
195
|
+
// The fork this came from always drove publication from the edge's SOURCE.
|
|
196
|
+
// The declared direction is the tenant's choice, not the platform's, so the
|
|
197
|
+
// mirror has to work or the generalisation is a lie.
|
|
198
|
+
const primary = rec('A1', 'Primary at the target end');
|
|
199
|
+
const d1 = rec('D1', 'Shared dependent');
|
|
200
|
+
const edges: RelationshipEdgeShape[] = [
|
|
201
|
+
{ id: 'E1', relType: 'covers', source: 'D1', target: 'A1' },
|
|
202
|
+
{ id: 'E2', relType: 'covers', source: 'D1', target: 'A2' },
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
it('leaves a dependent another live primary still reaches', () => {
|
|
206
|
+
const grants = [
|
|
207
|
+
grant('G-A1', 'A1', null),
|
|
208
|
+
grant('G-A2', 'A2', null),
|
|
209
|
+
grant('G-D1', 'D1', null),
|
|
210
|
+
];
|
|
211
|
+
const plan = unpublishPlan(primary, [d1], edges, grants, {
|
|
212
|
+
primaryRole: 'target',
|
|
213
|
+
now: NOW,
|
|
214
|
+
});
|
|
215
|
+
expect(plan.dependentsKept.map((r) => r.id)).toEqual(['D1']);
|
|
216
|
+
expect(plan.dependentsToWithdraw).toEqual([]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('withdraws it once no other primary at that end is live', () => {
|
|
220
|
+
const grants = [grant('G-A1', 'A1', null), grant('G-D1', 'D1', null)];
|
|
221
|
+
const plan = unpublishPlan(primary, [d1], edges, grants, {
|
|
222
|
+
primaryRole: 'target',
|
|
223
|
+
now: NOW,
|
|
224
|
+
});
|
|
225
|
+
expect(plan.dependentsToWithdraw.map((d) => d.record.id)).toEqual(['D1']);
|
|
226
|
+
});
|
|
227
|
+
});
|