@rebasepro/common 0.10.0 → 0.10.1-canary.14e53ae

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.
@@ -8,6 +8,7 @@ export * from "./resolutions";
8
8
  export * from "./policy";
9
9
  export * from "./permissions";
10
10
  export * from "./references";
11
+ export * from "./title-property";
11
12
  export * from "./navigation_from_path";
12
13
  export * from "./parent_references_from_path";
13
14
  export * from "./builders";
@@ -0,0 +1,43 @@
1
+ import type { CollectionConfig } from "@rebasepro/types";
2
+ /**
3
+ * All properties that could serve as the entity title, best first.
4
+ *
5
+ * Candidates are ranked from the property *schema* — identifiers (primary
6
+ * keys, foreign keys, UUID columns, user pickers), images and hidden fields are
7
+ * excluded structurally, so a collection whose id column is called something
8
+ * other than `id` is handled the same as one where it isn't.
9
+ *
10
+ * When the collection declares `propertiesOrder` the developer has already
11
+ * stated what comes first, so qualifying candidates keep that order; otherwise
12
+ * they are ranked (a name beats a description beats a relation).
13
+ *
14
+ * @group Collections
15
+ */
16
+ export declare function getTitlePropertyCandidates<M extends Record<string, unknown>>(collection: CollectionConfig<M>): string[];
17
+ /**
18
+ * The property that should fill the title slot for a collection, ignoring any
19
+ * concrete values. Prefer {@link getTitlePropertyKeyForValues} when an entity
20
+ * is at hand — it can skip candidates that happen to be empty or to hold an id.
21
+ *
22
+ * @group Collections
23
+ */
24
+ export declare function getTitlePropertyKey<M extends Record<string, unknown>>(collection: CollectionConfig<M>): string | undefined;
25
+ /**
26
+ * True when a value reads as a machine identifier (UUID, ObjectId/long hex,
27
+ * cuid) rather than as something worth showing to a person.
28
+ *
29
+ * @group Collections
30
+ */
31
+ export declare function looksLikeIdentifierValue(value: unknown): boolean;
32
+ /**
33
+ * The title property for a concrete entity: the best-ranked candidate that
34
+ * actually carries a readable value. Candidates that are empty, that repeat the
35
+ * entity id, or that hold an opaque identifier are skipped, so a free-text
36
+ * column that happens to store UUIDs never ends up as the title.
37
+ *
38
+ * Returns the top-ranked candidate when none of them has a usable value, so
39
+ * callers can still render a placeholder for that property.
40
+ *
41
+ * @group Collections
42
+ */
43
+ export declare function getTitlePropertyKeyForValues<M extends Record<string, unknown>>(collection: CollectionConfig<M>, values: Record<string, unknown> | undefined, entityId?: string | number): string | undefined;
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/common",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.10.1-canary.14e53ae",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "fast-equals": "6.0.0",
42
42
  "json-logic-js": "^2.0.5",
43
- "@rebasepro/types": "0.10.0",
44
- "@rebasepro/utils": "0.10.0"
43
+ "@rebasepro/types": "0.10.1-canary.14e53ae",
44
+ "@rebasepro/utils": "0.10.1-canary.14e53ae"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@jest/globals": "^30.4.1",
package/src/util/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from "./resolutions";
8
8
  export * from "./policy";
9
9
  export * from "./permissions";
10
10
  export * from "./references";
11
+ export * from "./title-property";
11
12
  export * from "./navigation_from_path";
12
13
  export * from "./parent_references_from_path";
13
14
  export * from "./builders";
