@rebasepro/app 0.12.1-canary.gf5f1d39 → 0.13.0

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 (49) hide show
  1. package/README.md +1 -1
  2. package/dist/auth/api.d.ts +3 -8
  3. package/dist/auth/index.d.ts +2 -2
  4. package/dist/auth/types.d.ts +1 -5
  5. package/dist/collections/form-layout.d.ts +107 -0
  6. package/dist/collections/index.d.ts +1 -0
  7. package/dist/collections/title-property.d.ts +22 -0
  8. package/dist/components/Debug/collection-views/sample_data.d.ts +3 -2
  9. package/dist/hooks/ApiConfigContext.d.ts +36 -2
  10. package/dist/hooks/useBackendStorageSource.d.ts +6 -1
  11. package/dist/hooks/useNavigationBlocker.d.ts +1 -1
  12. package/dist/hooks/useSnackbarController.d.ts +2 -1
  13. package/dist/hooks/useStudioBridge.d.ts +27 -0
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.es.js +681 -186
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/internal/common.d.ts +14 -2
  18. package/package.json +29 -25
  19. package/src/auth/api.ts +8 -9
  20. package/src/auth/index.ts +2 -2
  21. package/src/auth/types.ts +5 -6
  22. package/src/collections/form-layout.ts +386 -0
  23. package/src/collections/index.ts +1 -0
  24. package/src/collections/title-property.ts +41 -0
  25. package/src/components/Debug/UIReferenceView.tsx +9 -1
  26. package/src/components/Debug/collection-views/sample_data.ts +4 -2
  27. package/src/components/NotFoundPage.tsx +1 -1
  28. package/src/components/common/useDataTableController.tsx +1 -1
  29. package/src/core/Rebase.tsx +5 -1
  30. package/src/core/RebaseRouter.tsx +2 -1
  31. package/src/core/RebaseRoutes.tsx +1 -1
  32. package/src/hooks/ApiConfigContext.tsx +45 -3
  33. package/src/hooks/useAuthSubscription.ts +29 -3
  34. package/src/hooks/useBackendStorageSource.ts +8 -1
  35. package/src/hooks/useNavigationBlocker.tsx +1 -1
  36. package/src/hooks/useSnackbarController.tsx +34 -6
  37. package/src/hooks/useStudioBridge.tsx +50 -2
  38. package/src/index.ts +1 -1
  39. package/src/internal/common.tsx +15 -3
  40. package/src/internal/useRestoreScroll.tsx +1 -1
  41. package/src/locales/de.ts +23 -2
  42. package/src/locales/en.ts +23 -1
  43. package/src/locales/es.ts +23 -1
  44. package/src/locales/fr.ts +23 -1
  45. package/src/locales/hi.ts +23 -1
  46. package/src/locales/it.ts +23 -1
  47. package/src/locales/pt.ts +23 -1
  48. package/src/util/enums.ts +4 -2
  49. package/src/util/useStorageUploadController.tsx +5 -1
@@ -9,6 +9,18 @@
9
9
  */
10
10
  export declare const CONTAINER_FULL_WIDTH = "100vw";
11
11
  /** @internal See {@link CONTAINER_FULL_WIDTH}. */
12
- export declare const ADDITIONAL_TAB_WIDTH = "55vw";
13
- /** @internal See {@link CONTAINER_FULL_WIDTH}. */
14
12
  export declare const FORM_CONTAINER_WIDTH = "768px";
13
+ /**
14
+ * How wide the side panel opens when neither the caller nor the collection
15
+ * says otherwise.
16
+ *
17
+ * Sized from what it has to hold rather than from a round number: the form's
18
+ * content column plus its metadata rail plus gutters, which is also the width
19
+ * at which the form stops being a single stack and picks up the same
20
+ * two-column layout it has in full screen. Capped against the viewport so a
21
+ * panel never becomes the whole window on a laptop — at that point you want
22
+ * full screen, and there is a button for it.
23
+ *
24
+ * @internal See {@link CONTAINER_FULL_WIDTH}.
25
+ */
26
+ export declare const SIDE_PANEL_DEFAULT_WIDTH = "min(1160px, 88vw)";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/app",
3
3
  "type": "module",
