@rebasepro/app 0.13.0 → 0.13.1-canary.g249daa1

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 (46) 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/hooks/data/collectionQuery.d.ts +45 -0
  13. package/dist/index.es.js +686 -217
  14. package/dist/index.es.js.map +1 -1
  15. package/dist/util/entity_cache.d.ts +19 -3
  16. package/dist/util/previews.d.ts +19 -0
  17. package/package.json +7 -7
  18. package/src/auth/useRebaseAuthController.ts +9 -1
  19. package/src/collections/entity-display-cache.ts +195 -0
  20. package/src/collections/entity-display.ts +105 -0
  21. package/src/collections/entity_image_preview.ts +13 -0
  22. package/src/collections/form-layout.ts +8 -1
  23. package/src/collections/index.ts +4 -0
  24. package/src/collections/property-path.ts +30 -0
  25. package/src/collections/property_presentation.ts +39 -4
  26. package/src/collections/summary-property.ts +114 -0
  27. package/src/collections/title-property.ts +13 -16
  28. package/src/components/common/useColumnsIds.tsx +67 -29
  29. package/src/components/common/useDataTableController.tsx +10 -14
  30. package/src/core/Rebase.tsx +20 -2
  31. package/src/core/RebaseProps.tsx +10 -2
  32. package/src/hooks/data/collectionQuery.ts +55 -0
  33. package/src/hooks/data/useCollection.tsx +29 -21
  34. package/src/hooks/data/useFetch.tsx +17 -2
  35. package/src/hooks/data/useRelationSelector.tsx +20 -4
  36. package/src/hooks/useAuthSubscription.ts +18 -2
  37. package/src/hooks/useBuildLocalConfigurationPersistence.tsx +20 -12
  38. package/src/locales/de.ts +1 -0
  39. package/src/locales/en.ts +2 -0
  40. package/src/locales/es.ts +1 -0
  41. package/src/locales/fr.ts +1 -0
  42. package/src/locales/hi.ts +2 -1
  43. package/src/locales/it.ts +1 -0
  44. package/src/locales/pt.ts +1 -0
  45. package/src/util/entity_cache.ts +34 -31
  46. package/src/util/previews.ts +49 -29
