@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
@@ -0,0 +1,114 @@
1
+ import type { Property } from "@rebasepro/types";
2
+
3
+ /**
4
+ * Whether a property can stand in for a record in *one line*.
5
+ *
6
+ * Preview surfaces — the reference card, a list row, a board card — have a
7
+ * single line per property and no way to grow. That is a constraint on the
8
+ * *value*, not on the property's importance: a Markdown biography is the most
9
+ * interesting column on an author, and it is still the wrong thing to paste
10
+ * into a 44px card. Ranking it here means the picker can skip it without
11
+ * anyone having to configure their way out of a broken card.
12
+ *
13
+ * Kept beside {@link getTitlePropertyCandidates} because the two answer
14
+ * neighbouring questions — "what is this record called" and "what else can be
15
+ * said about it in a line" — from the same property schema.
16
+ *
17
+ * @group Collections
18
+ */
19
+ export const SUMMARY_RANK = {
20
+ /**
21
+ * No single-line form exists. A map renders as a key/value table and an
22
+ * array of maps as a stack of them; there is no first line to take.
23
+ */
24
+ UNUSABLE: 0,
25
+ /**
26
+ * Readable in one line, but only as an excerpt — the property holds a
27
+ * document, and the line is its opening. Worth showing when nothing better
28
+ * is available, never in preference to a value that fits whole.
29
+ */
30
+ EXCERPT: 1,
31
+ /** Short and self-contained: the line *is* the value. */
32
+ DIRECT: 2
33
+ } as const;
34
+
35
+ /** One of the three summary ranks. */
36
+ export type SummaryRank = typeof SUMMARY_RANK[keyof typeof SUMMARY_RANK];
37
+
38
+ /**
39
+ * File-storage backed content (single image, array of images, generic upload…).
40
+ * Preview surfaces give these their own image slot, so they are never also a
41
+ * text line.
42
+ *
43
+ * @group Collections
44
+ */
45
+ export function isStorageProperty(property: Property | undefined): boolean {
46
+ if (!property) return false;
47
+ if (property.type === "string" && (property.storage || property.admin?.urlPreview === "image")) return true;
48
+ if (property.type === "array" && property.of && !Array.isArray(property.of)) {
49
+ const inner = property.of;
50
+ if (inner.type === "string" && (inner.storage || inner.admin?.urlPreview === "image")) return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ /**
56
+ * True when the property holds free text long enough to be a document rather
57
+ * than a value — Markdown, or an explicitly multi-line string.
58
+ *
59
+ * @group Collections
60
+ */
61
+ export function isLongTextProperty(property: Property | undefined): boolean {
62
+ if (!property || property.type !== "string") return false;
63
+ return Boolean(property.admin?.markdown || property.admin?.multiline);
64
+ }
65
+
66
+ /**
67
+ * How well a property reads as one line of a preview. See {@link SUMMARY_RANK}.
68
+ *
69
+ * Decided from the property *schema* only: no values are consulted, so the
70
+ * answer is stable for a collection and can be memoised per collection rather
71
+ * than per row.
72
+ *
73
+ * @group Collections
74
+ */
75
+ export function rankSummaryProperty(property: Property | undefined): SummaryRank {
76
+ if (!property) return SUMMARY_RANK.UNUSABLE;
77
+
78
+ // Rendered by the image slot, not as text.
79
+ if (isStorageProperty(property)) return SUMMARY_RANK.UNUSABLE;
80
+
81
+ // A map is a table of its own, whether it declares its properties (a row
82
+ // per sub-property) or not (a key/value dump). Neither has a first line.
83
+ if (property.type === "map") return SUMMARY_RANK.UNUSABLE;
84
+
85
+ if (property.type === "array") {
86
+ // A tuple: several properties side by side, so there is nothing single
87
+ // about the line it would produce.
88
+ if (Array.isArray(property.of)) return SUMMARY_RANK.UNUSABLE;
89
+ if (!property.of) return property.oneOf ? SUMMARY_RANK.UNUSABLE : SUMMARY_RANK.DIRECT;
90
+ // Arrays of block-shaped things inherit the block shape.
91
+ const innerRank = rankSummaryProperty(property.of);
92
+ if (innerRank === SUMMARY_RANK.UNUSABLE) return SUMMARY_RANK.UNUSABLE;
93
+ // An array of documents is a stack of excerpts — worse than one
94
+ // excerpt, and never what a card should lead with.
95
+ if (innerRank === SUMMARY_RANK.EXCERPT) return SUMMARY_RANK.UNUSABLE;
96
+ // Short values render as a run of chips, which fits a line.
97
+ return SUMMARY_RANK.DIRECT;
98
+ }
99
+
100
+ if (isLongTextProperty(property)) return SUMMARY_RANK.EXCERPT;
101
+
102
+ // Numbers, dates, booleans, enums, plain strings, and single references or
103
+ // relations (which collapse to one linked line) all fit as they are.
104
+ return SUMMARY_RANK.DIRECT;
105
+ }
106
+
107
+ /**
108
+ * True when the property has any single-line form at all.
109
+ *
110
+ * @group Collections
111
+ */
112
+ export function canSummariseProperty(property: Property | undefined): boolean {
113
+ return rankSummaryProperty(property) !== SUMMARY_RANK.UNUSABLE;
114
+ }
@@ -2,6 +2,9 @@ import { isRelationalCollectionConfig, type Property, type Relation } from "@reb
2
2
  import { generateForeignKeyName } from "@rebasepro/utils";
3
3
  import type { AdminCollection } from "@rebasepro/admin-types";
4
4
  import { getPrimaryKeys, isPropertyBuilder } from "@rebasepro/common";
5
+ import { getPropertyInPath } from "./property-path";
6
+ import { getDisplayPropertyKey, hasDeclaredDisplay } from "./entity-display";
7
+ import { isStorageProperty } from "./summary-property";
5
8
 
6
9
  /**
7
10
  * How good a property is as the human-readable title of an entity.
@@ -56,19 +59,6 @@ function isHidden(property: Property): boolean {
56
59
  return Boolean(property.admin?.hideFromCollection);
57
60
  }
58
61
 
59
- /**
60
- * File-storage backed content (single image, array of images, generic upload…).
61
- * Rendered by the dedicated image slot, so never a title.
62
- */
63
- function isStorageProperty(property: Property): boolean {
64
- if (property.type === "string" && (property.storage || property.admin?.urlPreview === "image")) return true;
65
- if (property.type === "array" && property.of && !Array.isArray(property.of)) {
66
- const inner = property.of;
67
- if (inner.type === "string" && (inner.storage || inner.admin?.urlPreview === "image")) return true;
68
- }
69
- return false;
70
- }
71
-
72
62
  /**
73
63
  * Every column on this collection that stores a foreign key: the `localKey` of
74
64
  * each owning relation (declared inline on a property or in `relations[]`), the
@@ -182,8 +172,13 @@ export function getTitlePropertyCandidates<M extends Record<string, unknown>>(
182
172
  ): string[] {
183
173
  if (!collection.properties) return [];
184
174
 
185
- if (collection.titleProperty && collection.properties[collection.titleProperty as string]) {
186
- return [collection.titleProperty as string];
175
+ // `getPropertyInPath`, not `properties[key]`: a declared title on a nested
176
+ // field — `profile.display_name` — was looked up flat, found nothing, and
177
+ // silently fell through to the derived ranking. The dotted form has been
178
+ // documented as valid the whole time.
179
+ const declared = getDisplayPropertyKey(collection, "title");
180
+ if (declared && getPropertyInPath(collection.properties, declared)) {
181
+ return [declared];
187
182
  }
188
183
 
189
184
  const idKeys = new Set<string>(getPrimaryKeys(collection) as string[]);
@@ -237,7 +232,9 @@ export function getLeadingRelationTitleKey<M extends Record<string, unknown>>(
237
232
  collection: AdminCollection<M>
238
233
  ): string | undefined {
239
234
  if (!collection.properties) return undefined;
240
- if (collection.titleProperty) return undefined;
235
+ // The collection states what it is called, in one form or the other; either
236
+ // way it is not asking for the leading-relation heuristic.
237
+ if (hasDeclaredDisplay(collection, "title")) return undefined;
241
238
 
242
239
  const idKeys = new Set<string>(getPrimaryKeys(collection) as string[]);
243
240
  const foreignKeys = getForeignKeyColumns(collection);
@@ -1,28 +1,10 @@
1
- import type { Property, MapProperty, Properties } from "@rebasepro/types";
1
+ import type { Property, MapProperty, Properties, RelationProperty } from "@rebasepro/types";
2
2
  import { useMemo } from "react";
3
3
  ;
4
- import { getSubcollections } from "@rebasepro/common";
4
+ import { getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getSubcollections } from "@rebasepro/common";
5
5
  import type { AdminCollection } from "@rebasepro/admin-types";
6
-
7
- /**
8
- * Get a property in a property tree from a dot-path like `address.street`.
9
- * Inlined here to avoid importing property-aware utilities from the admin layer.
10
- */
11
- function getPropertyInPath(properties: Properties, path: string): Property | undefined {
12
- if (typeof properties === "object") {
13
- if (path in properties) {
14
- return (properties as Record<string, Property>)[path];
15
- }
16
- if (path.includes(".")) {
17
- const pathSegments = path.split(".");
18
- const childProperty = (properties as Record<string, Property>)[pathSegments[0]];
19
- if (typeof childProperty === "object" && childProperty.type === "map" && childProperty.properties) {
20
- return getPropertyInPath(childProperty.properties, pathSegments.slice(1).join("."));
21
- }
22
- }
23
- }
24
- return undefined;
25
- }
6
+ import { getPropertyInPath } from "../../collections/property-path";
7
+ import { isDisabled } from "../../collections/property_presentation";
26
8
 
27
9
  export type PropertyColumnConfig = {
28
10
  key: string;
@@ -32,6 +14,43 @@ export type PropertyColumnConfig = {
32
14
  export function getSubcollectionColumnId(collection: AdminCollection<any>) {
33
15
  return `subcollection:${collection.slug}`;
34
16
  }
17
+
18
+ /**
19
+ * The jump-to-tab columns a collection table does not need, because the relation
20
+ * behind them already has a column of its own.
21
+ *
22
+ * Every child view gets a 200px button column that opens its tab. For a relation
23
+ * declared in `relations` that button is the relation's only presence in the
24
+ * table. For one declared as a property it is the second: the property's own
25
+ * column is already there, hydrated by the list fetch's `include: ["*"]`, showing
26
+ * the child rows themselves — and carrying the *same heading*, because the tab
27
+ * takes its name from the declaring property. Two columns called "Applications",
28
+ * one of them a button, and nothing in the header to tell them apart.
29
+ *
30
+ * The property column wins: it shows what the children are, and each chip in it
31
+ * opens one. The tab stays reachable by opening the record, which is what the
32
+ * rest of the table's rows do anyway.
33
+ *
34
+ * Unless the author hid that column — `hideFromCollection` on the property, or a
35
+ * `propertiesOrder` that omits it, is a statement about the column and not about
36
+ * the relation, so the button comes back rather than the relation dropping out
37
+ * of the table altogether.
38
+ */
39
+ export function getRedundantChildViewColumnIds<M extends Record<string, any>>(collection: AdminCollection<M>): Set<string> {
40
+ const redundant = new Set<string>();
41
+ const properties = collection.properties ?? {};
42
+ const order = collection.propertiesOrder as string[] | undefined;
43
+
44
+ for (const [viewKey, propertyKey] of getChildViewDeclaringProperties(collection)) {
45
+ const property = properties[propertyKey] as Property | undefined;
46
+ if (!property || property.admin?.hideFromCollection) continue;
47
+ if (order && !order.includes(propertyKey)) continue;
48
+ redundant.add(`subcollection:${viewKey}`);
49
+ }
50
+
51
+ return redundant;
52
+ }
53
+
35
54
  export function useColumnIds<M extends Record<string, any>>(collection: AdminCollection<M>, includeSubcollections: boolean): PropertyColumnConfig[] {
36
55
  return useMemo(() => {
37
56
  if (collection.propertiesOrder) {
@@ -59,9 +78,14 @@ function hideAndExpandKeys<M extends Record<string, any>>(collection: AdminColle
59
78
  // Track processed keys to avoid duplicates
60
79
  const processedPropertyKeys = new Set<string>();
61
80
 
81
+ // Both column paths funnel through here, including a `propertiesOrder` that
82
+ // was saved back with the button column in it.
83
+ const redundantChildViewColumns = getRedundantChildViewColumnIds(collection);
84
+
62
85
  const result = keys.flatMap((key) => {
63
86
  // Skip if already processed (handles duplicates in propertiesOrder)
64
87
  if (processedPropertyKeys.has(key)) return [null];
88
+ if (redundantChildViewColumns.has(key)) return [null];
65
89
 
66
90
  // Check if it's a top-level property
67
91
  const property = collection.properties[key];
@@ -85,7 +109,7 @@ function hideAndExpandKeys<M extends Record<string, any>>(collection: AdminColle
85
109
  }
86
110
  return [{
87
111
  key,
88
- disabled: Boolean(property.admin?.disabled) || Boolean(property.admin?.readOnly)
112
+ disabled: isDisabled(property) || Boolean(property.admin?.readOnly)
89
113
  }];
90
114
  }
91
115
 
@@ -108,8 +132,8 @@ function hideAndExpandKeys<M extends Record<string, any>>(collection: AdminColle
108
132
 
109
133
  return [{
110
134
  key,
111
- disabled: Boolean(rootProperty.admin?.disabled) || Boolean(rootProperty.admin?.readOnly) ||
112
- Boolean(nestedProperty.admin?.disabled) || Boolean(nestedProperty.admin?.readOnly)
135
+ disabled: isDisabled(rootProperty) || Boolean(rootProperty.admin?.readOnly) ||
136
+ isDisabled(nestedProperty) || Boolean(nestedProperty.admin?.readOnly)
113
137
  }];
114
138
  }
115
139
  }
@@ -163,7 +187,7 @@ function hideAndExpandKeys<M extends Record<string, any>>(collection: AdminColle
163
187
  } else {
164
188
  result.push({
165
189
  key: propKey,
166
- disabled: Boolean(property.admin?.disabled) || Boolean(property.admin?.readOnly)
190
+ disabled: isDisabled(property) || Boolean(property.admin?.readOnly)
167
191
  });
168
192
  processedPropertyKeys.add(propKey);
169
193
  }
@@ -194,22 +218,36 @@ function getDefaultColumnKeys<M extends Record<string, any> = any>(collection: A
194
218
  }
195
219
 
196
220
  export function getColumnKeysForProperty(property: Property, key: string, disabled?: boolean): PropertyColumnConfig[] {
221
+ // A key with no property behind it describes no column. Callers walk whole
222
+ // property maps here to build a table's columns, so reading `.type` off a
223
+ // hole threw away every other column with it.
224
+ if (!property) return [];
197
225
  if (property.type === "map" && property.admin?.spreadChildren && property.properties) {
198
226
  return Object.entries(property.properties)
199
227
  .flatMap(([childKey, childProperty]) => getColumnKeysForProperty(
200
228
  childProperty as Readonly<Property>,
201
229
  `${key}.${childKey}`,
202
- disabled || Boolean(property.admin?.disabled) || Boolean(property.admin?.readOnly))
230
+ disabled || isDisabled(property) || Boolean(property.admin?.readOnly))
203
231
  );
204
232
  }
205
233
  return [{
206
234
  key,
207
- disabled: disabled || Boolean(property.admin?.disabled) || Boolean(property.admin?.readOnly)
235
+ disabled: disabled || isDisabled(property) || Boolean(property.admin?.readOnly)
208
236
  }];
209
237
  }
210
238
 
211
239
  export function getFormFieldKeys(collection: AdminCollection): string[] {
212
- const propertyKeys = collection.properties ? Object.keys(collection.properties) : [];
240
+ const properties = collection.properties ?? {};
241
+ // A many-relation the entity view already lists as a tab is not an input:
242
+ // the tab is the treatment, and rendering the same declaration again as a
243
+ // picker asked the author to select a collection's own children from a
244
+ // dropdown. `renderInForm` is how a project that wants the picker asks for
245
+ // it back — opt in, because the tab is the default.
246
+ const consumedByChildView = getChildViewRelationPropertyKeys(collection);
247
+ const propertyKeys = Object.keys(properties).filter(key => {
248
+ if (!consumedByChildView.has(key)) return true;
249
+ return (properties[key] as RelationProperty).admin?.renderInForm === true;
250
+ });
213
251
  const additionalFields = collection.additionalFields ?? [];
214
252
  const allKeys = [
215
253
  ...propertyKeys,
@@ -6,7 +6,7 @@ import type { ComponentOverrideMap } from "@rebasepro/admin-types";
6
6
  import React, { useEffect, useMemo, useRef, useState } from "react";
7
7
  import { CenteredView, Typography } from "@rebasepro/ui";
8
8
  import { User, CollectionRegistryController, DataDriver, DataSourceDefinition, RebaseData, DEFAULT_DATA_SOURCE_KEY, StorageSource, StorageSourceDefinition, DEFAULT_STORAGE_SOURCE_KEY } from "@rebasepro/types";
9
- import { RebaseContext } from "@rebasepro/admin-types";
9
+ import { RebaseContext, UNRENDERED_SLOTS } from "@rebasepro/admin-types";
10
10
  import { PluginProviderStack } from "./PluginProviderStack";
11
11
  import { PluginLifecycleManager } from "./PluginLifecycleManager";
12
12
  import { AuthControllerContext, CollectionResolverRegistrationContext, CollectionResolver } from "../contexts";
@@ -46,7 +46,7 @@ import { useBuildEffectiveRoleController } from "../hooks/useBuildEffectiveRoleC
46
46
  *
47
47
  * @group Core
48
48
  */
49
- export function Rebase<USER extends User>(props: RebaseProps<USER>) {
49
+ export function Rebase<USER extends User, DB = unknown>(props: RebaseProps<USER, DB>) {
50
50
 
51
51
  const {
52
52
  children,
@@ -90,6 +90,24 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
90
90
  ...((plugins ?? []).flatMap((p) => p.slots ?? []))
91
91
  ], [directSlots, plugins]);
92
92
 
93
+ // Seven of the twenty-nine declared slots are rendered nowhere. They have
94
+ // props interfaces and rows in the public slot reference, so registering
95
+ // for one looks exactly like registering for one that works — and then
96
+ // nothing happens, with no way to tell whether the fault is the plugin's.
97
+ // `UNRENDERED_SLOTS` is kept honest by `slot-render-sites.test.ts`, which
98
+ // derives the same set by scanning for render sites.
99
+ useEffect(() => {
100
+ const dead = new Set<string>(UNRENDERED_SLOTS);
101
+ const registered = new Set(resolvedSlots.map(s => s.slot).filter(slot => dead.has(slot)));
102
+ for (const slot of registered) {
103
+ console.warn(
104
+ `[Rebase] A component is registered for the "${slot}" slot, which this ` +
105
+ "build declares but renders nowhere — nothing will appear. This is a gap " +
106
+ "in Rebase, not in your plugin."
107
+ );
108
+ }
109
+ }, [resolvedSlots]);
110
+
93
111
 
94
112
  // Auth fallback logic
95
113
  const clientAuthController = useAuthSubscription(authControllerProp ? undefined : client?.auth);
@@ -105,7 +105,7 @@ import type { EffectiveRoleController } from "@rebasepro/types";
105
105
  *
106
106
  * @group Models
107
107
  */
108
- export type RebaseProps<USER extends User> = {
108
+ export type RebaseProps<USER extends User, DB = unknown> = {
109
109
 
110
110
  /**
111
111
  * The root components of your application. Use RebaseAdmin, RebaseStudio, and RebaseShell.
@@ -151,8 +151,16 @@ export type RebaseProps<USER extends User> = {
151
151
  * entry keyed `"(default)"` carries a driver.
152
152
  * `client.auth` is subscribed unless `authController` is provided.
153
153
  * `client.storage` is used unless `storageSource` is provided.
154
+ *
155
+ * `DB` is inferred from whatever is passed, and defaults to `unknown` for
156
+ * an untyped client. It has to be a parameter rather than a fixed
157
+ * `RebaseClient`: `RebaseClient<unknown>` is not a supertype of
158
+ * `RebaseClient<Database>` — the dynamic branch of `RebaseSdkData` is an
159
+ * index signature that no concrete instantiation satisfies — so pinning it
160
+ * here rejected the client of every project that generates a `Database`
161
+ * type, which is the whole typed-SDK path.
154
162
  */
155
- client?: RebaseClient;
163
+ client?: RebaseClient<DB>;
156
164
 
157
165
  /**
158
166
  * The data sources of the app. Each entry pairs a
@@ -147,6 +147,16 @@ export function useCollection<M extends Record<string, any>, USER extends User>(
147
147
  }, (res) => onEntitiesUpdate({ data: res.data as Entity<M>[],
148
148
  meta: res.meta }), onError);
149
149
  } else {
150
+ // The one-shot fallback, taken whenever the client has no socket.
151
+ // `listen` above cancels on cleanup, and every dependency of this
152
+ // effect — the search string, the filters, the sort, the page —
153
+ // changes while a request is in flight. A promise cannot be
154
+ // unsubscribed, so the cleanup has to disown its result instead:
155
+ // without this, whichever response arrives last wins, and responses
156
+ // do not arrive in request order. Typing into search would settle
157
+ // on the results for whichever query the server happened to finish
158
+ // last.
159
+ let cancelled = false;
150
160
  accessor.find({
151
161
  where: whereParams,
152
162
  limit: itemCount,
@@ -156,10 +166,17 @@ meta: res.meta }), onError);
156
166
  searchString,
157
167
  include: includeParams
158
168
  })
159
- .then((res) => onEntitiesUpdate({ data: res.data as Entity<M>[],
160
- meta: res.meta }))
161
- .catch(onError);
169
+ .then((res) => {
170
+ if (cancelled) return;
171
+ onEntitiesUpdate({ data: res.data as Entity<M>[],
172
+ meta: res.meta });
173
+ })
174
+ .catch((e) => {
175
+ if (cancelled) return;
176
+ onError(e);
177
+ });
162
178
  return () => {
179
+ cancelled = true;
163
180
  };
164
181
  }
165
182
  }, [path, itemCount, offset, page, currentSort, sortByProperty, filterValues, searchString, dataClient, collection]);
@@ -117,10 +117,25 @@ export function useFetch<M extends Record<string, any>, USER extends User = User
117
117
  if (accessor.listenById) {
118
118
  return accessor.listenById(entityId, (entity) => onEntityUpdate(entity as Entity<M> | undefined), onError);
119
119
  } else {
120
+ // The one-shot fallback, taken whenever the client has no
121
+ // socket. `listenById` above cancels on cleanup; a promise
122
+ // cannot, so the cleanup disowns its result instead. `entityId`
123
+ // is an effect dependency, and where this hook's caller is not
124
+ // remounted per record — the side panel replaces its contents
125
+ // in place — a slow response for the record just left would
126
+ // otherwise land on top of the one now showing.
127
+ let cancelled = false;
120
128
  accessor.findById(entityId)
121
- .then((entity) => onEntityUpdate(entity as Entity<M> | undefined))
122
- .catch(onError);
129
+ .then((entity) => {
130
+ if (cancelled) return;
131
+ onEntityUpdate(entity as Entity<M> | undefined);
132
+ })
133
+ .catch((e) => {
134
+ if (cancelled) return;
135
+ onError(e);
136
+ });
123
137
  return () => {
138
+ cancelled = true;
124
139
  };
125
140
  }
126
141
  }
@@ -174,6 +174,14 @@ export function useRelationSelector<M extends Record<string, any> = any>(
174
174
  }, (res) => onEntitiesUpdate({ data: res.data as Entity<M>[],
175
175
  meta: res.meta }), onErrorUpdate);
176
176
  } else {
177
+ // The one-shot fallback, taken whenever the client has no socket.
178
+ // `cleanupSubscription` is what keeps this hook's results matching
179
+ // the query that asked for them, and it can only do that if the
180
+ // fallback hands back something that actually cancels. A promise
181
+ // cannot be unsubscribed, so this disowns its result instead —
182
+ // otherwise the debounce is the only thing between a slow response
183
+ // for "ab" and its landing on top of the results for "abc".
184
+ let cancelled = false;
177
185
  accessor.find({
178
186
  where: whereParams,
179
187
  limit: limit,
@@ -182,10 +190,18 @@ meta: res.meta }), onErrorUpdate);
182
190
  searchString: currentSearch,
183
191
  include: includeParams
184
192
  })
185
- .then((res) => onEntitiesUpdate({ data: res.data as Entity<M>[],
186
- meta: res.meta }))
187
- .catch(onErrorUpdate);
188
- unsubscribe = () => {};
193
+ .then((res) => {
194
+ if (cancelled) return;
195
+ onEntitiesUpdate({ data: res.data as Entity<M>[],
196
+ meta: res.meta });
197
+ })
198
+ .catch((e) => {
199
+ if (cancelled) return;
200
+ onErrorUpdate(e);
201
+ });
202
+ unsubscribe = () => {
203
+ cancelled = true;
204
+ };
189
205
  }
190
206
 
191
207
  unsubscribeRef.current = unsubscribe || null;
@@ -50,9 +50,25 @@ export function useAuthSubscription(authClient?: AuthClient): AuthController {
50
50
  if (user) {
51
51
  setUser(user);
52
52
  }
53
+ setAuthError(undefined);
53
54
  })
54
- .catch(e => {
55
- // Ignore, user just isn't logged in
55
+ .catch((e: unknown) => {
56
+ // A 401 or 403 is the ordinary first load of an anonymous
57
+ // visitor: there is no session, which is not an error.
58
+ //
59
+ // Everything else is. This used to swallow the lot with
60
+ // "Ignore, user just isn't logged in", leaving `authError`
61
+ // — a field this controller declares and `Rebase` renders a
62
+ // full-screen "Error loading auth" view for — permanently
63
+ // undefined. So the view was unreachable, and a backend that
64
+ // was down, misconfigured, or blocked by CORS looked exactly
65
+ // like being signed out: the app showed the login form.
66
+ const status = (e as { status?: number } | undefined)?.status;
67
+ if (status === 401 || status === 403) {
68
+ setAuthError(undefined);
69
+ return;
70
+ }
71
+ setAuthError(e instanceof Error ? e : new Error(String(e)));
56
72
  })
57
73
  .finally(() => {
58
74
  setInitialLoading(false);
@@ -1,15 +1,16 @@
1
1
  import { useCallback, useEffect, useRef, useState, useMemo } from "react";
2
2
  import { PartialCollectionConfig, UserConfigurationPersistence } from "@rebasepro/admin-types";
3
3
  import { stripCollectionPath } from "@rebasepro/common";
4
- import { mergeDeep } from "@rebasepro/utils";
4
+ import { isArrayValue, isRecordValue, mergeDeep, readStoredJson, writeStoredJson } from "@rebasepro/utils";
5
5
 
6
6
  export function useBuildLocalConfigurationPersistence(): UserConfigurationPersistence {
7
7
 
8
8
  const configCache = useRef<Record<string, PartialCollectionConfig>>({});
9
9
 
10
- const getCollectionFromStorage = useCallback((storageKey: string) => {
11
- const item = localStorage.getItem(storageKey);
12
- return item ? JSON.parse(item) : {};
10
+ // Read during render by every collection view, so an unreadable value here
11
+ // used to throw the view away rather than fall back to an unconfigured one.
12
+ const getCollectionFromStorage = useCallback(<M extends Record<string, any>>(storageKey: string): PartialCollectionConfig<M> => {
13
+ return readStoredJson<PartialCollectionConfig<M>>(storageKey, { fallback: {}, accept: isRecordValue });
13
14
  }, []);
14
15
 
15
16
  const getCollectionConfig = useCallback(<M extends Record<string, any>>(path: string): PartialCollectionConfig<M> => {
@@ -22,9 +23,15 @@ export function useBuildLocalConfigurationPersistence(): UserConfigurationPersis
22
23
 
23
24
  const onCollectionModified = useCallback(<M extends Record<string, any>>(path: string, data: PartialCollectionConfig<M>) => {
24
25
  const storageKey = `collection_config::${stripCollectionPath(path)}`;
25
- localStorage.setItem(storageKey, JSON.stringify(data));
26
+ writeStoredJson(storageKey, data);
26
27
  const cachedConfig = configCache.current[storageKey];
27
- const newConfig = mergeDeep(cachedConfig ?? getCollectionFromStorage(path), data);
28
+ // `getCollectionFromStorage` takes the storage key, not the path — every
29
+ // other caller passes one. Reading `path` looked up a key this hook
30
+ // never writes, so the fallback could only ever contribute `{}`. It is
31
+ // masked today because the one caller merges the stored config in
32
+ // before calling, but `onCollectionModified` accepts a partial by type
33
+ // and by its public interface, and a real partial would lose the rest.
34
+ const newConfig = mergeDeep(cachedConfig ?? getCollectionFromStorage(storageKey), data);
28
35
  configCache.current[storageKey] = mergeDeep(configCache.current[storageKey], newConfig);
29
36
  }, [getCollectionFromStorage]);
30
37
 
@@ -33,23 +40,24 @@ export function useBuildLocalConfigurationPersistence(): UserConfigurationPersis
33
40
  const [collapsedGroups, _setCollapsedGroups] = useState<string[]>([]);
34
41
 
35
42
  useEffect(() => {
36
- _setRecentlyVisitedPaths(localStorage.getItem("recently_visited_paths") ? JSON.parse(localStorage.getItem("recently_visited_paths")!) : []);
37
- _setFavouritePaths(localStorage.getItem("favourite_paths") ? JSON.parse(localStorage.getItem("favourite_paths")!) : []);
38
- _setCollapsedGroups(localStorage.getItem("collapsed_groups") ? JSON.parse(localStorage.getItem("collapsed_groups")!) : []);
43
+ const readPaths = (key: string) => readStoredJson<string[]>(key, { fallback: [], accept: isArrayValue });
44
+ _setRecentlyVisitedPaths(readPaths("recently_visited_paths"));
45
+ _setFavouritePaths(readPaths("favourite_paths"));
46
+ _setCollapsedGroups(readPaths("collapsed_groups"));
39
47
  }, []);
40
48
 
41
49
  const setRecentlyVisitedPaths = useCallback((paths: string[]) => {
42
- localStorage.setItem("recently_visited_paths", JSON.stringify(paths));
50
+ writeStoredJson("recently_visited_paths", paths);
43
51
  _setRecentlyVisitedPaths(paths);
44
52
  }, []);
45
53
 
46
54
  const setFavouritePaths = useCallback((paths: string[]) => {
47
- localStorage.setItem("favourite_paths", JSON.stringify(paths));
55
+ writeStoredJson("favourite_paths", paths);
48
56
  _setFavouritePaths(paths);
49
57
  }, []);
50
58
 
51
59
  const setCollapsedGroups = useCallback((paths: string[]) => {
52
- localStorage.setItem("collapsed_groups", JSON.stringify(paths));
60
+ writeStoredJson("collapsed_groups", paths);
53
61
  _setCollapsedGroups(paths);
54
62
  }, []);
55
63
 
package/src/locales/de.ts CHANGED
@@ -77,6 +77,7 @@ export const de: RebaseTranslations = {
77
77
  column_cannot_be_edited: "Diese Spalte kann nicht direkt bearbeitet werden",
78
78
  close: "Schließen",
79
79
  hide_list: "Liste ausblenden",
80
+ show_list: "Liste einblenden",
80
81
  unsaved_local_changes: "Ungespeicherte lokale Änderungen",
81
82
  unsaved_local_changes_description: "Dieses Dokument wurde lokal bearbeitet und weist ungespeicherte Änderungen auf. Diese lokalen Änderungen gehen verloren, wenn Sie sie nicht anwenden.",
82
83
  preview_changes: "Änderungen in der Vorschau anzeigen",
package/src/locales/en.ts CHANGED
@@ -87,6 +87,7 @@ export const en: RebaseTranslations = {
87
87
  column_cannot_be_edited: "This column can't be edited directly",
88
88
  close: "Close",
89
89
  hide_list: "Hide list",
90
+ show_list: "Show list",
90
91
  unsaved_local_changes: "Unsaved Local changes",
91
92
  unsaved_local_changes_description: "This document was edited locally and has unsaved changes. These local changes will be lost if you don't apply them.",
92
93
  preview_changes: "Preview Changes",
@@ -993,6 +994,7 @@ export const en: RebaseTranslations = {
993
994
  inspect_record: "Inspect record",
994
995
  inspect: "Inspect",
995
996
  close_inspector: "Close inspector",
997
+ open_in_live_site: "Open in the live site",
996
998
 
997
999
  // ─── Revision history ───────────────────────────────────────
998
1000
  history_revert: "Revert to this version",
package/src/locales/es.ts CHANGED
@@ -85,6 +85,7 @@ export const es: RebaseTranslations = {
85
85
  column_cannot_be_edited: "Esta columna no se puede editar directamente",
86
86
  close: "Cerrar",
87
87
  hide_list: "Ocultar lista",
88
+ show_list: "Mostrar lista",
88
89
  unsaved_local_changes: "Cambios locales sin guardar",
89
90
  unsaved_local_changes_description: "Este documento ha sido editado localmente y tiene cambios sin guardar. Estos cambios locales se perderán si no los aplicas.",
90
91
  preview_changes: "Previsualizar cambios",
package/src/locales/fr.ts CHANGED
@@ -77,6 +77,7 @@ export const fr: RebaseTranslations = {
77
77
  column_cannot_be_edited: "Cette colonne ne peut pas être modifiée directement",
78
78
  close: "Fermer",
79
79
  hide_list: "Masquer la liste",
80
+ show_list: "Afficher la liste",
80
81
  unsaved_local_changes: "Modifications locales non enregistrées",
81
82
  unsaved_local_changes_description: "Ce document a été modifié localement et contient des modifications non enregistrées. Ces modifications locales seront perdues si vous ne les appliquez pas.",
82
83
  preview_changes: "Aperçu des modifications",