@rebasepro/admin 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/dist/{CollectionEditorDialog-HNXGxXdO.js → CollectionEditorDialog-iGgS4rLX.js} +233 -152
  2. package/dist/CollectionEditorDialog-iGgS4rLX.js.map +1 -0
  3. package/dist/{PropertyEditView-C2wp8kQ9.js → PropertyEditView-C9yhLyja.js} +129 -15
  4. package/dist/PropertyEditView-C9yhLyja.js.map +1 -0
  5. package/dist/{CollectionsStudioView-B1mN33GB.js → RouterCollectionsStudioView-Dqx27GD8.js} +61 -39
  6. package/dist/RouterCollectionsStudioView-Dqx27GD8.js.map +1 -0
  7. package/dist/collection_editor/extensibility_types.d.ts +135 -0
  8. package/dist/collection_editor/index.d.ts +6 -0
  9. package/dist/collection_editor/serializable_types.d.ts +374 -0
  10. package/dist/collection_editor/serializable_utils.d.ts +50 -0
  11. package/dist/collection_editor/ui/collection_editor/CollectionEditorDialog.d.ts +2 -1
  12. package/dist/collection_editor/ui/collection_editor/CollectionPropertiesEditorForm.d.ts +12 -1
  13. package/dist/collection_editor/ui/collection_editor/CollectionStudioView.d.ts +13 -1
  14. package/dist/collection_editor/ui/collection_editor/CollectionsStudioView.d.ts +24 -2
  15. package/dist/collection_editor/ui/collection_editor/DisplaySettingsForm.d.ts +2 -1
  16. package/dist/collection_editor/ui/collection_editor/GeneralSettingsForm.d.ts +4 -1
  17. package/dist/collection_editor/ui/collection_editor/PropertyEditView.d.ts +12 -1
  18. package/dist/collection_editor/ui/collection_editor/RouterCollectionStudioView.d.ts +15 -0
  19. package/dist/collection_editor/ui/collection_editor/RouterCollectionsStudioView.d.ts +14 -0
  20. package/dist/collection_editor/useJsonCollectionsConfigController.d.ts +37 -0
  21. package/dist/collection_editor/useSafeSnackbarController.d.ts +2 -0
  22. package/dist/collection_editor_ui.d.ts +4 -0
  23. package/dist/collection_editor_ui.js +33 -4
  24. package/dist/collection_editor_ui.js.map +1 -0
  25. package/dist/components/EntityCollectionView/EntityBoardCard.d.ts +3 -3
  26. package/dist/components/EntityCollectionView/hooks/useKanbanDragAndDrop.d.ts +3 -3
  27. package/dist/components/EntityEditView.d.ts +3 -2
  28. package/dist/components/RebaseCMS.d.ts +1 -1
  29. package/dist/form/EntityForm.d.ts +10 -9
  30. package/dist/form/EntityFormBinding.d.ts +27 -0
  31. package/dist/form/form_utils.d.ts +8 -0
  32. package/dist/form/index.d.ts +5 -1
  33. package/dist/{history-BO6VT85a.js → history-ChHgyyvM.js} +2 -2
  34. package/dist/{history-BO6VT85a.js.map → history-ChHgyyvM.js.map} +1 -1
  35. package/dist/hooks/navigation/contexts/CollectionRegistryContext.d.ts +1 -1
  36. package/dist/hooks/navigation/useAsyncResolver.d.ts +26 -0
  37. package/dist/hooks/navigation/useBuildCollectionRegistryController.d.ts +2 -0
  38. package/dist/hooks/navigation/useTopLevelNavigation.d.ts +1 -1
  39. package/dist/hooks/navigation/utils.d.ts +1 -1
  40. package/dist/index.d.ts +2 -0
  41. package/dist/index.js +2 -2
  42. package/dist/routes/CustomViewRoute.d.ts +2 -1
  43. package/dist/{src-BYniefVu.js → src-DOX2nOyh.js} +1586 -1283
  44. package/dist/src-DOX2nOyh.js.map +1 -0
  45. package/dist/types/components/EntityFormActionsProps.d.ts +1 -1
  46. package/dist/types/components/EntityFormProps.d.ts +59 -0
  47. package/dist/types/fields.d.ts +6 -1
  48. package/dist/util/dataControllerAdapter.d.ts +30 -0
  49. package/dist/util/navigation_utils.d.ts +1 -1
  50. package/dist/util/propertyConfigMapper.d.ts +17 -0
  51. package/package.json +18 -18
  52. package/src/collection_editor/extensibility_types.ts +168 -0
  53. package/src/collection_editor/index.ts +46 -0
  54. package/src/collection_editor/serializable_types.ts +487 -0
  55. package/src/collection_editor/serializable_utils.ts +588 -0
  56. package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +4 -4
  57. package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +134 -70
  58. package/src/collection_editor/ui/collection_editor/CollectionPropertiesEditorForm.tsx +95 -78
  59. package/src/collection_editor/ui/collection_editor/CollectionStudioView.tsx +23 -20
  60. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +79 -27
  61. package/src/collection_editor/ui/collection_editor/DisplaySettingsForm.tsx +9 -7
  62. package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +22 -5
  63. package/src/collection_editor/ui/collection_editor/GetCodeDialog.tsx +3 -3
  64. package/src/collection_editor/ui/collection_editor/PropertyEditView.tsx +155 -10
  65. package/src/collection_editor/ui/collection_editor/PropertyFieldPreview.tsx +6 -6
  66. package/src/collection_editor/ui/collection_editor/PropertyTree.tsx +1 -0
  67. package/src/collection_editor/ui/collection_editor/RouterCollectionStudioView.tsx +44 -0
  68. package/src/collection_editor/ui/collection_editor/RouterCollectionsStudioView.tsx +41 -0
  69. package/src/collection_editor/ui/collection_editor/properties/EnumPropertyField.tsx +4 -3
  70. package/src/collection_editor/useJsonCollectionsConfigController.tsx +286 -0
  71. package/src/collection_editor/useSafeSnackbarController.ts +9 -0
  72. package/src/collection_editor_ui.ts +6 -0
  73. package/src/components/DefaultDrawer.tsx +13 -3
  74. package/src/components/DrawerNavigationGroup.tsx +2 -2
  75. package/src/components/EntityCollectionView/EntityBoardCard.tsx +6 -6
  76. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -11
  77. package/src/components/EntityCollectionView/EntityCollectionCardView.tsx +40 -190
  78. package/src/components/EntityCollectionView/EntityCollectionListView.tsx +54 -183
  79. package/src/components/EntityCollectionView/hooks/useKanbanDragAndDrop.ts +6 -6
  80. package/src/components/EntityEditView.tsx +5 -4
  81. package/src/components/EntityEditViewFormActions.tsx +32 -26
  82. package/src/components/HomePage/ContentHomePage.tsx +13 -4
  83. package/src/components/RebaseCMS.tsx +3 -2
  84. package/src/components/RebaseNavigation.tsx +71 -7
  85. package/src/components/SelectableTable/SelectableTable.tsx +2 -2
  86. package/src/components/SideDialogs.tsx +0 -7
  87. package/src/components/common/default_entity_actions.tsx +9 -9
  88. package/src/contexts/BreacrumbsContext.tsx +15 -6
  89. package/src/editor/selectors/ai-selector.tsx +0 -1
  90. package/src/form/EntityForm.tsx +191 -428
  91. package/src/form/EntityFormActions.tsx +33 -47
  92. package/src/form/EntityFormBinding.tsx +323 -0
  93. package/src/form/components/FormLayout.tsx +1 -1
  94. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +7 -2
  95. package/src/form/form_utils.ts +163 -0
  96. package/src/form/index.tsx +14 -5
  97. package/src/hooks/navigation/contexts/CollectionRegistryContext.tsx +2 -2
  98. package/src/hooks/navigation/contexts/NavigationStateContext.tsx +2 -2
  99. package/src/hooks/navigation/contexts/UrlContext.tsx +1 -2
  100. package/src/hooks/navigation/useAsyncResolver.ts +100 -0
  101. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +15 -8
  102. package/src/hooks/navigation/useBuildNavigationStateController.tsx +12 -8
  103. package/src/hooks/navigation/useNavigationResolution.ts +33 -0
  104. package/src/hooks/navigation/useResolvedCollections.ts +29 -71
  105. package/src/hooks/navigation/useResolvedViews.tsx +40 -70
  106. package/src/hooks/navigation/useTopLevelNavigation.ts +20 -16
  107. package/src/hooks/navigation/utils.ts +7 -4
  108. package/src/index.ts +5 -0
  109. package/src/routes/CustomViewRoute.tsx +14 -6
  110. package/src/types/components/EntityFormActionsProps.tsx +1 -1
  111. package/src/types/components/EntityFormProps.tsx +68 -0
  112. package/src/types/fields.tsx +7 -1
  113. package/src/util/dataControllerAdapter.ts +93 -0
  114. package/src/util/navigation_utils.ts +1 -1
  115. package/src/util/propertyConfigMapper.ts +134 -0
  116. package/LICENSE +0 -21
  117. package/dist/CollectionEditorDialog-HNXGxXdO.js.map +0 -1
  118. package/dist/CollectionsStudioView-B1mN33GB.js.map +0 -1
  119. package/dist/PropertyEditView-C2wp8kQ9.js.map +0 -1
  120. package/dist/components/EntityCollectionView/Board.d.ts +0 -3
  121. package/dist/components/EntityCollectionView/BoardColumn.d.ts +0 -42
  122. package/dist/components/EntityCollectionView/BoardColumnTitle.d.ts +0 -9
  123. package/dist/components/EntityCollectionView/BoardSortableList.d.ts +0 -14
  124. package/dist/components/EntityCollectionView/board_types.d.ts +0 -105
  125. package/dist/hooks/navigation/useNavigationRegistry.d.ts +0 -12
  126. package/dist/hooks/navigation/useNavigationURLs.d.ts +0 -11
  127. package/dist/src-BYniefVu.js.map +0 -1
  128. package/src/components/EntityCollectionView/Board.tsx +0 -475
  129. package/src/components/EntityCollectionView/BoardColumn.tsx +0 -165
  130. package/src/components/EntityCollectionView/BoardColumnTitle.tsx +0 -46
  131. package/src/components/EntityCollectionView/BoardSortableList.tsx +0 -177
  132. package/src/components/EntityCollectionView/board_types.ts +0 -113
  133. package/src/hooks/navigation/useNavigationRegistry.ts +0 -164
  134. package/src/hooks/navigation/useNavigationURLs.ts +0 -56
