@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,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PUBLICATION, as pure logic (the mechanism is tenant-starter
|
|
3
|
+
* apps/core/public_rls.py).
|
|
4
|
+
*
|
|
5
|
+
* WHAT PUBLISHING IS. A `Grant(subject_type='public')` on a row. There is no
|
|
6
|
+
* published column and there must not be one: the anonymous read path switches
|
|
7
|
+
* into a read-only Postgres role whose RESTRICTIVE policy is
|
|
8
|
+
*
|
|
9
|
+
* id IN (SELECT entity_id FROM core_grant
|
|
10
|
+
* WHERE org_id = current_org AND subject_type = 'public'
|
|
11
|
+
* AND (expires_at IS NULL OR expires_at > now()))
|
|
12
|
+
*
|
|
13
|
+
* evaluated LIVE on every query. So UNPUBLISHING IS STAMPING `expires_at`, not
|
|
14
|
+
* deleting the row — "an expired share is a fact worth keeping" (core/models.py)
|
|
15
|
+
* — and there is no window in which a withdrawn row keeps serving.
|
|
16
|
+
*
|
|
17
|
+
* WHY PUBLICATION CASCADES ALONG ONE RELATIONSHIP. The same policy gates
|
|
18
|
+
* `core_relationship` on BOTH ENDS being published. A published record whose
|
|
19
|
+
* linked records are not published therefore renders as a page that lists
|
|
20
|
+
* NOTHING — which, on a page whose whole purpose is to state what a record is
|
|
21
|
+
* linked to, is not an empty list: it is an affirmative public statement that
|
|
22
|
+
* there is nothing to list. That is reachable by publishing one row at a time,
|
|
23
|
+
* so the unit of publication here is the record TOGETHER WITH its dependents.
|
|
24
|
+
*
|
|
25
|
+
* AND WHY WITHDRAWAL IS NOT SIMPLY THE INVERSE. A dependent can be linked from
|
|
26
|
+
* several primaries. Withdrawing one must not withdraw a dependent that another
|
|
27
|
+
* still-published primary asserts — so `unpublishPlan` subtracts the dependents
|
|
28
|
+
* still reachable from some OTHER live primary, and names both lists so the
|
|
29
|
+
* operator sees what stays public.
|
|
30
|
+
*
|
|
31
|
+
* PRIMARY AND DEPENDENT, NOT THE FORK'S NOUNS. This shipped inside one tenant as
|
|
32
|
+
* `article`/`patents`; the mechanism is the tenant platform's, not that tenant's,
|
|
33
|
+
* so the names here describe the ROLES — the record publication is driven from,
|
|
34
|
+
* and the records that must follow it to be visible at all.
|
|
35
|
+
*
|
|
36
|
+
* Lifted out of a tenant fork (bd startsim-768w.63); originally bd startsim-16d5.
|
|
37
|
+
*/
|
|
38
|
+
import type { EdgeRole } from './links';
|
|
39
|
+
import {
|
|
40
|
+
PUBLIC_SUBJECT,
|
|
41
|
+
sameId,
|
|
42
|
+
type EntityId,
|
|
43
|
+
type EntityRecordShape,
|
|
44
|
+
type GrantShape,
|
|
45
|
+
type RelationshipEdgeShape,
|
|
46
|
+
} from './types';
|
|
47
|
+
|
|
48
|
+
/** Is this grant a live publication right now? `expires_at` is compared the same
|
|
49
|
+
* way the RLS predicate compares it — NULL means "until revoked". */
|
|
50
|
+
export function isLivePublic(grant: GrantShape, now: number = Date.now()): boolean {
|
|
51
|
+
if (grant.subjectType !== PUBLIC_SUBJECT) return false;
|
|
52
|
+
if (!grant.expiresAt) return true;
|
|
53
|
+
const t = new Date(grant.expiresAt).getTime();
|
|
54
|
+
return Number.isNaN(t) ? true : t > now;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every public grant on a row, live or lapsed. Order is not assumed — callers
|
|
58
|
+
* pick by liveness. */
|
|
59
|
+
export function publicGrantsFor<G extends GrantShape>(grants: G[], entity: EntityId): G[] {
|
|
60
|
+
return grants.filter((g) => g.subjectType === PUBLIC_SUBJECT && sameId(g.entity, entity));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The grant that is publishing this row right now, if any. */
|
|
64
|
+
export function livePublicGrant<G extends GrantShape>(
|
|
65
|
+
grants: G[],
|
|
66
|
+
entity: EntityId,
|
|
67
|
+
now: number = Date.now(),
|
|
68
|
+
): G | null {
|
|
69
|
+
return publicGrantsFor(grants, entity).find((g) => isLivePublic(g, now)) ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isPublished(
|
|
73
|
+
grants: GrantShape[],
|
|
74
|
+
entity: EntityId,
|
|
75
|
+
now: number = Date.now(),
|
|
76
|
+
): boolean {
|
|
77
|
+
return livePublicGrant(grants, entity, now) !== null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The set of entity ids currently published. */
|
|
81
|
+
export function publishedIds(grants: GrantShape[], now: number = Date.now()): Set<string> {
|
|
82
|
+
const out = new Set<string>();
|
|
83
|
+
for (const g of grants) if (isLivePublic(g, now)) out.add(String(g.entity));
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One row's part in a plan: create a fresh grant, or revive a lapsed one. */
|
|
88
|
+
export interface PublishAction<G extends GrantShape = GrantShape> {
|
|
89
|
+
entity: EntityId;
|
|
90
|
+
label: string;
|
|
91
|
+
/** A lapsed public grant to clear the expiry on, rather than minting a second
|
|
92
|
+
* row that says the same thing. */
|
|
93
|
+
reinstate: G | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface PublishPlan<
|
|
97
|
+
R extends EntityRecordShape = EntityRecordShape,
|
|
98
|
+
G extends GrantShape = GrantShape,
|
|
99
|
+
> {
|
|
100
|
+
/** The driving record itself, when it is not already published. */
|
|
101
|
+
primary: PublishAction<G> | null;
|
|
102
|
+
/** Linked records that must become public for the primary's list to render. */
|
|
103
|
+
dependents: PublishAction<G>[];
|
|
104
|
+
/** Dependents already public — shown so a dialog states the whole outcome. */
|
|
105
|
+
alreadyPublic: R[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function actionFor<G extends GrantShape>(
|
|
109
|
+
record: EntityRecordShape,
|
|
110
|
+
grants: G[],
|
|
111
|
+
now: number,
|
|
112
|
+
): PublishAction<G> | null {
|
|
113
|
+
if (isPublished(grants, record.id, now)) return null;
|
|
114
|
+
const lapsed = publicGrantsFor(grants, record.id).find((g) => !isLivePublic(g, now)) ?? null;
|
|
115
|
+
return { entity: record.id, label: record.name || String(record.id), reinstate: lapsed };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What publishing this record makes public. Returns the whole outcome — what
|
|
120
|
+
* changes AND what was already public — because a confirm dialog's job is to
|
|
121
|
+
* state the resulting public page, not the diff.
|
|
122
|
+
*/
|
|
123
|
+
export function publishPlan<R extends EntityRecordShape, G extends GrantShape>(
|
|
124
|
+
primary: R,
|
|
125
|
+
dependents: R[],
|
|
126
|
+
grants: G[],
|
|
127
|
+
now: number = Date.now(),
|
|
128
|
+
): PublishPlan<R, G> {
|
|
129
|
+
const toPublish: PublishAction<G>[] = [];
|
|
130
|
+
const alreadyPublic: R[] = [];
|
|
131
|
+
for (const d of dependents) {
|
|
132
|
+
const action = actionFor(d, grants, now);
|
|
133
|
+
if (action) toPublish.push(action);
|
|
134
|
+
else alreadyPublic.push(d);
|
|
135
|
+
}
|
|
136
|
+
return { primary: actionFor(primary, grants, now), dependents: toPublish, alreadyPublic };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface UnpublishPlan<
|
|
140
|
+
R extends EntityRecordShape = EntityRecordShape,
|
|
141
|
+
G extends GrantShape = GrantShape,
|
|
142
|
+
> {
|
|
143
|
+
/** The live grants on the primary to stamp with an expiry. */
|
|
144
|
+
primaryGrants: G[];
|
|
145
|
+
/** Dependents no other published primary reaches: withdrawn with it. */
|
|
146
|
+
dependentsToWithdraw: { record: R; grants: G[] }[];
|
|
147
|
+
/** Dependents another published primary still asserts: left public, and named. */
|
|
148
|
+
dependentsKept: R[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* What withdrawing this record withdraws.
|
|
153
|
+
*
|
|
154
|
+
* `allEdges` is every edge of the cascading relationship in the org; the
|
|
155
|
+
* primary's own edges are subtracted here rather than by the caller, so "which
|
|
156
|
+
* dependents does some OTHER live primary still assert" is computed in one place
|
|
157
|
+
* and cannot be got subtly wrong on a screen that only knows about one record.
|
|
158
|
+
*
|
|
159
|
+
* `primaryRole` is which end of the declared edge the PRIMARY occupies — see
|
|
160
|
+
* links.LinkSide. It defaults to 'source' because that is how a cascade normally
|
|
161
|
+
* reads ("this record is marked with those"), but a fork whose driving record
|
|
162
|
+
* sits at the target end gets the mirror by passing it.
|
|
163
|
+
*/
|
|
164
|
+
export function unpublishPlan<R extends EntityRecordShape, G extends GrantShape>(
|
|
165
|
+
primary: R,
|
|
166
|
+
dependents: R[],
|
|
167
|
+
allEdges: RelationshipEdgeShape[],
|
|
168
|
+
grants: G[],
|
|
169
|
+
options: { primaryRole?: EdgeRole; now?: number } = {},
|
|
170
|
+
): UnpublishPlan<R, G> {
|
|
171
|
+
const { primaryRole = 'source', now = Date.now() } = options;
|
|
172
|
+
const nearOf = (e: RelationshipEdgeShape) => (primaryRole === 'source' ? e.source : e.target);
|
|
173
|
+
const farOf = (e: RelationshipEdgeShape) => (primaryRole === 'source' ? e.target : e.source);
|
|
174
|
+
|
|
175
|
+
const live = publishedIds(grants, now);
|
|
176
|
+
// Primaries that stay published once this one goes.
|
|
177
|
+
const stillAsserted = new Set<string>();
|
|
178
|
+
for (const e of allEdges) {
|
|
179
|
+
if (sameId(nearOf(e), primary.id)) continue;
|
|
180
|
+
if (live.has(String(nearOf(e)))) stillAsserted.add(String(farOf(e)));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const dependentsToWithdraw: UnpublishPlan<R, G>['dependentsToWithdraw'] = [];
|
|
184
|
+
const dependentsKept: R[] = [];
|
|
185
|
+
for (const d of dependents) {
|
|
186
|
+
if (!isPublished(grants, d.id, now)) continue;
|
|
187
|
+
if (stillAsserted.has(String(d.id))) {
|
|
188
|
+
dependentsKept.push(d);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
dependentsToWithdraw.push({
|
|
192
|
+
record: d,
|
|
193
|
+
grants: publicGrantsFor(grants, d.id).filter((g) => isLivePublic(g, now)),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
primaryGrants: publicGrantsFor(grants, primary.id).filter((g) => isLivePublic(g, now)),
|
|
199
|
+
dependentsToWithdraw,
|
|
200
|
+
dependentsKept,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Why a DEPENDENT is public, in its own words. Publication is driven from the
|
|
206
|
+
* primary, so a dependent's screen REPORTS rather than offers a toggle — a
|
|
207
|
+
* dependent published on its own asserts nothing, and letting somebody make one
|
|
208
|
+
* is how the two ends drift apart.
|
|
209
|
+
*/
|
|
210
|
+
export type DependentPublicationState<R extends EntityRecordShape = EntityRecordShape> =
|
|
211
|
+
| { kind: 'not-published' }
|
|
212
|
+
| { kind: 'published'; viaPrimaries: R[] }
|
|
213
|
+
| { kind: 'published-orphan' };
|
|
214
|
+
|
|
215
|
+
export function dependentPublication<R extends EntityRecordShape>(
|
|
216
|
+
dependent: EntityRecordShape,
|
|
217
|
+
primaries: R[],
|
|
218
|
+
grants: GrantShape[],
|
|
219
|
+
now: number = Date.now(),
|
|
220
|
+
): DependentPublicationState<R> {
|
|
221
|
+
if (!isPublished(grants, dependent.id, now)) return { kind: 'not-published' };
|
|
222
|
+
const via = primaries.filter((p) => isPublished(grants, p.id, now));
|
|
223
|
+
return via.length > 0 ? { kind: 'published', viaPrimaries: via } : { kind: 'published-orphan' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* THE ORDER THE WRITES MUST GO IN, and it is a safety property rather than a
|
|
228
|
+
* preference — so it lives here, next to the invariant it protects, and is
|
|
229
|
+
* pinned by a test rather than left as a loop in a component.
|
|
230
|
+
*
|
|
231
|
+
* A plan is N separate HTTP writes with no transaction behind them. Whatever
|
|
232
|
+
* order they run in, a 403 or a dropped connection part-way through leaves a
|
|
233
|
+
* PARTIAL result — so the only real choice is WHICH partial result is reachable.
|
|
234
|
+
*
|
|
235
|
+
* dependents, then the primary: the failure state is "dependents are public,
|
|
236
|
+
* nothing asserts them". Discloses nothing false, and is visible on a
|
|
237
|
+
* dependent's own screen as `published-orphan`.
|
|
238
|
+
*
|
|
239
|
+
* the primary, then dependents: the failure state is "a published record whose
|
|
240
|
+
* linked records are not public", which the public policy renders as a page
|
|
241
|
+
* linked to NOTHING — an affirmative public claim. That is the harm this
|
|
242
|
+
* module exists to make unreachable.
|
|
243
|
+
*/
|
|
244
|
+
export function publishOrder<G extends GrantShape>(
|
|
245
|
+
plan: PublishPlan<EntityRecordShape, G>,
|
|
246
|
+
): PublishAction<G>[] {
|
|
247
|
+
return [...plan.dependents, ...(plan.primary ? [plan.primary] : [])];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** One grant to expire, in the order withdrawal must take: the PRIMARY first,
|
|
251
|
+
* the mirror of publishOrder — a partial withdrawal must never strip a live
|
|
252
|
+
* record's dependents while the record itself is still being served. */
|
|
253
|
+
export function withdrawOrder<G extends GrantShape>(
|
|
254
|
+
plan: UnpublishPlan<EntityRecordShape, G>,
|
|
255
|
+
): G[] {
|
|
256
|
+
return [
|
|
257
|
+
...plan.primaryGrants,
|
|
258
|
+
...plan.dependentsToWithdraw.flatMap((d) => d.grants),
|
|
259
|
+
];
|
|
260
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REQUIRED-FIELD VALIDATION for a generic record form.
|
|
3
|
+
*
|
|
4
|
+
* A tenant's `AttributeDef.required` is declared but nothing in a generic form
|
|
5
|
+
* honours it by default — so a missing value surfaces as a raw server-error
|
|
6
|
+
* toast AFTER submit, next to a form that had already drawn a red asterisk on
|
|
7
|
+
* the field it wanted. This restores the capability FROM THE DECLARED SCHEMA
|
|
8
|
+
* rather than from a hand-written schema per type.
|
|
9
|
+
*
|
|
10
|
+
* PURE, so the rule is tested without a browser and shared by BOTH writers: the
|
|
11
|
+
* create form and an edit mode. Checking it in the create form only means
|
|
12
|
+
* blanking a required field on an existing record is free.
|
|
13
|
+
*
|
|
14
|
+
* Lifted out of a tenant fork (bd startsim-768w.63); originally bd startsim-4ph9d.
|
|
15
|
+
*/
|
|
16
|
+
import { toCamelKey, type EntityTypeShape } from './types';
|
|
17
|
+
|
|
18
|
+
/** Key used for the record's own `Entity.name`, which is not a declared attribute. */
|
|
19
|
+
export const NAME_FIELD = '__name__';
|
|
20
|
+
|
|
21
|
+
/** attribute name (or NAME_FIELD) -> message. Empty object means valid. */
|
|
22
|
+
export type FieldErrors = Record<string, string>;
|
|
23
|
+
|
|
24
|
+
function isBlank(value: unknown): boolean {
|
|
25
|
+
if (value === undefined || value === null) return true;
|
|
26
|
+
if (typeof value === 'string') return value.trim() === '';
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every problem with a submission, at once — not the first one. A form that
|
|
32
|
+
* reports one error per attempt makes a four-field type a four-round trip.
|
|
33
|
+
*/
|
|
34
|
+
export function validateRecord(
|
|
35
|
+
type: EntityTypeShape,
|
|
36
|
+
name: string,
|
|
37
|
+
values: Record<string, unknown>,
|
|
38
|
+
): FieldErrors {
|
|
39
|
+
const errors: FieldErrors = {};
|
|
40
|
+
if (isBlank(name)) errors[NAME_FIELD] = 'Give this record a name.';
|
|
41
|
+
|
|
42
|
+
for (const attr of type.attributes ?? []) {
|
|
43
|
+
const value = values[attr.name];
|
|
44
|
+
if (attr.required && isBlank(value)) {
|
|
45
|
+
errors[attr.name] = `${attr.name.replace(/_/g, ' ')} is required.`;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (attr.dataType === 'json' && !isBlank(value) && typeof value === 'string') {
|
|
49
|
+
try {
|
|
50
|
+
JSON.parse(value);
|
|
51
|
+
} catch {
|
|
52
|
+
errors[attr.name] = `${attr.name.replace(/_/g, ' ')} is not valid JSON.`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return errors;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function hasErrors(errors: FieldErrors): boolean {
|
|
60
|
+
return Object.keys(errors).length > 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* THE `data` BLOB AN EDIT WRITES BACK.
|
|
65
|
+
*
|
|
66
|
+
* Extracted from the drawer so the one rule that can silently delete a field is
|
|
67
|
+
* testable without a browser: the backend PATCH REPLACES `data` (it does not
|
|
68
|
+
* deep-merge), so this returns the FULL blob and every key it fails to carry is
|
|
69
|
+
* a field deleted from the record.
|
|
70
|
+
*
|
|
71
|
+
* THE TRAP IS THAT A GUARD CAN CAUSE THE HARM. A provenance attribute renders
|
|
72
|
+
* read-only (see ./system-attrs), and the obvious way to protect it — leave it
|
|
73
|
+
* out of the form — drops it out of `values`, at which point the blank-key rule
|
|
74
|
+
* below DELETES it on the next save. So it must stay in `values`, unchanged, and
|
|
75
|
+
* come through here like any other attribute.
|
|
76
|
+
*
|
|
77
|
+
* KEYS ARE CANONICALIZED TO camelCase, matching the @startsimpli/api client's
|
|
78
|
+
* response transform, so a round trip does not end up with both `legacy_uuid`
|
|
79
|
+
* and `legacyUuid` in one blob.
|
|
80
|
+
*/
|
|
81
|
+
export function mergeRecordData(
|
|
82
|
+
type: EntityTypeShape,
|
|
83
|
+
current: Record<string, unknown>,
|
|
84
|
+
values: Record<string, unknown>,
|
|
85
|
+
toCamel: (name: string) => string = toCamelKey,
|
|
86
|
+
): Record<string, unknown> {
|
|
87
|
+
const next: Record<string, unknown> = { ...current };
|
|
88
|
+
for (const attr of type.attributes ?? []) {
|
|
89
|
+
const camel = toCamel(attr.name);
|
|
90
|
+
let val = values[attr.name];
|
|
91
|
+
if (attr.dataType === 'json') {
|
|
92
|
+
const s = String(val ?? '').trim();
|
|
93
|
+
if (!s) {
|
|
94
|
+
delete next[camel];
|
|
95
|
+
delete next[attr.name];
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
val = JSON.parse(s);
|
|
99
|
+
}
|
|
100
|
+
if (val === '' || val === undefined || val === null) {
|
|
101
|
+
delete next[camel];
|
|
102
|
+
delete next[attr.name];
|
|
103
|
+
} else {
|
|
104
|
+
next[camel] = val;
|
|
105
|
+
if (camel !== attr.name) delete next[attr.name];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return next;
|
|
109
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SYSTEM-PROVENANCE ATTRIBUTES — declared like any other, editable by nobody.
|
|
3
|
+
*
|
|
4
|
+
* A tenant's `legacy_uuid` (or `external_id`, or `source_ref`) is a plain `text`
|
|
5
|
+
* attribute in the schema, so a generic record form renders it as an ordinary
|
|
6
|
+
* input. It is also, routinely, the key an inbound URL resolves on: a legacy
|
|
7
|
+
* address of the form `<host>/products/<uuid>` redirects by finding the row
|
|
8
|
+
* whose `legacy_uuid` matches. Those strings are already printed on articles in
|
|
9
|
+
* the field, so an operator who edits or blanks the field breaks the redirect
|
|
10
|
+
* for goods nobody can recall — silently, from a text box that looks like every
|
|
11
|
+
* other text box.
|
|
12
|
+
*
|
|
13
|
+
* A NAME CONVENTION, NOT A PER-APP LIST. Anything a system stamped to point BACK
|
|
14
|
+
* at where a row came from is the same class of value, so the rule is the shape
|
|
15
|
+
* of the name rather than an enumeration: an app that seeds `legacy_id` or
|
|
16
|
+
* `source_ref` gets the same protection without editing this file.
|
|
17
|
+
*
|
|
18
|
+
* IT IS A UI GUARD AND SAYS SO. A tenant API will happily PATCH any declared
|
|
19
|
+
* attribute; nothing here is a permission. Making it one belongs with the schema
|
|
20
|
+
* (an `immutable` flag on AttributeDef) and is filed as a platform gap rather
|
|
21
|
+
* than faked here.
|
|
22
|
+
*
|
|
23
|
+
* Lifted out of a tenant fork (bd startsim-768w.63); originally bd startsim-39dr0.
|
|
24
|
+
*/
|
|
25
|
+
import type { AttributeShape } from './types';
|
|
26
|
+
|
|
27
|
+
/** Exact names that are provenance regardless of shape. */
|
|
28
|
+
const SYSTEM_ATTR_NAMES = new Set(['legacy_uuid', 'legacy_id', 'external_id', 'source_ref']);
|
|
29
|
+
|
|
30
|
+
/** Suffixes that make a name provenance by convention. */
|
|
31
|
+
const SYSTEM_ATTR_SUFFIXES = ['_uuid', '_legacy_id'];
|
|
32
|
+
|
|
33
|
+
/** Prefix reserved for values a system wrote about itself. */
|
|
34
|
+
const SYSTEM_ATTR_PREFIX = '_';
|
|
35
|
+
|
|
36
|
+
export function isSystemAttribute(name: string): boolean {
|
|
37
|
+
const n = name.trim().toLowerCase();
|
|
38
|
+
if (!n) return false;
|
|
39
|
+
if (SYSTEM_ATTR_NAMES.has(n)) return true;
|
|
40
|
+
if (n.startsWith(SYSTEM_ATTR_PREFIX)) return true;
|
|
41
|
+
return SYSTEM_ATTR_SUFFIXES.some((s) => n.endsWith(s));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Why the field is locked — shown next to the read-only value, because a
|
|
45
|
+
* disabled input with no explanation reads as a bug. */
|
|
46
|
+
export const SYSTEM_ATTR_REASON =
|
|
47
|
+
'Set when this record was imported. A public URL printed elsewhere resolves on it, so it is not editable here.';
|
|
48
|
+
|
|
49
|
+
export function isSystemAttributeDef(attr: AttributeShape): boolean {
|
|
50
|
+
return isSystemAttribute(attr.name);
|
|
51
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE MINIMUM THIS PACKAGE NEEDS TO KNOW about a foundry tenant's data.
|
|
3
|
+
*
|
|
4
|
+
* WHY THESE ARE SHAPES AND NOT THE CONTRACT. Every tenant app already declares
|
|
5
|
+
* its own `foundry-api.ts` — the canonical template's, plus whatever each fork
|
|
6
|
+
* has added to it. If this package re-declared that contract there would be two
|
|
7
|
+
* authorities on it, and a tenant could not adopt a new `@startsimpli/ui`
|
|
8
|
+
* without also adopting a wire format it may not serve yet.
|
|
9
|
+
*
|
|
10
|
+
* So each type here lists ONLY the fields the functions below actually read.
|
|
11
|
+
* TypeScript's structural typing then accepts a tenant's own `EntityRecord`,
|
|
12
|
+
* `AttributeDef` and friends unchanged, with no import in either direction. A
|
|
13
|
+
* fork whose contract carries extra fields — or spells an id as a number where
|
|
14
|
+
* production sends a UUID string — still type-checks.
|
|
15
|
+
*
|
|
16
|
+
* IDS ARE `string | number` FOR THE SAME REASON the tenant contract models them
|
|
17
|
+
* that way: production serves UUID strings and the template's fixtures use
|
|
18
|
+
* numbers. Every comparison in this module goes through `sameId` rather than
|
|
19
|
+
* `===` so the two can never silently miss each other.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** An Entity's primary key. */
|
|
23
|
+
export type EntityId = string | number;
|
|
24
|
+
|
|
25
|
+
/** A schema object's primary key (types, attributes, relationships, grants). */
|
|
26
|
+
export type SchemaId = string | number;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The declared data types a tenant attribute can have. Widened to `string` at
|
|
30
|
+
* every use site so a tenant that adds one does not fail to type-check here.
|
|
31
|
+
*/
|
|
32
|
+
export type AttributeDataType =
|
|
33
|
+
| 'text'
|
|
34
|
+
| 'longtext'
|
|
35
|
+
| 'number'
|
|
36
|
+
| 'integer'
|
|
37
|
+
| 'boolean'
|
|
38
|
+
| 'date'
|
|
39
|
+
| 'enum'
|
|
40
|
+
| 'json'
|
|
41
|
+
| (string & {});
|
|
42
|
+
|
|
43
|
+
/** A declared attribute, as much of one as this package reads. */
|
|
44
|
+
export interface AttributeShape {
|
|
45
|
+
name: string;
|
|
46
|
+
dataType?: AttributeDataType;
|
|
47
|
+
required?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A declared entity type. */
|
|
51
|
+
export interface EntityTypeShape {
|
|
52
|
+
id: SchemaId;
|
|
53
|
+
key: string;
|
|
54
|
+
label: string;
|
|
55
|
+
attributes?: AttributeShape[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A record of some entity type. */
|
|
59
|
+
export interface EntityRecordShape {
|
|
60
|
+
id: EntityId;
|
|
61
|
+
name?: string | null;
|
|
62
|
+
externalId?: string | null;
|
|
63
|
+
data?: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A declared relationship: a fixed source type -> target type edge. */
|
|
67
|
+
export interface RelationshipDefShape {
|
|
68
|
+
key: string;
|
|
69
|
+
sourceType: SchemaId;
|
|
70
|
+
targetType: SchemaId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One edge between two records. */
|
|
74
|
+
export interface RelationshipEdgeShape {
|
|
75
|
+
id: SchemaId;
|
|
76
|
+
relType: string;
|
|
77
|
+
source: EntityId;
|
|
78
|
+
target: EntityId;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A share on a row. `subjectType === 'public'` is what publication means. */
|
|
82
|
+
export interface GrantShape {
|
|
83
|
+
id: SchemaId;
|
|
84
|
+
entity: EntityId;
|
|
85
|
+
subjectType: string;
|
|
86
|
+
expiresAt: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The `subject_type` that makes a grant a publication. */
|
|
90
|
+
export const PUBLIC_SUBJECT = 'public';
|
|
91
|
+
|
|
92
|
+
/** Same id, whatever the wire happened to send. */
|
|
93
|
+
export function sameId(a: EntityId | null | undefined, b: EntityId | null | undefined): boolean {
|
|
94
|
+
if (a == null || b == null) return false;
|
|
95
|
+
return String(a) === String(b);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* snake_case -> camelCase, matching the @startsimpli/api response transform.
|
|
100
|
+
*
|
|
101
|
+
* `_+` AND NOT `_`: a run of underscores collapses, because that is what the
|
|
102
|
+
* client's transform does and a tenant app that spells an attribute `foo__bar`
|
|
103
|
+
* would otherwise look it up under a key nothing ever wrote — silently, as a
|
|
104
|
+
* blank cell. Kept identical to the tenant template's own board.ts so a record
|
|
105
|
+
* read through this module and one read through an app's helper cannot land on
|
|
106
|
+
* different keys.
|
|
107
|
+
*/
|
|
108
|
+
export function toCamelKey(name: string): string {
|
|
109
|
+
return name.replace(/_+([a-z0-9])/g, (_m, c: string) => c.toUpperCase());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read one declared attribute out of a record's `data` blob.
|
|
114
|
+
*
|
|
115
|
+
* BOTH SPELLINGS ARE TRIED because the shared api client camelises response
|
|
116
|
+
* keys: an attribute declared `stock_no` arrives as `stockNo`, but a blob
|
|
117
|
+
* written before that transform (or by a fixture) still has the snake key.
|
|
118
|
+
*/
|
|
119
|
+
export function readRecordValue(
|
|
120
|
+
data: Record<string, unknown> | undefined | null,
|
|
121
|
+
name: string,
|
|
122
|
+
toCamel: (name: string) => string = toCamelKey,
|
|
123
|
+
): unknown {
|
|
124
|
+
if (!data) return undefined;
|
|
125
|
+
return data[toCamel(name)] ?? data[name];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A record's display label — `name` first, then its natural key, then the id.
|
|
129
|
+
* A type whose rows carry no `name` attribute still reads right, because the
|
|
130
|
+
* tenant stamps `Entity.name` at write time. */
|
|
131
|
+
export function recordLabel(
|
|
132
|
+
record: EntityRecordShape | null | undefined,
|
|
133
|
+
fallbackId?: EntityId,
|
|
134
|
+
): string {
|
|
135
|
+
if (!record) return fallbackId != null ? `#${String(fallbackId).slice(0, 8)}` : 'Unknown record';
|
|
136
|
+
return record.name || record.externalId || `#${String(record.id).slice(0, 8)}`;
|
|
137
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useNow } from './use-now'
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The wall clock, read the way React sanctions.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS RATHER THAN `Date.now()` IN RENDER. Anything that renders off
|
|
7
|
+
* the clock — "expired", "overdue", a relative timestamp — collides with two
|
|
8
|
+
* rules at once:
|
|
9
|
+
*
|
|
10
|
+
* - the clock is an EXTERNAL MUTABLE SOURCE, so reading it during render is
|
|
11
|
+
* impure (react-hooks/purity refuses it outright), and
|
|
12
|
+
* - a value computed on the server and again in the browser is a hydration
|
|
13
|
+
* mismatch waiting for the one date that falls between the two renders.
|
|
14
|
+
*
|
|
15
|
+
* `useSyncExternalStore` is the mechanism built for exactly that: a server
|
|
16
|
+
* snapshot, a client snapshot, and a re-render when they differ.
|
|
17
|
+
*
|
|
18
|
+
* THE SNAPSHOT IS FLOORED TO THE MINUTE because `getSnapshot` must return a
|
|
19
|
+
* value that is `Object.is`-stable between calls — a raw `Date.now()` would
|
|
20
|
+
* differ on every read and React would re-render forever.
|
|
21
|
+
*/
|
|
22
|
+
import { useSyncExternalStore } from 'react';
|
|
23
|
+
|
|
24
|
+
const MINUTE = 60_000;
|
|
25
|
+
|
|
26
|
+
/** No subscription: nothing pushes clock changes, so the value settles at mount
|
|
27
|
+
* and on any later re-render. Minute granularity is far finer than any
|
|
28
|
+
* "has this date passed" question needs. */
|
|
29
|
+
function subscribe(): () => void {
|
|
30
|
+
return () => {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getSnapshot(): number {
|
|
34
|
+
return Math.floor(Date.now() / MINUTE) * MINUTE;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 0 on the server: nothing is "expired" in a prerender, which under-claims
|
|
38
|
+
* rather than stamping a red "expired" on something that is not. Callers guard
|
|
39
|
+
* with `now > 0`. */
|
|
40
|
+
function getServerSnapshot(): number {
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useNow(): number {
|
|
45
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
46
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { csvCell, csvFilename, toCsv } from '../csv';
|
|
2
|
+
|
|
3
|
+
describe('csvCell', () => {
|
|
4
|
+
it('leaves a plain value alone', () => {
|
|
5
|
+
expect(csvCell('US20230306066A1')).toBe('US20230306066A1');
|
|
6
|
+
expect(csvCell(0)).toBe('0');
|
|
7
|
+
expect(csvCell(null)).toBe('');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('quotes commas, quotes and newlines', () => {
|
|
11
|
+
expect(csvCell('a,b')).toBe('"a,b"');
|
|
12
|
+
expect(csvCell('say "hi"')).toBe('"say ""hi"""');
|
|
13
|
+
expect(csvCell('line\nbreak')).toBe('"line\nbreak"');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('defuses a leading formula so Excel does not execute a record name', () => {
|
|
17
|
+
expect(csvCell('=1+1')).toBe("'=1+1");
|
|
18
|
+
expect(csvCell('+SUM(A1)')).toBe("'+SUM(A1)");
|
|
19
|
+
expect(csvCell('@import')).toBe("'@import");
|
|
20
|
+
// ...and still quotes it if it also needs quoting
|
|
21
|
+
expect(csvCell('=A1,B1')).toBe('"\'=A1,B1"');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('toCsv', () => {
|
|
26
|
+
it('writes the header row then one row per record, in column order', () => {
|
|
27
|
+
const csv = toCsv(
|
|
28
|
+
[
|
|
29
|
+
{ name: 'Web Crawling Service', patents: 2 },
|
|
30
|
+
{ name: 'Other, Inc', patents: 0 },
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
{ header: 'Name', value: (r) => r.name },
|
|
34
|
+
{ header: 'Patents', value: (r) => String(r.patents) },
|
|
35
|
+
],
|
|
36
|
+
);
|
|
37
|
+
expect(csv).toBe(
|
|
38
|
+
'Name,Patents\r\nWeb Crawling Service,2\r\n"Other, Inc",0\r\n',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('writes just the header for an empty list', () => {
|
|
43
|
+
expect(toCsv([], [{ header: 'Name', value: () => '' }])).toBe('Name\r\n');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('csvFilename', () => {
|
|
48
|
+
it('slugs the label and stamps the day', () => {
|
|
49
|
+
expect(csvFilename('Company profile', new Date('2026-08-24T12:00:00Z'))).toBe(
|
|
50
|
+
'company-profile-2026-08-24.csv',
|
|
51
|
+
);
|
|
52
|
+
expect(csvFilename(' ', new Date('2026-08-24T12:00:00Z'))).toBe('export-2026-08-24.csv');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('the table exporter writes the same file', () => {
|
|
57
|
+
// The unified table shipped its own CSV writer with no formula guard, no BOM
|
|
58
|
+
// and LF endings. It now goes through the primitives above, so a table export
|
|
59
|
+
// and a page-level export of the same rows cannot disagree.
|
|
60
|
+
it('guards a formula that arrives through a column value', () => {
|
|
61
|
+
const csv = toCsv(
|
|
62
|
+
[{ name: '=cmd|calc' }],
|
|
63
|
+
[{ header: 'Name', value: (r) => r.name }],
|
|
64
|
+
);
|
|
65
|
+
expect(csv).toBe("Name\r\n'=cmd|calc\r\n");
|
|
66
|
+
});
|
|
67
|
+
});
|