@firecms/core 3.5.2-canary.16bf7bb → 3.5.2-canary.a36570e

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/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 +4 -3
  6. package/dist/index.es.js +592 -567
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/index.umd.js +592 -567
  9. package/dist/index.umd.js.map +1 -1
  10. package/dist/internal/useLeavePageConfirmation.d.ts +11 -0
  11. package/dist/types/side_dialogs_controller.d.ts +4 -2
  12. package/package.json +3 -3
  13. package/src/components/EntityCollectionView/CollectionCountBadge.tsx +17 -0
  14. package/src/components/EntityCollectionView/CollectionPathTitle.tsx +68 -0
  15. package/src/components/EntityCollectionView/EntityCollectionView.tsx +12 -111
  16. package/src/components/EntityCollectionView/EntityIdHeaderWidget.tsx +166 -0
  17. package/src/components/EntityCollectionView/utils.ts +13 -7
  18. package/src/components/common/default_entity_actions.tsx +2 -1
  19. package/src/core/DefaultAppBar.tsx +12 -9
  20. package/src/core/EntityEditView.tsx +26 -1
  21. package/src/core/SideDialogs.tsx +6 -13
  22. package/src/hooks/useReferenceDialog.tsx +2 -2
  23. package/src/internal/useBuildSideDialogsController.tsx +44 -2
  24. package/src/internal/useLeavePageConfirmation.ts +25 -0
  25. package/src/types/side_dialogs_controller.tsx +4 -2
  26. package/dist/internal/useUnsavedChangesDialog.d.ts +0 -12
  27. package/src/internal/useUnsavedChangesDialog.tsx +0 -142
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Asks the browser to confirm leaving the page (reload, tab close, a link that
3
+ * loads another document) while `when` is true.
4
+ *
5
+ * Navigation inside the app is not intercepted: unsaved entity values are kept
6
+ * in the dirty entity cache, so they survive it. Cancelling link clicks here
7
+ * used to leave plain and new-tab links doing nothing, with no dialog shown.
8
+ *
9
+ * @param when - Whether leaving the page should be confirmed.
10
+ */
11
+ export declare function useLeavePageConfirmation(when: boolean): void;
@@ -5,9 +5,11 @@ import React from "react";
5
5
  */