@@ -0,0 +1,343 @@
1
+ import { CollectionConfig } from "@rebasepro/types";
2
+ import {
3
+ getTitlePropertyCandidates,
4
+ getTitlePropertyKey,
5
+ getTitlePropertyKeyForValues,
6
+ looksLikeIdentifierValue
7
+ } from "./title-property";
8
+
9
+ describe("Title property resolution", () => {
10
+
11
+ describe("identifiers never fill the title slot", () => {
12
+
13
+ it("ranks a userSelect below a real name", () => {
14
+ // Regression: `auth_user_id` is declared before `full_name` and is a
15
+ // plain string, so "first non-id string" picked the auth user UUID.
16
+ // A user picker resolves to a person, so it stays a candidate — but
17
+ // only behind every plain text field.
18
+ const collection: CollectionConfig = {
19
+ name: "Talents",
20
+ slug: "talents",
21
+ table: "talents",
22
+ properties: {
23
+ id: { name: "ID",
24
+ type: "string",
25
+ isId: "uuid" },
26
+ auth_user_id: { name: "Auth User ID",
27
+ type: "string",
28
+ userSelect: true },
29
+ full_name: { name: "Full Name",
30
+ type: "string" },
31
+ email: { name: "Email",
32
+ type: "string" }
33
+ }
34
+ };
35
+ expect(getTitlePropertyCandidates(collection)).toEqual(["full_name", "email", "auth_user_id"]);
36
+ });
37
+
38
+ it("skips the primary key even when it is not called `id`", () => {
39
+ const collection: CollectionConfig = {
40
+ name: "Devices",
41
+ slug: "devices",
42
+ table: "devices",
43
+ properties: {
44
+ serial: { name: "Serial",
45
+ type: "string",
46
+ isId: "uuid" },
47
+ model: { name: "Model",
48
+ type: "string" }
49
+ }
50
+ };
51
+ expect(getTitlePropertyKey(collection)).toBe("model");
52
+ });
53
+
54
+ it("skips uuid columns and declared foreign keys", () => {
55
+ const collection: CollectionConfig = {
56
+ name: "Orders",
57
+ slug: "orders",
58
+ table: "orders",
59
+ properties: {
60
+ id: { name: "ID",
61
+ type: "string",
62
+ isId: "uuid" },
63
+ external_ref: { name: "External ref",
64
+ type: "string",
65
+ columnType: "uuid" },
66
+ customer_id: { name: "Customer id",
67
+ type: "string" },
68
+ customer: {
69
+ name: "Customer",
70
+ type: "relation",
71
+ target: "customers",
72
+ cardinality: "one",
73
+ localKey: "customer_id"
74
+ },
75
+ reference: { name: "Reference",
76
+ type: "string" }
77
+ }
78
+ };
79
+ expect(getTitlePropertyKey(collection)).toBe("reference");
80
+ });
81
+
82
+ it("skips a foreign key whose column name was left to be inferred", () => {
83
+ const collection: CollectionConfig = {
84
+ name: "Posts",
85
+ slug: "posts",
86
+ table: "posts",
87
+ properties: {
88
+ id: { name: "ID",
89
+ type: "string",
90
+ isId: "uuid" },
91
+ author_id: { name: "Author id",
92
+ type: "string" },
93
+ author: {
94
+ name: "Author",
95
+ type: "relation",
96
+ target: "users",
97
+ cardinality: "one"
98
+ },
99
+ headline: { name: "Headline",
100
+ type: "string" }
101
+ }
102
+ };
103
+ expect(getTitlePropertyKey(collection)).toBe("headline");
104
+ });
105
+ });
106
+
107
+ describe("ranking", () => {
108
+
109
+ it("prefers a name-like key over an earlier plain string", () => {
110
+ const collection: CollectionConfig = {
111
+ name: "Products",
112
+ slug: "products",
113
+ table: "products",
114
+ properties: {
115
+ sku: { name: "SKU",
116
+ type: "string" },
117
+ title: { name: "Title",
118
+ type: "string" }
119
+ }
120
+ };
121
+ expect(getTitlePropertyKey(collection)).toBe("title");
122
+ });
123
+
124
+ it("prefers plain text over email, enum, description and relation", () => {
125
+ const collection: CollectionConfig = {
126
+ name: "Leads",
127
+ slug: "leads",
128
+ table: "leads",
129
+ properties: {
130
+ owner: { name: "Owner",
131
+ type: "relation",
132
+ target: "users",
133
+ cardinality: "one" },
134
+ bio: { name: "Bio",
135
+ type: "string",
136
+ ui: { multiline: true } },
137
+ status: {
138
+ name: "Status",
139
+ type: "string",
140
+ enum: { new: "New",
141
+ won: "Won" }
142
+ },
143
+ email: { name: "Email",
144
+ type: "string",
145
+ email: true },
146
+ company: { name: "Company",
147
+ type: "string" }
148
+ }
149
+ };
150
+ expect(getTitlePropertyCandidates(collection))
151
+ .toEqual(["company", "email", "status", "bio", "owner"]);
152
+ });
153
+
154
+ it("falls back to a relation when the collection holds no text", () => {
155
+ const collection: CollectionConfig = {
156
+ name: "Posts tags",
157
+ slug: "posts_tags",
158
+ table: "posts_tags",
159
+ properties: {
160
+ id: { name: "ID",
161
+ type: "string",
162
+ isId: "uuid" },
163
+ tag: { name: "Tag",
164
+ type: "relation",
165
+ target: "tags",
166
+ cardinality: "one",
167
+ localKey: "tag_id" }
168
+ }
169
+ };
170
+ expect(getTitlePropertyKey(collection)).toBe("tag");
171
+ });
172
+
173
+ it("honours an explicit titleProperty", () => {
174
+ const collection: CollectionConfig = {
175
+ name: "Talents",
176
+ slug: "talents",
177
+ table: "talents",
178
+ titleProperty: "email",
179
+ properties: {
180
+ full_name: { name: "Full Name",
181
+ type: "string" },
182
+ email: { name: "Email",
183
+ type: "string" }
184
+ }
185
+ };
186
+ expect(getTitlePropertyKey(collection)).toBe("email");
187
+ });
188
+
189
+ it("ignores hidden and image properties", () => {
190
+ const collection: CollectionConfig = {
191
+ name: "Assets",
192
+ slug: "assets",
193
+ table: "assets",
194
+ properties: {
195
+ internal_code: {
196
+ name: "Internal code",
197
+ type: "string",
198
+ ui: { hideFromCollection: true }
199
+ },
200
+ picture: {
201
+ name: "Picture",
202
+ type: "string",
203
+ storage: { storagePath: "assets" }
204
+ },
205
+ caption: { name: "Caption",
206
+ type: "string" }
207
+ }
208
+ };
209
+ expect(getTitlePropertyKey(collection)).toBe("caption");
210
+ });
211
+ });
212
+
213
+ describe("explicit propertiesOrder", () => {
214
+
215
+ it("keeps the declared order instead of reranking", () => {
216
+ const collection: CollectionConfig = {
217
+ name: "Databases",
218
+ slug: "databases",
219
+ table: "databases",
220
+ properties: {
221
+ id: { name: "ID",
222
+ type: "string",
223
+ isId: "uuid" },
224
+ project: { name: "Project",
225
+ type: "relation",
226
+ target: "projects",
227
+ cardinality: "one" },
228
+ connection_string: { name: "Connection string",
229
+ type: "string" }
230
+ },
231
+ propertiesOrder: ["id", "project", "connection_string"]
232
+ };
233
+ expect(getTitlePropertyKey(collection)).toBe("project");
234
+ });
235
+
236
+ it("still skips identifiers declared first", () => {
237
+ const collection: CollectionConfig = {
238
+ name: "Talents",
239
+ slug: "talents",
240
+ table: "talents",
241
+ properties: {
242
+ id: { name: "ID",
243
+ type: "string",
244
+ isId: "uuid" },
245
+ external_ref: { name: "External ref",
246
+ type: "string",
247
+ columnType: "uuid" },
248
+ tenant_id: { name: "Tenant id",
249
+ type: "string" },
250
+ tenant: {
251
+ name: "Tenant",
252
+ type: "relation",
253
+ target: "tenants",
254
+ cardinality: "one",
255
+ localKey: "tenant_id"
256
+ },
257
+ full_name: { name: "Full Name",
258
+ type: "string" }
259
+ },
260
+ propertiesOrder: ["id", "external_ref", "tenant_id", "full_name"]
261
+ };
262
+ expect(getTitlePropertyKey(collection)).toBe("full_name");
263
+ });
264
+
265
+ it("lets a declared user picker win, since it renders the person", () => {
266
+ const collection: CollectionConfig = {
267
+ name: "Assignments",
268
+ slug: "assignments",
269
+ table: "assignments",
270
+ properties: {
271
+ id: { name: "ID",
272
+ type: "string",
273
+ isId: "uuid" },
274
+ assignee: { name: "Assignee",
275
+ type: "string",
276
+ userSelect: true },
277
+ notes: { name: "Notes",
278
+ type: "string" }
279
+ },
280
+ propertiesOrder: ["id", "assignee", "notes"]
281
+ };
282
+ expect(getTitlePropertyKey(collection)).toBe("assignee");
283
+ });
284
+ });
285
+
286
+ describe("value-aware resolution", () => {
287
+
288
+ const collection: CollectionConfig = {
289
+ name: "Talents",
290
+ slug: "talents",
291
+ table: "talents",
292
+ properties: {
293
+ id: { name: "ID",
294
+ type: "string",
295
+ isId: "uuid" },
296
+ full_name: { name: "Full Name",
297
+ type: "string" },
298
+ email: { name: "Email",
299
+ type: "string" }
300
+ }
301
+ };
302
+
303
+ it("uses the top candidate when it holds a value", () => {
304
+ expect(getTitlePropertyKeyForValues(collection, {
305
+ full_name: "Priscila Alaniz",
306
+ email: "palaniz@peersocial.com.mx"
307
+ })).toBe("full_name");
308
+ });
309
+
310
+ it("falls through when the top candidate is empty", () => {
311
+ expect(getTitlePropertyKeyForValues(collection, {
312
+ full_name: "",
313
+ email: "palaniz@peersocial.com.mx"
314
+ })).toBe("email");
315
+ });
316
+
317
+ it("falls through when the top candidate holds an id", () => {
318
+ expect(getTitlePropertyKeyForValues(collection, {
319
+ full_name: "fdda6c2a-5310-4b0c-87cc-a13eb36e5167",
320
+ email: "palaniz@peersocial.com.mx"
321
+ })).toBe("email");
322
+ });
323
+
324
+ it("keeps the top candidate when nothing has a usable value", () => {
325
+ expect(getTitlePropertyKeyForValues(collection, {})).toBe("full_name");
326
+ });
327
+ });
328
+
329
+ describe("looksLikeIdentifierValue", () => {
330
+ it("detects uuids, long hex and cuids", () => {
331
+ expect(looksLikeIdentifierValue("fdda6c2a-5310-4b0c-87cc-a13eb36e5167")).toBe(true);
332
+ expect(looksLikeIdentifierValue("507f1f77bcf86cd799439011")).toBe(true);
333
+ expect(looksLikeIdentifierValue("cjld2cjxh0000qzrmn831i7rn")).toBe(true);
334
+ });
335
+
336
+ it("leaves human text alone", () => {
337
+ expect(looksLikeIdentifierValue("Priscila Alaniz")).toBe(false);
338
+ expect(looksLikeIdentifierValue("Consultoría ambiental")).toBe(false);
339
+ expect(looksLikeIdentifierValue("")).toBe(false);
340
+ expect(looksLikeIdentifierValue(42)).toBe(false);
341
+ });
342
+ });
343
+ });
@@ -0,0 +1,264 @@
1
+ import type { CollectionConfig, Property, Relation } from "@rebasepro/types";
2
+ import { generateForeignKeyName } from "@rebasepro/utils";
3
+ import { isPropertyBuilder } from "./entities";
4
+ import { getPrimaryKeys } from "./collections";
5
+
6
+ /**
7
+ * How good a property is as the human-readable title of an entity.
8
+ * Higher wins; ties are broken by declaration order.
9
+ * `DISQUALIFIED` means the property can never be a title.
10
+ */
11
+ const SCORE = {
12
+ DISQUALIFIED: -1,
13
+ /**
14
+ * Points at another entity. Only readable once the target resolves, and it
15
+ * renders as the raw key until then — last resort, for collections (like
16
+ * junction tables) that hold no text of their own.
17
+ */
18
+ REFERENCE: 5,
19
+ RELATION: 10,
20
+ /** A user picker: resolves to a person's name, like a relation does */
21
+ USER: 10,
22
+ /** Long form text — readable, but a description, not a name */
23
+ LONG_TEXT: 20,
24
+ /** A closed set of values: shared by many rows, so it identifies nothing */
25
+ ENUM: 30,
26
+ /** Identifying, but PII and usually secondary to a name */
27
+ EMAIL: 45,
28
+ /** Any plain, short, free text field */
29
+ PLAIN_TEXT: 60,
30
+ /** Free text whose name says it holds the entity's label */
31
+ NAMED: 100
32
+ } as const;
33
+
34
+ /**
35
+ * Keys whose name states outright that the property is the entity label.
36
+ * This is a *bonus* on top of the structural rules — never a requirement, and
37
+ * never the mechanism that keeps identifiers out of the title slot.
38
+ */
39
+ const TITLE_LIKE_KEYS = new Set([
40
+ "name",
41
+ "fullname",
42
+ "displayname",
43
+ "title",
44
+ "label",
45
+ "heading",
46
+ "subject",
47
+ "username",
48
+ "nickname"
49
+ ]);
50
+
51
+ function normalizeKey(key: string): string {
52
+ return key.toLowerCase().replace(/[^a-z0-9]/g, "");
53
+ }
54
+
55
+ function isHidden(property: Property): boolean {
56
+ return Boolean(property.ui?.hideFromCollection);
57
+ }
58
+
59
+ /**
60
+ * File-storage backed content (single image, array of images, generic upload…).
61
+ * Rendered by the dedicated image slot, so never a title.
62
+ */
63
+ function isStorageProperty(property: Property): boolean {
64
+ if (property.type === "string" && (property.storage || property.ui?.url === "image")) return true;
65
+ if (property.type === "array" && property.of && !Array.isArray(property.of)) {
66
+ const inner = property.of;
67
+ if (inner.type === "string" && (inner.storage || inner.ui?.url === "image")) return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /**
73
+ * Every column on this collection that stores a foreign key: the `localKey` of
74
+ * each owning relation (declared inline on a property or in `relations[]`), the
75
+ * source column of a junction, and the key a many-to-many joins on.
76
+ *
77
+ * These hold another entity's id, so they must never fill the title slot even
78
+ * though they are declared as plain strings.
79
+ */
80
+ function getForeignKeyColumns<M extends Record<string, unknown>>(collection: CollectionConfig<M>): Set<string> {
81
+ const keys = new Set<string>();
82
+
83
+ const addRelationKeys = (relation: Pick<Relation, "localKey" | "through" | "joinPath">) => {
84
+ if (relation.localKey) keys.add(relation.localKey);
85
+ if (relation.through?.sourceColumn) keys.add(relation.through.sourceColumn);
86
+ // Only the first hop of a join path starts on this collection's table.
87
+ const firstStep = relation.joinPath?.[0];
88
+ if (firstStep) {
89
+ const from = firstStep.on?.from;
90
+ for (const column of Array.isArray(from) ? from : [from]) {
91
+ if (column) keys.add(column);
92
+ }
93
+ }
94
+ };
95
+
96
+ for (const relation of collection.relations ?? []) {
97
+ addRelationKeys(relation);
98
+ }
99
+
100
+ for (const [key, propertyRaw] of Object.entries(collection.properties ?? {})) {
101
+ const property = propertyRaw as Property | undefined;
102
+ if (!property || isPropertyBuilder(property) || property.type !== "relation") continue;
103
+ addRelationKeys(property);
104
+ if (property.relation) addRelationKeys(property.relation);
105
+ // An owning one-to-one whose localKey was left to be inferred still
106
+ // consumes a column; assume the conventional `<relation>_id` name.
107
+ if ((property.cardinality ?? "one") === "one" && (property.direction ?? "owning") === "owning") {
108
+ keys.add(generateForeignKeyName(property.relationName || key));
109
+ }
110
+ }
111
+
112
+ return keys;
113
+ }
114
+
115
+ /**
116
+ * True when the property is declared to hold an opaque identifier rather than
117
+ * something a person reads: a primary key, a foreign key, a UUID column, or a
118
+ * picker bound to an auth user id. Decided from the property *schema*, never
119
+ * from the key name.
120
+ */
121
+ function isIdentifierProperty(property: Property, key: string, idKeys: Set<string>, foreignKeys: Set<string>): boolean {
122
+ if (idKeys.has(key)) return true;
123
+ if (foreignKeys.has(key)) return true;
124
+ if ("isId" in property && property.isId) return true;
125
+ if (property.type === "string" && property.columnType === "uuid") return true;
126
+ return false;
127
+ }
128
+
129
+ function scoreTitleCandidate(property: Property, key: string, idKeys: Set<string>, foreignKeys: Set<string>): number {
130
+ if (isHidden(property)) return SCORE.DISQUALIFIED;
131
+ if (isIdentifierProperty(property, key, idKeys, foreignKeys)) return SCORE.DISQUALIFIED;
132
+ if (isStorageProperty(property)) return SCORE.DISQUALIFIED;
133
+
134
+ if (property.type === "relation") {
135
+ const isMany = property.cardinality === "many" || property.relation?.cardinality === "many";
136
+ return isMany ? SCORE.DISQUALIFIED : SCORE.RELATION;
137
+ }
138
+ if (property.type === "reference") {
139
+ return SCORE.REFERENCE;
140
+ }
141
+ if (property.type !== "string") {
142
+ // Numbers, dates, booleans, maps and arrays never read as a title.
143
+ return SCORE.DISQUALIFIED;
144
+ }
145
+
146
+ // A user picker stores an auth user id but renders the person behind it, so
147
+ // it ranks with relations: usable as a title, but only as a last resort.
148
+ if (property.userSelect) return SCORE.USER;
149
+ if (property.enum) return SCORE.ENUM;
150
+ if (property.ui?.multiline || property.ui?.markdown) return SCORE.LONG_TEXT;
151
+ if (property.email) return SCORE.EMAIL;
152
+ if (TITLE_LIKE_KEYS.has(normalizeKey(key))) return SCORE.NAMED;
153
+ return SCORE.PLAIN_TEXT;
154
+ }
155
+
156
+ /**
157
+ * All properties that could serve as the entity title, best first.
158
+ *
159
+ * Candidates are ranked from the property *schema* — identifiers (primary
160
+ * keys, foreign keys, UUID columns, user pickers), images and hidden fields are
161
+ * excluded structurally, so a collection whose id column is called something
162
+ * other than `id` is handled the same as one where it isn't.
163
+ *
164
+ * When the collection declares `propertiesOrder` the developer has already
165
+ * stated what comes first, so qualifying candidates keep that order; otherwise
166
+ * they are ranked (a name beats a description beats a relation).
167
+ *
168
+ * @group Collections
169
+ */
170
+ export function getTitlePropertyCandidates<M extends Record<string, unknown>>(
171
+ collection: CollectionConfig<M>
172
+ ): string[] {
173
+ if (!collection.properties) return [];
174
+
175
+ if (collection.titleProperty && collection.properties[collection.titleProperty as string]) {
176
+ return [collection.titleProperty as string];
177
+ }
178
+
179
+ const idKeys = new Set<string>(getPrimaryKeys(collection) as string[]);
180
+ const foreignKeys = getForeignKeyColumns(collection);
181
+ const explicitOrder = collection.propertiesOrder as string[] | undefined;
182
+ const order = explicitOrder ?? Object.keys(collection.properties);
183
+
184
+ const scored: { key: string; score: number; index: number }[] = [];
185
+ order.forEach((key, index) => {
186
+ const property = collection.properties[key];
187
+ if (!property || isPropertyBuilder(property)) return;
188
+ const score = scoreTitleCandidate(property as Property, key, idKeys, foreignKeys);
189
+ if (score === SCORE.DISQUALIFIED) return;
190
+ scored.push({ key,
191
+ score,
192
+ index });
193
+ });
194
+
195
+ // An explicit `propertiesOrder` is a statement of what matters first, so it
196
+ // wins over the ranking — the ranking only decides between properties the
197
+ // developer never ordered.
198
+ if (!explicitOrder) {
199
+ scored.sort((a, b) => (b.score - a.score) || (a.index - b.index));
200
+ }
201
+
202
+ return scored.map(candidate => candidate.key);
203
+ }
204
+
205
+ /**
206
+ * The property that should fill the title slot for a collection, ignoring any
207
+ * concrete values. Prefer {@link getTitlePropertyKeyForValues} when an entity
208
+ * is at hand — it can skip candidates that happen to be empty or to hold an id.
209
+ *
210
+ * @group Collections
211
+ */
212
+ export function getTitlePropertyKey<M extends Record<string, unknown>>(
213
+ collection: CollectionConfig<M>
214
+ ): string | undefined {
215
+ return getTitlePropertyCandidates(collection)[0];
216
+ }
217
+
218
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
219
+ const LONG_HEX_REGEX = /^[0-9a-f]{24,}$/i;
220
+ const CUID_REGEX = /^c[a-z0-9]{20,}$/i;
221
+
222
+ /**
223
+ * True when a value reads as a machine identifier (UUID, ObjectId/long hex,
224
+ * cuid) rather than as something worth showing to a person.
225
+ *
226
+ * @group Collections
227
+ */
228
+ export function looksLikeIdentifierValue(value: unknown): boolean {
229
+ if (typeof value !== "string") return false;
230
+ const trimmed = value.trim();
231
+ return UUID_REGEX.test(trimmed) || LONG_HEX_REGEX.test(trimmed) || CUID_REGEX.test(trimmed);
232
+ }
233
+
234
+ /**
235
+ * The title property for a concrete entity: the best-ranked candidate that
236
+ * actually carries a readable value. Candidates that are empty, that repeat the
237
+ * entity id, or that hold an opaque identifier are skipped, so a free-text
238
+ * column that happens to store UUIDs never ends up as the title.
239
+ *
240
+ * Returns the top-ranked candidate when none of them has a usable value, so
241
+ * callers can still render a placeholder for that property.
242
+ *
243
+ * @group Collections
244
+ */
245
+ export function getTitlePropertyKeyForValues<M extends Record<string, unknown>>(
246
+ collection: CollectionConfig<M>,
247
+ values: Record<string, unknown> | undefined,
248
+ entityId?: string | number
249
+ ): string | undefined {
250
+ const candidates = getTitlePropertyCandidates(collection);
251
+ if (!values || candidates.length === 0) return candidates[0];
252
+
253
+ for (const key of candidates) {
254
+ const value = values[key];
255
+ if (value === undefined || value === null || value === "") continue;
256
+ if (typeof value === "string") {
257
+ if (looksLikeIdentifierValue(value)) continue;
258
+ if (entityId !== undefined && value === String(entityId)) continue;
259
+ }
260
+ return key;
261
+ }
262
+
263
+ return candidates[0];
264
+ }