4
- "version": "0.12.1-canary.gf5f1d39",
4
+ "version": "0.13.0",
5
5
  "description": "Rebase core — framework-agnostic runtime for data-driven admin panels",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -18,7 +18,7 @@
18
18
  "types": "./dist/index.d.ts",
19
19
  "source": "src/index.ts",
20
20
  "engines": {
21
- "node": ">=20"
21
+ "node": ">=22.22.0"
22
22
  },
23
23
  "keywords": [
24
24
  "rebase",
@@ -45,48 +45,47 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "compressorjs": "^1.3.0",
48
- "fast-equals": "6.0.0",
49
- "fuse.js": "^7.4.2",
50
- "i18next": "^26.3.1",
51
- "lucide-react": "1.18.0",
48
+ "fast-equals": "6.0.2",
49
+ "fuse.js": "^7.5.0",
50
+ "i18next": "^26.3.6",
51
+ "lucide-react": "1.27.0",
52
52
  "magic-string": "^0.30.0",
53
53
  "notistack": "^3.0.2",
54
- "react-i18next": "^17.0.8",
55
- "@rebasepro/admin-types": "0.12.1-canary.gf5f1d39",
56
- "@rebasepro/common": "0.12.1-canary.gf5f1d39",
57
- "@rebasepro/forms": "0.12.1-canary.gf5f1d39",
58
- "@rebasepro/types": "0.12.1-canary.gf5f1d39",
59
- "@rebasepro/ui": "0.12.1-canary.gf5f1d39",
60
- "@rebasepro/utils": "0.12.1-canary.gf5f1d39"
54
+ "react-i18next": "^17.0.11",
55
+ "@rebasepro/admin-types": "0.13.0",
56
+ "@rebasepro/common": "0.13.0",
57
+ "@rebasepro/forms": "0.13.0",
58
+ "@rebasepro/types": "0.13.0",
59
+ "@rebasepro/ui": "0.13.0",
60
+ "@rebasepro/utils": "0.13.0"
61
61
  },
62
62
  "peerDependencies": {
63
- "react": ">=19.0.0",
64
- "react-dom": ">=19.0.0",
65
- "react-router": "^7.0.0",
66
- "react-router-dom": "^7.0.0",
63
+ "react": ">=19.2.7",
64
+ "react-dom": ">=19.2.7",
65
+ "react-router": "^8.3.0",
67
66
  "typescript": ">=5.0.0"
68
67
  },
69
68
  "devDependencies": {
70
69
  "@jest/globals": "^30.4.1",
71
- "@testing-library/jest-dom": "^6.9.1",
70
+ "@testing-library/jest-dom": "^7.0.0",
72
71
  "@testing-library/react": "^16.3.2",
73
72
  "@testing-library/user-event": "^14.6.1",
74
73
  "@types/jest": "^30.0.0",
75
- "@types/node": "^25.9.3",
74
+ "@types/node": "^26.1.2",
76
75
  "@types/react": "^19.2.17",
77
76
  "@types/react-dom": "^19.2.3",
78
77
  "@types/react-measure": "^2.0.12",
79
- "@vitejs/plugin-react": "^6.0.2",
78
+ "@vitejs/plugin-react": "^6.0.4",
80
79
  "babel-plugin-react-compiler": "beta",
81
80
  "cross-env": "^10.1.0",
82
81
  "esbuild": "^0.28.1",
83
82
  "jest": "^30.4.2",
84
83
  "jest-environment-jsdom": "^30.4.1",
85
- "react-router-dom": "^7.17.0",
86
- "ts-jest": "^29.4.11",
84
+ "react-router": "^8.3.0",
85
+ "ts-jest": "^29.4.12",
87
86
  "tsd": "^0.33.0",
88
87
  "typescript": "^6.0.3",
89
- "vite": "^8.0.16"
88
+ "vite": "^8.1.5"
90
89
  },