6
6
  export interface SideDialogsController {
7
7
  /**
8
- * Close the last panel
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.
9
11
  */
10
- close: () => void;
12
+ close: (key?: string) => void;
11
13
  /**
12
14
  * List of side panels currently open
13
15
  */
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.16bf7bb",
4
+ "version": "3.5.2-canary.a36570e",
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.16bf7bb",
57
- "@firecms/ui": "3.5.2-canary.16bf7bb",
56
+ "@firecms/formex": "3.5.2-canary.a36570e",
57
+ "@firecms/ui": "3.5.2-canary.a36570e",
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",
@@ -0,0 +1,17 @@
1
+ import React from "react";
2
+ import { Skeleton } from "@firecms/ui";
3
+
4
+ /**
5
+ * Number of entities in a collection, as shown next to its name.
6
+ * `undefined` renders nothing, `null` a loading placeholder.
7
+ */
8
+ export function CollectionCountBadge({ count }: { count: number | null | undefined }) {
9
+ if (count === undefined)
10
+ return null;
11
+ if (count === null)
12
+ return <Skeleton className="w-8 h-4 rounded-md"/>;
13
+ return <span
14
+ className="text-xs text-surface-accent-500 dark:text-surface-accent-400 bg-surface-100 dark:bg-surface-700 px-1 py-0 rounded">
15
+ {count}
16
+ </span>;
17
+ }
@@ -0,0 +1,68 @@
1
+ import React, { useMemo } from "react";
2
+ import { cls, Typography } from "@firecms/ui";
3
+ import { useNavigationController } from "../../hooks/useNavigationController";
4
+ import { getNavigationEntriesFromPath } from "../../util/navigation_from_path";
5
+ import { CollectionCountBadge } from "./CollectionCountBadge";
6
+
7
+ /**
8
+ * A name and its entity count, under what is above it: breadcrumbs folded on two lines
9
+ * for where a row of them would not fit.
10
+ */
11
+ export function PathTitle({
12
+ parents,
13
+ name,
14
+ count,
15
+ className
16
+ }: {
17
+ parents: string[],
18
+ name: string,
19
+ /** `undefined` shows no count, `null` a loading one. */
20
+ count: number | null | undefined,
21
+ className?: string
22
+ }) {
23
+ return <div className={cls("flex flex-col", className)}>
24
+ {parents.length > 0 && <Typography variant={"caption"} color={"secondary"} noWrap>
25
+ {parents.join(" / ")}
26
+ </Typography>}
27
+ <div className="flex flex-row items-center gap-2 min-w-0">
28
+ <Typography variant={"body2"} noWrap className={"font-medium"}>
29
+ {name}
30
+ </Typography>
31
+ <CollectionCountBadge count={count}/>
32
+ </div>
33
+ </div>;
34
+ }
35
+
36
+ /**
37
+ * A collection's name and entity count, under the collections and entities above it,
38
+ * the same things the app bar's breadcrumbs show. A side panel covers those, so it
39
+ * shows a subcollection's in its own top bar.
40
+ */
41
+ export function CollectionPathTitle({
42
+ path,
43
+ name,
44
+ count
45
+ }: {
46
+ /** The escaped chain, as in `fullIdPath`. */
47
+ path: string,
48
+ name: string,
49
+ /** `undefined` shows no count, `null` a loading one. */
50
+ count: number | null | undefined
51
+ }) {
52
+ const navigation = useNavigationController();
53
+ const parents = useMemo(() => getNavigationEntriesFromPath({
54
+ path,
55
+ collections: navigation.collections ?? []
56
+ })
57
+ .slice(0, -1)
58
+ .map(entry => entry.type === "entity"
59
+ ? entry.entityId
60
+ : entry.type === "collection" ? entry.collection.name : entry.view.name),
61
+ [path, navigation.collections]);
62
+
63
+ // it keeps its width, up to the cap, and the tabs next to it scroll instead
64
+ return <PathTitle parents={parents}
65
+ name={name}
66
+ count={count}
67
+ className={"shrink-0 mx-2 max-w-[140px] sm:max-w-[240px] lg:max-w-[320px]"}/>;
68
+ }
@@ -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,166 @@
1
+ import React, { useCallback, useRef } 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 { useDataSource } from "../../hooks/data/useDataSource";
10
+ import { addRecentId, getRecentIds, removeRecentIds } from "./utils";
11
+
12
+ const DEFAULT_ENTITY_OPEN_MODE: "side_panel" | "full_screen" = "side_panel";
13
+
14
+ /**
15
+ * "Find by id" in the header of a collection's id column, listing the ids opened in it lately.
16
+ */
17
+ export function EntityIdHeaderWidget({
18
+ collection,
19
+ path,
20
+ pathSegments,
21
+ fullIdPath
22
+ }: {
23
+ collection: EntityCollection,
24
+ path: string,
25
+ /** `path` split at its real segment boundaries, when the caller knows them. */
26
+ pathSegments?: string[],
27
+ fullIdPath: string
28
+ }) {
29
+
30
+ const navigation = useNavigationController();
31
+ const dataSource = useDataSource(collection);
32
+ const [openPopup, setOpenPopup] = React.useState(false);
33
+ const [searchString, setSearchString] = React.useState("");
34
+ // The recent ids are kept per full path rather than per collection: a subcollection has
35
+ // the same id under every parent, and an id found under one parent does not exist under
36
+ // the others. Only the ones known to exist are listed.
37
+ const [recentIds, setRecentIds] = React.useState<string[]>([]);
38
+ const latestCheck = useRef(0);
39
+ const sideEntityController = useSideEntityController();
40
+ const { t } = useTranslation();
41
+
42
+ // Resolved as useEntityFetch resolves them, so the check reads what the preview shows.
43
+ const resolvedPath = navigation.resolveIdsFrom(path, pathSegments);
44
+ const resolvedPathSegments = pathSegments
45
+ ? (navigation.resolveSegmentsFrom?.(pathSegments) ?? pathSegments)
46
+ : undefined;
47
+
48
+ /** Whether the entity exists, or `undefined` when that could not be told. */
49
+ const entityExists = useCallback(async (entityId: string): Promise<boolean | undefined> => {
50
+ try {
51
+ const entity = await dataSource.fetchEntity({
52
+ path: resolvedPath,
53
+ pathSegments: resolvedPathSegments,
54
+ entityId,
55
+ collection
56
+ });
57
+ return Boolean(entity?.values);
58
+ } catch (e) {
59
+ return undefined;
60
+ }
61
+ }, [dataSource, resolvedPath, resolvedPathSegments, collection]);
62
+
63
+ // A recent id that no longer exists would be fetched and shown as "not found" on every
64
+ // opening, so it is forgotten. One that could not be checked is kept for next time.
65
+ const refreshRecentIds = () => {
66
+ const ids = getRecentIds(fullIdPath);
67
+ const check = ++latestCheck.current;
68
+ Promise.all(ids.map(entityExists)).then((results) => {
69
+ if (check !== latestCheck.current) return;
70
+ const missing = ids.filter((_, i) => results[i] === false);
71
+ if (missing.length > 0)
72
+ removeRecentIds(fullIdPath, missing);
73
+ setRecentIds(ids.filter((_, i) => results[i] === true));
74
+ });
75
+ };
76
+
77
+ const openEntityMode = collection?.openEntityMode ?? DEFAULT_ENTITY_OPEN_MODE;
78
+
79
+ return (
80
+ <Tooltip title={!openPopup ? t("find_by_id") : undefined} asChild={false}>
81
+ <Popover
82
+ open={openPopup}
83
+ onOpenChange={(open) => {
84
+ // read on each opening: rows opened since the last one are recent too
85
+ if (open) refreshRecentIds();
86
+ setOpenPopup(open);
87
+ }}
88
+ sideOffset={0}
89
+ align={"start"}
90
+ alignOffset={-117}
91
+ trigger={
92
+ <IconButton size={"small"}>
93
+ <SearchIcon size={"small"} />
94
+ </IconButton>
95
+ }>
96
+ <div
97
+ className={cls("my-2 rounded-lg bg-surface-50 dark:bg-surface-950 text-surface-900 dark:text-white")}>
98
+ <form noValidate={true}
99
+ onSubmit={(e) => {
100
+ e.preventDefault();
101
+ const entityId = searchString.trim();
102
+ // Enter submits even while the button is disabled, and an empty
103
+ // id would open a new entity instead
104
+ if (!entityId) return;
105
+ setOpenPopup(false);
106
+ // remembered only once found, or every opening would fetch it again
107
+ entityExists(entityId).then((exists) => {
108
+ if (exists) addRecentId(fullIdPath, entityId);
109
+ });
110
+ navigateToEntity({
111
+ openEntityMode,
112
+ collection,
113
+ entityId,
114
+ path,
115
+ pathSegments,
116
+ fullIdPath,
117
+ sideEntityController,
118
+ navigation
119
+ })
120
+ }}
121
+ className={"w-96 max-w-full"}>
122
+
123
+ <div className="flex p-2 w-full gap-2">
124
+ <input
125
+ autoFocus={openPopup}
126
+ placeholder={t("find_entity_by_id")}
127
+ // size={"small"}
128
+ onChange={(e) => {
129
+ setSearchString(e.target.value);
130
+ }}
131
+ value={searchString}
132
+ className={"rounded-lg bg-white dark:bg-surface-800 flex-grow bg-transparent outline-none p-2 " + focusedDisabled} />
133
+ <Button variant={"text"}
134
+ disabled={!(searchString.trim())}
135
+ type={"submit"}
136
+ ><KeyboardTabIcon /></Button>
137
+ </div>
138
+ </form>
139
+ {recentIds && recentIds.length > 0 && <div className="flex flex-col gap-2 p-2">
140
+ {recentIds.map(id => (
141
+ <ReferencePreview reference={new EntityReference(id, path, undefined, pathSegments)}
142
+ key={id}
143
+ hover={true}
144
+ onClick={() => {
145
+ setOpenPopup(false);
146
+ navigateToEntity({
147
+ openEntityMode,
148
+ collection,
149
+ entityId: id,
150
+ path,
151
+ pathSegments,
152
+ fullIdPath,
153
+ sideEntityController,
154
+ navigation
155
+ })
156
+ }}
157
+ includeEntityLink={false}
158
+ size={"small"} />
159
+ ))}
160
+ </div>}
161
+ </div>
162
+ </Popover>
163
+
164
+ </Tooltip>
165
+ );
166
+ }
@@ -1,19 +1,25 @@
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
+ }
20
+
21
+ export function removeRecentIds(key: string, ids: string[]) {
22
+ const remaining = getRecentIds(key).filter(id => !ids.includes(id));
23
+ saveSearchedIdsLocally(key, remaining);
24
+ return remaining;
19
25
  }
@@ -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}