@@ -0,0 +1,163 @@
1
+ import type { EntityCollection, PropertyConfig } from "@rebasepro/types";
2
+ import type { AuthController, Entity, EntityStatus, EntityValues } from "@rebasepro/types";
3
+ import { deepEqual as equal } from "fast-equals";
4
+ import { getIn, setIn } from "@rebasepro/formex";
5
+ import { getDefaultValuesFor } from "@rebasepro/common";
6
+ import { isObject, mergeDeep } from "@rebasepro/utils";
7
+ import { z } from "zod";
8
+
9
+ // extract touched values for nested touched trees and map to current values
10
+ export function extractTouchedValues(values: unknown, touched: Record<string, boolean>): Record<string, unknown> {
11
+ let acc: Record<string, unknown> = {};
12
+ if (!touched || typeof touched !== "object") {
13
+ return acc;
14
+ }
15
+
16
+ Object.entries(touched).forEach(([key, value]) => {
17
+ if (value) {
18
+ acc = setIn(acc, key, getIn(values, key)) as Record<string, unknown>;
19
+ }
20
+ })
21
+
22
+ return acc;
23
+ }
24
+
25
+ /**
26
+ * Recursively removes empty plain objects `{}` and empty arrays `[]` from a value tree.
27
+ * This prevents ghost containers created by `setIn` intermediate path construction
28
+ * (e.g. `{ address: {} }` when only `address.city` was touched but value is undefined)
29
+ * from falsely triggering the unsaved local changes indicator.
30
+ */
31
+ /**
32
+ * Check if a value is semantically empty (null, undefined, or empty string).
33
+ */
34
+ function isSemanticEmpty(v: unknown): boolean {
35
+ return v === null || v === undefined || v === "";
36
+ }
37
+
38
+ export function removeEmptyContainers(obj: unknown): unknown {
39
+ if (Array.isArray(obj)) {
40
+ const cleaned = obj.map(removeEmptyContainers);
41
+ // Keep arrays even if they contain only nulls/undefined — that's intentional data
42
+ return cleaned;
43
+ }
44
+ if (obj && typeof obj === "object" && Object.getPrototypeOf(obj) === Object.prototype) {
45
+ const result: Record<string, unknown> = {};
46
+ for (const key of Object.keys(obj)) {
47
+ const cleaned = removeEmptyContainers((obj as Record<string, unknown>)[key]);
48
+ // Skip empty plain objects
49
+ if (cleaned && typeof cleaned === "object" && !Array.isArray(cleaned)
50
+ && Object.getPrototypeOf(cleaned) === Object.prototype
51
+ && Object.keys(cleaned).length === 0) {
52
+ continue;
53
+ }
54
+ result[key] = cleaned;
55
+ }
56
+ // After cleaning, check if all remaining values are semantically empty
57
+ // (null, undefined, or ""). This catches ghost objects like {type: "", value: null}
58
+ // created by oneOf block initialization that aren't meaningful changes.
59
+ if (Object.keys(result).length > 0 && Object.values(result).every(isSemanticEmpty)) {
60
+ return {};
61
+ }
62
+ return result;
63
+ }
64
+ return obj;
65
+ }
66
+
67
+ export function getChanges<T extends object>(source: Partial<T>, comparison: Partial<T>): Partial<T> {
68
+ const changes: Partial<T> = {};
69
+
70
+ if (!source) {
71
+ return {};
72
+ }
73
+ if (!comparison) {
74
+ return source;
75
+ }
76
+
77
+ const allKeys = Array.from(new Set([...Object.keys(source), ...Object.keys(comparison)]));
78
+
79
+ for (const key of allKeys) {
80
+ const sourceValue = (source as Record<string, unknown>)[key];
81
+ const comparisonValue = (comparison as Record<string, unknown>)[key];
82
+
83
+ if (equal(sourceValue, comparisonValue)) {
84
+ continue;
85
+ }
86
+
87
+ const sourceHasKey = source && typeof source === "object" && Object.prototype.hasOwnProperty.call(source, key);
88
+ const comparisonHasKey = comparison && typeof comparison === "object" && Object.prototype.hasOwnProperty.call(comparison, key);
89
+
90
+ if (comparisonHasKey && !sourceHasKey) {
91
+ (changes as Record<string, unknown>)[key] = undefined;
92
+ } else if (Array.isArray(sourceValue)) {
93
+ const comparisonArray = Array.isArray(comparisonValue) ? comparisonValue : [];
94
+ if (sourceValue.length !== comparisonArray.length) {
95
+ (changes as Record<string, unknown>)[key] = sourceValue;
96
+ continue;
97
+ }
98
+ const hasChanges = sourceValue.some((item, index) => !equal(item, comparisonArray[index]));
99
+ if (hasChanges) {
100
+ (changes as Record<string, unknown>)[key] = sourceValue;
101
+ }
102
+ } else if (isObject(sourceValue) && sourceValue && isObject(comparisonValue) && comparisonValue) {
103
+ const nestedChanges = getChanges(sourceValue, comparisonValue);
104
+ if (Object.keys(nestedChanges).length > 0) {
105
+ (changes as Record<string, unknown>)[key] = nestedChanges;
106
+ }
107
+ } else {
108
+ (changes as Record<string, unknown>)[key] = sourceValue;
109
+ }
110
+ }
111
+
112
+ return changes;
113
+ }
114
+
115
+ export function getInitialEntityValues<M extends Record<string, unknown>>(
116
+ authController: AuthController,
117
+ collection: EntityCollection,
118
+ path: string,
119
+ status: "new" | "existing" | "copy",
120
+ entity: Entity<M> | undefined,
121
+ propertyConfigs?: Record<string, PropertyConfig>
122
+ ): Partial<EntityValues<M>> {
123
+ const properties = collection.properties;
124
+ if ((status === "existing" || status === "copy") && entity) {
125
+ let values: Partial<EntityValues<M>>;
126
+ if (!collection.alwaysApplyDefaultValues) {
127
+ values = entity.values ?? getDefaultValuesFor(properties);
128
+ } else {
129
+ const defaultValues = getDefaultValuesFor(properties);
130
+ values = mergeDeep(defaultValues, entity.values ?? {});
131
+ }
132
+ // When copying, clear ID fields so the database generates new IDs
133
+ if (status === "copy") {
134
+ const result = { ...values };
135
+ for (const [key, property] of Object.entries(properties)) {
136
+ if (property && "isId" in property && property.isId) {
137
+ delete (result as Record<string, unknown>)[key];
138
+ }
139
+ }
140
+ return result;
141
+ }
142
+ return values;
143
+ } else if (status === "new") {
144
+ return getDefaultValuesFor(properties);
145
+ } else {
146
+ console.error({
147
+ status,
148
+ entity
149
+ });
150
+ throw new Error("Form has not been initialised with the correct parameters");
151
+ }
152
+ }
153
+
154
+ export function zodToFormErrors(zodError: z.ZodError): Record<string, string> {
155
+ let errors: Record<string, string> = {};
156
+ for (const issue of zodError.issues) {
157
+ const path = issue.path.join(".");
158
+ if (path && !getIn(errors, path)) {
159
+ errors = setIn(errors, path, issue.message) as Record<string, string>;
160
+ }
161
+ }
162
+ return errors;
163
+ }
@@ -1,8 +1,8 @@
1
- export {
2
- EntityForm,
3
- zodToFormErrors
4
- } from "./EntityForm";
5
- // EntityFormProps is exported from @rebasepro/types
1
+ export { EntityForm } from "./EntityForm";
2
+ export type { EntityFormProps, OnUpdateParams } from "../types/components/EntityFormProps";
3
+
4
+ export { EntityFormBinding } from "./EntityFormBinding";
5
+ export type { EntityFormBindingProps } from "./EntityFormBinding";
6
6
 
