@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.
Files changed (30) hide show
  1. package/package.json +3 -2
  2. package/src/components/navigation/__tests__/breadcrumbs.test.tsx +16 -0
  3. package/src/components/navigation/breadcrumbs.tsx +39 -0
  4. package/src/components/navigation/index.ts +1 -0
  5. package/src/components/unified-table/UnifiedTable.tsx +30 -2
  6. package/src/components/unified-table/__tests__/integration/search-and-empty-state.test.tsx +210 -0
  7. package/src/components/unified-table/components/DataTableCore/index.tsx +6 -2
  8. package/src/components/unified-table/components/Toolbar/StandardTableToolbar.tsx +53 -3
  9. package/src/components/unified-table/utils/export.ts +5 -37
  10. package/src/foundry/LinkPanel.tsx +394 -0
  11. package/src/foundry/__tests__/LinkPanel.test.tsx +139 -0
  12. package/src/foundry/__tests__/links.test.ts +251 -0
  13. package/src/foundry/__tests__/list-query.test.ts +200 -0
  14. package/src/foundry/__tests__/publication.test.ts +227 -0
  15. package/src/foundry/__tests__/record-validation.test.ts +132 -0
  16. package/src/foundry/__tests__/types.test.ts +53 -0
  17. package/src/foundry/index.ts +21 -0
  18. package/src/foundry/links.ts +227 -0
  19. package/src/foundry/list-query.ts +241 -0
  20. package/src/foundry/publication.ts +260 -0
  21. package/src/foundry/record-validation.ts +109 -0
  22. package/src/foundry/system-attrs.ts +51 -0
  23. package/src/foundry/types.ts +137 -0
  24. package/src/hooks/index.ts +1 -0
  25. package/src/hooks/use-now.ts +46 -0
  26. package/src/index.ts +1 -0
  27. package/src/utils/__tests__/csv.test.ts +67 -0
  28. package/src/utils/__tests__/download-csv.test.ts +40 -0
  29. package/src/utils/csv.ts +77 -0
  30. 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
@@ -1,5 +1,6 @@
1
1
  // Main entry point for @startsimpli/ui package
2
2
  export * from './components'
3
+ export * from './hooks'
3
4
  export * from './utils'
4
5
  export * from './theme'
5
6
  export { THEME_TOKENS } from './theme/contract'
@@ -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
+ });