@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.
- package/dist/components/EntityCollectionView/CollectionCountBadge.d.ts +8 -0
- package/dist/components/EntityCollectionView/CollectionPathTitle.d.ts +24 -0
- package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +5 -0
- package/dist/components/EntityCollectionView/EntityIdHeaderWidget.d.ts +12 -0
- package/dist/components/EntityCollectionView/utils.d.ts +4 -3
- package/dist/index.es.js +592 -567
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +592 -567
- package/dist/index.umd.js.map +1 -1
- package/dist/internal/useLeavePageConfirmation.d.ts +11 -0
- package/dist/types/side_dialogs_controller.d.ts +4 -2
- package/package.json +3 -3
- package/src/components/EntityCollectionView/CollectionCountBadge.tsx +17 -0
- package/src/components/EntityCollectionView/CollectionPathTitle.tsx +68 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +12 -111
- package/src/components/EntityCollectionView/EntityIdHeaderWidget.tsx +166 -0
- package/src/components/EntityCollectionView/utils.ts +13 -7
- package/src/components/common/default_entity_actions.tsx +2 -1
- package/src/core/DefaultAppBar.tsx +12 -9
- package/src/core/EntityEditView.tsx +26 -1
- package/src/core/SideDialogs.tsx +6 -13
- package/src/hooks/useReferenceDialog.tsx +2 -2
- package/src/internal/useBuildSideDialogsController.tsx +44 -2
- package/src/internal/useLeavePageConfirmation.ts +25 -0
- package/src/types/side_dialogs_controller.tsx +4 -2
- package/dist/internal/useUnsavedChangesDialog.d.ts +0 -12
- 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.
|
|
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.
|
|
57
|
-
"@firecms/ui": "3.5.2-canary.
|
|
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
|
|
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(
|
|
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(
|
|
4
|
-
const recentIds = getRecentIds(
|
|
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(
|
|
9
|
+
saveSearchedIdsLocally(key, newRecentIds);
|
|
10
10
|
return newRecentIds;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function saveSearchedIdsLocally(
|
|
14
|
-
setLocalStorageItem("recent_id_searches::" +
|
|
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(
|
|
18
|
-
return getLocalStorageJSON("recent_id_searches::" +
|
|
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
|
-
|
|
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
|
|
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}
|