@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.
Files changed (27) hide show
  1. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +0 -5
  2. package/dist/components/EntityCollectionView/utils.d.ts +3 -3
  3. package/dist/index.es.js +676 -817
  4. package/dist/index.es.js.map +1 -1
  5. package/dist/index.umd.js +670 -811
  6. package/dist/index.umd.js.map +1 -1
  7. package/dist/internal/useUnsavedChangesDialog.d.ts +12 -0
  8. package/dist/types/side_dialogs_controller.d.ts +2 -4
  9. package/package.json +3 -3
  10. package/src/components/EntityCollectionView/EntityCollectionView.tsx +111 -12
  11. package/src/components/EntityCollectionView/utils.ts +7 -7
  12. package/src/components/common/default_entity_actions.tsx +1 -2
  13. package/src/core/DefaultAppBar.tsx +9 -12
  14. package/src/core/EntityEditView.tsx +1 -26
  15. package/src/core/SideDialogs.tsx +13 -6
  16. package/src/hooks/useReferenceDialog.tsx +2 -2
  17. package/src/internal/useBuildSideDialogsController.tsx +2 -44
  18. package/src/internal/useUnsavedChangesDialog.tsx +142 -0
  19. package/src/types/side_dialogs_controller.tsx +2 -4
  20. package/dist/components/EntityCollectionView/CollectionCountBadge.d.ts +0 -8
  21. package/dist/components/EntityCollectionView/CollectionPathTitle.d.ts +0 -24
  22. package/dist/components/EntityCollectionView/EntityIdHeaderWidget.d.ts +0 -12
  23. package/dist/internal/useLeavePageConfirmation.d.ts +0 -11
  24. package/src/components/EntityCollectionView/CollectionCountBadge.tsx +0 -17
  25. package/src/components/EntityCollectionView/CollectionPathTitle.tsx +0 -68
  26. package/src/components/EntityCollectionView/EntityIdHeaderWidget.tsx +0 -124
  27. 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: (key?: string) => void;
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.09e1281",
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.09e1281",
57
- "@firecms/ui": "3.5.2-canary.09e1281",
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(fullIdPath ?? fullPath, clickedEntity.id);
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, onCountChange]);
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(key: string, id: string) {
4
- const recentIds = getRecentIds(key);
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(key, newRecentIds);
9
+ saveSearchedIdsLocally(collectionId, newRecentIds);
10
10
  return newRecentIds;
11
11
  }
12
12
 
13
- export function saveSearchedIdsLocally(key: string, ids: string[]) {
14
- setLocalStorageItem("recent_id_searches::" + key, JSON.stringify(ids));
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(key: string): string[] {
18
- return getLocalStorageJSON("recent_id_searches::" + key, []);
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
- // the same key the collection view's "find by id" reads them with
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
- <CollectionCountBadge count={breadcrumb.count}/>
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}
@@ -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 { useLeavePageConfirmation } from "../internal/useLeavePageConfirmation";
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
- useLeavePageConfirmation(blocked && !drawerCloseRequested);
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(panel?.key);
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(panel?.key);
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(`reference_${referenceDialogProps.path}`);
49
- }, [sideDialogsController, referenceDialogProps.path]);
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 panelsInHistory = panelKeys
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((key?: string) => {
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
+ }