@firecms/core 3.5.0 → 3.5.2-canary.09e1281

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 (56) hide show
  1. package/dist/components/EntityCollectionView/CollectionCountBadge.d.ts +8 -0
  2. package/dist/components/EntityCollectionView/CollectionPathTitle.d.ts +24 -0
  3. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +5 -0
  4. package/dist/components/EntityCollectionView/EntityIdHeaderWidget.d.ts +12 -0
  5. package/dist/components/EntityCollectionView/utils.d.ts +3 -3
  6. package/dist/components/HomePage/HomePageDnD.d.ts +4 -2
  7. package/dist/{de-CBOjCvsy.js → de-CUF9f7S-.js} +2 -1
  8. package/dist/{de-CBOjCvsy.js.map → de-CUF9f7S-.js.map} +1 -1
  9. package/dist/{editor-BSo_aS1I.js → editor-WZytSO_1.js} +172 -137
  10. package/dist/editor-WZytSO_1.js.map +1 -0
  11. package/dist/{fr-DRfttcG0.js → fr-DmkMX6hp.js} +2 -1
  12. package/dist/{fr-DRfttcG0.js.map → fr-DmkMX6hp.js.map} +1 -1
  13. package/dist/{hi-DwieOZNf.js → hi-BKuaMr7w.js} +2 -1
  14. package/dist/{hi-DwieOZNf.js.map → hi-BKuaMr7w.js.map} +1 -1
  15. package/dist/index.es.js +787 -638
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/index.umd.js +946 -756
  18. package/dist/index.umd.js.map +1 -1
  19. package/dist/internal/useLeavePageConfirmation.d.ts +11 -0
  20. package/dist/{it-l2KXtx9n.js → it-BwQflFGz.js} +2 -1
  21. package/dist/{it-l2KXtx9n.js.map → it-BwQflFGz.js.map} +1 -1
  22. package/dist/{pl-DPZrdKhi.js → pl-BPsAvZvc.js} +2 -1
  23. package/dist/{pl-DPZrdKhi.js.map → pl-BPsAvZvc.js.map} +1 -1
  24. package/dist/{pt-BoHpabw1.js → pt-D5q7OQp1.js} +2 -1
  25. package/dist/{pt-BoHpabw1.js.map → pt-D5q7OQp1.js.map} +1 -1
  26. package/dist/types/side_dialogs_controller.d.ts +4 -2
  27. package/dist/types/translations.d.ts +1 -0
  28. package/package.json +3 -3
  29. package/src/components/EntityCollectionView/CollectionCountBadge.tsx +17 -0
  30. package/src/components/EntityCollectionView/CollectionPathTitle.tsx +68 -0
  31. package/src/components/EntityCollectionView/EntityCollectionView.tsx +12 -111
  32. package/src/components/EntityCollectionView/EntityIdHeaderWidget.tsx +124 -0
  33. package/src/components/EntityCollectionView/utils.ts +7 -7
  34. package/src/components/HomePage/DefaultHomePage.tsx +2 -0
  35. package/src/components/HomePage/HomePageDnD.tsx +12 -4
  36. package/src/components/common/default_entity_actions.tsx +2 -1
  37. package/src/core/DefaultAppBar.tsx +12 -9
  38. package/src/core/EntityEditView.tsx +26 -1
  39. package/src/core/SideDialogs.tsx +6 -13
  40. package/src/editor/selectors/link-selector.tsx +37 -12
  41. package/src/hooks/useReferenceDialog.tsx +2 -2
  42. package/src/internal/useBuildSideDialogsController.tsx +44 -2
  43. package/src/internal/useLeavePageConfirmation.ts +25 -0
  44. package/src/locales/de.ts +1 -0
  45. package/src/locales/en.ts +1 -0
  46. package/src/locales/es.ts +1 -0
  47. package/src/locales/fr.ts +1 -0
  48. package/src/locales/hi.ts +1 -0
  49. package/src/locales/it.ts +1 -0
  50. package/src/locales/pl.ts +1 -0
  51. package/src/locales/pt.ts +1 -0
  52. package/src/types/side_dialogs_controller.tsx +4 -2
  53. package/src/types/translations.ts +1 -0
  54. package/dist/editor-BSo_aS1I.js.map +0 -1
  55. package/dist/internal/useUnsavedChangesDialog.d.ts +0 -12
  56. package/src/internal/useUnsavedChangesDialog.tsx +0 -142
