@firecms/core 3.5.2-canary.09e1281 → 3.5.2-canary.c5764a3
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/components/EntityCollectionView/EntityCollectionView.d.ts +0 -5
- package/dist/components/EntityCollectionView/utils.d.ts +3 -3
- package/dist/index.es.js +676 -817
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +670 -811
- package/dist/index.umd.js.map +1 -1
- package/dist/internal/useUnsavedChangesDialog.d.ts +12 -0
- package/dist/types/side_dialogs_controller.d.ts +2 -4
- package/package.json +3 -3
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +111 -12
- package/src/components/EntityCollectionView/utils.ts +7 -7
- package/src/components/common/default_entity_actions.tsx +1 -2
- package/src/core/DefaultAppBar.tsx +9 -12
- package/src/core/EntityEditView.tsx +1 -26
- package/src/core/SideDialogs.tsx +13 -6
- package/src/hooks/useReferenceDialog.tsx +2 -2
- package/src/internal/useBuildSideDialogsController.tsx +2 -44
- package/src/internal/useUnsavedChangesDialog.tsx +142 -0
- package/src/types/side_dialogs_controller.tsx +2 -4
- package/dist/components/EntityCollectionView/CollectionCountBadge.d.ts +0 -8
- package/dist/components/EntityCollectionView/CollectionPathTitle.d.ts +0 -24
- package/dist/components/EntityCollectionView/EntityIdHeaderWidget.d.ts +0 -12
- package/dist/internal/useLeavePageConfirmation.d.ts +0 -11
- package/src/components/EntityCollectionView/CollectionCountBadge.tsx +0 -17
- package/src/components/EntityCollectionView/CollectionPathTitle.tsx +0 -68
- package/src/components/EntityCollectionView/EntityIdHeaderWidget.tsx +0 -124
- package/src/internal/useLeavePageConfirmation.ts +0 -25
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom hook to handle navigation blocking when there are unsaved changes.
|
|
3
|
+
*
|
|
4
|
+
* @param when - Indicates whether to block navigation.
|
|
5
|
+
* @param onSuccess - Callback invoked when navigation is confirmed.
|
|
6
|
+
* @returns An object containing the state of navigation blocking and handlers.
|
|
7
|
+
*/
|
|
8
|
+
export declare function useNavigationUnsavedChangesDialog(when: boolean, onSuccess: () => void): {
|
|
9
|
+
navigationWasBlocked: boolean;
|
|
10
|
+
handleCancel: () => void;
|
|
11
|
+
handleOk: () => void;
|
|
12
|
+
};
|
|
@@ -5,11 +5,9 @@ import React from "react";
|
|
|
5
5
|
*/
|
|
6
6
|
export interface SideDialogsController {
|
|
7
7
|
/**
|
|
8
|
-
* Close the last panel
|
|
9
|
-
* When the `key` of a panel without a URL is given, that panel is closed
|
|
10
|
-
* instead, even if other panels are open on top of it.
|
|
8
|
+
* Close the last panel
|
|
11
9
|
*/
|
|
12
|
-
close: (
|
|
10
|
+
close: () => void;
|
|
13
11
|
/**
|
|
14
12
|
* List of side panels currently open
|
|
15
13
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firecms/core",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "3.5.2-canary.
|
|
4
|
+
"version": "3.5.2-canary.c5764a3",
|
|
5
5
|
"description": "Awesome Firebase/Firestore-based headless open-source CMS",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/firecmsco"
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"@dnd-kit/core": "^6.3.1",
|
|
54
54
|
"@dnd-kit/modifiers": "^9.0.0",
|
|
55
55
|
"@dnd-kit/sortable": "^10.0.0",
|
|
56
|
-
"@firecms/formex": "3.5.2-canary.
|
|
57
|
-
"@firecms/ui": "3.5.2-canary.
|
|
56
|
+
"@firecms/formex": "3.5.2-canary.c5764a3",
|
|
57
|
+
"@firecms/ui": "3.5.2-canary.c5764a3",
|
|
58
58
|
"@floating-ui/dom": "^1.7.4",
|
|
59
59
|
"@radix-ui/react-portal": "^1.1.10",
|
|
60
60
|
"@radix-ui/react-slot": "^1.2.4",
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
Entity,
|
|
9
9
|
EntityAction,
|
|
10
10
|
EntityCollection,
|
|
11
|
+
EntityReference,
|
|
11
12
|
EntityTableController,
|
|
12
13
|
FilterValues,
|
|
13
14
|
PartialEntityCollection,
|
|
@@ -58,9 +59,14 @@ import {
|
|
|
58
59
|
AddIcon,
|
|
59
60
|
Button,
|
|
60
61
|
cls,
|
|
62
|
+
focusedDisabled,
|
|
63
|
+
IconButton,
|
|
61
64
|
KeyboardTabIcon,
|
|
62
65
|
Markdown,
|
|
66
|
+
Popover,
|
|
67
|
+
SearchIcon,
|
|
63
68
|
Skeleton,
|
|
69
|
+
Tooltip,
|
|
64
70
|
Typography
|
|
65
71
|
} from "@firecms/ui";
|
|
66
72
|
import { setIn } from "@firecms/formex";
|
|
@@ -82,8 +88,7 @@ import { DeleteEntityDialog } from "../DeleteEntityDialog";
|
|
|
82
88
|
import { useAnalyticsController } from "../../hooks/useAnalyticsController";
|
|
83
89
|
import { useSelectionController } from "./useSelectionController";
|
|
84
90
|
import { EntityCollectionViewStartActions } from "./EntityCollectionViewStartActions";
|
|
85
|
-
import { addRecentId } from "./utils";
|
|
86
|
-
import { EntityIdHeaderWidget } from "./EntityIdHeaderWidget";
|
|
91
|
+
import { addRecentId, getRecentIds } from "./utils";
|
|
87
92
|
import { useScrollRestoration } from "../common/useScrollRestoration";
|
|
88
93
|
import { entityCacheKey } from "../../util/entity_cache";
|
|
89
94
|
|
|
@@ -126,12 +131,6 @@ export type EntityCollectionViewProps<M extends Record<string, any>> = {
|
|
|
126
131
|
*/
|
|
127
132
|
updateUrl?: boolean;
|
|
128
133
|
|
|
129
|
-
/**
|
|
130
|
-
* Called with the number of entities in the collection, with the current filters
|
|
131
|
-
* applied, each time it is counted: the count the breadcrumbs show.
|
|
132
|
-
*/
|
|
133
|
-
onCountChange?: (count: number) => void;
|
|
134
|
-
|
|
135
134
|
} & EntityCollection<M>;
|
|
136
135
|
|
|
137
136
|
/**
|
|
@@ -167,7 +166,6 @@ export const EntityCollectionView = React.memo(
|
|
|
167
166
|
isSubCollection,
|
|
168
167
|
className,
|
|
169
168
|
updateUrl,
|
|
170
|
-
onCountChange,
|
|
171
169
|
...collectionProp
|
|
172
170
|
}: EntityCollectionViewProps<M>
|
|
173
171
|
) {
|
|
@@ -352,7 +350,7 @@ export const EntityCollectionView = React.memo(
|
|
|
352
350
|
});
|
|
353
351
|
|
|
354
352
|
if (collection) {
|
|
355
|
-
addRecentId(
|
|
353
|
+
addRecentId(collection.id, clickedEntity.id);
|
|
356
354
|
}
|
|
357
355
|
|
|
358
356
|
// In a collection group the row's own path is the real one, so the row's segments
|
|
@@ -797,9 +795,8 @@ export const EntityCollectionView = React.memo(
|
|
|
797
795
|
useEffect(() => {
|
|
798
796
|
if (docsCount !== undefined) {
|
|
799
797
|
breadcrumbs.updateCount(fullPath, docsCount);
|
|
800
|
-
onCountChange?.(docsCount);
|
|
801
798
|
}
|
|
802
|
-
}, [docsCount, fullPath, breadcrumbs.updateCount
|
|
799
|
+
}, [docsCount, fullPath, breadcrumbs.updateCount]);
|
|
803
800
|
|
|
804
801
|
// EntitiesCount fetches count and updates breadcrumb - no visual rendering needed here
|
|
805
802
|
const countFetcher = <EntitiesCount
|
|
@@ -1201,3 +1198,105 @@ function buildPropertyWidthOverwrite(key: string, width: number): PartialEntityC
|
|
|
1201
1198
|
}
|
|
1202
1199
|
return { properties: { [key]: { columnWidth: width } } } as PartialEntityCollection;
|
|
1203
1200
|
}
|
|
1201
|
+
|
|
1202
|
+
function EntityIdHeaderWidget({
|
|
1203
|
+
collection,
|
|
1204
|
+
path,
|
|
1205
|
+
pathSegments,
|
|
1206
|
+
fullIdPath
|
|
1207
|
+
}: {
|
|
1208
|
+
collection: EntityCollection,
|
|
1209
|
+
path: string,
|
|
1210
|
+
/** `path` split at its real segment boundaries, when the caller knows them. */
|
|
1211
|
+
pathSegments?: string[],
|
|
1212
|
+
fullIdPath: string
|
|
1213
|
+
}) {
|
|
1214
|
+
|
|
1215
|
+
const navigation = useNavigationController();
|
|
1216
|
+
const [openPopup, setOpenPopup] = React.useState(false);
|
|
1217
|
+
const [searchString, setSearchString] = React.useState("");
|
|
1218
|
+
const [recentIds, setRecentIds] = React.useState<string[]>(getRecentIds(collection.id));
|
|
1219
|
+
const sideEntityController = useSideEntityController();
|
|
1220
|
+
const { t } = useTranslation();
|
|
1221
|
+
|
|
1222
|
+
const openEntityMode = collection?.openEntityMode ?? DEFAULT_ENTITY_OPEN_MODE;
|
|
1223
|
+
|
|
1224
|
+
return (
|
|
1225
|
+
<Tooltip title={!openPopup ? t("find_by_id") : undefined} asChild={false}>
|
|
1226
|
+
<Popover
|
|
1227
|
+
open={openPopup}
|
|
1228
|
+
onOpenChange={setOpenPopup}
|
|
1229
|
+
sideOffset={0}
|
|
1230
|
+
align={"start"}
|
|
1231
|
+
alignOffset={-117}
|
|
1232
|
+
trigger={
|
|
1233
|
+
<IconButton size={"small"}>
|
|
1234
|
+
<SearchIcon size={"small"} />
|
|
1235
|
+
</IconButton>
|
|
1236
|
+
}>
|
|
1237
|
+
<div
|
|
1238
|
+
className={cls("my-2 rounded-lg bg-surface-50 dark:bg-surface-950 text-surface-900 dark:text-white")}>
|
|
1239
|
+
<form noValidate={true}
|
|
1240
|
+
onSubmit={(e) => {
|
|
1241
|
+
e.preventDefault();
|
|
1242
|
+
if (!searchString) return;
|
|
1243
|
+
setOpenPopup(false);
|
|
1244
|
+
const entityId = searchString.trim();
|
|
1245
|
+
setRecentIds(addRecentId(collection.id, entityId));
|
|
1246
|
+
navigateToEntity({
|
|
1247
|
+
openEntityMode,
|
|
1248
|
+
collection,
|
|
1249
|
+
entityId,
|
|
1250
|
+
path,
|
|
1251
|
+
pathSegments,
|
|
1252
|
+
fullIdPath,
|
|
1253
|
+
sideEntityController,
|
|
1254
|
+
navigation
|
|
1255
|
+
})
|
|
1256
|
+
}}
|
|
1257
|
+
className={"w-96 max-w-full"}>
|
|
1258
|
+
|
|
1259
|
+
<div className="flex p-2 w-full gap-2">
|
|
1260
|
+
<input
|
|
1261
|
+
autoFocus={openPopup}
|
|
1262
|
+
placeholder={t("find_entity_by_id")}
|
|
1263
|
+
// size={"small"}
|
|
1264
|
+
onChange={(e) => {
|
|
1265
|
+
setSearchString(e.target.value);
|
|
1266
|
+
}}
|
|
1267
|
+
value={searchString}
|
|
1268
|
+
className={"rounded-lg bg-white dark:bg-surface-800 flex-grow bg-transparent outline-none p-2 " + focusedDisabled} />
|
|
1269
|
+
<Button variant={"text"}
|
|
1270
|
+
disabled={!(searchString.trim())}
|
|
1271
|
+
type={"submit"}
|
|
1272
|
+
><KeyboardTabIcon /></Button>
|
|
1273
|
+
</div>
|
|
1274
|
+
</form>
|
|
1275
|
+
{recentIds && recentIds.length > 0 && <div className="flex flex-col gap-2 p-2">
|
|
1276
|
+
{recentIds.map(id => (
|
|
1277
|
+
<ReferencePreview reference={new EntityReference(id, path, undefined, pathSegments)}
|
|
1278
|
+
key={id}
|
|
1279
|
+
hover={true}
|
|
1280
|
+
onClick={() => {
|
|
1281
|
+
setOpenPopup(false);
|
|
1282
|
+
navigateToEntity({
|
|
1283
|
+
openEntityMode,
|
|
1284
|
+
collection,
|
|
1285
|
+
entityId: id,
|
|
1286
|
+
path,
|
|
1287
|
+
pathSegments,
|
|
1288
|
+
fullIdPath,
|
|
1289
|
+
sideEntityController,
|
|
1290
|
+
navigation
|
|
1291
|
+
})
|
|
1292
|
+
}}
|
|
1293
|
+
includeEntityLink={false}
|
|
1294
|
+
size={"small"} />
|
|
1295
|
+
))}
|
|
1296
|
+
</div>}
|
|
1297
|
+
</div>
|
|
1298
|
+
</Popover>
|
|
1299
|
+
|
|
1300
|
+
</Tooltip>
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { getLocalStorageJSON, setLocalStorageItem } from "../../util/local_storage";
|
|
2
2
|
|
|
3
|
-
export function addRecentId(
|
|
4
|
-
const recentIds = getRecentIds(
|
|
3
|
+
export function addRecentId(collectionId: string, id: string) {
|
|
4
|
+
const recentIds = getRecentIds(collectionId);
|
|
5
5
|
const newRecentIds = [id, ...recentIds.filter(i => i !== id)];
|
|
6
6
|
if (newRecentIds.length > 5) {
|
|
7
7
|
newRecentIds.pop();
|
|
8
8
|
}
|
|
9
|
-
saveSearchedIdsLocally(
|
|
9
|
+
saveSearchedIdsLocally(collectionId, newRecentIds);
|
|
10
10
|
return newRecentIds;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function saveSearchedIdsLocally(
|
|
14
|
-
setLocalStorageItem("recent_id_searches::" +
|
|
13
|
+
export function saveSearchedIdsLocally(collectionId: string, ids: string[]) {
|
|
14
|
+
setLocalStorageItem("recent_id_searches::" + collectionId, JSON.stringify(ids));
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function getRecentIds(
|
|
18
|
-
return getLocalStorageJSON("recent_id_searches::" +
|
|
17
|
+
export function getRecentIds(collectionId: string): string[] {
|
|
18
|
+
return getLocalStorageJSON("recent_id_searches::" + collectionId, []);
|
|
19
19
|
}
|
|
@@ -38,8 +38,7 @@ export const editEntityAction: EntityAction = {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
if (collection) {
|
|
41
|
-
|
|
42
|
-
addRecentId(fullIdPath ?? fullPath ?? collection.id, entity.id);
|
|
41
|
+
addRecentId(collection.id, entity.id);
|
|
43
42
|
}
|
|
44
43
|
|
|
45
44
|
const path = collection?.collectionGroup ? entity.path : (fullPath ?? collection?.path ?? entity.path);
|
|
@@ -21,8 +21,6 @@ import { User } from "../types";
|
|
|
21
21
|
import { useApp } from "../app/useApp";
|
|
22
22
|
import { useBreadcrumbsController } from "../hooks/useBreadcrumbsController";
|
|
23
23
|
import { DrawerLogo } from "./DefaultDrawer";
|
|
24
|
-
import { CollectionCountBadge } from "../components/EntityCollectionView/CollectionCountBadge";
|
|
25
|
-
import { PathTitle } from "../components/EntityCollectionView/CollectionPathTitle";
|
|
26
24
|
|
|
27
25
|
export type DefaultAppBarProps<ADDITIONAL_PROPS = object> = {
|
|
28
26
|
|
|
@@ -83,7 +81,6 @@ export const DefaultAppBar = function DefaultAppBar({
|
|
|
83
81
|
const navigation = useNavigationController();
|
|
84
82
|
|
|
85
83
|
const breadcrumbs = useBreadcrumbsController();
|
|
86
|
-
const lastBreadcrumb = breadcrumbs.breadcrumbs?.[breadcrumbs.breadcrumbs.length - 1];
|
|
87
84
|
|
|
88
85
|
const authController = useAuthController();
|
|
89
86
|
const {
|
|
@@ -180,7 +177,15 @@ export const DefaultAppBar = function DefaultAppBar({
|
|
|
180
177
|
{breadcrumb.title}
|
|
181
178
|
</Typography>
|
|
182
179
|
{/* Show count badge for collection breadcrumbs: undefined = not applicable, null = loading, number = count */}
|
|
183
|
-
|
|
180
|
+
{breadcrumb.count !== undefined && (
|
|
181
|
+
breadcrumb.count !== null ? (
|
|
182
|
+
<span className="text-xs text-surface-accent-500 dark:text-surface-accent-400 bg-surface-100 dark:bg-surface-700 px-1 py-0 rounded">
|
|
183
|
+
{breadcrumb.count}
|
|
184
|
+
</span>
|
|
185
|
+
) : (
|
|
186
|
+
<Skeleton className="w-8 h-4 rounded-md" />
|
|
187
|
+
)
|
|
188
|
+
)}
|
|
184
189
|
</div>
|
|
185
190
|
</Link>
|
|
186
191
|
</React.Fragment>;
|
|
@@ -188,14 +193,6 @@ export const DefaultAppBar = function DefaultAppBar({
|
|
|
188
193
|
</div>
|
|
189
194
|
</div>}
|
|
190
195
|
|
|
191
|
-
{/* A row of breadcrumbs does not fit a small screen, but where you are and how
|
|
192
|
-
many entities are there still matter, so they are folded on two lines. */}
|
|
193
|
-
{lastBreadcrumb && <PathTitle
|
|
194
|
-
className={"lg:hidden min-w-0 mr-2"}
|
|
195
|
-
parents={breadcrumbs.breadcrumbs.slice(0, -1).map(breadcrumb => breadcrumb.title)}
|
|
196
|
-
name={lastBreadcrumb.title}
|
|
197
|
-
count={lastBreadcrumb.count}/>}
|
|
198
|
-
|
|
199
196
|
|
|
200
197
|
{startAdornment}
|
|
201
198
|
|
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
import {
|
|
25
25
|
useAuthController,
|
|
26
26
|
useCustomizationController,
|
|
27
|
-
useDataSource,
|
|
28
27
|
useEntityFetch,
|
|
29
28
|
useFireCMSContext,
|
|
30
29
|
useLargeLayout
|
|
@@ -36,7 +35,6 @@ import { EntityEditViewFormActions } from "./EntityEditViewFormActions";
|
|
|
36
35
|
import { EntityJsonPreview } from "../components/EntityJsonPreview";
|
|
37
36
|
import { createFormexStub } from "../util/createFormexStub";
|
|
38
37
|
import { useTranslation } from "../hooks/useTranslation";
|
|
39
|
-
import { CollectionPathTitle } from "../components/EntityCollectionView/CollectionPathTitle";
|
|
40
38
|
|
|
41
39
|
export const MAIN_TAB_VALUE = "__main_##Q$SC^#S6";
|
|
42
40
|
export const JSON_TAB_VALUE = "__json";
|
|
@@ -240,11 +238,6 @@ export function EntityEditViewInner<M extends Record<string, any>>({
|
|
|
240
238
|
|
|
241
239
|
const subcollections = (collection.subcollections ?? []).filter(c => !c.hideFromNavigation);
|
|
242
240
|
const subcollectionsCount = subcollections?.length ?? 0;
|
|
243
|
-
|
|
244
|
-
// The number of entities in each subcollection tab, as its collection view counts them.
|
|
245
|
-
const [subcollectionCounts, setSubcollectionCounts] = useState<Record<string, number>>({});
|
|
246
|
-
const selectedSubcollection = subcollections.find(subcollection => (subcollection.id ?? subcollection.path) === selectedTab);
|
|
247
|
-
const selectedSubcollectionDataSource = useDataSource(selectedSubcollection ?? collection);
|
|
248
241
|
const customViews = collection.entityViews ?? [];
|
|
249
242
|
const customViewsCount = customViews?.length ?? 0;
|
|
250
243
|
const includeJsonView = collection.includeJsonView === undefined ? true : collection.includeJsonView;
|
|
@@ -393,9 +386,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
|
|
|
393
386
|
isSubCollection={true}
|
|
394
387
|
updateUrl={false}
|
|
395
388
|
{...subcollection}
|
|
396
|
-
openEntityMode={layout}
|
|
397
|
-
onCountChange={(count) => setSubcollectionCounts(counts =>
|
|
398
|
-
counts[subcollectionId] === count ? counts : { ...counts, [subcollectionId]: count })} />
|
|
389
|
+
openEntityMode={layout} />
|
|
399
390
|
: <div className="flex items-center justify-center w-full h-full p-3">
|
|
400
391
|
<Typography variant={"label"}>
|
|
401
392
|
{t("youd_need_to_save_before_additional_collections")}
|
|
@@ -543,20 +534,6 @@ export function EntityEditViewInner<M extends Record<string, any>>({
|
|
|
543
534
|
|
|
544
535
|
const shouldShowTopBar = Boolean(barActions) || hasAdditionalViews;
|
|
545
536
|
|
|
546
|
-
// A side panel covers the app bar's breadcrumbs, which show where a collection is and
|
|
547
|
-
// how many entities it has, so the panel shows its subcollection's in its own top bar.
|
|
548
|
-
const subcollectionTitle = layout === "side_panel" && selectedSubcollection && usedEntity
|
|
549
|
-
? (() => {
|
|
550
|
-
const subcollectionId = selectedSubcollection.id ?? selectedSubcollection.path;
|
|
551
|
-
return <CollectionPathTitle
|
|
552
|
-
path={fullIdPath
|
|
553
|
-
? `${fullIdPath}/${encodeEntityId(usedEntity.id)}/${removeInitialAndTrailingSlashes(subcollectionId)}`
|
|
554
|
-
: `${path}/${usedEntity.id}/${removeInitialAndTrailingSlashes(selectedSubcollection.path)}`}
|
|
555
|
-
name={selectedSubcollection.name}
|
|
556
|
-
count={subcollectionCounts[subcollectionId] ?? (selectedSubcollectionDataSource.countEntities ? null : undefined)}/>;
|
|
557
|
-
})()
|
|
558
|
-
: null;
|
|
559
|
-
|
|
560
537
|
let result = <div className="relative flex flex-col h-full w-full bg-white dark:bg-surface-900">
|
|
561
538
|
|
|
562
539
|
{shouldShowTopBar && <div
|
|
@@ -569,8 +546,6 @@ export function EntityEditViewInner<M extends Record<string, any>>({
|
|
|
569
546
|
status
|
|
570
547
|
})}
|
|
571
548
|
|
|
572
|
-
{subcollectionTitle}
|
|
573
|
-
|
|
574
549
|
<div className={"flex-grow"} />
|
|
575
550
|
|
|
576
551
|
{pluginActionsTop}
|
package/src/core/SideDialogs.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import React, { useContext, useEffect, useRef, useState } from "react";
|
|
|
2
2
|
import { useSideDialogsController } from "../hooks";
|
|
3
3
|
import { EntitySidePanelProps, SideDialogPanelProps } from "../types";
|
|
4
4
|
import { Sheet } from "@firecms/ui";
|
|
5
|
-
import {
|
|
5
|
+
import { useNavigationUnsavedChangesDialog } from "../internal/useUnsavedChangesDialog";
|
|
6
6
|
import { ErrorBoundary } from "../components";
|
|
7
7
|
import { UnsavedChangesDialog } from "../components/UnsavedChangesDialog";
|
|
8
8
|
import { EntitySidePanel } from "./EntitySidePanel";
|
|
@@ -123,7 +123,14 @@ function SideDialogView({
|
|
|
123
123
|
|
|
124
124
|
const sideDialogsController = useSideDialogsController();
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
const {
|
|
127
|
+
navigationWasBlocked,
|
|
128
|
+
handleOk: handleNavigationOk,
|
|
129
|
+
handleCancel: handleNavigationCancel
|
|
130
|
+
} = useNavigationUnsavedChangesDialog(
|
|
131
|
+
blocked && !drawerCloseRequested,
|
|
132
|
+
() => setBlocked(false)
|
|
133
|
+
);
|
|
127
134
|
|
|
128
135
|
useEffect(() => {
|
|
129
136
|
if (panel)
|
|
@@ -133,7 +140,7 @@ function SideDialogView({
|
|
|
133
140
|
const handleDrawerCloseOk = () => {
|
|
134
141
|
setBlocked(false);
|
|
135
142
|
setDrawerCloseRequested(false);
|
|
136
|
-
sideDialogsController.close(
|
|
143
|
+
sideDialogsController.close();
|
|
137
144
|
panel?.onClose?.();
|
|
138
145
|
};
|
|
139
146
|
|
|
@@ -145,7 +152,7 @@ function SideDialogView({
|
|
|
145
152
|
if (blocked && !force) {
|
|
146
153
|
setDrawerCloseRequested(true);
|
|
147
154
|
} else {
|
|
148
|
-
sideDialogsController.close(
|
|
155
|
+
sideDialogsController.close();
|
|
149
156
|
panel?.onClose?.();
|
|
150
157
|
}
|
|
151
158
|
};
|
|
@@ -205,8 +212,8 @@ function SideDialogView({
|
|
|
205
212
|
|
|
206
213
|
<UnsavedChangesDialog
|
|
207
214
|
open={drawerCloseRequested}
|
|
208
|
-
handleOk={handleDrawerCloseOk}
|
|
209
|
-
handleCancel={handleDrawerCloseCancel}
|
|
215
|
+
handleOk={drawerCloseRequested ? handleDrawerCloseOk : handleNavigationOk}
|
|
216
|
+
handleCancel={drawerCloseRequested ? handleDrawerCloseCancel : handleNavigationCancel}
|
|
210
217
|
body={blockedNavigationMessage} />
|
|
211
218
|
|
|
212
219
|
</SideDialogContext.Provider>
|
|
@@ -45,8 +45,8 @@ export function useReferenceDialog<M extends Record<string, any>>(referenceDialo
|
|
|
45
45
|
}, [navigation, referenceDialogProps, sideDialogsController]);
|
|
46
46
|
|
|
47
47
|
const close = useCallback(() => {
|
|
48
|
-
sideDialogsController.close(
|
|
49
|
-
}, [sideDialogsController
|
|
48
|
+
sideDialogsController.close();
|
|
49
|
+
}, [sideDialogsController]);
|
|
50
50
|
|
|
51
51
|
return {
|
|
52
52
|
open,
|
|
@@ -14,9 +14,6 @@ export function useBuildSideDialogsController(): SideDialogsController {
|
|
|
14
14
|
const routesStore = useRef<Record<string, SideDialogPanelProps>>({});
|
|
15
15
|
const routesCount = useRef<number>(0);
|
|
16
16
|
|
|
17
|
-
// The history entry each panel without a URL was opened on, by panel key.
|
|
18
|
-
const entryKeysOfPanelsWithoutUrl = useRef<Record<string, string>>({});
|
|
19
|
-
|
|
20
17
|
const updateSidePanels = (newPanels: SideDialogPanelProps[]) => {
|
|
21
18
|
sidePanelsRef.current = newPanels;
|
|
22
19
|
setSidePanels(newPanels);
|
|
@@ -25,55 +22,20 @@ export function useBuildSideDialogsController(): SideDialogsController {
|
|
|
25
22
|
useEffect(() => {
|
|
26
23
|
const state = location.state as any;
|
|
27
24
|
const panelKeys: string[] = state?.panels ?? [];
|
|
28
|
-
const
|
|
25
|
+
const newPanels = panelKeys
|
|
29
26
|
.map(key => routesStore.current[key])
|
|
30
27
|
.filter(p => Boolean(p)) as SideDialogPanelProps[];
|
|
31
|
-
|
|
32
|
-
// A panel without a URL, like a reference dialog, pushes no history entry, so the
|
|
33
|
-
// entry under it does not list it, and going back to that entry, as closing a
|
|
34
|
-
// panel opened on top of it does, would drop it. It stays open for as long as
|
|
35
|
-
// what it was opened over does: the panel below it or, with none, the entry itself.
|
|
36
|
-
const keptPanels: SideDialogPanelProps[] = [];
|
|
37
|
-
let matched = 0;
|
|
38
|
-
for (const panel of sidePanelsRef.current) {
|
|
39
|
-
if (panelsInHistory[matched]?.key === panel.key) {
|
|
40
|
-
keptPanels.push(panel);
|
|
41
|
-
matched++;
|
|
42
|
-
} else if (!panel.urlPath &&
|
|
43
|
-
(keptPanels.length > 0 || entryKeysOfPanelsWithoutUrl.current[panel.key] === location.key)) {
|
|
44
|
-
keptPanels.push(panel);
|
|
45
|
-
} else {
|
|
46
|
-
break;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
const newPanels = [...keptPanels, ...panelsInHistory.slice(matched)];
|
|
50
|
-
|
|
51
28
|
if (!equal(sidePanelsRef.current.map(p => p.key), newPanels.map(p => p.key))) {
|
|
52
29
|
updateSidePanels(newPanels);
|
|
53
30
|
}
|
|
54
31
|
}, [location]);
|
|
55
32
|
|
|
56
|
-
const close = useCallback((
|
|
33
|
+
const close = useCallback(() => {
|
|
57
34
|
|
|
58
35
|
const currentPanels = sidePanelsRef.current;
|
|
59
36
|
if (currentPanels.length === 0)
|
|
60
37
|
return;
|
|
61
38
|
|
|
62
|
-
// A panel can ask to close itself while another one is open on top of it: the
|
|
63
|
-
// reference dialog does when an entity created from it is saved. Closing the top
|
|
64
|
-
// one instead would close that entity, and then the entity's own close would take
|
|
65
|
-
// the panel under the dialog with it. Only a panel without a URL can be taken out
|
|
66
|
-
// of the middle, since it has no history entry of its own. Should two panels share
|
|
67
|
-
// a key (a reference dialog opened from an entity created in another one for the
|
|
68
|
-
// same collection), it is the upper one.
|
|
69
|
-
const index = key !== undefined ? currentPanels.findLastIndex(p => p.key === key) : -1;
|
|
70
|
-
if (index !== -1 && index < currentPanels.length - 1 && !currentPanels[index].urlPath) {
|
|
71
|
-
updateSidePanels(currentPanels.filter((_, i) => i !== index));
|
|
72
|
-
if (routesCount.current > 0)
|
|
73
|
-
routesCount.current--;
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
39
|
const lastSidePanel = currentPanels[currentPanels.length - 1];
|
|
78
40
|
const updatedPanels = [...currentPanels.slice(0, -1)];
|
|
79
41
|
updateSidePanels(updatedPanels);
|
|
@@ -103,8 +65,6 @@ export function useBuildSideDialogsController(): SideDialogsController {
|
|
|
103
65
|
|
|
104
66
|
newPanels.forEach((panel) => {
|
|
105
67
|
routesStore.current[panel.key] = panel;
|
|
106
|
-
if (!panel.urlPath)
|
|
107
|
-
entryKeysOfPanelsWithoutUrl.current[panel.key] = location.key;
|
|
108
68
|
});
|
|
109
69
|
routesCount.current = routesCount.current + newPanels.length;
|
|
110
70
|
|
|
@@ -135,8 +95,6 @@ export function useBuildSideDialogsController(): SideDialogsController {
|
|
|
135
95
|
const newPanels: SideDialogPanelProps[] = Array.isArray(panelProps) ? panelProps : [panelProps];
|
|
136
96
|
newPanels.forEach((panel) => {
|
|
137
97
|
routesStore.current[panel.key] = panel;
|
|
138
|
-
if (!panel.urlPath)
|
|
139
|
-
entryKeysOfPanelsWithoutUrl.current[panel.key] = location.key;
|
|
140
98
|
});
|
|
141
99
|
|
|
142
100
|
const baseLocation = (location.state as any)?.base_location ?? location;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@firecms/ui";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Type representing a pending navigation action.
|
|
6
|
+
*/
|
|
7
|
+
type PendingNavigation =
|
|
8
|
+
| {
|
|
9
|
+
type: "popstate";
|
|
10
|
+
delta: number;
|
|
11
|
+
}
|
|
12
|
+
| {
|
|
13
|
+
type: "link";
|
|
14
|
+
href: string;
|
|
15
|
+
}
|
|
16
|
+
| null;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Custom hook to handle navigation blocking when there are unsaved changes.
|
|
20
|
+
*
|
|
21
|
+
* @param when - Indicates whether to block navigation.
|
|
22
|
+
* @param onSuccess - Callback invoked when navigation is confirmed.
|
|
23
|
+
* @returns An object containing the state of navigation blocking and handlers.
|
|
24
|
+
*/
|
|
25
|
+
export function useNavigationUnsavedChangesDialog(
|
|
26
|
+
when: boolean,
|
|
27
|
+
onSuccess: () => void
|
|
28
|
+
): {
|
|
29
|
+
navigationWasBlocked: boolean;
|
|
30
|
+
handleCancel: () => void;
|
|
31
|
+
handleOk: () => void;
|
|
32
|
+
} {
|
|
33
|
+
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
|
34
|
+
const [pendingNavigation, setPendingNavigation] = useState<PendingNavigation>(null);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Handler to cancel the navigation attempt.
|
|
38
|
+
*/
|
|
39
|
+
const handleCancel = useCallback(() => {
|
|
40
|
+
setIsDialogOpen(false);
|
|
41
|
+
setPendingNavigation(null);
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Handler to confirm and proceed with the navigation.
|
|
46
|
+
*/
|
|
47
|
+
const handleOk = useCallback(() => {
|
|
48
|
+
setIsDialogOpen(false);
|
|
49
|
+
if (pendingNavigation) {
|
|
50
|
+
onSuccess();
|
|
51
|
+
if (pendingNavigation.type === "popstate") {
|
|
52
|
+
window.history.go(pendingNavigation.delta);
|
|
53
|
+
} else if (pendingNavigation.type === "link") {
|
|
54
|
+
window.location.href = pendingNavigation.href;
|
|
55
|
+
}
|
|
56
|
+
setPendingNavigation(null);
|
|
57
|
+
}
|
|
58
|
+
}, [onSuccess, pendingNavigation]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Event handler for beforeunload to handle page refresh or tab close.
|
|
62
|
+
*/
|
|
63
|
+
const handleBeforeUnload = useCallback(
|
|
64
|
+
(e: BeforeUnloadEvent) => {
|
|
65
|
+
if (when) {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
e.returnValue = "";
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
[when]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Event handler for popstate to handle back and forward browser buttons.
|
|
75
|
+
*/
|
|
76
|
+
const handlePopState = useCallback(
|
|
77
|
+
(e: PopStateEvent) => {
|
|
78
|
+
if (when) {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
// Assuming backward navigation; adjust delta as needed
|
|
81
|
+
setPendingNavigation({
|
|
82
|
+
type: "popstate",
|
|
83
|
+
delta: -1
|
|
84
|
+
});
|
|
85
|
+
setIsDialogOpen(true);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
[when]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Event handler to intercept link clicks within the application.
|
|
93
|
+
*/
|
|
94
|
+
const handleLinkClick = useCallback(
|
|
95
|
+
(e: MouseEvent) => {
|
|
96
|
+
if (!when) return;
|
|
97
|
+
|
|
98
|
+
const target = e.target as HTMLElement;
|
|
99
|
+
const anchor = target.closest("a[href]") as HTMLAnchorElement | null;
|
|
100
|
+
if (anchor && anchor.host === window.location.host) {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
const href = anchor.getAttribute("href");
|
|
103
|
+
if (href) {
|
|
104
|
+
setPendingNavigation({
|
|
105
|
+
type: "link",
|
|
106
|
+
href
|
|
107
|
+
});
|
|
108
|
+
setIsDialogOpen(true);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
[when]
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Effect hook to add and clean up event listeners based on the `when` condition.
|
|
117
|
+
*/
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (when) {
|
|
120
|
+
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
121
|
+
window.addEventListener("popstate", handlePopState);
|
|
122
|
+
document.addEventListener("click", handleLinkClick);
|
|
123
|
+
} else {
|
|
124
|
+
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
125
|
+
window.removeEventListener("popstate", handlePopState);
|
|
126
|
+
document.removeEventListener("click", handleLinkClick);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Cleanup on unmount or when `when` changes
|
|
130
|
+
return () => {
|
|
131
|
+
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
132
|
+
window.removeEventListener("popstate", handlePopState);
|
|
133
|
+
document.removeEventListener("click", handleLinkClick);
|
|
134
|
+
};
|
|
135
|
+
}, [when, handleBeforeUnload, handlePopState, handleLinkClick]);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
navigationWasBlocked: isDialogOpen,
|
|
139
|
+
handleCancel,
|
|
140
|
+
handleOk,
|
|
141
|
+
};
|
|
142
|
+
}
|