@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.
- package/README.md +1 -1
- package/dist/auth/api.d.ts +3 -8
- package/dist/auth/index.d.ts +2 -2
- package/dist/auth/types.d.ts +1 -5
- package/dist/collections/form-layout.d.ts +107 -0
- package/dist/collections/index.d.ts +1 -0
- package/dist/collections/title-property.d.ts +22 -0
- package/dist/components/Debug/collection-views/sample_data.d.ts +3 -2
- package/dist/hooks/ApiConfigContext.d.ts +36 -2
- package/dist/hooks/useBackendStorageSource.d.ts +6 -1
- package/dist/hooks/useNavigationBlocker.d.ts +1 -1
- package/dist/hooks/useSnackbarController.d.ts +2 -1
- package/dist/hooks/useStudioBridge.d.ts +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.es.js +681 -186
- package/dist/index.es.js.map +1 -1
- package/dist/internal/common.d.ts +14 -2
- package/package.json +29 -25
- package/src/auth/api.ts +8 -9
- package/src/auth/index.ts +2 -2
- package/src/auth/types.ts +5 -6
- package/src/collections/form-layout.ts +386 -0
- package/src/collections/index.ts +1 -0
- package/src/collections/title-property.ts +41 -0
- package/src/components/Debug/UIReferenceView.tsx +9 -1
- package/src/components/Debug/collection-views/sample_data.ts +4 -2
- package/src/components/NotFoundPage.tsx +1 -1
- package/src/components/common/useDataTableController.tsx +1 -1
- package/src/core/Rebase.tsx +5 -1
- package/src/core/RebaseRouter.tsx +2 -1
- package/src/core/RebaseRoutes.tsx +1 -1
- package/src/hooks/ApiConfigContext.tsx +45 -3
- package/src/hooks/useAuthSubscription.ts +29 -3
- package/src/hooks/useBackendStorageSource.ts +8 -1
- package/src/hooks/useNavigationBlocker.tsx +1 -1
- package/src/hooks/useSnackbarController.tsx +34 -6
- package/src/hooks/useStudioBridge.tsx +50 -2
- package/src/index.ts +1 -1
- package/src/internal/common.tsx +15 -3
- package/src/internal/useRestoreScroll.tsx +1 -1
- package/src/locales/de.ts +23 -2
- package/src/locales/en.ts +23 -1
- package/src/locales/es.ts +23 -1
- package/src/locales/fr.ts +23 -1
- package/src/locales/hi.ts +23 -1
- package/src/locales/it.ts +23 -1
- package/src/locales/pt.ts +23 -1
- package/src/util/enums.ts +4 -2
- 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.
|
|
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": ">=
|
|
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.
|
|
49
|
-
"fuse.js": "^7.
|
|
50
|
-
"i18next": "^26.3.
|
|
51
|
-
"lucide-react": "1.
|
|
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.
|
|
55
|
-
"@rebasepro/admin-types": "0.
|
|
56
|
-
"@rebasepro/common": "0.
|
|
57
|
-
"@rebasepro/forms": "0.
|
|
58
|
-
"@rebasepro/types": "0.
|
|
59
|
-
"@rebasepro/ui": "0.
|
|
60
|
-
"@rebasepro/utils": "0.
|
|
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.
|
|
64
|
-
"react-dom": ">=19.
|
|
65
|
-
"react-router": "^
|
|
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": "^
|
|
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": "^
|
|
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.
|
|
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
|
|
86
|
-
"ts-jest": "^29.4.
|
|
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.
|
|
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(
|
|
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}/
|
|
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
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
+
}
|
package/src/collections/index.ts
CHANGED
|
@@ -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
|