7
7
  export { SelectFieldBinding } from "./field_bindings/SelectFieldBinding";
8
8
  export { MultiSelectFieldBinding } from "./field_bindings/MultiSelectFieldBinding";
@@ -26,3 +26,12 @@ export * from "./components";
26
26
 
27
27
  export { PropertyFieldBinding } from "./PropertyFieldBinding";
28
28
  export * from "./useClearRestoreValue";
29
+
30
+ // Shared form utilities
31
+ export {
32
+ extractTouchedValues,
33
+ removeEmptyContainers,
34
+ getChanges,
35
+ getInitialEntityValues,
36
+ zodToFormErrors
37
+ } from "./form_utils";
@@ -1,6 +1,6 @@
1
1
  import type { EntityCollection } from "@rebasepro/types";
2
2
  import React, { createContext } from "react";
3
- import { CollectionRegistryController, EntityReference } from "@rebasepro/types";
3
+ import { CollectionRegistryController } from "@rebasepro/types";
4
4
 
5
5
  export const CollectionRegistryContext = createContext<CollectionRegistryController>({
6
6
  getCollection: () => undefined,
@@ -12,7 +12,7 @@ export const CollectionRegistryContext = createContext<CollectionRegistryControl
12
12
  initialised: false
13
13
  });
14
14
 