91
90
  "files": [
92
91
  "dist",
@@ -104,13 +103,15 @@
104
103
  },
105
104
  "jest": {
106
105
  "transform": {
107
- "^.+\\.tsx?$": "ts-jest"
106
+ "^.+\\.tsx?$": "ts-jest",
107
+ "^.+\\.m?jsx?$": "<rootDir>/../../scripts/jest/react-router-esm-transform.cjs"
108
108
  },
109
109
  "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
110
110
  "moduleFileExtensions": [
111
111
  "ts",
112
112
  "tsx",
113
113
  "js",
114
+ "mjs",
114
115
  "jsx",
115
116
  "json",
116
117
  "node"
@@ -127,7 +128,10 @@
127
128
  "\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js",
128
129
  "^@rebasepro/([a-z0-9-]+)$": "<rootDir>/../$1/src/index.ts",
129
130
  "^@rebasepro/admin-types$": "<rootDir>/../admin-types/src/index.ts"
130
- }
131
+ },
132
+ "transformIgnorePatterns": [
133
+ "node_modules/(?!.*(?:react-router|cookie-es|fractional-indexing))"
134
+ ]
131
135
  },
132
136
  "scripts": {
133
137
  "watch": "vite build --watch",
package/src/auth/api.ts CHANGED
@@ -10,13 +10,7 @@
10
10
  */
11
11
 
12
12
  import { RebaseApiError } from "@rebasepro/types";
13
-
14
- /**
15
- * @deprecated Use {@link RebaseApiError} (from `@rebasepro/types`, re-exported by
16
- * `@rebasepro/client`). Kept as an alias so `instanceof` / imports keep working;
17
- * errors thrown here now carry `.code` and `.status` on the unified type.
18
- */
19
- export const AuthApiError = RebaseApiError;
13
+ import { DEFAULT_API_PATH } from "../hooks/ApiConfigContext";
20
14
 
21
15
  async function handleResponse<T>(response: Response): Promise<T> {
22
16
  let data: Record<string, unknown>;
@@ -113,7 +107,12 @@ export function createAuthConfigCache(): AuthConfigCache {
113
107
  * Concurrent calls are deduplicated: only one network request is made
114
108
  * and all callers share the same promise.
115
109
  */
116
- export async function fetchAuthConfig(apiUrl: string, cache: AuthConfigCache): Promise<AuthConfigResponse> {
110
+ export async function fetchAuthConfig(
111
+ apiUrl: string,
112
+ cache: AuthConfigCache,
113
+ /** The backend's `basePath`; only needed if it is not the default. */
114
+ apiPath: string = DEFAULT_API_PATH
115
+ ): Promise<AuthConfigResponse> {
117
116
  if (cache.cached) {
118
117
  return cache.cached;
119
118
  }
@@ -123,7 +122,7 @@ export async function fetchAuthConfig(apiUrl: string, cache: AuthConfigCache): P
123
122
  }
124
123
 
125
124
  cache.inflight = (async () => {
126
- const response = await fetchWithHandling(`${apiUrl}/api/auth/config`, {
125
+ const response = await fetchWithHandling(`${apiUrl.replace(/\/+$/, "")}${apiPath}/auth/config`, {
127
126
  method: "GET",
128
127
  headers: { "Content-Type": "application/json" }
129
128
  });
package/src/auth/index.ts CHANGED
@@ -13,14 +13,14 @@
13
13
  export type {
14
14
  RebaseAuthController,
15
15
  RebaseAuthControllerProps,
16
+ User,
16
17
  AuthTokens,
17
18
  DeviceSession,
18
- UserInfo,
19
19
  AuthResponse,
20
20
  RefreshResponse
21
21
  } from "./types";
22
22
 
23
23
  export { useRebaseAuthController } from "./useRebaseAuthController";
24
24
  // API utilities
25
- export { fetchAuthConfig, clearAuthConfigCache, createAuthConfigCache, AuthApiError } from "./api";
25
+ export { fetchAuthConfig, clearAuthConfigCache, createAuthConfigCache } from "./api";
26
26
  export type { AuthConfigResponse, AuthConfigCache } from "./api";
package/src/auth/types.ts CHANGED
@@ -2,12 +2,11 @@ import { User, AuthTokens, DeviceSession, RebaseSession, AuthChangeEvent } from
2
2
  import { AuthController } from "@rebasepro/admin-types";
3
3
  import type { AuthConfigResponse } from "./api";
4
4
 
5
- /** @deprecated Use `User` from `@rebasepro/types` instead. */
6
- export type UserInfo = User;
7
- /** @deprecated Use `DeviceSession` from `@rebasepro/types` instead. */
8
- export type Session = DeviceSession;
9
- // Re-export canonical types for backward compatibility
10
- export type { AuthTokens, DeviceSession };
5
+ // Re-export canonical types so the auth entry point stays self-contained. `User`
6
+ // joins them because the controller hands one back and a consumer installs
7
+ // `@rebasepro/app` alone — `@rebasepro/types` is this package's dependency, not
8
+ // theirs, so it is not a specifier they can import from.
9
+ export type { User, AuthTokens, DeviceSession };
11
10
 
12
11
  /**
13
12
  * Auth controller that extends the base AuthController
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Form layout resolution.
3
+ *
4
+ * Turns a collection into the shape the entity form renders: an ordered list of
5
+ * sections holding grid-spanned fields, plus the metadata rail beside them.
6
+ *
7
+ * The reason this is a pure function rather than logic inside the form is that
8
+ * the *defaults* are the interesting part. A collection that never writes an
9
+ * `admin.form` block still has to get a two-column layout out of this — the flat
10
+ * run of full-width fields it produced before was the single biggest cost in the
11
+ * form, and no amount of config would have fixed it for collections nobody
12
+ * hand-tunes. Deriving it here means it is testable in isolation, which matters
13
+ * because "what span does a date get" is exactly the kind of rule that rots.
14
+ */
15
+ import type {
16
+ AdminCollection,
17
+ FormSection,
18
+ PropertySpan
19
+ } from "@rebasepro/admin-types";
20
+ import type { Property } from "@rebasepro/types";
21
+ import { isHidden } from "./property_presentation";
22
+
23
+ /**
24
+ * Columns in the form grid.
25
+ *
26
+ * A local literal rather than an import of `FORM_GRID_COLUMNS`: everything else
27
+ * this module takes from `@rebasepro/admin-types` is a *type*, erased at build
28
+ * time, and a runtime value would make this pure function depend on that
29
+ * package's built output — which is exactly what broke the dev server, since
30
+ * `@rebasepro/app` resolves that package to its dist. `satisfies PropertySpan`
31
+ * keeps the two honest: a full-width field and the column count are the same
32
+ * number by definition.
33
+ */
34
+ const GRID_COLUMNS = 4 satisfies PropertySpan;
35
+
36
+ /** A field placed on the grid. */
37
+ export interface ResolvedFormField {
38
+ key: string;
39
+ /** Columns occupied, out of `GRID_COLUMNS`. Ignored in the rail. */
40
+ span: PropertySpan;
41
+ /** True for an `additionalFields` entry rather than a property. */
42
+ additional: boolean;
43
+ /**
44
+ * The span was written on the property rather than derived from its type.
45
+ * Row filling leaves these alone: an author who wrote a width meant it.
46
+ */
47
+ spanExplicit?: boolean;
48
+ }
49
+
50
+ export interface ResolvedFormSection {
51
+ key: string;
52
+ title?: string;
53
+ collapsible: boolean;
54
+ /** Initial state only; the form owns it after first interaction. */
55
+ collapsed: boolean;
56
+ fields: ResolvedFormField[];
57
+ }
58
+
59
+ export interface ResolvedFormLayout {
60
+ sections: ResolvedFormSection[];
61
+ /** Fields shown in the rail. Empty means no rail fields. */
62
+ sidebar: ResolvedFormField[];
63
+ /** Show the read-only id/created/updated block at the foot of the rail. */
64
+ showRecordMeta: boolean;
65
+ /** True when there is anything at all to put in the rail. */
66
+ hasRail: boolean;
67
+ }
68
+
69
+ export interface ResolveFormLayoutParams<M extends Record<string, unknown>> {
70
+ collection: AdminCollection<M>;
71
+ /**
72
+ * Field keys in render order, already filtered by `propertiesOrder` — i.e.
73
+ * the output of `getFormFieldKeys`. Passed in rather than recomputed so the
74
+ * form and the layout can never disagree about which fields exist.
75
+ */
76
+ fieldKeys: string[];
77
+ /**
78
+ * Which fields the user may edit right now. A manual id is editable while
79
+ * creating and frozen afterwards, and that decides whether it belongs in the
80
+ * form at all or only in the record block.
81
+ */
82
+ status: "new" | "existing" | "copy";
83
+ }
84
+
85
+ /* -------------------------------------------------------------------------- */
86
+ /* span derivation */
87
+ /* -------------------------------------------------------------------------- */
88
+
89
+ /** Does this property's editor need the full width of the column? */
90
+ function needsFullWidth(property: Property): boolean {
91
+ switch (property.type) {
92
+ case "map":
93
+ case "binary":
94
+ case "vector":
95
+ return true;
96
+ case "array": {
97
+ // A tag-style array of plain short strings reads fine at half width.
98
+ // Anything else — files, nested maps, `oneOf` blocks — does not.
99
+ if (property.oneOf) return true;
100
+ const of = Array.isArray(property.of) ? undefined : property.of;
101
+ if (!of) return true;
102
+ if (of.type !== "string" && of.type !== "number") return true;
103
+ if (of.type === "string" && (of.storage || of.admin?.markdown || of.admin?.multiline)) return true;
104
+ return false;
105
+ }
106
+ case "string":
107
+ return Boolean(property.storage || property.admin?.markdown || property.admin?.multiline);
108
+ default:
109
+ return false;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * The span a property gets when nothing is declared.
115
+ *
116
+ * Deliberately coarse: three buckets, chosen by how much room the *editor*
117
+ * needs, not by how important the field is. Importance is what `admin.form`
118
+ * sections are for.
119
+ */
120
+ export function deriveSpan(property: Property, isTitleProperty: boolean): PropertySpan {
121
+ if (needsFullWidth(property)) return 4;
122
+ // The record's name carries the row on its own; it is the first thing read.
123
+ if (isTitleProperty) return 4;
124
+ switch (property.type) {
125
+ case "number":
126
+ case "boolean":
127
+ return 1;
128
+ case "date":
129
+ case "geopoint":
130
+ case "reference":
131
+ case "relation":
132
+ return 2;
133
+ case "array":
134
+ return 2; // short primitive arrays only — the rest returned 4 above
135
+ case "string":
136
+ return 2;
137
+ default:
138
+ return 2;
139
+ }
140
+ }
141
+
142
+ /* -------------------------------------------------------------------------- */
143
+ /* id handling */
144
+ /* -------------------------------------------------------------------------- */
145
+
146
+ /**
147
+ * Can the user still type this id?
148
+ *
149
+ * A `manual` id is a real field while creating and frozen once the row exists.
150
+ * Every generated strategy (`uuid`, `cuid`, a raw SQL default) is never typed.
151
+ * Getting this wrong in the "frozen" direction would make manual-id collections
152
+ * uncreatable, so it fails towards showing the field.
153
+ */
154
+ export function isIdPropertyEditable(property: Property, status: "new" | "existing" | "copy"): boolean {
155
+ if (!("isId" in property) || !property.isId) return true;
156
+ if (status === "existing") return false;
157
+ return property.isId === "manual";
158
+ }
159
+
160
+ function isIdProperty(property: Property | undefined): boolean {
161
+ return Boolean(property && "isId" in property && property.isId);
162
+ }
163
+
164
+ /**
165
+ * An audit timestamp the database maintains — `created_at`, `updated_at`.
166
+ *
167
+ * Not a guess from the name: `autoValue` says the column is written by the
168
+ * system on create or update, so the field is never typed. Rendering it as an
169
+ * input in the middle of the form promised an edit that cannot happen, and the
170
+ * record block already shows both values — the same two dates twice on one
171
+ * screen.
172
+ */
173
+ export function isAuditTimestamp(property: Property | undefined): boolean {
174
+ return Boolean(
175
+ property
176
+ && property.type === "date"
177
+ && property.autoValue
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Close the trailing gap in each row.
183
+ *
184
+ * Without this a section of half-width fields with an odd count leaves a hole —
185
+ * `name | sku` then `brand | ␣␣` — a field stranded beside half a row of
186
+ * nothing, which reads as a mistake rather than a layout. The remainder is
187
+ * spread across the row's derived fields rather than dumped on the last one, so
188
+ * a lone half-width field becomes full width and a pair grows evenly. Fields
189
+ * with an explicit span are never resized.
190
+ */
191
+ export function fillRows(fields: ResolvedFormField[]): ResolvedFormField[] {
192
+ const rows: ResolvedFormField[][] = [];
193
+ let row: ResolvedFormField[] = [];
194
+ let used = 0;
195
+
196
+ for (const field of fields) {
197
+ if (used + field.span > GRID_COLUMNS && row.length) {
198
+ rows.push(row);
199
+ row = [];
200
+ used = 0;
201
+ }
202
+ row.push(field);
203
+ used += field.span;
204
+ }
205
+ if (row.length) rows.push(row);
206
+
207
+ return rows.flatMap(entries => {
208
+ const total = entries.reduce((sum, f) => sum + f.span, 0);
209
+ let gap = GRID_COLUMNS - total;
210
+ if (gap <= 0) return entries;
211
+
212
+ // Spread the remainder across the row's derived fields rather than
213
+ // dumping it all on the last one: two half-width fields beside a gap
214
+ // become two three-quarter fields, not one full-width and one half.
215
+ // Fields with an explicit span keep it — the author picked that width.
216
+ const growable = entries
217
+ .map((f, i) => [f, i] as const)
218
+ .filter(([f]) => !f.spanExplicit);
219
+
220
+ if (!growable.length) return entries;
221
+
222
+ const next = [...entries];
223
+ let cursor = 0;
224
+ while (gap > 0) {
225
+ const [, index] = growable[cursor % growable.length];
226
+ next[index] = { ...next[index], span: (next[index].span + 1) as PropertySpan };
227
+ gap -= 1;
228
+ cursor += 1;
229
+ }
230
+ return next;
231
+ });
232
+ }
233
+
234
+ /* -------------------------------------------------------------------------- */
235
+ /* resolution */
236
+ /* -------------------------------------------------------------------------- */
237
+
238
+ function buildField<M extends Record<string, unknown>>(
239
+ key: string,
240
+ collection: AdminCollection<M>,
241
+ titlePropertyKey: string | undefined
242
+ ): ResolvedFormField | undefined {
243
+ const property = collection.properties?.[key] as Property | undefined;
244
+
245
+ if (!property) {
246
+ // An additionalFields entry: a rendered card, not an input. Full width,
247
+ // because we have no idea what the Builder puts inside it.
248
+ const additional = collection.additionalFields?.some(f => f.key === key);
249
+ return additional ? { key, span: 4, additional: true } : undefined;
250
+ }
251
+
252
+ if (isHidden(property)) return undefined;
253
+
254
+ const admin = property.admin;
255
+ const explicit = admin?.span !== undefined;
256
+ const span: PropertySpan = admin?.span ?? deriveSpan(property, key === titlePropertyKey);
257
+
258
+ return { key, span, additional: false, spanExplicit: explicit };
259
+ }
260
+
261
+ /**
262
+ * Resolve the layout for a collection's generated form.
263
+ *
264
+ * Never drops a field: anything not named by a section lands in a trailing
265
+ * group, so adding a column to the database cannot make it silently invisible
266
+ * in the panel.
267
+ */
268
+ export function resolveFormLayout<M extends Record<string, unknown>>({
269
+ collection,
270
+ fieldKeys,
271
+ status
272
+ }: ResolveFormLayoutParams<M>): ResolvedFormLayout {
273
+
274
+ const config = collection.form;
275
+ const titlePropertyKey = collection.titleProperty as string | undefined;
276
+
277
+ const available = new Map<string, ResolvedFormField>();
278
+ for (const key of fieldKeys) {
279
+ const field = buildField(key, collection, titlePropertyKey);
280
+ if (field) available.set(key, field);
281
+ }
282
+
283
+ /* ---- the id ---------------------------------------------------------- */
284
+ // A single surrogate key is routed out of the form and into the record
285
+ // block, unless it is still typeable. This is what `hideIdFromForm` used to
286
+ // be for; that flag now only decides whether the record block shows it.
287
+ //
288
+ // A COMPOSITE key is not. Postgres has no `id` — a row is addressed by one
289
+ // or more real columns, and `entity.id` is a token we synthesise on top
290
+ // (`a:::b`, see `buildCompositeId`). When the key spans several columns
291
+ // those columns are data: on a junction row, `order_id` and `product_id`
292
+ // are *which order* and *which product*. Hiding them behind an address
293
+ // nobody can read would remove the only meaningful thing on the form.
294
+ const idKeys = [...available.keys()].filter(key =>
295
+ isIdProperty(collection.properties?.[key] as Property | undefined));
296
+
297
+ if (idKeys.length === 1) {
298
+ const property = collection.properties?.[idKeys[0]] as Property;
299
+ if (!isIdPropertyEditable(property, status)) {
300
+ available.delete(idKeys[0]);
301
+ }
302
+ }
303
+
304
+ /* ---- audit timestamps ------------------------------------------------ */
305
+ // Same treatment as the id, and for the same reason: the record block shows
306
+ // them, so leaving them in the column renders each date twice — once as a
307
+ // disabled input the user cannot change, once as metadata.
308
+ const showRecordMetaResolved = config?.showRecordMeta ?? !collection.hideIdFromForm;
309
+ if (showRecordMetaResolved) {
310
+ for (const key of [...available.keys()]) {
311
+ const property = collection.properties?.[key] as Property | undefined;
312
+ // An explicit `sidebar` entry wins — the author asked for it there.
313
+ if (config?.sidebar?.includes(key as never)) continue;
314
+ if (isAuditTimestamp(property)) available.delete(key);
315
+ }
316
+ }
317
+
318
+ /* ---- the rail -------------------------------------------------------- */
319
+ const sidebar: ResolvedFormField[] = [];
320
+ if (config?.sidebar) {
321
+ for (const key of config.sidebar as string[]) {
322
+ const field = available.get(key);
323
+ if (!field) continue; // unknown, hidden, or already the id
324
+ sidebar.push(field);
325
+ available.delete(key);
326
+ }
327
+ }
328
+
329
+ const showRecordMeta = showRecordMetaResolved;
330
+
331
+ /* ---- sections -------------------------------------------------------- */
332
+ const sections: ResolvedFormSection[] = [];
333
+
334
+ if (config?.sections?.length) {
335
+ for (const section of config.sections as FormSection<M>[]) {
336
+ const fields: ResolvedFormField[] = [];
337
+ for (const key of section.properties as string[]) {
338
+ const field = available.get(key);
339
+ if (!field) continue;
340
+ fields.push(field);
341
+ available.delete(key);
342
+ }
343
+ const titled = Boolean(section.title);
344
+ sections.push({
345
+ key: section.key,
346
+ title: section.title,
347
+ collapsible: section.collapsible ?? titled,
348
+ collapsed: titled ? Boolean(section.collapsed) : false,
349
+ fields
350
+ });
351
+ }
352
+ }
353
+
354
+ // Whatever no section claimed. Appended rather than dropped — a new column
355
+ // showing up unstyled is recoverable, a new column vanishing is not.
356
+ const leftovers = fieldKeys
357
+ .map(key => available.get(key))
358
+ .filter((f): f is ResolvedFormField => Boolean(f));
359
+
360
+ if (leftovers.length) {
361
+ // Always a trailing group, never merged into an existing section. Folding
362
+ // them into the first untitled section put `created_at`/`updated_at`
363
+ // between the product's images and its pricing — the reader cannot tell
364
+ // "we grouped this here" from "nobody placed this yet", and the second is
365
+ // what actually happened.
366
+ sections.push({
367
+ key: sections.length ? "__other" : "__main",
368
+ collapsible: false,
369
+ collapsed: false,
370
+ fields: leftovers
371
+ });
372
+ }
373
+
374
+ // A configured section that ended up empty (every key hidden, unknown, or
375
+ // claimed by the rail) would render as a heading over nothing.
376
+ const nonEmpty = sections
377
+ .filter(s => s.fields.length > 0)
378
+ .map(s => ({ ...s, fields: fillRows(s.fields) }));
379
+
380
+ return {
381
+ sections: nonEmpty,
382
+ sidebar,
383
+ showRecordMeta,
384
+ hasRail: sidebar.length > 0 || showRecordMeta
385
+ };
386
+ }
@@ -1,6 +1,7 @@
1
1
  export * from "./collection_view_config";
2
2
  export * from "./entity_image_preview";
3
3
  export * from "./filter-operator-resolution";
4
+ export * from "./form-layout";
4
5
  export * from "./navigation_from_path";
5
6
  export * from "./navigation_utils";
6
7
  export * from "./parent_references_from_path";
@@ -212,6 +212,47 @@ index });
212
212
  return scored.map(candidate => candidate.key);
213
213
  }
214
214
 
215
+ /**
216
+ * The relation a collection *leads* with, if it leads with one.
217
+ *
218
+ * A junction-shaped collection — a pipeline entry, a membership, a booking —
219
+ * holds no label of its own: it is "this candidate, for that vacancy". The
220
+ * general ranking puts relations last (they only read once the target
221
+ * resolves), so the title slot would otherwise fall to whatever free text
222
+ * happens to follow: a note, a comment. Preview surfaces that render one row
223
+ * per entity (list, cards, board) prefer the leading relation instead.
224
+ *
225
+ * The decision is structural — the first property that could be a title, in the
226
+ * order the developer declared (or ordered) them. Declaration order is a
227
+ * statement about what the collection is about; property *names* are not
228
+ * consulted here.
229
+ *
230
+ * Returns `undefined` when that first property is anything but a
231
+ * single-cardinality relation, or when the collection states its own
232
+ * `titleProperty`.
233
+ *
234
+ * @group Collections
235
+ */
236
+ export function getLeadingRelationTitleKey<M extends Record<string, unknown>>(
237
+ collection: AdminCollection<M>
238
+ ): string | undefined {
239
+ if (!collection.properties) return undefined;
240
+ if (collection.titleProperty) return undefined;
241
+
242
+ const idKeys = new Set<string>(getPrimaryKeys(collection) as string[]);
243
+ const foreignKeys = getForeignKeyColumns(collection);
244
+ const order = (collection.propertiesOrder as string[] | undefined) ?? Object.keys(collection.properties);
245
+
246
+ for (const key of order) {
247
+ const property = collection.properties[key];
248
+ if (!property || isPropertyBuilder(property)) continue;
249
+ if (scoreTitleCandidate(property as Property, key, idKeys, foreignKeys) === SCORE.DISQUALIFIED) continue;
250
+ return (property as Property).type === "relation" ? key : undefined;
251
+ }
252
+
253
+ return undefined;
254
+ }
255
+
215
256
  /**
216
257
  * The property that should fill the title slot for a collection, ignoring any
217
258
  * concrete values. Prefer {@link getTitlePropertyKeyForValues} when an entity