@rebasepro/app 0.9.1-canary.ff338b5 → 0.10.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/app",
3
3
  "type": "module",
4
- "version": "0.9.1-canary.ff338b5",
4
+ "version": "0.10.0",
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,11 +52,11 @@
52
52
  "magic-string": "^0.30.0",
53
53
  "notistack": "^3.0.2",
54
54
  "react-i18next": "^17.0.8",
55
- "@rebasepro/common": "0.9.1-canary.ff338b5",
56
- "@rebasepro/forms": "0.9.1-canary.ff338b5",
57
- "@rebasepro/types": "0.9.1-canary.ff338b5",
58
- "@rebasepro/ui": "0.9.1-canary.ff338b5",
59
- "@rebasepro/utils": "0.9.1-canary.ff338b5"
55
+ "@rebasepro/common": "0.10.0",
56
+ "@rebasepro/types": "0.10.0",
57
+ "@rebasepro/forms": "0.10.0",
58
+ "@rebasepro/ui": "0.10.0",
59
+ "@rebasepro/utils": "0.10.0"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "react": ">=19.0.0",
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@rebasepro/ui";
3
- import { useTranslation } from "../hooks";
3
+ import { useTranslation } from "../hooks/useTranslation";
4
4
 
5
5
  export interface UnsavedChangesDialogProps {
6
6
  open: boolean;
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { AdminModeController } from "../hooks";
2
+ import type { AdminModeController } from "../hooks/useAdminModeController";
3
3
 
4
4
  const DEFAULT_ADMIN_MODE_STATE: AdminModeController = {
5
5
  mode: "content",
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { ModeController } from "../hooks";
2
+ import type { ModeController } from "../hooks/useModeController";
3
3
 
4
4
  const DEFAULT_MODE_STATE: ModeController = {
5
5
  mode: "light",
@@ -288,6 +288,7 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
288
288
  executeSql: wsAdmin.executeSql!.bind(wsAdmin),
289
289
  fetchAvailableDatabases: wsAdmin.fetchAvailableDatabases?.bind(wsAdmin),
290
290
  fetchAvailableRoles: wsAdmin.fetchAvailableRoles?.bind(wsAdmin),
291
+ fetchApplicationRoles: wsAdmin.fetchApplicationRoles?.bind(wsAdmin),
291
292
  fetchCurrentDatabase: wsAdmin.fetchCurrentDatabase?.bind(wsAdmin),
292
293
  fetchUnmappedTables: wsAdmin.fetchUnmappedTables?.bind(wsAdmin),
293
294
  fetchTableMetadata: wsAdmin.fetchTableMetadata?.bind(wsAdmin),
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useMemo, useState } from "react";
2
- import { AdminModeController } from "./index";
2
+ import type { AdminModeController } from "./useAdminModeController";
3
3
 
4
4
  /**
5
5
  * Use this hook to build an admin mode controller that determines
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useState, useMemo } from "react";
2
2
 
3
- import { ModeController } from "./index";
3
+ import type { ModeController } from "./useModeController";
4
4
 
5
5
  /**
6
6
  * Use this hook to build a color mode controller that determines
@@ -0,0 +1,108 @@
1
+ import React, { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
2
+ import { Blocker, BlockerFunction, useBlocker } from "react-router-dom";
3
+
4
+ const IDLE_BLOCKER: Blocker = {
5
+ state: "unblocked",
6
+ proceed: undefined,
7
+ reset: undefined,
8
+ location: undefined
9
+ };
10
+
11
+ type NavigationBlockerContextValue = {
12
+ register: (id: string, predicate: BlockerFunction) => void;
13
+ unregister: (id: string) => void;
14
+ blocker: Blocker;
15
+ /** Id of the registration that caused the current block, if any. */
16
+ blockedBy: string | null;
17
+ };
18
+
19
+ const NavigationBlockerContext = createContext<NavigationBlockerContextValue | null>(null);
20
+
21
+ /**
22
+ * Owns the single React Router blocker for the whole app.
23
+ *
24
+ * React Router only honours **one** blocker at a time: `shouldBlockNavigation`
25
+ * picks the last-registered blocker function and silently ignores every other
26
+ * one (it only logs a dev warning). Because blockers register in a `useEffect`,
27
+ * the winner is whichever surface mounted most recently — so an unsaved-changes
28
+ * guard could be disabled by an unrelated component mounting after it.
29
+ *
30
+ * This provider registers the only `useBlocker` in the tree and multiplexes it,
31
+ * so every surface that needs to guard navigation gets a say regardless of
32
+ * mount order. Surfaces register through {@link useNavigationBlocker}.
33
+ */
34
+ export function NavigationBlockerProvider({ children }: { children: React.ReactNode }) {
35
+
36
+ const predicates = useRef(new Map<string, BlockerFunction>());
37
+
38
+ // State, not a ref: the owning registration only renders its dialog once
39
+ // this has propagated, so it has to go through React.
40
+ const [blockedBy, setBlockedBy] = useState<string | null>(null);
41
+
42
+ const shouldBlock = useCallback<BlockerFunction>((args) => {
43
+ for (const [id, predicate] of predicates.current) {
44
+ if (predicate(args)) {
45
+ setBlockedBy(id);
46
+ return true;
47
+ }
48
+ }
49
+ setBlockedBy(null);
50
+ return false;
51
+ }, []);
52
+
53
+ const blocker = useBlocker(shouldBlock);
54
+
55
+ const register = useCallback((id: string, predicate: BlockerFunction) => {
56
+ predicates.current.set(id, predicate);
57
+ }, []);
58
+
59
+ const unregister = useCallback((id: string) => {
60
+ predicates.current.delete(id);
61
+ setBlockedBy((current) => (current === id ? null : current));
62
+ }, []);
63
+
64
+ const value = useMemo<NavigationBlockerContextValue>(() => ({
65
+ register,
66
+ unregister,
67
+ blocker,
68
+ blockedBy
69
+ }), [register, unregister, blocker, blockedBy]);
70
+
71
+ return (
72
+ <NavigationBlockerContext.Provider value={value}>
73
+ {children}
74
+ </NavigationBlockerContext.Provider>
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Guard navigation with `predicate`, sharing the app-wide blocker.
80
+ *
81
+ * Returns a {@link Blocker} that is only ever in the `blocked` state when *this*
82
+ * registration is what blocked the navigation — so several guards can coexist
83
+ * without each of them popping a dialog.
84
+ *
85
+ * Returns an idle blocker when no {@link NavigationBlockerProvider} is mounted
86
+ * above, rather than competing for React Router's single blocker slot.
87
+ */
88
+ export function useNavigationBlocker(predicate: BlockerFunction): Blocker {
89
+
90
+ const context = useContext(NavigationBlockerContext);
91
+ const id = useId();
92
+
93
+ // Keep the latest predicate without re-registering on every render.
94
+ const predicateRef = useRef(predicate);
95
+ predicateRef.current = predicate;
96
+
97
+ const register = context?.register;
98
+ const unregister = context?.unregister;
99
+
100
+ useEffect(() => {
101
+ if (!register || !unregister) return;
102
+ register(id, (args) => predicateRef.current(args));
103
+ return () => unregister(id);
104
+ }, [register, unregister, id]);
105
+
106
+ if (!context) return IDLE_BLOCKER;
107
+ return context.blockedBy === id ? context.blocker : IDLE_BLOCKER;
108
+ }
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useState, useMemo } from "react";
2
- import { useBlocker } from "react-router-dom";
3
2
  import { UnsavedChangesDialogProps } from "../components/UnsavedChangesDialog";
3
+ import { useNavigationBlocker } from "./useNavigationBlocker";
4
4
 
5
5
  /**
6
6
  * A single, unified hook to prevent navigation when there are unsaved changes.
@@ -18,9 +18,12 @@ export function useUnsavedChangesDialog(
18
18
  } {
19
19
  const [manualDialogOpen, setManualDialogOpen] = useState(false);
20
20
 
21
- const blocker = useBlocker(
22
- ({ currentLocation, nextLocation }) =>
23
- when && currentLocation.pathname !== nextLocation.pathname
21
+ const blocker = useNavigationBlocker(
22
+ useCallback(
23
+ ({ currentLocation, nextLocation }) =>
24
+ when && currentLocation.pathname !== nextLocation.pathname,
25
+ [when]
26
+ )
24
27
  );
25
28
 
26
29
  useEffect(() => {
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./contexts";
12
12
  export { CONTAINER_FULL_WIDTH, ADDITIONAL_TAB_WIDTH, FORM_CONTAINER_WIDTH } from "./internal/common";
13
13
  export { useRestoreScroll } from "./internal/useRestoreScroll";
14
14
  export { useUnsavedChangesDialog } from "./hooks/useUnsavedChangesDialog";
15
+ export { NavigationBlockerProvider, useNavigationBlocker } from "./hooks/useNavigationBlocker";
15
16
  export type { UnsavedChangesDialogProps } from "./components/UnsavedChangesDialog";
16
17
  export { UnsavedChangesDialog } from "./components/UnsavedChangesDialog";
17
18
  export * from "./i18n/RebaseI18nProvider";