@rebasepro/app 0.9.1-canary.fd3754b → 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/auth/index.d.ts +6 -5
- package/dist/contexts/AdminModeController.d.ts +1 -1
- package/dist/contexts/CollectionResolverContext.d.ts +23 -0
- package/dist/contexts/ModeController.d.ts +1 -1
- package/dist/contexts/index.d.ts +1 -0
- 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 +240 -104
- package/dist/index.es.js.map +1 -1
- package/dist/util/previews.d.ts +9 -0
- package/package.json +14 -21
- package/src/auth/index.ts +6 -5
- package/src/components/LoginView/LoginView.tsx +7 -2
- package/src/components/UnsavedChangesDialog.tsx +1 -1
- package/src/components/common/useDataTableController.tsx +8 -2
- package/src/contexts/AdminModeController.tsx +1 -1
- package/src/contexts/CollectionResolverContext.tsx +24 -0
- package/src/contexts/ModeController.tsx +1 -1
- package/src/contexts/index.ts +1 -0
- package/src/core/Rebase.tsx +21 -5
- package/src/hooks/data/useCollection.tsx +2 -4
- package/src/hooks/data/useRelationSelector.tsx +10 -3
- 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/src/util/previews.ts +20 -0
- package/dist/index.umd.js +0 -17021
- package/dist/index.umd.js.map +0 -1
package/dist/util/previews.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { CollectionConfig, PropertyConfig } from "@rebasepro/types";
|
|
2
2
|
import { AuthController } from "@rebasepro/types";
|
|
3
3
|
export declare function getEntityPreviewKeys(authController: AuthController, targetCollection: CollectionConfig<any>, fields: Record<string, PropertyConfig>, previewProperties?: string[], limit?: number): string[];
|
|
4
|
+
/**
|
|
5
|
+
* The `include` params that eager-load a collection's relations in the same
|
|
6
|
+
* request as its rows, so previews never fetch once per relation cell.
|
|
7
|
+
*
|
|
8
|
+
* Only the REST transport reads `include`; the realtime transport embeds
|
|
9
|
+
* relation data unconditionally and ignores it. Passing it either way keeps a
|
|
10
|
+
* realtime-less deployment rendering the same cells as a realtime one.
|
|
11
|
+
*/
|
|
12
|
+
export declare function getRelationIncludeParams(collection: CollectionConfig<any>): string[] | undefined;
|
|
4
13
|
export declare function getEntityTitlePropertyKey<M extends Record<string, any>>(collection: CollectionConfig<M>, propertyConfigs: Record<string, PropertyConfig>): string | undefined;
|
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"
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
"url": "https://github.com/rebasepro/rebase.git",
|
|
14
14
|
"directory": "packages/app"
|
|
15
15
|
},
|
|
16
|
-
"main": "./dist/index.
|
|
16
|
+
"main": "./dist/index.es.js",
|
|
17
17
|
"module": "./dist/index.es.js",
|
|
18
18
|
"types": "./dist/index.d.ts",
|
|
19
19
|
"source": "src/index.ts",
|
|
20
20
|
"engines": {
|
|
21
|
-
"node": ">=
|
|
21
|
+
"node": ">=20"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|
|
24
24
|
"rebase",
|
|
@@ -34,14 +34,12 @@
|
|
|
34
34
|
".": {
|
|
35
35
|
"types": "./dist/index.d.ts",
|
|
36
36
|
"development": "./dist/index.es.js",
|
|
37
|
-
"import": "./dist/index.es.js"
|
|
38
|
-
"require": "./dist/index.umd.js"
|
|
37
|
+
"import": "./dist/index.es.js"
|
|
39
38
|
},
|
|
40
39
|
"./vitePlugin": {
|
|
41
40
|
"types": "./dist/vitePlugin.d.ts",
|
|
42
41
|
"development": "./dist/vitePlugin.js",
|
|
43
|
-
"import": "./dist/vitePlugin.js"
|
|
44
|
-
"require": "./dist/vitePlugin.js"
|
|
42
|
+
"import": "./dist/vitePlugin.js"
|
|
45
43
|
},
|
|
46
44
|
"./package.json": "./package.json"
|
|
47
45
|
},
|
|
@@ -54,11 +52,11 @@
|
|
|
54
52
|
"magic-string": "^0.30.0",
|
|
55
53
|
"notistack": "^3.0.2",
|
|
56
54
|
"react-i18next": "^17.0.8",
|
|
57
|
-
"@rebasepro/
|
|
58
|
-
"@rebasepro/
|
|
59
|
-
"@rebasepro/
|
|
60
|
-
"@rebasepro/ui": "0.
|
|
61
|
-
"@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"
|
|
62
60
|
},
|
|
63
61
|
"peerDependencies": {
|
|
64
62
|
"react": ">=19.0.0",
|
|
@@ -69,6 +67,7 @@
|
|
|
69
67
|
},
|
|
70
68
|
"devDependencies": {
|
|
71
69
|
"@jest/globals": "^30.4.1",
|
|
70
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
72
71
|
"@testing-library/react": "^16.3.2",
|
|
73
72
|
"@testing-library/user-event": "^14.6.1",
|
|
74
73
|
"@types/jest": "^30.0.0",
|
|
@@ -118,25 +117,19 @@
|
|
|
118
117
|
"testEnvironment": "jsdom",
|
|
119
118
|
"testPathIgnorePatterns": [
|
|
120
119
|
"/node_modules/",
|
|
121
|
-
"/src/components/"
|
|
122
|
-
"objects.test.ts",
|
|
123
|
-
"navigation.test.ts",
|
|
124
|
-
"resolutions.test.ts"
|
|
120
|
+
"/src/components/"
|
|
125
121
|
],
|
|
126
122
|
"setupFilesAfterEnv": [
|
|
127
123
|
"<rootDir>/test/setupTests.ts"
|
|
128
124
|
],
|
|
129
125
|
"moduleNameMapper": {
|
|
130
126
|
"\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js",
|
|
131
|
-
"^@rebasepro/
|
|
132
|
-
"^@rebasepro/common$": "<rootDir>/../common/src/index.ts",
|
|
133
|
-
"^@rebasepro/types$": "<rootDir>/../types/src/index.ts",
|
|
134
|
-
"^@rebasepro/forms$": "<rootDir>/../forms/src/index.ts"
|
|
127
|
+
"^@rebasepro/([a-z0-9-]+)$": "<rootDir>/../$1/src/index.ts"
|
|
135
128
|
}
|
|
136
129
|
},
|
|
137
130
|
"scripts": {
|
|
138
131
|
"watch": "vite build --watch",
|
|
139
|
-
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && pnpm exec esbuild src/vitePlugin.ts --platform=node --format=esm --outfile=dist/vitePlugin.js",
|
|
132
|
+
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && pnpm exec esbuild src/vitePlugin.ts --platform=node --format=esm --outfile=dist/vitePlugin.js && node ../../scripts/assert-build-output.mjs",
|
|
140
133
|
"test:lint": "eslint \"src/**\" --quiet",
|
|
141
134
|
"test": "jest --passWithNoTests",
|
|
142
135
|
"clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
|
package/src/auth/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Authentication controller for Rebase frontends.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* for communicating with a Rebase backend's JWT auth endpoints.
|
|
4
|
+
* Provides the `useRebaseAuthController` hook and API utilities for
|
|
5
|
+
* communicating with a Rebase backend's JWT auth endpoints.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
7
|
+
* The generic LoginView and RebaseAuth components that render on top of this
|
|
8
|
+
* controller are in `../components`. Both are re-exported from the package
|
|
9
|
+
* root, which is where applications should import them from.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
// Types
|
|
@@ -628,15 +628,20 @@ function LoginForm({
|
|
|
628
628
|
};
|
|
629
629
|
}, [onClose]);
|
|
630
630
|
|
|
631
|
+
// Both controller methods record the failure in `authProviderError` — which
|
|
632
|
+
// this form renders above — and then rethrow for programmatic callers. This
|
|
633
|
+
// is not one of those: without a catch here every rejected sign-in becomes an
|
|
634
|
+
// unhandled rejection, reported as a crash by anything listening for one,
|
|
635
|
+
// while the user has already been shown the error.
|
|
631
636
|
function handleEnterPassword() {
|
|
632
637
|
if (email && password && authController.emailPasswordLogin) {
|
|
633
|
-
authController.emailPasswordLogin(email, password);
|
|
638
|
+
void Promise.resolve(authController.emailPasswordLogin(email, password)).catch(() => undefined);
|
|
634
639
|
}
|
|
635
640
|
}
|
|
636
641
|
|
|
637
642
|
function handleRegistration() {
|
|
638
643
|
if (email && password && authController.register) {
|
|
639
|
-
authController.register(email, password, displayName);
|
|
644
|
+
void Promise.resolve(authController.register(email, password, displayName)).catch(() => undefined);
|
|
640
645
|
}
|
|
641
646
|
}
|
|
642
647
|
|
|
@@ -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;
|
|
@@ -5,6 +5,7 @@ import { useLocation } from "react-router-dom";
|
|
|
5
5
|
import { useData, useRebaseContext } from "../../hooks";
|
|
6
6
|
import { useDataOrder } from "../../hooks/data/useDataOrder";
|
|
7
7
|
import { populateFetchCache } from "../../hooks/data/useFetch";
|
|
8
|
+
import { getRelationIncludeParams } from "../../util/previews";
|
|
8
9
|
import { Entity, EntityReference, EntityRelation, EntityTableController, FilterValues, RebaseContext, SelectedCellProps, User, WhereFilterOp, FindResponse } from "@rebasepro/types";
|
|
9
10
|
import { ScrollRestorationController } from "./useScrollRestoration";
|
|
10
11
|
|
|
@@ -254,19 +255,24 @@ export function useDataTableController<M extends Record<string, any> = any, USER
|
|
|
254
255
|
|
|
255
256
|
let unsubscribe: (() => void) | undefined;
|
|
256
257
|
|
|
258
|
+
// Eagerly include relations to avoid N+1 fetches.
|
|
259
|
+
const includeParams = getRelationIncludeParams(collection);
|
|
260
|
+
|
|
257
261
|
if (accessor.listen) {
|
|
258
262
|
unsubscribe = accessor.listen({
|
|
259
263
|
where: whereParams,
|
|
260
264
|
limit: itemCount,
|
|
261
265
|
orderBy: orderByParams,
|
|
262
|
-
searchString
|
|
266
|
+
searchString,
|
|
267
|
+
include: includeParams
|
|
263
268
|
}, (res) => onEntitiesUpdate(res.data as Entity<M>[]), onError);
|
|
264
269
|
} else {
|
|
265
270
|
accessor.find({
|
|
266
271
|
where: whereParams,
|
|
267
272
|
limit: itemCount,
|
|
268
273
|
orderBy: orderByParams,
|
|
269
|
-
searchString
|
|
274
|
+
searchString,
|
|
275
|
+
include: includeParams
|
|
270
276
|
})
|
|
271
277
|
.then((res) => onEntitiesUpdate(res.data as Entity<M>[]))
|
|
272
278
|
.catch(onError);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Look up a collection's config by slug.
|
|
5
|
+
*/
|
|
6
|
+
export type CollectionResolver = (slug: string) => { properties?: Record<string, unknown> } | undefined;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Lets the layer that owns the collections hand a resolver *up* to the data
|
|
10
|
+
* layer, which needs a collection's primary keys to give its rows an address.
|
|
11
|
+
*
|
|
12
|
+
* The inversion is forced by the composition: `<Rebase client>` builds the data
|
|
13
|
+
* layer, and `<RebaseAdmin collections>` sits inside it — so the collections are
|
|
14
|
+
* not in scope where the data is created, and `Rebase` cannot take them as a
|
|
15
|
+
* prop without giving every headless BaaS app a collections argument it has no
|
|
16
|
+
* use for.
|
|
17
|
+
*
|
|
18
|
+
* Registration is a ref write, so it is safe to call during render — and it has
|
|
19
|
+
* to be. The views that fetch rows are *below* the registrar, and child effects
|
|
20
|
+
* run before parent effects, so registering in an effect would bind after the
|
|
21
|
+
* first page of rows had already been converted.
|
|
22
|
+
*/
|
|
23
|
+
export const CollectionResolverRegistrationContext =
|
|
24
|
+
React.createContext<(resolver: CollectionResolver | undefined) => void>(() => undefined);
|
package/src/contexts/index.ts
CHANGED
package/src/core/Rebase.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import { CenteredView, Typography } from "@rebasepro/ui";
|
|
|
8
8
|
import { RebaseContext, User, CollectionRegistryController, DataDriver, DataSourceDefinition, RebaseData, DEFAULT_DATA_SOURCE_KEY, StorageSource, StorageSourceDefinition, DEFAULT_STORAGE_SOURCE_KEY } from "@rebasepro/types";
|
|
9
9
|
import { PluginProviderStack } from "./PluginProviderStack";
|
|
10
10
|
import { PluginLifecycleManager } from "./PluginLifecycleManager";
|
|
11
|
-
import { AuthControllerContext } from "../contexts";
|
|
11
|
+
import { AuthControllerContext, CollectionResolverRegistrationContext, CollectionResolver } from "../contexts";
|
|
12
12
|
import { useCustomizationController, useRebaseContext, useAuthSubscription } from "../hooks";
|
|
13
13
|
import { ApiConfigProvider, useApiConfig } from "../hooks/ApiConfigContext";
|
|
14
14
|
import { ErrorView } from "../components";
|
|
@@ -98,6 +98,18 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
|
|
|
98
98
|
return dataSourcesProp ?? [];
|
|
99
99
|
}, [dataSourcesProp]);
|
|
100
100
|
|
|
101
|
+
// Rows arrive as columns only; their address is derived from the
|
|
102
|
+
// collection's primary keys. The collections live below us (in RebaseAdmin),
|
|
103
|
+
// so they register a resolver here and we read it lazily, per row. Headless
|
|
104
|
+
// apps register nothing and are unaffected.
|
|
105
|
+
const collectionResolverRef = useRef<CollectionResolver | undefined>(undefined);
|
|
106
|
+
const registerCollectionResolver = React.useCallback((resolver: CollectionResolver | undefined) => {
|
|
107
|
+
collectionResolverRef.current = resolver;
|
|
108
|
+
}, []);
|
|
109
|
+
const entityDataOptions = useMemo(() => ({
|
|
110
|
+
resolveCollection: (slug: string) => collectionResolverRef.current?.(slug)
|
|
111
|
+
}), []);
|
|
112
|
+
|
|
101
113
|
// Build the data-source context: the declared registry plus a RebaseData
|
|
102
114
|
// per direct/custom source (those carrying a client-side driver). Server-
|
|
103
115
|
// mediated sources have no entry — they ride the default client.
|
|
@@ -131,12 +143,12 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
|
|
|
131
143
|
...definition,
|
|
132
144
|
transport: ds.transport ?? (driver ? "direct" : "server")
|
|
133
145
|
};
|
|
134
|
-
if (driver) sources[ds.key] = buildRebaseData(driver);
|
|
146
|
+
if (driver) sources[ds.key] = buildRebaseData(driver, entityDataOptions);
|
|
135
147
|
}
|
|
136
148
|
const value: DataSourcesContextValue = { registry, sources };
|
|
137
149
|
dataSourcesRef.current = { sig, value };
|
|
138
150
|
return value;
|
|
139
|
-
}, [normalizedDataSources]);
|
|
151
|
+
}, [normalizedDataSources, entityDataOptions]);
|
|
140
152
|
|
|
141
153
|
// Default data source — serves every collection not routed to a
|
|
142
154
|
// registered direct/custom source. Resolution:
|
|
@@ -148,7 +160,7 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
|
|
|
148
160
|
if (registeredDefault) return registeredDefault;
|
|
149
161
|
// CMS boundary: the SDK client returns flat rows; wrap them into the
|
|
150
162
|
// Entity view-model the admin (`useData()`) renders.
|
|
151
|
-
if (client?.data) return wrapAsEntityData(client.data);
|
|
163
|
+
if (client?.data) return wrapAsEntityData(client.data, entityDataOptions);
|
|
152
164
|
const built = Object.values(dataSourcesValue.sources);
|
|
153
165
|
if (built.length === 1) return built[0];
|
|
154
166
|
if (built.length > 1) {
|
|
@@ -158,7 +170,7 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
|
|
|
158
170
|
);
|
|
159
171
|
}
|
|
160
172
|
throw new Error("Rebase requires either `client` or a `dataSources` entry with a driver to be provided");
|
|
161
|
-
}, [client, dataSourcesValue]);
|
|
173
|
+
}, [client, dataSourcesValue, entityDataOptions]);
|
|
162
174
|
|
|
163
175
|
// Storage fallback logic
|
|
164
176
|
const resolvedStorage = storageSourceProp ?? client?.storage;
|
|
@@ -276,6 +288,7 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
|
|
|
276
288
|
executeSql: wsAdmin.executeSql!.bind(wsAdmin),
|
|
277
289
|
fetchAvailableDatabases: wsAdmin.fetchAvailableDatabases?.bind(wsAdmin),
|
|
278
290
|
fetchAvailableRoles: wsAdmin.fetchAvailableRoles?.bind(wsAdmin),
|
|
291
|
+
fetchApplicationRoles: wsAdmin.fetchApplicationRoles?.bind(wsAdmin),
|
|
279
292
|
fetchCurrentDatabase: wsAdmin.fetchCurrentDatabase?.bind(wsAdmin),
|
|
280
293
|
fetchUnmappedTables: wsAdmin.fetchUnmappedTables?.bind(wsAdmin),
|
|
281
294
|
fetchTableMetadata: wsAdmin.fetchTableMetadata?.bind(wsAdmin),
|
|
@@ -349,6 +362,8 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
|
|
|
349
362
|
value={storageSourcesValue}>
|
|
350
363
|
<StorageSourceContext.Provider
|
|
351
364
|
value={resolvedStorage!}>
|
|
365
|
+
<CollectionResolverRegistrationContext.Provider
|
|
366
|
+
value={registerCollectionResolver}>
|
|
352
367
|
<DataSourcesContext.Provider
|
|
353
368
|
value={dataSourcesValue}>
|
|
354
369
|
<RebaseDataContext.Provider
|
|
@@ -373,6 +388,7 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
|
|
|
373
388
|
</DatabaseAdminContext.Provider>
|
|
374
389
|
</RebaseDataContext.Provider>
|
|
375
390
|
</DataSourcesContext.Provider>
|
|
391
|
+
</CollectionResolverRegistrationContext.Provider>
|
|
376
392
|
</StorageSourceContext.Provider>
|
|
377
393
|
</StorageSourcesContext.Provider>
|
|
378
394
|
</UserConfigurationPersistenceContext.Provider>
|
|
@@ -3,6 +3,7 @@ import { useEffect, useState, useMemo } from "react";
|
|
|
3
3
|
import { Entity, FilterValues, User } from "@rebasepro/types";
|
|
4
4
|
import { useData } from "./useData";
|
|
5
5
|
import { isSchemaDriftError, useSchemaDriftContext } from "../../components/SchemaDriftBanner";
|
|
6
|
+
import { getRelationIncludeParams } from "../../util/previews";
|
|
6
7
|
/**
|
|
7
8
|
* @group Hooks and utilities
|
|
8
9
|
*/
|
|
@@ -131,10 +132,7 @@ export function useCollection<M extends Record<string, any>, USER extends User>(
|
|
|
131
132
|
const accessor = dataClient.collection(path);
|
|
132
133
|
|
|
133
134
|
// Eagerly include relations to avoid N+1 fetches.
|
|
134
|
-
const
|
|
135
|
-
(p) => p.type === "relation" || p.type === "reference"
|
|
136
|
-
);
|
|
137
|
-
const includeParams = hasRelations ? ["*"] : undefined;
|
|
135
|
+
const includeParams = getRelationIncludeParams(collection);
|
|
138
136
|
|
|
139
137
|
if (accessor.listen) {
|
|
140
138
|
return accessor.listen({
|
|
@@ -2,6 +2,7 @@ import type { CollectionConfig } from "@rebasepro/types";
|
|
|
2
2
|
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
|
3
3
|
import { useData } from "./useData";
|
|
4
4
|
import { Entity, EntityRelation, FilterValues } from "@rebasepro/types";
|
|
5
|
+
import { getRelationIncludeParams } from "../../util/previews";
|
|
5
6
|
export interface RelationItem {
|
|
6
7
|
id: string | number;
|
|
7
8
|
label: string;
|
|
@@ -133,6 +134,10 @@ export function useRelationSelector<M extends Record<string, any> = any>(
|
|
|
133
134
|
}
|
|
134
135
|
}, []);
|
|
135
136
|
|
|
137
|
+
// Eagerly include relations to avoid N+1 fetches. Referentially stable, so
|
|
138
|
+
// it can be a dependency below without re-triggering the fetch.
|
|
139
|
+
const includeParams = getRelationIncludeParams(collection);
|
|
140
|
+
|
|
136
141
|
const fetchData = useCallback(() => {
|
|
137
142
|
cleanupSubscription();
|
|
138
143
|
setError(undefined);
|
|
@@ -163,7 +168,8 @@ export function useRelationSelector<M extends Record<string, any> = any>(
|
|
|
163
168
|
where: whereParams,
|
|
164
169
|
limit: limit,
|
|
165
170
|
orderBy: undefined,
|
|
166
|
-
searchString: currentSearch
|
|
171
|
+
searchString: currentSearch,
|
|
172
|
+
include: includeParams
|
|
167
173
|
}, (res) => onEntitiesUpdate({ data: res.data as Entity<M>[],
|
|
168
174
|
meta: res.meta }), onErrorUpdate);
|
|
169
175
|
} else {
|
|
@@ -172,7 +178,8 @@ meta: res.meta }), onErrorUpdate);
|
|
|
172
178
|
limit: limit,
|
|
173
179
|
offset: 0,
|
|
174
180
|
orderBy: undefined,
|
|
175
|
-
searchString: currentSearch
|
|
181
|
+
searchString: currentSearch,
|
|
182
|
+
include: includeParams
|
|
176
183
|
})
|
|
177
184
|
.then((res) => onEntitiesUpdate({ data: res.data as Entity<M>[],
|
|
178
185
|
meta: res.meta }))
|
|
@@ -181,7 +188,7 @@ meta: res.meta }))
|
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
unsubscribeRef.current = unsubscribe || null;
|
|
184
|
-
}, [dataClient, path, fixedFilter, limit, currentSearch, entityToRelationItem, cleanupSubscription, setLoading]);
|
|
191
|
+
}, [dataClient, path, fixedFilter, limit, currentSearch, entityToRelationItem, cleanupSubscription, setLoading, includeParams]);
|
|
185
192
|
|
|
186
193
|
// Search function with debouncing
|
|
187
194
|
const search = useCallback((searchString: string) => {
|
|
@@ -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";
|
package/src/util/previews.ts
CHANGED
|
@@ -74,6 +74,26 @@ export function getEntityPreviewKeys(
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// Returned as-is rather than rebuilt per call, so the result is referentially
|
|
78
|
+
// stable and safe to use as a hook dependency.
|
|
79
|
+
const INCLUDE_ALL_RELATIONS: string[] = ["*"];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The `include` params that eager-load a collection's relations in the same
|
|
83
|
+
* request as its rows, so previews never fetch once per relation cell.
|
|
84
|
+
*
|
|
85
|
+
* Only the REST transport reads `include`; the realtime transport embeds
|
|
86
|
+
* relation data unconditionally and ignores it. Passing it either way keeps a
|
|
87
|
+
* realtime-less deployment rendering the same cells as a realtime one.
|
|
88
|
+
*/
|
|
89
|
+
export function getRelationIncludeParams(collection: CollectionConfig<any>): string[] | undefined {
|
|
90
|
+
if (!collection.properties) return undefined;
|
|
91
|
+
const hasRelations = Object.values(collection.properties).some(property =>
|
|
92
|
+
property && !isPropertyBuilder(property) &&
|
|
93
|
+
(isRelationProperty(property as Property) || isReferenceProperty(property as Property)));
|
|
94
|
+
return hasRelations ? INCLUDE_ALL_RELATIONS : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
77
97
|
export function getEntityTitlePropertyKey<M extends Record<string, any>>(collection: CollectionConfig<M>, propertyConfigs: Record<string, PropertyConfig>): string | undefined {
|
|
78
98
|
if (collection.titleProperty) {
|
|
79
99
|
return collection.titleProperty as string;
|