@startsimpli/ui 0.4.104 → 0.4.105
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 +4 -3
- 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,132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasErrors,
|
|
3
|
+
mergeRecordData,
|
|
4
|
+
NAME_FIELD,
|
|
5
|
+
validateRecord,
|
|
6
|
+
} from '../record-validation';
|
|
7
|
+
import { isSystemAttribute } from '../system-attrs';
|
|
8
|
+
import { toCamelKey } from '../types';
|
|
9
|
+
import type { AttributeDef, EntityTypeDef } from '../types';
|
|
10
|
+
|
|
11
|
+
function attr(
|
|
12
|
+
name: string,
|
|
13
|
+
dataType: AttributeDef['dataType'] = 'text',
|
|
14
|
+
required = false,
|
|
15
|
+
): AttributeDef {
|
|
16
|
+
return { id: `id-${name}`, name, dataType, required, config: {} };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The live patent type: `number` required, the rest optional.
|
|
20
|
+
const PATENT: EntityTypeDef = {
|
|
21
|
+
id: 'patent-id',
|
|
22
|
+
key: 'patent',
|
|
23
|
+
label: 'Patent',
|
|
24
|
+
attributes: [
|
|
25
|
+
attr('number', 'text', true),
|
|
26
|
+
attr('title'),
|
|
27
|
+
attr('expiration', 'date'),
|
|
28
|
+
attr('legacy_uuid'),
|
|
29
|
+
attr('notes', 'json'),
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('validateRecord', () => {
|
|
34
|
+
it('accepts a complete record', () => {
|
|
35
|
+
expect(
|
|
36
|
+
validateRecord(PATENT, 'US1', { number: 'US1', title: 'A patent' }),
|
|
37
|
+
).toEqual({});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('honours the red asterisk the form has always drawn', () => {
|
|
41
|
+
const errors = validateRecord(PATENT, 'US1', { number: ' ' });
|
|
42
|
+
expect(errors.number).toBe('number is required.');
|
|
43
|
+
expect(hasErrors(errors)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('reports EVERY problem at once, not the first', () => {
|
|
47
|
+
const errors = validateRecord(PATENT, '', { number: '', notes: '{oops' });
|
|
48
|
+
expect(Object.keys(errors).sort()).toEqual([NAME_FIELD, 'notes', 'number'].sort());
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('requires the record name, which is Entity.name and not the `name` attribute', () => {
|
|
52
|
+
expect(validateRecord(PATENT, ' ', { number: 'US1' })[NAME_FIELD]).toBe(
|
|
53
|
+
'Give this record a name.',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('accepts blank optional JSON but refuses malformed JSON', () => {
|
|
58
|
+
expect(validateRecord(PATENT, 'US1', { number: 'US1', notes: '' })).toEqual({});
|
|
59
|
+
expect(validateRecord(PATENT, 'US1', { number: 'US1', notes: '{' }).notes).toContain(
|
|
60
|
+
'not valid JSON',
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('mergeRecordData', () => {
|
|
66
|
+
const current = {
|
|
67
|
+
number: 'US20230306066A1',
|
|
68
|
+
title: 'Systems and methods for web scraping',
|
|
69
|
+
legacyUuid: '19d1486e-c003-4060-8150-cad5920dca92',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
it('PRESERVES a system-provenance attribute across an edit', () => {
|
|
73
|
+
// The guard renders legacy_uuid read-only but keeps it in `values`. If it
|
|
74
|
+
// were dropped from the form instead, the blank rule below would delete it —
|
|
75
|
+
// silently breaking the /products/<uuid> redirect printed on the article.
|
|
76
|
+
expect(isSystemAttribute('legacy_uuid')).toBe(true);
|
|
77
|
+
const next = mergeRecordData(
|
|
78
|
+
PATENT,
|
|
79
|
+
current,
|
|
80
|
+
{
|
|
81
|
+
number: 'US20230306066A1',
|
|
82
|
+
title: 'A better title',
|
|
83
|
+
legacy_uuid: '19d1486e-c003-4060-8150-cad5920dca92',
|
|
84
|
+
},
|
|
85
|
+
toCamelKey,
|
|
86
|
+
);
|
|
87
|
+
expect(next.legacyUuid).toBe('19d1486e-c003-4060-8150-cad5920dca92');
|
|
88
|
+
expect(next.title).toBe('A better title');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('would have LOST it had the field been dropped from the form — the trap, pinned', () => {
|
|
92
|
+
const next = mergeRecordData(PATENT, current, { number: 'US1', title: 'T' }, toCamelKey);
|
|
93
|
+
expect(next.legacyUuid).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('canonicalizes to camelCase and does not leave both spellings behind', () => {
|
|
97
|
+
const next = mergeRecordData(
|
|
98
|
+
PATENT,
|
|
99
|
+
{ legacy_uuid: 'old' },
|
|
100
|
+
{ number: 'US1', legacy_uuid: 'old' },
|
|
101
|
+
toCamelKey,
|
|
102
|
+
);
|
|
103
|
+
expect(next.legacyUuid).toBe('old');
|
|
104
|
+
expect('legacy_uuid' in next).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('keeps undeclared keys the schema does not know about', () => {
|
|
108
|
+
const next = mergeRecordData(
|
|
109
|
+
PATENT,
|
|
110
|
+
{ number: 'US1', _origin: 'seed' },
|
|
111
|
+
{ number: 'US1' },
|
|
112
|
+
toCamelKey,
|
|
113
|
+
);
|
|
114
|
+
expect(next._origin).toBe('seed');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('isSystemAttribute', () => {
|
|
119
|
+
it('catches the declared provenance names and the conventions around them', () => {
|
|
120
|
+
expect(isSystemAttribute('legacy_uuid')).toBe(true);
|
|
121
|
+
expect(isSystemAttribute('LEGACY_UUID')).toBe(true);
|
|
122
|
+
expect(isSystemAttribute('source_ref')).toBe(true);
|
|
123
|
+
expect(isSystemAttribute('_origin')).toBe(true);
|
|
124
|
+
expect(isSystemAttribute('vendor_uuid')).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('leaves ordinary business fields editable', () => {
|
|
128
|
+
for (const n of ['name', 'number', 'title', 'stock_no', 'code', 'description', 'expiration']) {
|
|
129
|
+
expect(isSystemAttribute(n)).toBe(false);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key transform is a JOIN between this package and every tenant app: the
|
|
3
|
+
* app writes `Entity.data` through its own helper and this module reads it. Two
|
|
4
|
+
* spellings of the same rule produce a blank cell and no error, so the rule is
|
|
5
|
+
* pinned here rather than assumed.
|
|
6
|
+
*/
|
|
7
|
+
import { readRecordValue, recordLabel, sameId, toCamelKey } from '../types';
|
|
8
|
+
|
|
9
|
+
describe('toCamelKey', () => {
|
|
10
|
+
it('camelises a snake_case attribute name', () => {
|
|
11
|
+
expect(toCamelKey('stock_no')).toBe('stockNo');
|
|
12
|
+
expect(toCamelKey('legacy_uuid')).toBe('legacyUuid');
|
|
13
|
+
expect(toCamelKey('country_code_2')).toBe('countryCode2');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('COLLAPSES a run of underscores, the way the api client does', () => {
|
|
17
|
+
// `_` rather than `_+` here would look `foo__bar` up as `foo_Bar`, a key
|
|
18
|
+
// nothing ever wrote.
|
|
19
|
+
expect(toCamelKey('foo__bar')).toBe('fooBar');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('leaves an already-camel name alone', () => {
|
|
23
|
+
expect(toCamelKey('stockNo')).toBe('stockNo');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('readRecordValue', () => {
|
|
28
|
+
it('prefers the camelised key and falls back to the raw one', () => {
|
|
29
|
+
expect(readRecordValue({ stockNo: 'SW4435' }, 'stock_no')).toBe('SW4435');
|
|
30
|
+
expect(readRecordValue({ stock_no: 'SW4435' }, 'stock_no')).toBe('SW4435');
|
|
31
|
+
expect(readRecordValue({}, 'stock_no')).toBeUndefined();
|
|
32
|
+
expect(readRecordValue(undefined, 'stock_no')).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('takes the app own transform when it has one', () => {
|
|
36
|
+
expect(readRecordValue({ STOCK_NO: 'x' }, 'stock_no', (n) => n.toUpperCase())).toBe('x');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('sameId / recordLabel', () => {
|
|
41
|
+
it('compares a UUID string and a fixture number as strings', () => {
|
|
42
|
+
expect(sameId('7', 7)).toBe(true);
|
|
43
|
+
expect(sameId(null, 7)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('falls back name -> external id -> short id', () => {
|
|
47
|
+
expect(recordLabel({ id: 'abcdef012345', name: 'Widget' })).toBe('Widget');
|
|
48
|
+
expect(recordLabel({ id: 'abcdef012345', name: '', externalId: 'SW1' })).toBe('SW1');
|
|
49
|
+
expect(recordLabel({ id: 'abcdef012345' })).toBe('#abcdef01');
|
|
50
|
+
expect(recordLabel(null, 'abcdef012345')).toBe('#abcdef01');
|
|
51
|
+
expect(recordLabel(null)).toBe('Unknown record');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FOUNDRY TENANT UI — the list, link and publication verbs every tenant app
|
|
3
|
+
* needs, and no tenant app should have to rediscover.
|
|
4
|
+
*
|
|
5
|
+
* A SUBPATH AND NOT THE ROOT BARREL, deliberately. Everything here speaks the
|
|
6
|
+
* foundry tenant contract (declared types, attributes, relationships, grants).
|
|
7
|
+
* `org-landing`, `present-web` and the rest of the @startsimpli/ui consumers have
|
|
8
|
+
* no such API and should not pull this in by importing the package.
|
|
9
|
+
*
|
|
10
|
+
* import { linkSidesFor, listParams } from '@startsimpli/ui/foundry'
|
|
11
|
+
*
|
|
12
|
+
* Lifted out of one tenant's fork under CLAUDE.md rule 9 — bd startsim-768w.63.
|
|
13
|
+
*/
|
|
14
|
+
export * from './types';
|
|
15
|
+
export * from './links';
|
|
16
|
+
export * from './list-query';
|
|
17
|
+
export * from './publication';
|
|
18
|
+
export * from './record-validation';
|
|
19
|
+
export * from './system-attrs';
|
|
20
|
+
export { LinkPanel, linkQueryKey } from './LinkPanel';
|
|
21
|
+
export type { LinkPanelApi, LinkPanelProps, LinkPanelPage } from './LinkPanel';
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LINK SIDE — one relationship, read from either end.
|
|
3
|
+
*
|
|
4
|
+
* A tenant's relationships are declared, directional and few: `RelationshipDef`
|
|
5
|
+
* fixes a source type and a target type, and the tenant's `_validate_edge_types`
|
|
6
|
+
* refuses an edge written the other way round. The screen the user is on does
|
|
7
|
+
* NOT change that. So the natural unit is not "the products of a category" or
|
|
8
|
+
* "the patents on a product" — it is a LINK SIDE: "for a record of type X,
|
|
9
|
+
* relationship R reaches records of type Y, and X sits at <end> of the edge".
|
|
10
|
+
*
|
|
11
|
+
* Modelling it that way is what stops the two ends drifting. The alternative —
|
|
12
|
+
* one component per direction — is two fetches and two chances to disagree about
|
|
13
|
+
* what a link is, and the reverse one is the one nobody demos.
|
|
14
|
+
*
|
|
15
|
+
* THE DIRECTION TRAP THIS MODULE EXISTS TO CONTAIN. "Link this patent to a
|
|
16
|
+
* product" must still POST source=<product>, target=<patent>, because that is
|
|
17
|
+
* the direction the def declares. A component that naively used
|
|
18
|
+
* (thisRecord, thatRecord) is wrong exactly half the time. `edgeEndpoints()` is
|
|
19
|
+
* the single place that decision is made.
|
|
20
|
+
*
|
|
21
|
+
* Lifted out of a tenant fork (bd startsim-768w.63); originally bd startsim-16d5.
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
sameId,
|
|
25
|
+
type EntityId,
|
|
26
|
+
type EntityRecordShape,
|
|
27
|
+
type EntityTypeShape,
|
|
28
|
+
type RelationshipDefShape,
|
|
29
|
+
type RelationshipEdgeShape,
|
|
30
|
+
} from './types';
|
|
31
|
+
|
|
32
|
+
export { sameId, recordLabel } from './types';
|
|
33
|
+
|
|
34
|
+
/** Which end of the edge THIS record occupies. */
|
|
35
|
+
export type EdgeRole = 'source' | 'target';
|
|
36
|
+
|
|
37
|
+
export interface LinkSide {
|
|
38
|
+
/** RelationshipDef.key — the `rel_type` on the wire. */
|
|
39
|
+
relType: string;
|
|
40
|
+
/** The end THIS record occupies in the declared edge. */
|
|
41
|
+
role: EdgeRole;
|
|
42
|
+
/** The type key of the records on the OTHER end. */
|
|
43
|
+
farTypeKey: string;
|
|
44
|
+
/** The other type's human label, for the panel heading. */
|
|
45
|
+
farTypeLabel: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every link side a type has, from the org's declared relationships.
|
|
50
|
+
*
|
|
51
|
+
* A self-referential def (source_type === target_type) yields BOTH sides, which
|
|
52
|
+
* is correct and is why this returns a list rather than a map keyed by relType:
|
|
53
|
+
* "translations of this draft" and "the draft this is a translation of" are two
|
|
54
|
+
* different panels over one relationship.
|
|
55
|
+
*/
|
|
56
|
+
export function linkSidesFor(
|
|
57
|
+
type: EntityTypeShape | null | undefined,
|
|
58
|
+
defs: RelationshipDefShape[],
|
|
59
|
+
types: EntityTypeShape[],
|
|
60
|
+
): LinkSide[] {
|
|
61
|
+
if (!type) return [];
|
|
62
|
+
const byId = new Map(types.map((t) => [String(t.id), t]));
|
|
63
|
+
const sides: LinkSide[] = [];
|
|
64
|
+
for (const def of defs) {
|
|
65
|
+
const src = byId.get(String(def.sourceType));
|
|
66
|
+
const tgt = byId.get(String(def.targetType));
|
|
67
|
+
if (!src || !tgt) continue; // a def naming a type this caller cannot read
|
|
68
|
+
if (String(def.sourceType) === String(type.id)) {
|
|
69
|
+
sides.push({ relType: def.key, role: 'source', farTypeKey: tgt.key, farTypeLabel: tgt.label });
|
|
70
|
+
}
|
|
71
|
+
if (String(def.targetType) === String(type.id)) {
|
|
72
|
+
sides.push({ relType: def.key, role: 'target', farTypeKey: src.key, farTypeLabel: src.label });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return sides;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The `related_direction` that returns the FAR records for a side.
|
|
80
|
+
*
|
|
81
|
+
* MEASURED against a live tenant rather than read off the docstring, because
|
|
82
|
+
* `filters._one_hop`'s prose ("direction describes the RECORD's end") is
|
|
83
|
+
* ambiguous about WHICH record and the wrong answer returns an empty list with
|
|
84
|
+
* `applied_filters` still reporting success. The parameter describes the RESULT
|
|
85
|
+
* record's end: so when THIS record is the source, the far record is the target
|
|
86
|
+
* -> 'in'; when this record is the target, the far record is the source -> 'out'.
|
|
87
|
+
*/
|
|
88
|
+
export function farDirection(side: LinkSide): 'in' | 'out' {
|
|
89
|
+
return side.role === 'source' ? 'in' : 'out';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The `related_direction` for listing records OF THIS TYPE that are linked to a
|
|
94
|
+
* named FAR record — "the products in this category", "the patents on this
|
|
95
|
+
* article". The mirror of `farDirection`, and the reason it is its own function
|
|
96
|
+
* rather than a `!` at a call site: the parameter always describes the RESULT
|
|
97
|
+
* record's end of the edge, and here the result is the NEAR record, so the two
|
|
98
|
+
* readings are opposites and swapping them returns an empty list with
|
|
99
|
+
* `applied_filters` still reporting success.
|
|
100
|
+
*/
|
|
101
|
+
export function nearDirection(side: LinkSide): 'in' | 'out' {
|
|
102
|
+
return side.role === 'source' ? 'out' : 'in';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The (source, target) a NEW edge for this side must carry — the declared
|
|
107
|
+
* direction, never the user's viewpoint. See the module note.
|
|
108
|
+
*/
|
|
109
|
+
export function edgeEndpoints(
|
|
110
|
+
side: LinkSide,
|
|
111
|
+
thisId: EntityId,
|
|
112
|
+
farId: EntityId,
|
|
113
|
+
): { relType: string; source: EntityId; target: EntityId } {
|
|
114
|
+
return side.role === 'source'
|
|
115
|
+
? { relType: side.relType, source: thisId, target: farId }
|
|
116
|
+
: { relType: side.relType, source: farId, target: thisId };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The edges from a record's full edge set that belong to one side. */
|
|
120
|
+
export function edgesForSide<E extends RelationshipEdgeShape>(
|
|
121
|
+
edges: E[],
|
|
122
|
+
side: LinkSide,
|
|
123
|
+
thisId: EntityId,
|
|
124
|
+
): E[] {
|
|
125
|
+
return edges.filter(
|
|
126
|
+
(e) =>
|
|
127
|
+
e.relType === side.relType &&
|
|
128
|
+
(side.role === 'source' ? sameId(e.source, thisId) : sameId(e.target, thisId)),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The far end of an edge, given which end this record is on. */
|
|
133
|
+
export function farIdOf(edge: RelationshipEdgeShape, side: LinkSide): EntityId {
|
|
134
|
+
return side.role === 'source' ? edge.target : edge.source;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface LinkRow<
|
|
138
|
+
E extends RelationshipEdgeShape = RelationshipEdgeShape,
|
|
139
|
+
R extends EntityRecordShape = EntityRecordShape,
|
|
140
|
+
> {
|
|
141
|
+
edge: E;
|
|
142
|
+
record: R | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Pair each edge with the far record it points at.
|
|
147
|
+
*
|
|
148
|
+
* BOTH HALVES ARE NEEDED AND NEITHER SUBSTITUTES FOR THE OTHER: the entity
|
|
149
|
+
* query carries the far record's NAME and its attributes, and the edge carries
|
|
150
|
+
* the id that unlinking deletes. A row whose record is null is an edge whose far
|
|
151
|
+
* end this caller cannot read (a per-type or per-row gate) — it is rendered as
|
|
152
|
+
* such rather than dropped, because silently hiding an association would
|
|
153
|
+
* understate what the record is linked to.
|
|
154
|
+
*/
|
|
155
|
+
export function joinLinkRows<E extends RelationshipEdgeShape, R extends EntityRecordShape>(
|
|
156
|
+
edges: E[],
|
|
157
|
+
side: LinkSide,
|
|
158
|
+
farRecords: R[],
|
|
159
|
+
): LinkRow<E, R>[] {
|
|
160
|
+
const byId = new Map(farRecords.map((r) => [String(r.id), r]));
|
|
161
|
+
return edges.map((edge) => ({
|
|
162
|
+
edge,
|
|
163
|
+
record: byId.get(String(farIdOf(edge, side))) ?? null,
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Candidates for a new link: everything of the far type that is not already
|
|
169
|
+
* linked, and never the record itself.
|
|
170
|
+
*
|
|
171
|
+
* ALREADY-LINKED IS EXCLUDED IN THE PICKER because nothing below it enforces
|
|
172
|
+
* cardinality — neither "at most one" nor per-org uniqueness is expressible on
|
|
173
|
+
* the relationship model (bd startsim-768w.16.17), and the tenant accepts a
|
|
174
|
+
* duplicate `(rel_type, source, target)` without complaint. Two identical edges
|
|
175
|
+
* render the same far record twice.
|
|
176
|
+
*/
|
|
177
|
+
export function linkCandidates<R extends EntityRecordShape>(
|
|
178
|
+
pool: R[],
|
|
179
|
+
linkedFarIds: EntityId[],
|
|
180
|
+
selfId: EntityId,
|
|
181
|
+
): R[] {
|
|
182
|
+
const taken = new Set(linkedFarIds.map(String));
|
|
183
|
+
return pool.filter((r) => !taken.has(String(r.id)) && !sameId(r.id, selfId));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Per-record link counts for one page of a table, from one bulk edge read. */
|
|
187
|
+
export function countLinksByRecord(
|
|
188
|
+
edges: RelationshipEdgeShape[],
|
|
189
|
+
side: LinkSide,
|
|
190
|
+
): Map<string, number> {
|
|
191
|
+
const out = new Map<string, number>();
|
|
192
|
+
for (const e of edges) {
|
|
193
|
+
if (e.relType !== side.relType) continue;
|
|
194
|
+
const key = String(side.role === 'source' ? e.source : e.target);
|
|
195
|
+
out.set(key, (out.get(key) ?? 0) + 1);
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The counts a STRANGER would see — the same edges, narrowed by the rule the
|
|
202
|
+
* public read path enforces.
|
|
203
|
+
*
|
|
204
|
+
* THE RESTRICTIVE POLICY GATES AN EDGE ON BOTH ENDS BEING PUBLISHED
|
|
205
|
+
* (tenant-starter apps/core/public_rls.py; the app layer says it again in
|
|
206
|
+
* `PublicEntityDetailSerializer.get_related`). So an admin row reading "3 links"
|
|
207
|
+
* and a public page listing two of them are BOTH correct, and the difference is
|
|
208
|
+
* the thing that must not be silent — on a legal notice the number is the claim.
|
|
209
|
+
*
|
|
210
|
+
* Zero when the record itself is unpublished, because then it has no public page
|
|
211
|
+
* at all — not "3 links that nobody can see".
|
|
212
|
+
*/
|
|
213
|
+
export function countPublicLinksByRecord(
|
|
214
|
+
edges: RelationshipEdgeShape[],
|
|
215
|
+
side: LinkSide,
|
|
216
|
+
publishedIds: Set<string>,
|
|
217
|
+
): Map<string, number> {
|
|
218
|
+
const out = new Map<string, number>();
|
|
219
|
+
for (const e of edges) {
|
|
220
|
+
if (e.relType !== side.relType) continue;
|
|
221
|
+
const nearId = String(side.role === 'source' ? e.source : e.target);
|
|
222
|
+
const farId = String(side.role === 'source' ? e.target : e.source);
|
|
223
|
+
if (!publishedIds.has(nearId) || !publishedIds.has(farId)) continue;
|
|
224
|
+
out.set(nearId, (out.get(nearId) ?? 0) + 1);
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LIST QUERY — search, sort, per-column filters, as pure functions.
|
|
3
|
+
*
|
|
4
|
+
* A tenant's list endpoint serves `?search=`, `?order=attr.<name>` and the full
|
|
5
|
+
* `?attr.<name>[__op]=` operator set, so every one of those verbs belongs on the
|
|
6
|
+
* SERVER. Doing them in the browser is only correct while the whole corpus fits
|
|
7
|
+
* in one page, and it stops being correct silently.
|
|
8
|
+
*
|
|
9
|
+
* MEASURED AGAINST A LIVE TENANT, not read off a docstring, because every
|
|
10
|
+
* mistake in this file is SILENT:
|
|
11
|
+
*
|
|
12
|
+
* ?order=attr.<declared> 200, `applied_filters: ["order"]`, really ordered
|
|
13
|
+
* ?order=attr.<undeclared> 200, `applied_filters: ["order"]`, ORDERED BY NOTHING
|
|
14
|
+
* ?order=name 400 {"order": "expected recent, oldest, relevance,
|
|
15
|
+
* or attr.<name>"}
|
|
16
|
+
* ?order=recent / oldest created_at desc / asc
|
|
17
|
+
* ?attr.<date>__icontains= 400 "must be an ISO-8601 date or timestamp"
|
|
18
|
+
*
|
|
19
|
+
* So a sort arrow that moves while the rows do not is one typo away at all
|
|
20
|
+
* times, and the only defence is that the column -> attribute mapping is derived
|
|
21
|
+
* from the SAME `type.attributes` list the columns were built from.
|
|
22
|
+
*
|
|
23
|
+
* THE ID MAPPING IS THE TRAP. A column builder must namespace an attribute whose
|
|
24
|
+
* name collides with a framing column — a type that declares an attribute
|
|
25
|
+
* literally called `name` needs the id `attr_name`, because the id `name`
|
|
26
|
+
* belongs to the framing column that renders `Entity.name`. `order=attr.attr_name`
|
|
27
|
+
* would be accepted and sort nothing.
|
|
28
|
+
*
|
|
29
|
+
* Lifted out of a tenant fork (bd startsim-768w.63); originally bd startsim-4ph9d.
|
|
30
|
+
*/
|
|
31
|
+
import type { AttributeShape } from './types';
|
|
32
|
+
|
|
33
|
+
/** The leading column, rendering `Entity.name`. */
|
|
34
|
+
export const NAME_COLUMN_ID = 'name';
|
|
35
|
+
/** The trailing column, rendering `Entity.created_at`. */
|
|
36
|
+
export const CREATED_COLUMN_ID = 'createdAt';
|
|
37
|
+
/** Prefix a column builder uses when an attribute name collides with a framing id. */
|
|
38
|
+
export const ATTR_COLUMN_PREFIX = 'attr_';
|
|
39
|
+
|
|
40
|
+
const RESERVED_COLUMN_IDS = new Set([NAME_COLUMN_ID, CREATED_COLUMN_ID]);
|
|
41
|
+
|
|
42
|
+
/** The column id an attribute gets. One definition, two callers, so the table
|
|
43
|
+
* and the query can never disagree about a name. */
|
|
44
|
+
export function columnIdForAttribute(attr: AttributeShape): string {
|
|
45
|
+
return RESERVED_COLUMN_IDS.has(attr.name) ? `${ATTR_COLUMN_PREFIX}${attr.name}` : attr.name;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The declared attribute a column renders, or null for a framing/derived column.
|
|
50
|
+
*
|
|
51
|
+
* INVERTS `columnIdForAttribute` rather than re-deriving the id rule, so the two
|
|
52
|
+
* cannot drift — and so an attribute whose own name happens to start with
|
|
53
|
+
* `attr_` maps to itself instead of being mistaken for a namespaced one.
|
|
54
|
+
*/
|
|
55
|
+
export function attributeForColumn<A extends AttributeShape>(
|
|
56
|
+
columnId: string,
|
|
57
|
+
attributes: A[],
|
|
58
|
+
): A | null {
|
|
59
|
+
if (RESERVED_COLUMN_IDS.has(columnId)) return null;
|
|
60
|
+
return attributes.find((a) => columnIdForAttribute(a) === columnId) ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type SortDirection = 'asc' | 'desc';
|
|
64
|
+
|
|
65
|
+
export interface ListSort {
|
|
66
|
+
columnId: string;
|
|
67
|
+
direction: SortDirection;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Can the SERVER order by this column?
|
|
72
|
+
*
|
|
73
|
+
* `Entity.name` cannot be ordered (`?order=name` is a 400), which is why a type
|
|
74
|
+
* that wants a sortable primary column declares its own attribute for it. The
|
|
75
|
+
* leading Name column is therefore NOT sortable and should say so — a sort
|
|
76
|
+
* control that silently orders by a different value than the one in the cell is
|
|
77
|
+
* the failure this whole module is shaped to avoid. Association-count and any
|
|
78
|
+
* other derived column: same answer, no server ordering exists for a COUNT.
|
|
79
|
+
*/
|
|
80
|
+
export function isSortableColumn(columnId: string, attributes: AttributeShape[]): boolean {
|
|
81
|
+
if (columnId === CREATED_COLUMN_ID) return true;
|
|
82
|
+
return attributeForColumn(columnId, attributes) !== null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The `?order=` value for a sort, or null when the column cannot be ordered. */
|
|
86
|
+
export function orderParam(
|
|
87
|
+
sort: ListSort | null | undefined,
|
|
88
|
+
attributes: AttributeShape[],
|
|
89
|
+
): string | null {
|
|
90
|
+
if (!sort) return null;
|
|
91
|
+
if (sort.columnId === CREATED_COLUMN_ID) return sort.direction === 'desc' ? 'recent' : 'oldest';
|
|
92
|
+
const attr = attributeForColumn(sort.columnId, attributes);
|
|
93
|
+
if (!attr) return null;
|
|
94
|
+
return `${sort.direction === 'desc' ? '-' : ''}attr.${attr.name}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A per-column filter the table can offer. `kind` decides both the widget and
|
|
99
|
+
* the operator, because the two are not independent: `__icontains` on a date
|
|
100
|
+
* attribute is a 400, not an empty result.
|
|
101
|
+
*/
|
|
102
|
+
export interface ColumnFilterDef {
|
|
103
|
+
/** Filter id === the table column id it sits under. */
|
|
104
|
+
id: string;
|
|
105
|
+
label: string;
|
|
106
|
+
kind: 'text' | 'date-window';
|
|
107
|
+
attrName: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Value the dropdown/select uses for "no filter" (UnifiedTable's own sentinel). */
|
|
111
|
+
export const ALL_VALUE = 'all';
|
|
112
|
+
|
|
113
|
+
/** Date-window choices, expressed as an operator against today. */
|
|
114
|
+
export const DATE_WINDOWS = {
|
|
115
|
+
past: 'Before today',
|
|
116
|
+
future: 'Today or later',
|
|
117
|
+
} as const;
|
|
118
|
+
export type DateWindow = keyof typeof DATE_WINDOWS;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The filters a type offers, from its declared attributes.
|
|
122
|
+
*
|
|
123
|
+
* `longtext` is filterable the same way `text` is (both land in `value_text`),
|
|
124
|
+
* but a `json` or `boolean` attribute gets nothing: neither has a widget here
|
|
125
|
+
* worth the ambiguity, and a filter box that quietly matches nothing is the
|
|
126
|
+
* thing `ignored_filters` exists to prevent.
|
|
127
|
+
*/
|
|
128
|
+
export function columnFiltersFor(attributes: AttributeShape[]): ColumnFilterDef[] {
|
|
129
|
+
const out: ColumnFilterDef[] = [];
|
|
130
|
+
for (const attr of attributes) {
|
|
131
|
+
const id = columnIdForAttribute(attr);
|
|
132
|
+
if (attr.dataType === 'text' || attr.dataType === 'longtext') {
|
|
133
|
+
out.push({ id, label: attr.name.replace(/_/g, ' '), kind: 'text', attrName: attr.name });
|
|
134
|
+
} else if (attr.dataType === 'date') {
|
|
135
|
+
out.push({
|
|
136
|
+
id,
|
|
137
|
+
label: attr.name.replace(/_/g, ' '),
|
|
138
|
+
kind: 'date-window',
|
|
139
|
+
attrName: attr.name,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Labels for a dropdown of far records, DISAMBIGUATED.
|
|
148
|
+
*
|
|
149
|
+
* `FilterItem.options` is a `string[]` and the widget reports back the option it
|
|
150
|
+
* rendered, so the label has to identify the record uniquely. `name` is usually
|
|
151
|
+
* free text and two records may share one; two identical options would collapse
|
|
152
|
+
* into a single entry that filters by whichever id the map happened to keep.
|
|
153
|
+
* Repeats get a short id suffix; unique names are left alone.
|
|
154
|
+
*/
|
|
155
|
+
export function farOptionLabels(
|
|
156
|
+
records: { id: string | number; name: string }[],
|
|
157
|
+
): Map<string, string> {
|
|
158
|
+
const counts = new Map<string, number>();
|
|
159
|
+
for (const r of records) {
|
|
160
|
+
const base = r.name || String(r.id);
|
|
161
|
+
counts.set(base, (counts.get(base) ?? 0) + 1);
|
|
162
|
+
}
|
|
163
|
+
const out = new Map<string, string>();
|
|
164
|
+
for (const r of records) {
|
|
165
|
+
const base = r.name || String(r.id);
|
|
166
|
+
const label =
|
|
167
|
+
(counts.get(base) ?? 0) > 1 ? `${base} (#${String(r.id).slice(0, 8)})` : base;
|
|
168
|
+
out.set(label, String(r.id));
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A link-side filter: "products IN this category", "patents ON this article". */
|
|
174
|
+
export interface RelatedFilter {
|
|
175
|
+
/** The far record's id. */
|
|
176
|
+
relatedTo: string;
|
|
177
|
+
/** RelationshipDef.key. */
|
|
178
|
+
via: string;
|
|
179
|
+
/** The end THIS type's records occupy — see links.nearDirection. */
|
|
180
|
+
direction: 'in' | 'out';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface ListQuery {
|
|
184
|
+
type: string;
|
|
185
|
+
page: number;
|
|
186
|
+
search?: string;
|
|
187
|
+
sort?: ListSort | null;
|
|
188
|
+
/** columnId -> raw filter value (already stripped of the 'all' sentinel). */
|
|
189
|
+
filters?: Record<string, string>;
|
|
190
|
+
related?: RelatedFilter | null;
|
|
191
|
+
pageSize?: number;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The querystring for one page of a list.
|
|
196
|
+
*
|
|
197
|
+
* RAW SERVER NAMES, deliberately. `@startsimpli/api` camel->snake converts
|
|
198
|
+
* request BODIES and not `params` (its url-builder appends keys verbatim), and
|
|
199
|
+
* `attr.number__icontains` could not survive that transform anyway — there is no
|
|
200
|
+
* camelCase spelling of a double underscore. A mistyped key here does not error:
|
|
201
|
+
* the response reports it under `ignored_filters` and returns the UNFILTERED
|
|
202
|
+
* page, which reads as "everything matches".
|
|
203
|
+
*/
|
|
204
|
+
export function listParams(
|
|
205
|
+
query: ListQuery,
|
|
206
|
+
attributes: AttributeShape[],
|
|
207
|
+
today: Date = new Date(),
|
|
208
|
+
): Record<string, string | number> {
|
|
209
|
+
const params: Record<string, string | number> = { type: query.type, page: query.page };
|
|
210
|
+
if (query.pageSize) params.page_size = query.pageSize;
|
|
211
|
+
|
|
212
|
+
const search = (query.search ?? '').trim();
|
|
213
|
+
if (search) params.search = search;
|
|
214
|
+
|
|
215
|
+
const order = orderParam(query.sort, attributes);
|
|
216
|
+
if (order) params.order = order;
|
|
217
|
+
|
|
218
|
+
const defs = new Map(columnFiltersFor(attributes).map((d) => [d.id, d]));
|
|
219
|
+
for (const [columnId, raw] of Object.entries(query.filters ?? {})) {
|
|
220
|
+
const value = String(raw ?? '').trim();
|
|
221
|
+
if (!value || value === ALL_VALUE) continue;
|
|
222
|
+
const def = defs.get(columnId);
|
|
223
|
+
if (!def) continue; // a filter for a column this type does not declare
|
|
224
|
+
if (def.kind === 'text') {
|
|
225
|
+
params[`attr.${def.attrName}__icontains`] = value;
|
|
226
|
+
} else if (value === 'past' || value === 'future') {
|
|
227
|
+
// Dates are compared as normalized ISO text server-side, so the bound has
|
|
228
|
+
// to be a plain YYYY-MM-DD. `lt today` / `gte today` splits a date column
|
|
229
|
+
// into "already past" and "still running" with no third state.
|
|
230
|
+
const iso = today.toISOString().slice(0, 10);
|
|
231
|
+
params[`attr.${def.attrName}__${value === 'past' ? 'lt' : 'gte'}`] = iso;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (query.related) {
|
|
236
|
+
params.related_to = query.related.relatedTo;
|
|
237
|
+
params.related_via = query.related.via;
|
|
238
|
+
params.related_direction = query.related.direction;
|
|
239
|
+
}
|
|
240
|
+
return params;
|
|
241
|
+
}
|