@@ -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",
package/src/locales/hi.ts CHANGED
@@ -69,7 +69,7 @@ export const hi: RebaseTranslations = {
69
69
  group_by: "इसके द्वारा समूहित करें",
70
70
  initialize_kanban_order: "कानबन क्रम प्रारंभ करें",
71
71
  copy_id: "ID कॉपी करें",
72
- add_specific: "विशिष्ट {{name}} जोड़ें",
72
+ add_specific: "{{name}} जोड़ें",
73
73
  select_specific: "विशिष्ट {{name}} चुनें",
74
74
  select_from: "{{name}} से चुनें",
75
75
  done: "हो गया",
@@ -77,6 +77,7 @@ export const hi: RebaseTranslations = {
77
77
  column_cannot_be_edited: "इस कॉलम को सीधे संपादित नहीं किया जा सकता",
78
78
  close: "बंद करें",
79
79
  hide_list: "सूची छिपाएँ",
80
+ show_list: "सूची दिखाएँ",
80
81
  unsaved_local_changes: "सहेजे नहीं गए स्थानीय परिवर्तन",
81
82
  unsaved_local_changes_description: "इस दस्तावेज़ को स्थानीय स्तर पर संपादित किया गया था और इसमें सहेजे नहीं गए परिवर्तन हैं। यदि आप इन्हें लागू नहीं करते हैं तो ये स्थानीय परिवर्तन खो जाएंगे।",
82
83
  preview_changes: "परिवर्तनों का पूर्वावलोकन करें",
package/src/locales/it.ts CHANGED
@@ -77,6 +77,7 @@ export const it: RebaseTranslations = {
77
77
  column_cannot_be_edited: "Questa colonna non può essere modificata direttamente",
78
78
  close: "Chiudi",
79
79
  hide_list: "Nascondi elenco",
80
+ show_list: "Mostra elenco",
80
81
  unsaved_local_changes: "Modifiche locali non salvate",
81
82
  unsaved_local_changes_description: "Questo documento è stato modificato localmente e ha modifiche non salvate. Queste modifiche locali andranno perse se non le applichi.",
82
83
  preview_changes: "Anteprima modifiche",
package/src/locales/pt.ts CHANGED
@@ -82,6 +82,7 @@ export const pt: RebaseTranslations = {
82
82
  column_cannot_be_edited: "Esta coluna não pode ser editada diretamente",
83
83
  close: "Fechar",
84
84
  hide_list: "Ocultar lista",
85
+ show_list: "Mostrar lista",
85
86
  unsaved_local_changes: "Alterações locais não guardadas",
86
87
  unsaved_local_changes_description: "Este documento foi editado localmente e tem alterações não guardadas. Estas alterações locais serão perdidas se não as aplicar.",
87
88
  preview_changes: "Pré-visualizar alterações",
@@ -4,7 +4,21 @@ import { isObject, isPlainObject } from "@rebasepro/utils";
4
4
  // Define a unique prefix for entity keys in sessionStorage to avoid key collisions
5
5
  const LOCAL_STORAGE_PREFIX = "entity_cache::";
6
6
 
7
- // In-memory cache to store entities for quick access
7
+ /**
8
+ * The in-session handoff channel: an edit in flight, parked here by the layout
9
+ * that is being replaced and picked up by the one taking over — the split's
10
+ * "hide list", full screen's "show list", the side panel's "open full screen".
11
+ * A record loaded from it opens *dirty*, because that is what it is for.
12
+ *
13
+ * Deliberately **not** hydrated from `sessionStorage`, and it is the one thing
14
+ * here that isn't. The persisted half of this file is the local-changes backup:
15
+ * a draft that outlives a reload, offered back through the banner or applied on
16
+ * open, according to the collection's `localChangesBackup`. Loading those
17
+ * drafts into this map on startup made every one of them look like a handoff —
18
+ * the first visit to the record after a reload silently opened dirty carrying a
19
+ * draft the user had walked away from, and the banner that exists to ask about
20
+ * it never appeared.
21
+ */
8
22
  const entityCache: Map<string, object> = new Map();
9
23
 
10
24
  // Check `sessionStorage` availability once during initialization
@@ -93,35 +107,12 @@ function customReviver(_key: string, value: unknown): unknown {
93
107
  return value;
94
108
  }
95
109
 
96
- // Initialize the in-memory cache by loading entities from `sessionStorage`
97
- if (isSessionStorageAvailable) {
98
- try {
99
- // Iterate over all keys in sessionStorage to find those with the specified prefix
100
- for (let i = 0; i < sessionStorage.length; i++) {
101
- const fullKey = sessionStorage.key(i);
102
- if (fullKey && fullKey.startsWith(LOCAL_STORAGE_PREFIX)) {
103
- const path = fullKey.substring(LOCAL_STORAGE_PREFIX.length);
104
- const entityString = sessionStorage.getItem(fullKey);
105
- if (entityString) {
106
- try {
107
- const entity: object = JSON.parse(entityString, customReviver);
108
- entityCache.set(path, entity);
109
- } catch (parseError) {
110
- console.error(
111
- `Failed to parse entity for path "${path}" from sessionStorage:`,
112
- parseError
113
- );
114
- }
115
- }
116
- }
117
- }
118
- } catch (error) {
119
- console.error("Error accessing sessionStorage during initialization:", error);
120
- }
121
- }
122
-
123
110
  /**
124
- * Saves data to the in-memory cache and persists it individually in `sessionStorage`.
111
+ * Saves data to the local-changes backup in `sessionStorage`.
112
+ *
113
+ * The backup only — a draft that survives a reload is not an edit in flight.
114
+ * {@link saveEntityToMemoryCache} is the other channel; see {@link entityCache}
115
+ * for what separates them.
125
116
  * @param path - The unique path/key for the data.
126
117
  * @param data - The data to cache and persist.
127
118
  */
@@ -142,10 +133,19 @@ export function saveEntityToCache(path: string, data: object): void {
142
133
  }
143
134
  }
144
135
 
136
+ /**
137
+ * Consumes a handoff. The channel is one-shot: whoever picks an edit up owns it
138
+ * from then on, and an edit left behind here is one that reopens a record dirty
139
+ * long after the user stopped looking at it.
140
+ */
145
141
  export function removeEntityFromMemoryCache(path: string): void {
146
142
  entityCache.delete(path);
147
143
  }
148
144
 
145
+ /**
146
+ * Parks an edit in flight for the layout about to take over. See
147
+ * {@link entityCache}: this is the handoff, not the backup.
148
+ */
149
149
  export function saveEntityToMemoryCache(path: string, data: object): void {
150
150
  entityCache.set(path, data);
151
151
  }
@@ -156,8 +156,11 @@ export function getEntityFromMemoryCache(path: string): object | undefined {
156
156
 
157
157
 
158
158
  /**
159
- * Retrieves a entity from the in-memory cache or `sessionStorage`.
160
- * If the entity is not in the cache but exists in `sessionStorage`, it loads it into the cache.
159
+ * Retrieves a entity from the local-changes backup in `sessionStorage`.
160
+ *
161
+ * The backup only — the handoff channel is read through
162
+ * {@link getEntityFromMemoryCache}, and the two mean different things to the
163
+ * form that receives them.
161
164
  * @param path - The unique path/key for the entity.
162
165
  * @returns The cached entity or `undefined` if not found.
163
166
  */
@@ -3,6 +3,7 @@ import type { PropertyConfig, AdminCollection } from "@rebasepro/admin-types";
3
3
  import { AuthController } from "@rebasepro/admin-types";
4
4
  import { isPropertyBuilder } from "@rebasepro/common";
5
5
  import { getTitlePropertyKey } from "@rebasepro/app";
6
+ import { SUMMARY_RANK, isStorageProperty, rankSummaryProperty } from "../collections/summary-property";
6
7
 
7
8
  function isReferenceProperty(property: Property) {
8
9
  if (!property) return null;
@@ -30,24 +31,24 @@ function isHiddenProperty(property: Property | undefined): boolean {
30
31
  }
31
32
 
32
33
  /**
33
- * Returns true when the property holds file-storage content (single image,
34
- * array of images, generic upload, …). These properties are rendered by the
35
- * dedicated image-slot and should NOT appear as regular preview columns.
34
+ * The properties a preview surface should render for a record, best first.
35
+ *
36
+ * Three things decide the answer, in this order:
37
+ *
38
+ * 1. `previewProperties` — passed in, or declared on the collection. A stated
39
+ * list is returned verbatim, ranking and limit included: a developer who
40
+ * asks for the Markdown biography gets the Markdown biography.
41
+ * 2. Whether the value has a one-line form at all. A map renders as a
42
+ * key/value table and a Markdown field as a document; neither fits a card
43
+ * line, so they never take a slot from a value that does. See
44
+ * {@link rankSummaryProperty}.
45
+ * 3. `propertiesOrder`, which breaks ties.
46
+ *
47
+ * The middle step is the one that is easy to get wrong by leaving out.
48
+ * `propertiesOrder` states the *column* order of a collection table — it is
49
+ * not a statement that the first three columns summarise a record, and reading
50
+ * it as one is how a card ends up rendering somebody's entire biography.
36
51
  */
37
- function isStorageProperty(property: Property | undefined): boolean {
38
- if (!property) return false;
39
- // Single string with storage config
40
- if (property.type === "string" && property.storage) return true;
41
- // String displayed as image URL
42
- if (property.type === "string" && property.admin?.urlPreview === "image") return true;
43
- // Array whose inner element has storage config or image URL
44
- if (property.type === "array" && property.of && !Array.isArray(property.of)) {
45
- const inner = property.of;
46
- if (inner.type === "string" && (inner.storage || inner.admin?.urlPreview === "image")) return true;
47
- }
48
- return false;
49
- }
50
-
51
52
  export function getEntityPreviewKeys(
52
53
  authController: AuthController,
53
54
  targetCollection: AdminCollection<any>,
@@ -61,19 +62,38 @@ export function getEntityPreviewKeys(
61
62
  }
62
63
  if (listProperties && listProperties.length > 0) {
63
64
  return listProperties;
64
- } else {
65
- listProperties = (targetCollection.propertiesOrder as string[]) || allProperties;
66
- return listProperties
67
- .filter(key => {
68
- const prop = targetCollection.properties[key];
69
- const isIdProp = prop && typeof prop === "object" && "isId" in prop && Boolean((prop as { isId?: boolean }).isId);
70
- return !isIdProp && key !== "id";
71
- })
72
- .filter(key => {
73
- const property = targetCollection.properties[key];
74
- return property && !isPropertyBuilder(property) && !isReferenceProperty(property) && !isRelationProperty(property) && !isHiddenProperty(property) && !isStorageProperty(property as Property);
75
- }).slice(0, limit);
76
65
  }
66
+
67
+ // Relations read as a link to somewhere else rather than as a value here,
68
+ // so they are left to the dedicated relation chips — unless the collection
69
+ // ordered its properties explicitly, which puts them back in play.
70
+ const hasExplicitOrder = !!(targetCollection.propertiesOrder as string[] | undefined);
71
+ const order = (targetCollection.propertiesOrder as string[]) || allProperties;
72
+
73
+ const direct: string[] = [];
74
+ const excerpt: string[] = [];
75
+
76
+ for (const key of order) {
77
+ const property = targetCollection.properties[key];
78
+ if (!property || isPropertyBuilder(property)) continue;
79
+ if (key === "id" || ("isId" in property && Boolean((property as { isId?: boolean }).isId))) continue;
80
+ if (isReferenceProperty(property)) continue;
81
+ if (!hasExplicitOrder && isRelationProperty(property)) continue;
82
+ if (isHiddenProperty(property)) continue;
83
+ if (isStorageProperty(property as Property)) continue;
84
+
85
+ const rank = rankSummaryProperty(property as Property);
86
+ if (rank === SUMMARY_RANK.UNUSABLE) continue;
87
+ (rank === SUMMARY_RANK.EXCERPT ? excerpt : direct).push(key);
88
+
89
+ // Every remaining slot can already be filled by a value that fits
90
+ // whole, so nothing below can change the answer.
91
+ if (direct.length >= limit) break;
92
+ }
93
+
94
+ // A long-text field is a poor summary but a fine last resort: better an
95
+ // opening line than a card with nothing under the title.
96
+ return [...direct, ...excerpt].slice(0, limit);
77
97
  }
78
98
 
79
99
  // Returned as-is rather than rebuilt per call, so the result is referentially