@@ -8,7 +8,6 @@ import {
8
8
  Entity,
9
9
  EntityAction,
10
10
  EntityCollection,
11
- EntityReference,
12
11
  EntityTableController,
13
12
  FilterValues,
14
13
  PartialEntityCollection,
@@ -59,14 +58,9 @@ import {
59
58
  AddIcon,
60
59
  Button,
61
60
  cls,
62
- focusedDisabled,
63
- IconButton,
64
61
  KeyboardTabIcon,
65
62
  Markdown,
66
- Popover,
67
- SearchIcon,
68
63
  Skeleton,
69
- Tooltip,
70
64
  Typography
71
65
  } from "@firecms/ui";
72
66
  import { setIn } from "@firecms/formex";
@@ -88,7 +82,8 @@ import { DeleteEntityDialog } from "../DeleteEntityDialog";
88
82
  import { useAnalyticsController } from "../../hooks/useAnalyticsController";
89
83
  import { useSelectionController } from "./useSelectionController";
90
84
  import { EntityCollectionViewStartActions } from "./EntityCollectionViewStartActions";
91
- import { addRecentId, getRecentIds } from "./utils";
85
+ import { addRecentId } from "./utils";
86
+ import { EntityIdHeaderWidget } from "./EntityIdHeaderWidget";
92
87
  import { useScrollRestoration } from "../common/useScrollRestoration";
93
88
  import { entityCacheKey } from "../../util/entity_cache";
94
89
 
@@ -131,6 +126,12 @@ export type EntityCollectionViewProps<M extends Record<string, any>> = {
131
126
  */
132
127
  updateUrl?: boolean;
133
128
 
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
+
134
135
  } & EntityCollection<M>;
135
136
 
136
137
  /**
@@ -166,6 +167,7 @@ export const EntityCollectionView = React.memo(
166
167
  isSubCollection,
167
168
  className,
168
169
  updateUrl,
170
+ onCountChange,
169
171
  ...collectionProp
170
172
  }: EntityCollectionViewProps<M>
171
173
  ) {
@@ -350,7 +352,7 @@ export const EntityCollectionView = React.memo(
350
352
  });
351
353
 
352
354
  if (collection) {
353
- addRecentId(collection.id, clickedEntity.id);
355
+ addRecentId(fullIdPath ?? fullPath, clickedEntity.id);
354
356
  }
355
357
 
356
358
  // In a collection group the row's own path is the real one, so the row's segments
@@ -795,8 +797,9 @@ export const EntityCollectionView = React.memo(
795
797
  useEffect(() => {
796
798
  if (docsCount !== undefined) {
797
799
  breadcrumbs.updateCount(fullPath, docsCount);
800
+ onCountChange?.(docsCount);
798
801
  }
799
- }, [docsCount, fullPath, breadcrumbs.updateCount]);
802
+ }, [docsCount, fullPath, breadcrumbs.updateCount, onCountChange]);
800
803
 
801
804
  // EntitiesCount fetches count and updates breadcrumb - no visual rendering needed here
802
805
  const countFetcher = <EntitiesCount
@@ -1198,105 +1201,3 @@ function buildPropertyWidthOverwrite(key: string, width: number): PartialEntityC
1198
1201
  }
1199
1202
  return { properties: { [key]: { columnWidth: width } } } as PartialEntityCollection;
1200
1203
  }
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
- }
@@ -0,0 +1,124 @@
1
+ import React from "react";
2
+ import { Button, cls, focusedDisabled, IconButton, KeyboardTabIcon, Popover, SearchIcon, Tooltip } from "@firecms/ui";
3
+ import { EntityCollection, EntityReference } from "../../types";
4
+ import { navigateToEntity } from "../../util/navigation_utils";
5
+ import { ReferencePreview } from "../../preview";
6
+ import { useNavigationController } from "../../hooks/useNavigationController";
7
+ import { useSideEntityController } from "../../hooks/useSideEntityController";
8
+ import { useTranslation } from "../../hooks/useTranslation";
9
+ import { addRecentId, getRecentIds } from "./utils";
10
+
11
+ const DEFAULT_ENTITY_OPEN_MODE: "side_panel" | "full_screen" = "side_panel";
12
+
13
+ /**
14
+ * "Find by id" in the header of a collection's id column, listing the ids opened in it lately.
15
+ */
16
+ export function EntityIdHeaderWidget({
17
+ collection,
18
+ path,
19
+ pathSegments,
20
+ fullIdPath
21
+ }: {
22
+ collection: EntityCollection,
23
+ path: string,
24
+ /** `path` split at its real segment boundaries, when the caller knows them. */
25
+ pathSegments?: string[],
26
+ fullIdPath: string
27
+ }) {
28
+
29
+ const navigation = useNavigationController();
30
+ const [openPopup, setOpenPopup] = React.useState(false);
31
+ const [searchString, setSearchString] = React.useState("");
32
+ // Kept per full path rather than per collection: a subcollection has the same id under
33
+ // every parent, and an id found under one parent does not exist under the others.
34
+ const [recentIds, setRecentIds] = React.useState<string[]>(() => getRecentIds(fullIdPath));
35
+ const sideEntityController = useSideEntityController();
36
+ const { t } = useTranslation();
37
+
38
+ const openEntityMode = collection?.openEntityMode ?? DEFAULT_ENTITY_OPEN_MODE;
39
+
40
+ return (
41
+ <Tooltip title={!openPopup ? t("find_by_id") : undefined} asChild={false}>
42
+ <Popover
43
+ open={openPopup}
44
+ onOpenChange={(open) => {
45
+ // rows opened since this header mounted are recent too
46
+ if (open) setRecentIds(getRecentIds(fullIdPath));
47
+ setOpenPopup(open);
48
+ }}
49
+ sideOffset={0}
50
+ align={"start"}
51
+ alignOffset={-117}
52
+ trigger={
53
+ <IconButton size={"small"}>
54
+ <SearchIcon size={"small"} />
55
+ </IconButton>
56
+ }>
57
+ <div
58
+ className={cls("my-2 rounded-lg bg-surface-50 dark:bg-surface-950 text-surface-900 dark:text-white")}>
59
+ <form noValidate={true}
60
+ onSubmit={(e) => {
61
+ e.preventDefault();
62
+ const entityId = searchString.trim();
63
+ // Enter submits even while the button is disabled, and an empty
64
+ // id would open a new entity instead
65
+ if (!entityId) return;
66
+ setOpenPopup(false);
67
+ setRecentIds(addRecentId(fullIdPath, entityId));
68
+ navigateToEntity({
69
+ openEntityMode,
70
+ collection,
71
+ entityId,
72
+ path,
73
+ pathSegments,
74
+ fullIdPath,
75
+ sideEntityController,
76
+ navigation
77
+ })
78
+ }}
79
+ className={"w-96 max-w-full"}>
80
+
81
+ <div className="flex p-2 w-full gap-2">
82
+ <input
83
+ autoFocus={openPopup}
84
+ placeholder={t("find_entity_by_id")}
85
+ // size={"small"}
86
+ onChange={(e) => {
87
+ setSearchString(e.target.value);
88
+ }}
89
+ value={searchString}
90
+ className={"rounded-lg bg-white dark:bg-surface-800 flex-grow bg-transparent outline-none p-2 " + focusedDisabled} />
91
+ <Button variant={"text"}
92
+ disabled={!(searchString.trim())}
93
+ type={"submit"}
94
+ ><KeyboardTabIcon /></Button>
95
+ </div>
96
+ </form>
97
+ {recentIds && recentIds.length > 0 && <div className="flex flex-col gap-2 p-2">
98
+ {recentIds.map(id => (
99
+ <ReferencePreview reference={new EntityReference(id, path, undefined, pathSegments)}
100
+ key={id}
101
+ hover={true}
102
+ onClick={() => {
103
+ setOpenPopup(false);
104
+ navigateToEntity({
105
+ openEntityMode,
106
+ collection,
107
+ entityId: id,
108
+ path,
109
+ pathSegments,
110
+ fullIdPath,
111
+ sideEntityController,
112
+ navigation
113
+ })
114
+ }}
115
+ includeEntityLink={false}
116
+ size={"small"} />
117
+ ))}
118
+ </div>}
119
+ </div>
120
+ </Popover>
121
+
122
+ </Tooltip>
123
+ );
124
+ }
@@ -1,19 +1,19 @@
1
1
  import { getLocalStorageJSON, setLocalStorageItem } from "../../util/local_storage";
2
2
 
3
- export function addRecentId(collectionId: string, id: string) {
4
- const recentIds = getRecentIds(collectionId);
3
+ export function addRecentId(key: string, id: string) {
4
+ const recentIds = getRecentIds(key);
5
5
  const newRecentIds = [id, ...recentIds.filter(i => i !== id)];
6
6
  if (newRecentIds.length > 5) {
7
7
  newRecentIds.pop();
8
8
  }
9
- saveSearchedIdsLocally(collectionId, newRecentIds);
9
+ saveSearchedIdsLocally(key, newRecentIds);
10
10
  return newRecentIds;
11
11
  }
12
12
 
13
- export function saveSearchedIdsLocally(collectionId: string, ids: string[]) {
14
- setLocalStorageItem("recent_id_searches::" + collectionId, JSON.stringify(ids));
13
+ export function saveSearchedIdsLocally(key: string, ids: string[]) {
14
+ setLocalStorageItem("recent_id_searches::" + key, JSON.stringify(ids));
15
15
  }
16
16
 
17
- export function getRecentIds(collectionId: string): string[] {
18
- return getLocalStorageJSON("recent_id_searches::" + collectionId, []);
17
+ export function getRecentIds(key: string): string[] {
18
+ return getLocalStorageJSON("recent_id_searches::" + key, []);
19
19
  }
@@ -465,6 +465,7 @@ export function DefaultHomePage({
465
465
  isPotentialCardDropTarget={
466
466
  isDraggingCardOnly
467
467
  }
468
+ disabled={dndDisabled}
468
469
  >
469
470
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 ">
470
471
  {entriesInGroup.map(
@@ -472,6 +473,7 @@ export function DefaultHomePage({
472
473
  <SortableNavigationCard
473
474
  key={entry.url}
474
475
  entry={entry}
476
+ disabled={dndDisabled}
475
477
  onClick={() => {
476
478
  let event: CMSAnalyticsEvent =
477
479
  "unmapped_event";
@@ -67,10 +67,12 @@ const cloneItemsForDnd = (items: { name: string; entries: NavigationEntry[] }[])
67
67
  /* ─────────────────────────────────────────────────────────── */
68
68
  export function SortableNavigationCard({
69
69
  entry,
70
- onClick
70
+ onClick,
71
+ disabled
71
72
  }: {
72
73
  entry: NavigationEntry;
73
74
  onClick?: () => void;
75
+ disabled?: boolean;
74
76
  }) {
75
77
  const {
76
78
  setNodeRef,
@@ -82,7 +84,8 @@ export function SortableNavigationCard({
82
84
  } =
83
85
  useSortable({
84
86
  id: entry.url,
85
- animateLayoutChanges
87
+ animateLayoutChanges,
88
+ disabled
86
89
  });
87
90
 
88
91
  const style = {
@@ -102,14 +105,19 @@ export function NavigationGroupDroppable({
102
105
  id,
103
106
  itemIds,
104
107
  children,
105
- isPotentialCardDropTarget = false
108
+ isPotentialCardDropTarget = false,
109
+ disabled
106
110
  }: {
107
111
  id: UniqueIdentifier;
108
112
  itemIds: UniqueIdentifier[];
109
113
  children: React.ReactNode;
110
114
  isPotentialCardDropTarget?: boolean;
115
+ disabled?: boolean;
111
116
  }) {
112
- const { setNodeRef } = useDroppable({ id });
117
+ const { setNodeRef } = useDroppable({
118
+ id,
119
+ disabled
120
+ });
113
121
 
114
122
  return (
115
123
  <div
@@ -38,7 +38,8 @@ export const editEntityAction: EntityAction = {
38
38
  });
39
39
 
40
40
  if (collection) {
41
- addRecentId(collection.id, entity.id);
41
+ // the same key the collection view's "find by id" reads them with
42
+ addRecentId(fullIdPath ?? fullPath ?? collection.id, entity.id);
42
43
  }
43
44
 
44
45
  const path = collection?.collectionGroup ? entity.path : (fullPath ?? collection?.path ?? entity.path);
@@ -21,6 +21,8 @@ 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";
24
26
 
25
27
  export type DefaultAppBarProps<ADDITIONAL_PROPS = object> = {
26
28
 
@@ -81,6 +83,7 @@ export const DefaultAppBar = function DefaultAppBar({
81
83
  const navigation = useNavigationController();
82
84
 
83
85
  const breadcrumbs = useBreadcrumbsController();
86
+ const lastBreadcrumb = breadcrumbs.breadcrumbs?.[breadcrumbs.breadcrumbs.length - 1];
84
87
 
85
88
  const authController = useAuthController();
86
89
  const {
@@ -177,15 +180,7 @@ export const DefaultAppBar = function DefaultAppBar({
177
180
  {breadcrumb.title}
178
181
  </Typography>
179
182
  {/* Show count badge for collection breadcrumbs: undefined = not applicable, null = loading, number = 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
- )}
183
+ <CollectionCountBadge count={breadcrumb.count}/>
189
184
  </div>
190
185
  </Link>
191
186
  </React.Fragment>;
@@ -193,6 +188,14 @@ export const DefaultAppBar = function DefaultAppBar({
193
188
  </div>
194
189
  </div>}
195
190
 
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
+
196
199
 
197
200
  {startAdornment}
198
201
 
@@ -24,6 +24,7 @@ import {
24
24
  import {
25
25
  useAuthController,
26
26
  useCustomizationController,
27
+ useDataSource,
27
28
  useEntityFetch,
28
29
  useFireCMSContext,
29
30
  useLargeLayout
@@ -35,6 +36,7 @@ import { EntityEditViewFormActions } from "./EntityEditViewFormActions";
35
36
  import { EntityJsonPreview } from "../components/EntityJsonPreview";
36
37
  import { createFormexStub } from "../util/createFormexStub";
37
38
  import { useTranslation } from "../hooks/useTranslation";
39
+ import { CollectionPathTitle } from "../components/EntityCollectionView/CollectionPathTitle";
38
40
 
39
41
  export const MAIN_TAB_VALUE = "__main_##Q$SC^#S6";
40
42
  export const JSON_TAB_VALUE = "__json";
@@ -238,6 +240,11 @@ export function EntityEditViewInner<M extends Record<string, any>>({
238
240
 
239
241
  const subcollections = (collection.subcollections ?? []).filter(c => !c.hideFromNavigation);
240
242
  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);
241
248
  const customViews = collection.entityViews ?? [];
242
249
  const customViewsCount = customViews?.length ?? 0;
243
250
  const includeJsonView = collection.includeJsonView === undefined ? true : collection.includeJsonView;
@@ -386,7 +393,9 @@ export function EntityEditViewInner<M extends Record<string, any>>({
386
393
  isSubCollection={true}
387
394
  updateUrl={false}
388
395
  {...subcollection}
389
- openEntityMode={layout} />
396
+ openEntityMode={layout}
397
+ onCountChange={(count) => setSubcollectionCounts(counts =>
398
+ counts[subcollectionId] === count ? counts : { ...counts, [subcollectionId]: count })} />
390
399
  : <div className="flex items-center justify-center w-full h-full p-3">
391
400
  <Typography variant={"label"}>
392
401
  {t("youd_need_to_save_before_additional_collections")}
@@ -534,6 +543,20 @@ export function EntityEditViewInner<M extends Record<string, any>>({
534
543
 
535
544
  const shouldShowTopBar = Boolean(barActions) || hasAdditionalViews;
536
545
 
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
+
537
560
  let result = <div className="relative flex flex-col h-full w-full bg-white dark:bg-surface-900">
538
561
 
539
562
  {shouldShowTopBar && <div
@@ -546,6 +569,8 @@ export function EntityEditViewInner<M extends Record<string, any>>({
546
569
  status
547
570
  })}
548
571
 
572
+ {subcollectionTitle}
573
+
549
574
  <div className={"flex-grow"} />
550
575
 
551
576
  {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 { useNavigationUnsavedChangesDialog } from "../internal/useUnsavedChangesDialog";
5
+ import { useLeavePageConfirmation } from "../internal/useLeavePageConfirmation";
6
6
  import { ErrorBoundary } from "../components";
7
7
  import { UnsavedChangesDialog } from "../components/UnsavedChangesDialog";
8
8
  import { EntitySidePanel } from "./EntitySidePanel";
@@ -123,14 +123,7 @@ function SideDialogView({
123
123
 
124
124
  const sideDialogsController = useSideDialogsController();
125
125
 
126
- const {
127
- navigationWasBlocked,
128
- handleOk: handleNavigationOk,
129
- handleCancel: handleNavigationCancel
130
- } = useNavigationUnsavedChangesDialog(
131
- blocked && !drawerCloseRequested,
132
- () => setBlocked(false)
133
- );
126
+ useLeavePageConfirmation(blocked && !drawerCloseRequested);
134
127
 
135
128
  useEffect(() => {
136
129
  if (panel)
@@ -140,7 +133,7 @@ function SideDialogView({
140
133
  const handleDrawerCloseOk = () => {
141
134
  setBlocked(false);
142
135
  setDrawerCloseRequested(false);
143
- sideDialogsController.close();
136
+ sideDialogsController.close(panel?.key);
144
137
  panel?.onClose?.();
145
138
  };
146
139
 
@@ -152,7 +145,7 @@ function SideDialogView({
152
145
  if (blocked && !force) {
153
146
  setDrawerCloseRequested(true);
154
147
  } else {
155
- sideDialogsController.close();
148
+ sideDialogsController.close(panel?.key);
156
149
  panel?.onClose?.();
157
150
  }
158
151
  };
@@ -212,8 +205,8 @@ function SideDialogView({
212
205
 
213
206
  <UnsavedChangesDialog
214
207
  open={drawerCloseRequested}
215
- handleOk={drawerCloseRequested ? handleDrawerCloseOk : handleNavigationOk}
216
- handleCancel={drawerCloseRequested ? handleDrawerCloseCancel : handleNavigationCancel}
208
+ handleOk={handleDrawerCloseOk}
209
+ handleCancel={handleDrawerCloseCancel}
217
210
  body={blockedNavigationMessage} />
218
211
 
219
212
  </SideDialogContext.Provider>
@@ -1,5 +1,5 @@
1
- import { useEffect, useRef, } from "react";
2
- import { Button, CheckIcon, cls, DeleteIcon, focusedDisabled, Popover } from "@firecms/ui";
1
+ import { useEffect, useRef, useState} from "react";
2
+ import { Button, CheckIcon, cls, DeleteIcon, focusedDisabled, Popover, Typography } from "@firecms/ui";
3
3
  import { useTranslation } from "../../hooks/useTranslation";
4
4
  import { useProseMirrorContext } from "../hooks/useProseMirrorContext";
5
5
  import { getMarkAttributes, isMarkActive, setMark, unsetMark } from "../utils/prosemirror-utils";
@@ -39,25 +39,36 @@ export const LinkSelector = ({
39
39
  const { state, view } = useProseMirrorContext();
40
40
  const { t } = useTranslation();
41
41
 
42
- // Autofocus on input by default
42
+ const [error, setError] = useState<string | null>(null);
43
+ const isInputInvalid = !!error;
44
+
43
45
  useEffect(() => {
44
- if (open && inputRef.current) {
45
- inputRef.current.focus();
46
- }
46
+ setError(null);
47
47
  }, [open]);
48
48
 
49
49
  if (!state || !view) return null;
50
50
 
51
- const handleSubmit = (e: any) => {
51
+ const handleChange = (e: React.ChangeEvent<HTMLFormElement>) => {
52
+ if (isInputInvalid) setError(null);
53
+ };
54
+
55
+ const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
52
56
  e.preventDefault();
57
+ e.stopPropagation();
58
+ setError(null);
59
+
53
60
  const value = inputRef.current?.value;
54
61
  if (!value) return;
55
62
  const url = getUrlFromString(value);
56
- if (url) {
57
- setMark(schema.marks.link, { href: url })(view.state, view.dispatch);
58
- view.focus();
59
- onOpenChange(false);
63
+
64
+ if (!url) {
65
+ setError(t("editor_invalid_url"));
66
+ return;
60
67
  }
68
+
69
+ setMark(schema.marks.link, { href: url })(view.state, view.dispatch);
70
+ view.focus();
71
+ onOpenChange(false);
61
72
  };
62
73
 
63
74
  const handleRemoveLink = () => {
@@ -86,13 +97,15 @@ export const LinkSelector = ({
86
97
  <form
87
98
  onSubmit={handleSubmit}
88
99
  className="flex p-1 gap-1"
100
+ onChange={handleChange}
89
101
  >
90
102
  <input
91
103
  ref={inputRef}
92
104
  autoFocus={open}
93
105
  placeholder={t("editor_paste_or_type_link")}
94
106
  defaultValue={href}
95
- className={cls("text-surface-900 dark:text-white flex-grow bg-transparent p-1 text-sm outline-none", focusedDisabled)} />
107
+ className={cls("text-surface-900 dark:text-white flex-grow bg-transparent p-1 text-sm outline-none", focusedDisabled)}
108
+ aria-invalid={isInputInvalid} />
96
109
 
97
110
  {href ? (
98
111
  <Button
@@ -113,6 +126,18 @@ export const LinkSelector = ({
113
126
  </Button>
114
127
  )}
115
128
  </form>
129
+ {error && (
130
+ <div className="p-2">
131
+ <Typography
132
+ variant="caption"
133
+ color="error"
134
+ role="alert"
135
+ aria-live="polite"
136
+ >
137
+ {error}
138
+ </Typography>
139
+ </div>
140
+ )}
116
141
  </Popover>
117
142
  );
118
143
  };