@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.
Files changed (43) hide show
  1. package/dist/collections/entity-display-cache.d.ts +75 -0
  2. package/dist/collections/entity-display.d.ts +35 -0
  3. package/dist/collections/entity_image_preview.d.ts +8 -0
  4. package/dist/collections/form-layout.d.ts +6 -0
  5. package/dist/collections/index.d.ts +4 -0
  6. package/dist/collections/property-path.d.ts +16 -0
  7. package/dist/collections/property_presentation.d.ts +18 -3
  8. package/dist/collections/summary-property.d.ts +65 -0
  9. package/dist/components/common/useColumnsIds.d.ts +22 -0
  10. package/dist/core/Rebase.d.ts +1 -1
  11. package/dist/core/RebaseProps.d.ts +10 -2
  12. package/dist/index.es.js +660 -210
  13. package/dist/index.es.js.map +1 -1
  14. package/dist/util/entity_cache.d.ts +19 -3
  15. package/dist/util/previews.d.ts +19 -0
  16. package/package.json +7 -7
  17. package/src/auth/useRebaseAuthController.ts +9 -1
  18. package/src/collections/entity-display-cache.ts +195 -0
  19. package/src/collections/entity-display.ts +105 -0
  20. package/src/collections/entity_image_preview.ts +13 -0
  21. package/src/collections/form-layout.ts +8 -1
  22. package/src/collections/index.ts +4 -0
  23. package/src/collections/property-path.ts +30 -0
  24. package/src/collections/property_presentation.ts +39 -4
  25. package/src/collections/summary-property.ts +114 -0
  26. package/src/collections/title-property.ts +13 -16
  27. package/src/components/common/useColumnsIds.tsx +67 -29
  28. package/src/core/Rebase.tsx +20 -2
  29. package/src/core/RebaseProps.tsx +10 -2
  30. package/src/hooks/data/useCollection.tsx +20 -3
  31. package/src/hooks/data/useFetch.tsx +17 -2
  32. package/src/hooks/data/useRelationSelector.tsx +20 -4
  33. package/src/hooks/useAuthSubscription.ts +18 -2
  34. package/src/hooks/useBuildLocalConfigurationPersistence.tsx +20 -12
  35. package/src/locales/de.ts +1 -0
  36. package/src/locales/en.ts +2 -0
  37. package/src/locales/es.ts +1 -0
  38. package/src/locales/fr.ts +1 -0
  39. package/src/locales/hi.ts +2 -1
  40. package/src/locales/it.ts +1 -0
  41. package/src/locales/pt.ts +1 -0
  42. package/src/util/entity_cache.ts +34 -31
  43. package/src/util/previews.ts +49 -29
@@ -1,15 +1,31 @@
1
1
  /**
2
- * Saves data to the in-memory cache and persists it individually in `sessionStorage`.
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 in-memory cache or `sessionStorage`.
12
- * If the entity is not in the cache but exists in `sessionStorage`, it loads it into the cache.
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
  */
@@ -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.0",
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.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"
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
  }
@@ -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. The
14
- * declarative conditions feature is authored and stored, never applied. It lives
15
- * here now because here is where it would be called from once it is wired up.
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