@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/dist/contexts/AdminModeController.d.ts +1 -1
- package/dist/contexts/ModeController.d.ts +1 -1
- package/dist/hooks/useBuildAdminModeController.d.ts +1 -1
- package/dist/hooks/useBuildModeController.d.ts +1 -1
- package/dist/hooks/useNavigationBlocker.d.ts +29 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +89 -3
- package/dist/index.es.js.map +1 -1
- package/package.json +6 -6
- package/src/components/UnsavedChangesDialog.tsx +1 -1
- package/src/contexts/AdminModeController.tsx +1 -1
- package/src/contexts/ModeController.tsx +1 -1
- package/src/core/Rebase.tsx +1 -0
- package/src/hooks/useBuildAdminModeController.tsx +1 -1
- package/src/hooks/useBuildModeController.tsx +1 -1
- package/src/hooks/useNavigationBlocker.tsx +108 -0
- package/src/hooks/useUnsavedChangesDialog.tsx +7 -4
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/app",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
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.
|
|
56
|
-
"@rebasepro/
|
|
57
|
-
"@rebasepro/
|
|
58
|
-
"@rebasepro/ui": "0.
|
|
59
|
-
"@rebasepro/utils": "0.
|
|
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;
|
package/src/core/Rebase.tsx
CHANGED
|
@@ -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),
|
|
@@ -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 =
|
|
22
|
-
(
|
|
23
|
-
|
|
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";
|