15
- export function useCollectionRegistryController<DB = Record<string, unknown>, EC extends EntityCollection = EntityCollection<any>>(): CollectionRegistryController<DB, EC> {
15
+ export function useCollectionRegistryController<DB = Record<string, unknown>, EC extends EntityCollection = EntityCollection>(): CollectionRegistryController<DB, EC> {
16
16
  const context = React.useContext(CollectionRegistryContext);
17
17
  if (context === undefined) {
18
18
  throw new Error("useCollectionRegistryController must be used within a CollectionRegistryContext.Provider");
@@ -1,6 +1,6 @@
1
- import type { NavigationStateController, AppView, NavigationResult, RebasePlugin } from "@rebasepro/types";
1
+ import type { NavigationStateController } from "@rebasepro/types";
2
2
  import React, { createContext } from "react";
3
- ;
3
+
4
4
 
5
5
  export const NavigationStateContext = createContext<NavigationStateController>({
6
6
  loading: true,
@@ -1,8 +1,7 @@
1
1
  import type { UrlController } from "@rebasepro/types";
2
2
  import React, { createContext } from "react";
3
- import { NavigateOptions } from "react-router-dom";
4
3
 
5
- ;
4
+
6
5
 
7
6
  export const UrlContext = createContext<UrlController>({
8
7
  basePath: "/",
@@ -0,0 +1,100 @@
1
+ import { useCallback, useEffect, useRef, useState, useMemo } from "react";
2
+
3
+ export type UseAsyncResolverResult<T> = {
4
+ data: T;
5
+ loading: boolean;
6
+ error: Error | undefined;
7
+ refresh: () => void;
8
+ };
9
+
10
+ /**
11
+ * Generic hook that resolves an async value with loading/error/refresh
12
+ * state management, cancellation on unmount or deps change, and
13
+ * ref-based change detection to prevent unnecessary state updates.
14
+ *
15
+ * Extracted from the common pattern in useResolvedCollections and
16
+ * useResolvedViews.
17
+ */
18
+ export function useAsyncResolver<T>({
19
+ resolver,
20
+ initialValue,
21
+ isEqual,
22
+ deps,
23
+ disabled,
24
+ }: {
25
+ /** Async function that resolves the data */
26
+ resolver: () => Promise<T>;
27
+ /** Initial value before first resolution */
28
+ initialValue: T;
29
+ /** Equality check to prevent unnecessary state updates */
30
+ isEqual: (a: T, b: T) => boolean;
31
+ /** Effect dependencies — when these change, resolver re-runs */
32
+ deps: React.DependencyList;
33
+ /** When true, skip resolution */
34
+ disabled?: boolean;
35
+ }): UseAsyncResolverResult<T> {
36
+
37
+ const [data, setData] = useState<T>(initialValue);
38
+ const [loading, setLoading] = useState(true);
39
+ const [error, setError] = useState<Error | undefined>(undefined);
40
+ const [refreshTrigger, setRefreshTrigger] = useState(0);
41
+
42
+ const refresh = useCallback(() => {
43
+ setRefreshTrigger(prev => prev + 1);
44
+ }, []);
45
+
46
+ // Ref for change detection — avoids state updates when data hasn't changed
47
+ const dataRef = useRef<T>(initialValue);
48
+
49
+ // Store resolver in a ref so we always call the latest version
50
+ // without adding it to effect deps (it's typically a new closure each render)
51
+ const resolverRef = useRef(resolver);
52
+ resolverRef.current = resolver;
53
+
54
+ // Same for isEqual
55
+ const isEqualRef = useRef(isEqual);
56
+ isEqualRef.current = isEqual;
57
+
58
+ useEffect(() => {
59
+ if (disabled) return;
60
+
61
+ let cancelled = false;
62
+
63
+ (async () => {
64
+ try {
65
+ const result = await resolverRef.current();
66
+
67
+ if (cancelled) return;
68
+
69
+ // Only update state if data actually changed
70
+ if (!isEqualRef.current(dataRef.current, result)) {
71
+ dataRef.current = result;
72
+ setData(result);
73
+ }
74
+
75
+ setError(undefined);
76
+ } catch (e) {
77
+ if (!cancelled) {
78
+ console.error(e);
79
+ setError(e as Error);
80
+ }
81
+ } finally {
82
+ if (!cancelled) {
83
+ setLoading(false);
84
+ }
85
+ }
86
+ })();
87
+
88
+ return () => {
89
+ cancelled = true;
90
+ };
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, [...deps, refreshTrigger, disabled]);
93
+
94
+ return useMemo(() => ({
95
+ data,
96
+ loading,
97
+ error,
98
+ refresh
99
+ }), [data, loading, error, refresh]);
100
+ }
@@ -1,16 +1,21 @@
1
- import type { EntityCollection } from "@rebasepro/types";
2
- import { useCallback, useRef, useState, useMemo } from "react";
1
+ import type { EntityCollection, DataSourceDefinition } from "@rebasepro/types";
2
+ import { useCallback, useEffect, useRef, useState, useMemo } from "react";
3
3
  import { CollectionRegistry, getParentReferencesFromPath as commonGetParentReferencesFromPath, removeInitialAndTrailingSlashes, getSubcollections } from "@rebasepro/common";
4
4
  import { EntityReference, UserConfigurationPersistence, CollectionRegistryController } from "@rebasepro/types";
5
5
  import { mergeDeep } from "@rebasepro/utils";
6
6
 
7
7
  export function useBuildCollectionRegistryController(props: {
8
- userConfigPersistence?: UserConfigurationPersistence
8
+ userConfigPersistence?: UserConfigurationPersistence,
9
+ dataSources?: Record<string, DataSourceDefinition>
9
10
  }): CollectionRegistryController & {
10
11
  collectionRegistryRef: React.MutableRefObject<CollectionRegistry>;
11
12
  } {
12
- const { userConfigPersistence } = props;
13
- const collectionRegistryRef = useRef<CollectionRegistry>(new CollectionRegistry());
13
+ const { userConfigPersistence, dataSources } = props;
14
+ const collectionRegistryRef = useRef<CollectionRegistry>(new CollectionRegistry(undefined, dataSources));
15
+ // Keep the registry's data sources in sync (used to resolve engine during
16
+ // normalization). Applied synchronously before the async registration
17
+ // effect in useResolvedCollections runs.
18
+ if (dataSources) collectionRegistryRef.current.setDataSources(dataSources);
14
19
  const [initialised, setInitialised] = useState(false);
15
20
 
16
21
  const getCollection = useCallback((
@@ -156,9 +161,11 @@ export function useBuildCollectionRegistryController(props: {
156
161
 
157
162
  // Determine initialised automatically based on whether collections exist,
158
163
  // though the NavigationStateController also plays a role in overall init
159
- if (!initialised && collections.length > 0) {
160
- setInitialised(true);
161
- }
164
+ useEffect(() => {
165
+ if (!initialised && collections.length > 0) {
166
+ setInitialised(true);
167
+ }
168
+ }, [initialised, collections.length]);
162
169
 
163
170
  return useMemo(() => ({
164
171
  collections,
@@ -1,5 +1,5 @@
1
1
  import type { AppView, EntityCollection, NavigationResult, RebasePlugin, NavigationStateController, UrlController, NavigationGroupMapping } from "@rebasepro/types";
2
- import { useCallback, useMemo } from "react";
2
+ import { useCallback, useMemo, useRef } from "react";
3
3
 
4
4
  import { AuthController, RebaseData, CollectionRegistryController, User } from "@rebasepro/types";
5
5
  import type { EntityCollectionsBuilder, AppViewsBuilder, EffectiveRoleController } from "@rebasepro/types";
@@ -100,10 +100,16 @@ export function useBuildNavigationStateController<EC extends EntityCollection, U
100
100
  collectionRegistryController
101
101
  });
102
102
 
103
- // Expose a combined refresh function
103
+ // Expose a combined refresh function with microtask batching
104
+ const pendingRefreshRef = useRef(false);
104
105
  const refreshNavigation = useCallback(() => {
105
- refreshCollections();
106
- refreshViews();
106
+ if (pendingRefreshRef.current) return;
107
+ pendingRefreshRef.current = true;
108
+ queueMicrotask(() => {
109
+ pendingRefreshRef.current = false;
110
+ refreshCollections();
111
+ refreshViews();
112
+ });
107
113
  }, [refreshCollections, refreshViews]);
108
114
 
109
115
  return useMemo(() => ({
@@ -112,8 +118,7 @@ export function useBuildNavigationStateController<EC extends EntityCollection, U
112
118
  topLevelNavigation,
113
119
  loading: collectionsLoading || viewsLoading,
114
120
  navigationLoadingError: collectionsError ?? viewsError,
115
- refreshNavigation,
116
- plugins
121
+ refreshNavigation
117
122
  }), [
118
123
  views,
119
124
  adminViews,
@@ -122,7 +127,6 @@ export function useBuildNavigationStateController<EC extends EntityCollection, U
122
127
  viewsLoading,
123
128
  collectionsError,
124
129
  viewsError,
125
- refreshNavigation,
126
- plugins
130
+ refreshNavigation
127
131
  ]);
128
132
  }
@@ -211,5 +211,38 @@ export async function resolveAppViews(
211
211
  }
212
212
  }
213
213
 
214
+ // Detect duplicate view slugs (dev warning)
215
+ if (process.env.NODE_ENV !== 'production') {
216
+ const slugCounts = new Map<string, number>();
217
+ resolvedViews.forEach(v => {
218
+ slugCounts.set(v.slug, (slugCounts.get(v.slug) ?? 0) + 1);
219
+ });
220
+ slugCounts.forEach((count, slug) => {
221
+ if (count > 1) {
222
+ console.warn(
223
+ `[Rebase] Duplicate view slug "${slug}" detected (${count} views). ` +
224
+ `Last-write-wins. Ensure unique slugs across CMS views and plugins.`
225
+ );
226
+ }
227
+ });
228
+ }
229
+
230
+ // Filter by roles — applies to CMS, plugin, and builder-returned views
231
+ resolvedViews = filterViewsByRole(resolvedViews, authController);
232
+
214
233
  return resolvedViews;
215
234
  }
235
+
236
+ /**
237
+ * Filter views by the `roles` field on AppView.
238
+ * When `roles` is set, the view is only included if the current user
239
+ * has at least one of the listed roles. Views without `roles` (or with
240
+ * an empty array) are always included.
241
+ */
242
+ function filterViewsByRole(views: AppView[], authController: AuthController): AppView[] {
243
+ const userRoles = authController.user?.roles ?? [];
244
+ return views.filter(view => {
245
+ if (!view.roles || view.roles.length === 0) return true;
246
+ return view.roles.some(role => userRoles.includes(role));
247
+ });
248
+ }
@@ -1,5 +1,5 @@
1
1
  import type { EntityCollection, RebasePlugin } from "@rebasepro/types";
2
- import { useCallback, useEffect, useRef, useState, useMemo } from "react";
2
+ import { useRef, useMemo } from "react";
3
3
 
4
4
  import { AuthController, CollectionRegistryController, RebaseData, User } from "@rebasepro/types";
5
5
  import type { EntityCollectionsBuilder } from "@rebasepro/types";
@@ -8,6 +8,7 @@ import { CollectionRegistry } from "@rebasepro/common";
8
8
 
9
9
  import { resolveCollections } from "./useNavigationResolution";
10
10
  import { areCollectionListsEqual } from "./utils";
11
+ import { useAsyncResolver } from "./useAsyncResolver";
11
12
 
12
13
  export type UseResolvedCollectionsProps<EC extends EntityCollection, USER extends User> = {
13
14
  authController: AuthController<USER>;
@@ -50,16 +51,11 @@ export function useResolvedCollections<EC extends EntityCollection, USER extends
50
51
  collectionRegistryController
51
52
  } = props;
52
53
 
53
- const [loading, setLoading] = useState(true);
54
- const [error, setError] = useState<Error | undefined>(undefined);
55
- const [resolvedCollections, setResolvedCollections] = useState<EntityCollection[]>([]);
56
-
57
- // Track the trigger count to allow force-refresh
58
- const [refreshTrigger, setRefreshTrigger] = useState(0);
59
-
60
- const refresh = useCallback(() => {
61
- setRefreshTrigger(prev => prev + 1);
62
- }, []);
54
+ // Stable identity string for the user — avoids re-triggering when the
55
+ // authController object reference changes but uid/roles are the same.
56
+ const userIdentity = authController.user
57
+ ? `${authController.user.uid}:${(authController.user.roles ?? []).sort().join(',')}`
58
+ : null;
63
59
 
64
60
  // Use refs for values that may be new objects each render but shouldn't
65
61
  // re-trigger the effect. The effect reads them at execution time.
@@ -70,74 +66,36 @@ export function useResolvedCollections<EC extends EntityCollection, USER extends
70
66
  const pluginsRef = useRef(plugins);
71
67
  pluginsRef.current = plugins;
72
68
 
69
+ const { data: collections, loading, error, refresh } = useAsyncResolver<EntityCollection[]>({
70
+ resolver: async () => {
71
+ const resolved = await resolveCollections(
72
+ collectionsProp,
73
+ authControllerRef.current,
74
+ dataRef.current,
75
+ pluginsRef.current
76
+ );
73
77
 
74
- // Ref for resolved collections change detection
75
- const resolvedCollectionsRef = useRef<EntityCollection[]>([]);
76
-
77
- const initialLoading = authController.initialLoading;
78
- const user = authController.user;
79
-
80
- useEffect(() => {
81
- if (disabled || initialLoading) return;
82
-
83
- let cancelled = false;
84
-
85
- (async () => {
86
- try {
87
- const resolved = await resolveCollections(
88
- collectionsProp,
89
- authControllerRef.current,
90
- dataRef.current,
91
- pluginsRef.current
92
- );
78
+ const deduped = [...resolved];
93
79
 
94
- if (cancelled) return;
80
+ // Register with the CollectionRegistry; returns true if changed
81
+ const changed = collectionRegistryController.collectionRegistryRef.current.registerMultiple(deduped);
95
82
 
96
- const deduped = [...resolved];
97
-
98
- // Register with the CollectionRegistry; returns true if changed
99
- const changed = collectionRegistryController.collectionRegistryRef.current.registerMultiple(deduped);
100
-
101
- if (changed) {
102
- console.debug("Collections have changed", deduped);
103
- }
104
-
105
- // Only update state if collections actually changed
106
- if (!areCollectionListsEqual(resolvedCollectionsRef.current, deduped)) {
107
- resolvedCollectionsRef.current = deduped;
108
- setResolvedCollections(deduped);
109
- }
110
-
111
- setError(undefined);
112
- } catch (e) {
113
- if (!cancelled) {
114
- console.error(e);
115
- setError(e as Error);
116
- }
117
- } finally {
118
- if (!cancelled) {
119
- setLoading(false);
120
- }
83
+ if (changed) {
84
+ console.debug("Collections have changed", deduped);
121
85
  }
122
- })();
123
86
 
124
- return () => {
125
- cancelled = true;
126
- };
127
- }, [
128
- collectionsProp,
129
- disabled,
130
- collectionRegistryController.collectionRegistryRef,
131
- refreshTrigger,
132
- initialLoading,
133
- user
134
- ]);
87
+ return deduped;
88
+ },
89
+ initialValue: [],
90
+ isEqual: areCollectionListsEqual,
91
+ deps: [collectionsProp, userIdentity, disabled],
92
+ disabled: disabled || authController.initialLoading,
93
+ });
135
94
 
136
95
  return useMemo(() => ({
137
- collections: resolvedCollections,
96
+ collections,
138
97
  loading,
139
98
  error,
140
99
  refresh
141
- }), [resolvedCollections, loading, error, refresh]);
100
+ }), [collections, loading, error, refresh]);
142
101
  }
143
-