@rebasepro/app 0.13.0 → 0.13.1-canary.g18cfeb7
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/dist/collections/entity-display-cache.d.ts +75 -0
- package/dist/collections/entity-display.d.ts +35 -0
- package/dist/collections/entity_image_preview.d.ts +8 -0
- package/dist/collections/form-layout.d.ts +6 -0
- package/dist/collections/index.d.ts +4 -0
- package/dist/collections/property-path.d.ts +16 -0
- package/dist/collections/property_presentation.d.ts +18 -3
- package/dist/collections/summary-property.d.ts +65 -0
- package/dist/components/common/useColumnsIds.d.ts +22 -0
- package/dist/core/Rebase.d.ts +1 -1
- package/dist/core/RebaseProps.d.ts +10 -2
- package/dist/index.es.js +660 -210
- package/dist/index.es.js.map +1 -1
- package/dist/util/entity_cache.d.ts +19 -3
- package/dist/util/previews.d.ts +19 -0
- package/package.json +7 -7
- package/src/auth/useRebaseAuthController.ts +9 -1
- package/src/collections/entity-display-cache.ts +195 -0
- package/src/collections/entity-display.ts +105 -0
- package/src/collections/entity_image_preview.ts +13 -0
- package/src/collections/form-layout.ts +8 -1
- package/src/collections/index.ts +4 -0
- package/src/collections/property-path.ts +30 -0
- package/src/collections/property_presentation.ts +39 -4
- package/src/collections/summary-property.ts +114 -0
- package/src/collections/title-property.ts +13 -16
- package/src/components/common/useColumnsIds.tsx +67 -29
- package/src/core/Rebase.tsx +20 -2
- package/src/core/RebaseProps.tsx +10 -2
- package/src/hooks/data/useCollection.tsx +20 -3
- package/src/hooks/data/useFetch.tsx +17 -2
- package/src/hooks/data/useRelationSelector.tsx +20 -4
- package/src/hooks/useAuthSubscription.ts +18 -2
- package/src/hooks/useBuildLocalConfigurationPersistence.tsx +20 -12
- package/src/locales/de.ts +1 -0
- package/src/locales/en.ts +2 -0
- package/src/locales/es.ts +1 -0
- package/src/locales/fr.ts +1 -0
- package/src/locales/hi.ts +2 -1
- package/src/locales/it.ts +1 -0
- package/src/locales/pt.ts +1 -0
- package/src/util/entity_cache.ts +34 -31
- package/src/util/previews.ts +49 -29
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Saves data to the
|
|
2
|
+
* Saves data to the local-changes backup in `sessionStorage`.
|
|
3
|
+
*
|
|
4
|
+
* The backup only — a draft that survives a reload is not an edit in flight.
|
|
5
|
+
* {@link saveEntityToMemoryCache} is the other channel; see {@link entityCache}
|
|
6
|
+
* for what separates them.
|
|
3
7
|
* @param path - The unique path/key for the data.
|
|
4
8
|
* @param data - The data to cache and persist.
|
|
5
9
|
*/
|
|
6
10
|
export declare function saveEntityToCache(path: string, data: object): void;
|
|
11
|
+
/**
|
|
12
|
+
* Consumes a handoff. The channel is one-shot: whoever picks an edit up owns it
|
|
13
|
+
* from then on, and an edit left behind here is one that reopens a record dirty
|
|
14
|
+
* long after the user stopped looking at it.
|
|
15
|
+
*/
|
|
7
16
|
export declare function removeEntityFromMemoryCache(path: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* Parks an edit in flight for the layout about to take over. See
|
|
19
|
+
* {@link entityCache}: this is the handoff, not the backup.
|
|
20
|
+
*/
|
|
8
21
|
export declare function saveEntityToMemoryCache(path: string, data: object): void;
|
|
9
22
|
export declare function getEntityFromMemoryCache(path: string): object | undefined;
|
|
10
23
|
/**
|
|
11
|
-
* Retrieves a entity from the
|
|
12
|
-
*
|
|
24
|
+
* Retrieves a entity from the local-changes backup in `sessionStorage`.
|
|
25
|
+
*
|
|
26
|
+
* The backup only — the handoff channel is read through
|
|
27
|
+
* {@link getEntityFromMemoryCache}, and the two mean different things to the
|
|
28
|
+
* form that receives them.
|
|
13
29
|
* @param path - The unique path/key for the entity.
|
|
14
30
|
* @returns The cached entity or `undefined` if not found.
|
|
15
31
|
*/
|
package/dist/util/previews.d.ts
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import type { PropertyConfig, AdminCollection } from "@rebasepro/admin-types";
|
|
2
2
|
import { AuthController } from "@rebasepro/admin-types";
|
|
3
|
+
/**
|
|
4
|
+
* The properties a preview surface should render for a record, best first.
|
|
5
|
+
*
|
|
6
|
+
* Three things decide the answer, in this order:
|
|
7
|
+
*
|
|
8
|
+
* 1. `previewProperties` — passed in, or declared on the collection. A stated
|
|
9
|
+
* list is returned verbatim, ranking and limit included: a developer who
|
|
10
|
+
* asks for the Markdown biography gets the Markdown biography.
|
|
11
|
+
* 2. Whether the value has a one-line form at all. A map renders as a
|
|
12
|
+
* key/value table and a Markdown field as a document; neither fits a card
|
|
13
|
+
* line, so they never take a slot from a value that does. See
|
|
14
|
+
* {@link rankSummaryProperty}.
|
|
15
|
+
* 3. `propertiesOrder`, which breaks ties.
|
|
16
|
+
*
|
|
17
|
+
* The middle step is the one that is easy to get wrong by leaving out.
|
|
18
|
+
* `propertiesOrder` states the *column* order of a collection table — it is
|
|
19
|
+
* not a statement that the first three columns summarise a record, and reading
|
|
20
|
+
* it as one is how a card ends up rendering somebody's entire biography.
|
|
21
|
+
*/
|
|
3
22
|
export declare function getEntityPreviewKeys(authController: AuthController, targetCollection: AdminCollection<any>, fields: Record<string, PropertyConfig>, previewProperties?: string[], limit?: number): string[];
|
|
4
23
|
/**
|
|
5
24
|
* The `include` params that eager-load a collection's relations in the same
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/app",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.13.
|
|
4
|
+
"version": "0.13.1-canary.g18cfeb7",
|
|
5
5
|
"description": "Rebase core — framework-agnostic runtime for data-driven admin panels",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -52,12 +52,12 @@
|
|
|
52
52
|
"magic-string": "^0.30.0",
|
|
53
53
|
"notistack": "^3.0.2",
|
|
54
54
|
"react-i18next": "^17.0.11",
|
|
55
|
-
"@rebasepro/admin-types": "0.13.
|
|
56
|
-
"@rebasepro/common": "0.13.
|
|
57
|
-
"@rebasepro/forms": "0.13.
|
|
58
|
-
"@rebasepro/types": "0.13.
|
|
59
|
-
"@rebasepro/ui": "0.13.
|
|
60
|
-
"@rebasepro/utils": "0.13.
|
|
55
|
+
"@rebasepro/admin-types": "0.13.1-canary.g18cfeb7",
|
|
56
|
+
"@rebasepro/common": "0.13.1-canary.g18cfeb7",
|
|
57
|
+
"@rebasepro/forms": "0.13.1-canary.g18cfeb7",
|
|
58
|
+
"@rebasepro/types": "0.13.1-canary.g18cfeb7",
|
|
59
|
+
"@rebasepro/ui": "0.13.1-canary.g18cfeb7",
|
|
60
|
+
"@rebasepro/utils": "0.13.1-canary.g18cfeb7"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
63
|
"react": ">=19.2.7",
|
|
@@ -100,7 +100,15 @@ export function useRebaseAuthController(
|
|
|
100
100
|
// Fetch backend auth configuration
|
|
101
101
|
auth.getAuthConfig().then((config: AuthConfigResponse) => {
|
|
102
102
|
if (isMountedRef.current) setAuthConfig(config);
|
|
103
|
-
}).catch(() => {
|
|
103
|
+
}).catch((e: unknown) => {
|
|
104
|
+
// Swallowed entirely before. This is what tells the login view which
|
|
105
|
+
// providers exist, so losing it renders a login form that is wrong
|
|
106
|
+
// rather than one that is broken — the hardest kind to report. Not
|
|
107
|
+
// promoted to `authError`, which blanks the whole app: a signed-in
|
|
108
|
+
// user is unaffected by this failing.
|
|
109
|
+
console.warn("[Rebase] Could not load the backend auth configuration; " +
|
|
110
|
+
"the login view will fall back to defaults.", e);
|
|
111
|
+
});
|
|
104
112
|
|
|
105
113
|
const unsubscribe = auth.onAuthStateChange(syncState);
|
|
106
114
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store behind a computed display value.
|
|
3
|
+
*
|
|
4
|
+
* A record's title is asked for far more often than it changes, and by many
|
|
5
|
+
* components at once: a list of fifty rows, each row's relation chips, the
|
|
6
|
+
* breadcrumb above them. Resolving per component is what makes an async display
|
|
7
|
+
* value a bad idea — fifty rows becomes fifty reads, then fifty more on the next
|
|
8
|
+
* render.
|
|
9
|
+
*
|
|
10
|
+
* So resolution is keyed by record *and* role, in-flight calls are shared, and
|
|
11
|
+
* results are kept until something says otherwise. Deliberately not a React
|
|
12
|
+
* thing: the same store answers an imperative caller (an export, a breadcrumb
|
|
13
|
+
* built outside the tree), and it is testable without a renderer.
|
|
14
|
+
*/
|
|
15
|
+
import type { EntityDisplayRole } from "@rebasepro/admin-types";
|
|
16
|
+
|
|
17
|
+
type CacheEntry = {
|
|
18
|
+
/** Set once resolved. `null` means "resolved to nothing", not "unknown". */
|
|
19
|
+
value: unknown;
|
|
20
|
+
} | {
|
|
21
|
+
/** Shared by every caller that arrives while the first one is in flight. */
|
|
22
|
+
promise: Promise<unknown>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type EntityDisplayKey = string;
|
|
26
|
+
|
|
27
|
+
/** The identity of one role of one record, as a cache key. */
|
|
28
|
+
export function entityDisplayKey(
|
|
29
|
+
path: string,
|
|
30
|
+
entityId: string | number | undefined,
|
|
31
|
+
role: EntityDisplayRole
|
|
32
|
+
): EntityDisplayKey {
|
|
33
|
+
return `${role} ${path} ${entityId ?? ""}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class EntityDisplayCache {
|
|
37
|
+
|
|
38
|
+
private readonly entries = new Map<EntityDisplayKey, CacheEntry>();
|
|
39
|
+
private readonly listeners = new Set<() => void>();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The resolved value, or `undefined` when this pair has not been resolved
|
|
43
|
+
* yet. `null` is a resolved absence, and the two must stay distinct: a
|
|
44
|
+
* caller that reads "not yet" as "nothing" flickers its fallback in on every
|
|
45
|
+
* mount.
|
|
46
|
+
*/
|
|
47
|
+
peek(key: EntityDisplayKey): unknown | undefined {
|
|
48
|
+
const entry = this.entries.get(key);
|
|
49
|
+
if (!entry || "promise" in entry) return undefined;
|
|
50
|
+
return entry.value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True while a resolution for this pair is in flight. */
|
|
54
|
+
isLoading(key: EntityDisplayKey): boolean {
|
|
55
|
+
const entry = this.entries.get(key);
|
|
56
|
+
return Boolean(entry && "promise" in entry);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve once per record and role. Concurrent callers share the first
|
|
61
|
+
* call's promise; later callers get the cached value with no promise at all.
|
|
62
|
+
*
|
|
63
|
+
* A resolver that throws is recorded as "nothing" rather than retried: the
|
|
64
|
+
* alternative is every render re-running a call that just failed. And it is
|
|
65
|
+
* reported here, which is a correction.
|
|
66
|
+
*
|
|
67
|
+
* It used to say "the caller that saw the rejection is the one that logs
|
|
68
|
+
* it", and `useEntityDisplay` duly attached a `.catch()` that warned. But
|
|
69
|
+
* both failure paths below swallow and return a *resolved* promise, so that
|
|
70
|
+
* catch could never run — the two halves each did the reasonable thing and
|
|
71
|
+
* between them the log was unreachable. A resolver that blew up produced a
|
|
72
|
+
* blank chip and total silence, which is the failure mode
|
|
73
|
+
* `EntityDisplayResolver`'s own contract ("treated as `undefined` and logged
|
|
74
|
+
* once") exists to rule out.
|
|
75
|
+
*
|
|
76
|
+
* Reporting belongs here for the reason the caller could not do it: this is
|
|
77
|
+
* the one place that runs exactly once per key, so "once" is a property of
|
|
78
|
+
* the code rather than a hope about how many components mount.
|
|
79
|
+
*/
|
|
80
|
+
resolve(key: EntityDisplayKey, resolver: () => unknown): Promise<unknown> {
|
|
81
|
+
const entry = this.entries.get(key);
|
|
82
|
+
if (entry) {
|
|
83
|
+
return "promise" in entry ? entry.promise : Promise.resolve(entry.value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let produced: unknown;
|
|
87
|
+
try {
|
|
88
|
+
produced = resolver();
|
|
89
|
+
} catch (error: unknown) {
|
|
90
|
+
this.fail(key, error);
|
|
91
|
+
return Promise.resolve(null);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A synchronous resolver never enters the loading state. Putting it
|
|
95
|
+
// through one would render every caller's fallback for a frame, for
|
|
96
|
+
// nothing.
|
|
97
|
+
if (!isPromise(produced)) {
|
|
98
|
+
const value = normalize(produced);
|
|
99
|
+
this.set(key, value);
|
|
100
|
+
return Promise.resolve(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const promise = produced
|
|
104
|
+
.then(resolved => {
|
|
105
|
+
const value = normalize(resolved);
|
|
106
|
+
this.set(key, value);
|
|
107
|
+
return value;
|
|
108
|
+
})
|
|
109
|
+
.catch((error: unknown) => {
|
|
110
|
+
this.fail(key, error);
|
|
111
|
+
return null;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
this.entries.set(key, { promise });
|
|
115
|
+
return promise;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Drop what is known about a record, so the next ask resolves again. Called
|
|
120
|
+
* after a write: the row that just saved may be called something else now.
|
|
121
|
+
*/
|
|
122
|
+
invalidate(path: string, entityId?: string | number): void {
|
|
123
|
+
const suffix = ` ${path} ${entityId ?? ""}`;
|
|
124
|
+
let changed = false;
|
|
125
|
+
for (const key of [...this.entries.keys()]) {
|
|
126
|
+
// `undefined` id means the whole collection — after an import, or a
|
|
127
|
+
// locale switch that changes what every title in it reads.
|
|
128
|
+
const matches = entityId === undefined
|
|
129
|
+
? key.includes(` ${path} `) || key.endsWith(` ${path} `)
|
|
130
|
+
: key.endsWith(suffix);
|
|
131
|
+
if (matches) {
|
|
132
|
+
this.entries.delete(key);
|
|
133
|
+
changed = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (changed) this.emit();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Drop everything. The user signed out, or the app swapped datasource. */
|
|
140
|
+
clear(): void {
|
|
141
|
+
if (this.entries.size === 0) return;
|
|
142
|
+
this.entries.clear();
|
|
143
|
+
this.emit();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
subscribe(listener: () => void): () => void {
|
|
147
|
+
this.listeners.add(listener);
|
|
148
|
+
return () => {
|
|
149
|
+
this.listeners.delete(listener);
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private set(key: EntityDisplayKey, value: unknown): void {
|
|
154
|
+
this.entries.set(key, { value });
|
|
155
|
+
this.emit();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Record a failed resolution as "nothing", and say so once.
|
|
160
|
+
*
|
|
161
|
+
* The key is the message: it is `<role> <path> <id>`, which is exactly what a
|
|
162
|
+
* reader needs to find the resolver that blew up. A warning with no key would
|
|
163
|
+
* tell them a display resolver failed somewhere in a list of fifty rows.
|
|
164
|
+
*
|
|
165
|
+
* `console.warn` rather than a thrown error, because this runs while a row is
|
|
166
|
+
* rendering: the contract is that a title which cannot be fetched must not
|
|
167
|
+
* take down the row that shows it.
|
|
168
|
+
*/
|
|
169
|
+
private fail(key: EntityDisplayKey, error: unknown): void {
|
|
170
|
+
console.warn(`[rebase] Could not resolve display value for ${key}:`, error);
|
|
171
|
+
this.set(key, null);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private emit(): void {
|
|
175
|
+
for (const listener of [...this.listeners]) listener();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Empty strings and empty arrays are absences, not values — a title of `" "`
|
|
181
|
+
* would otherwise beat the derived one and render as a blank heading.
|
|
182
|
+
*/
|
|
183
|
+
function normalize(value: unknown): unknown {
|
|
184
|
+
if (value === undefined || value === null) return null;
|
|
185
|
+
if (typeof value === "string") {
|
|
186
|
+
const trimmed = value.trim();
|
|
187
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
188
|
+
}
|
|
189
|
+
if (Array.isArray(value)) return value.length > 0 ? value : null;
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isPromise(value: unknown): value is Promise<unknown> {
|
|
194
|
+
return typeof (value as Promise<unknown> | undefined)?.then === "function";
|
|
195
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a collection's `display` block.
|
|
3
|
+
*
|
|
4
|
+
* Two questions, kept apart because they are answered at different times: which
|
|
5
|
+
* *property* fills a role (readable from values already in hand) and which
|
|
6
|
+
* *resolver* fills it (may have to go to the network). A caller that cannot
|
|
7
|
+
* await — a sort comparator, an export column, a server render — uses the key
|
|
8
|
+
* and is documented to ignore resolvers.
|
|
9
|
+
*/
|
|
10
|
+
import type {
|
|
11
|
+
AdminCollection,
|
|
12
|
+
EntityDisplayResolver,
|
|
13
|
+
EntityDisplayRole
|
|
14
|
+
} from "@rebasepro/admin-types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Collections that have already warned about a deprecated field, so a list of
|
|
18
|
+
* fifty rows produces one line in the console rather than fifty.
|
|
19
|
+
*/
|
|
20
|
+
const deprecationWarned = new Set<string>();
|
|
21
|
+
|
|
22
|
+
function collectionId(collection: AdminCollection<never> | AdminCollection<any>): string {
|
|
23
|
+
return (collection.slug ?? collection.name ?? "collection") as string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function warnOnce(id: string, message: string): void {
|
|
27
|
+
const key = `${id}:${message}`;
|
|
28
|
+
if (deprecationWarned.has(key)) return;
|
|
29
|
+
deprecationWarned.add(key);
|
|
30
|
+
console.warn(message);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What the collection declares for a role, before deciding which form it is.
|
|
35
|
+
*
|
|
36
|
+
* `display.title` wins over the deprecated `titleProperty`: a collection setting
|
|
37
|
+
* both is mid-migration, and the new field is the one it means.
|
|
38
|
+
*/
|
|
39
|
+
function getDeclaredSource<M extends Record<string, unknown>>(
|
|
40
|
+
collection: AdminCollection<M>,
|
|
41
|
+
role: EntityDisplayRole
|
|
42
|
+
): string | EntityDisplayResolver<M, unknown> | undefined {
|
|
43
|
+
|
|
44
|
+
const display = collection.display as Record<string, unknown> | undefined;
|
|
45
|
+
const declared = display?.[role];
|
|
46
|
+
if (declared !== undefined) return declared as string | EntityDisplayResolver<M, unknown>;
|
|
47
|
+
|
|
48
|
+
if (role === "title" && collection.titleProperty) {
|
|
49
|
+
const id = collectionId(collection);
|
|
50
|
+
warnOnce(
|
|
51
|
+
id,
|
|
52
|
+
`[rebase] Collection "${id}" uses admin.titleProperty, which is deprecated. ` +
|
|
53
|
+
`Move it to admin.display.title — the same string works there, and display.title ` +
|
|
54
|
+
`also accepts a resolver for a title the record does not carry.`
|
|
55
|
+
);
|
|
56
|
+
return collection.titleProperty as string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The property path a role is declared to read, when it is declared as a path.
|
|
64
|
+
*
|
|
65
|
+
* Returns `undefined` for a role filled by a resolver — a resolver has no key —
|
|
66
|
+
* and for a role the collection says nothing about, which is then derived.
|
|
67
|
+
*
|
|
68
|
+
* @group Collections
|
|
69
|
+
*/
|
|
70
|
+
export function getDisplayPropertyKey<M extends Record<string, unknown>>(
|
|
71
|
+
collection: AdminCollection<M>,
|
|
72
|
+
role: EntityDisplayRole
|
|
73
|
+
): string | undefined {
|
|
74
|
+
const declared = getDeclaredSource(collection, role);
|
|
75
|
+
return typeof declared === "string" ? declared : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The resolver a role is declared to use, when it is declared as one.
|
|
80
|
+
*
|
|
81
|
+
* @group Collections
|
|
82
|
+
*/
|
|
83
|
+
export function getDisplayResolver<M extends Record<string, unknown>>(
|
|
84
|
+
collection: AdminCollection<M>,
|
|
85
|
+
role: EntityDisplayRole
|
|
86
|
+
): EntityDisplayResolver<M, unknown> | undefined {
|
|
87
|
+
const declared = getDeclaredSource(collection, role);
|
|
88
|
+
return typeof declared === "function" ? declared : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* True when the collection states this role at all, in either form.
|
|
93
|
+
*
|
|
94
|
+
* The derivation is a guess about what a collection probably means; a statement
|
|
95
|
+
* outranks it, and the heuristics that look for "the first enum" or "the leading
|
|
96
|
+
* relation" have to stand down when one exists.
|
|
97
|
+
*
|
|
98
|
+
* @group Collections
|
|
99
|
+
*/
|
|
100
|
+
export function hasDeclaredDisplay<M extends Record<string, unknown>>(
|
|
101
|
+
collection: AdminCollection<M>,
|
|
102
|
+
role: EntityDisplayRole
|
|
103
|
+
): boolean {
|
|
104
|
+
return getDeclaredSource(collection, role) !== undefined;
|
|
105
|
+
}
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import { CollectionConfig } from "@rebasepro/types";
|
|
2
|
+
import type { AdminCollection } from "@rebasepro/admin-types";
|
|
3
|
+
import { getDisplayPropertyKey } from "./entity-display";
|
|
2
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The property that fills a record's image slot.
|
|
7
|
+
*
|
|
8
|
+
* `admin.display.image` first, then six fallbacks in descending confidence —
|
|
9
|
+
* the first image-typed storage property, an array of them, a URL rendered as an
|
|
10
|
+
* image, and so on. The ladder stays for collections that say nothing; a
|
|
11
|
+
* collection that names its picture is not guessed at.
|
|
12
|
+
*/
|
|
3
13
|
export function getEntityImagePreviewPropertyKey<M extends Record<string, unknown>>(collection: CollectionConfig<M>): string | undefined {
|
|
4
14
|
|
|
15
|
+
const declared = getDisplayPropertyKey(collection as AdminCollection<M>, "image");
|
|
16
|
+
if (declared) return declared;
|
|
17
|
+
|
|
5
18
|
// find first storage property of type image
|
|
6
19
|
for (const key in collection.properties) {
|
|
7
20
|
const property = collection.properties[key];
|
|
@@ -54,6 +54,12 @@ export interface ResolvedFormSection {
|
|
|
54
54
|
/** Initial state only; the form owns it after first interaction. */
|
|
55
55
|
collapsed: boolean;
|
|
56
56
|
fields: ResolvedFormField[];
|
|
57
|
+
/**
|
|
58
|
+
* Declared arrangement for the read-only view. Carried through untouched —
|
|
59
|
+
* the resolver decides *which* fields a section holds, not how the surface
|
|
60
|
+
* that renders it stacks them, and only the read view honours this.
|
|
61
|
+
*/
|
|
62
|
+
readVariant?: "grid" | "summary";
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
export interface ResolvedFormLayout {
|
|
@@ -346,7 +352,8 @@ export function resolveFormLayout<M extends Record<string, unknown>>({
|
|
|
346
352
|
title: section.title,
|
|
347
353
|
collapsible: section.collapsible ?? titled,
|
|
348
354
|
collapsed: titled ? Boolean(section.collapsed) : false,
|
|
349
|
-
fields
|
|
355
|
+
fields,
|
|
356
|
+
readVariant: section.readVariant
|
|
350
357
|
});
|
|
351
358
|
}
|
|
352
359
|
}
|
package/src/collections/index.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
export * from "./collection_view_config";
|
|
2
2
|
export * from "./entity_image_preview";
|
|
3
|
+
export * from "./entity-display";
|
|
4
|
+
export * from "./entity-display-cache";
|
|
3
5
|
export * from "./filter-operator-resolution";
|
|
4
6
|
export * from "./form-layout";
|
|
5
7
|
export * from "./navigation_from_path";
|
|
6
8
|
export * from "./navigation_utils";
|
|
7
9
|
export * from "./parent_references_from_path";
|
|
10
|
+
export * from "./property-path";
|
|
8
11
|
export * from "./property_presentation";
|
|
12
|
+
export * from "./summary-property";
|
|
9
13
|
export * from "./title-property";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Properties, Property } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The property at a dotted path, walking `map` children — `address.street`.
|
|
5
|
+
*
|
|
6
|
+
* The value counterpart is `getValueInPath` in `@rebasepro/utils`; this is the
|
|
7
|
+
* schema half, and the two have to be used together. Reading a dotted path off
|
|
8
|
+
* an entity while looking its property up with a flat `properties[path]` gives
|
|
9
|
+
* the value and `undefined` for how to render it, which is how a declared title
|
|
10
|
+
* on a nested field silently fell back to a derived one.
|
|
11
|
+
*
|
|
12
|
+
* There were three copies of this: one private to `useColumnsIds`, one exported
|
|
13
|
+
* from the admin layer, and the flat lookup in the title resolver that was not
|
|
14
|
+
* this function at all. This is the one, in the lowest layer that needs it —
|
|
15
|
+
* admin re-exports it under the name it already published.
|
|
16
|
+
*/
|
|
17
|
+
export function getPropertyInPath(properties: Properties, path: string): Property | undefined {
|
|
18
|
+
if (typeof properties !== "object" || !properties) return undefined;
|
|
19
|
+
if (path in properties) {
|
|
20
|
+
return (properties as Record<string, Property>)[path];
|
|
21
|
+
}
|
|
22
|
+
if (path.includes(".")) {
|
|
23
|
+
const pathSegments = path.split(".");
|
|
24
|
+
const childProperty = (properties as Record<string, Property>)[pathSegments[0]];
|
|
25
|
+
if (typeof childProperty === "object" && childProperty?.type === "map" && childProperty.properties) {
|
|
26
|
+
return getPropertyInPath(childProperty.properties, pathSegments.slice(1).join("."));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
@@ -10,17 +10,40 @@
|
|
|
10
10
|
* That last one is worth knowing about rather than assuming: the collection editor
|
|
11
11
|
* has a whole Conditions UI, `serializable_utils` persists what it writes, and
|
|
12
12
|
* `BaseProperty.conditions` documents itself as "evaluated at runtime like property
|
|
13
|
-
* builders" — but the evaluator below is reached only from its own tests.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* builders" — but the evaluator below is reached only from its own tests. It lives
|
|
14
|
+
* here because here is where it would be called from once it is wired up.
|
|
15
|
+
*
|
|
16
|
+
* The one part of `conditions` that *is* applied is the literal case:
|
|
17
|
+
* `hidden`/`readOnly`/`disabled` stated as a plain boolean rather than as a rule.
|
|
18
|
+
* A literal needs no context, so `isHidden`/`isReadOnly`/`isDisabled` can answer
|
|
19
|
+
* it directly, and those three gates are consulted everywhere a field is laid
|
|
20
|
+
* out. A *rule* still is not evaluated anywhere in production — the split is
|
|
21
|
+
* deliberate, not an oversight: it is the difference between a condition that
|
|
22
|
+
* needs an entity to be evaluated against and one that does not.
|
|
16
23
|
*/
|
|
17
|
-
import type { ConditionContext, EnumValueConfig, Property, PropertyConditions, ReferenceProperty } from "@rebasepro/types";
|
|
24
|
+
import type { ConditionContext, ConditionRule, EnumValueConfig, Property, PropertyConditions, ReferenceProperty } from "@rebasepro/types";
|
|
18
25
|
import type { AdminArrayOptions, AdminReferenceOptions } from "@rebasepro/admin-types";
|
|
19
26
|
import { evaluateCondition } from "@rebasepro/common";
|
|
20
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A condition stated as a literal rather than as a rule.
|
|
30
|
+
*
|
|
31
|
+
* `PropertyConditions` accepts either, and the two are answered in different
|
|
32
|
+
* places: a rule needs a context and so can only be evaluated while rendering a
|
|
33
|
+
* particular entity, but a literal is already the answer. Reading it here is
|
|
34
|
+
* what makes `hidden: true` work without every gate below having to build a
|
|
35
|
+
* condition context first — and without the caller reaching for
|
|
36
|
+
* `{ "==": [1, 1] }` to say something it can say with a boolean.
|
|
37
|
+
*/
|
|
38
|
+
function literalCondition(condition: ConditionRule | undefined): boolean {
|
|
39
|
+
return condition === true;
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
export function isReadOnly(property: Property): boolean {
|
|
22
43
|
if (property.admin?.readOnly)
|
|
23
44
|
return true;
|
|
45
|
+
if (literalCondition(property.conditions?.readOnly))
|
|
46
|
+
return true;
|
|
24
47
|
if (property.type === "date") {
|
|
25
48
|
if (property.autoValue)
|
|
26
49
|
return true;
|
|
@@ -32,9 +55,21 @@ export function isReadOnly(property: Property): boolean {
|
|
|
32
55
|
}
|
|
33
56
|
|
|
34
57
|
export function isHidden(property: Property): boolean {
|
|
58
|
+
if (literalCondition(property.conditions?.hidden)) return true;
|
|
35
59
|
return typeof property.admin?.disabled === "object" && Boolean(property.admin?.disabled.hidden);
|
|
36
60
|
}
|
|
37
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Whether the field is disabled by its own declaration, ignoring form state.
|
|
64
|
+
*
|
|
65
|
+
* The `admin.disabled` block and `conditions.disabled: true` say the same thing
|
|
66
|
+
* two ways, so every caller that gated on the first now asks here instead of
|
|
67
|
+
* growing a second check of its own.
|
|
68
|
+
*/
|
|
69
|
+
export function isDisabled(property: Property): boolean {
|
|
70
|
+
return Boolean(property.admin?.disabled) || literalCondition(property.conditions?.disabled);
|
|
71
|
+
}
|
|
72
|
+
|
|
38
73
|
export function applyPropertyConditions(
|
|
39
74
|
property: Property,
|
|
40
75
|
context: ConditionContext
|