@firecms/core 3.3.0-canary.1e1cce9 → 3.3.0-canary.289082f

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 (43) hide show
  1. package/dist/app/useApp.d.ts +1 -0
  2. package/dist/core/DefaultDrawer.d.ts +10 -3
  3. package/dist/core/field_configs.d.ts +1 -1
  4. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
  5. package/dist/form/index.d.ts +1 -0
  6. package/dist/index.es.js +1741 -492
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/index.umd.js +1740 -491
  9. package/dist/index.umd.js.map +1 -1
  10. package/dist/locales/pl.d.ts +2 -0
  11. package/dist/preview/index.d.ts +1 -0
  12. package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
  13. package/dist/util/geopoint.d.ts +22 -0
  14. package/dist/util/index.d.ts +1 -0
  15. package/dist/util/navigation_blocking.d.ts +22 -0
  16. package/dist/util/navigation_from_path.d.ts +10 -0
  17. package/dist/util/navigation_utils.d.ts +20 -0
  18. package/package.json +14 -7
  19. package/src/app/Scaffold.tsx +34 -44
  20. package/src/app/useApp.tsx +1 -0
  21. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  22. package/src/components/common/useDataSourceTableController.tsx +25 -6
  23. package/src/core/DefaultDrawer.tsx +150 -64
  24. package/src/core/DrawerNavigationGroup.tsx +27 -29
  25. package/src/core/DrawerNavigationItem.tsx +10 -12
  26. package/src/core/EntityEditView.tsx +57 -32
  27. package/src/core/field_configs.tsx +15 -0
  28. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  29. package/src/form/index.tsx +1 -0
  30. package/src/i18n/FireCMSi18nProvider.tsx +2 -0
  31. package/src/internal/useBuildSideEntityController.tsx +2 -1
  32. package/src/locales/pl.ts +730 -0
  33. package/src/preview/PropertyPreview.tsx +12 -0
  34. package/src/preview/index.ts +1 -0
  35. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  36. package/src/routes/FireCMSRoute.tsx +44 -25
  37. package/src/util/entities.ts +2 -0
  38. package/src/util/geopoint.ts +81 -0
  39. package/src/util/index.ts +1 -0
  40. package/src/util/navigation_blocking.ts +45 -0
  41. package/src/util/navigation_from_path.ts +23 -6
  42. package/src/util/navigation_utils.ts +36 -2
  43. package/src/util/parent_references_from_path.ts +4 -2
@@ -0,0 +1,2 @@
1
+ import { FireCMSTranslations } from "../types/translations";
2
+ export declare const pl: FireCMSTranslations;
@@ -4,6 +4,7 @@ export * from "./property_previews/ArrayOfStringsPreview";
4
4
  export * from "./property_previews/ArrayPropertyEnumPreview";
5
5
  export * from "./property_previews/ArrayOfMapsPreview";
6
6
  export * from "./property_previews/NumberPropertyPreview";
7
+ export * from "./property_previews/GeopointPropertyPreview";
7
8
  export * from "./property_previews/StringPropertyPreview";
8
9
  export * from "./property_previews/ArrayOfStorageComponentsPreview";
9
10
  export * from "./property_previews/ArrayOfReferencesPreview";
@@ -0,0 +1,4 @@
1
+ import React from "react";
2
+ import { GeoPoint } from "../../types";
3
+ import { PropertyPreviewProps } from "../PropertyPreviewProps";
4
+ export declare function GeopointPropertyPreview({ value, size }: PropertyPreviewProps<GeoPoint>): React.ReactElement;
@@ -0,0 +1,22 @@
1
+ import { GeoPoint } from "../types";
2
+ export type GeoPointLike = GeoPoint | {
3
+ latitude?: number;
4
+ longitude?: number;
5
+ lat?: number;
6
+ lng?: number;
7
+ _lat?: number;
8
+ _long?: number;
9
+ } | null | undefined;
10
+ export interface GeoPointCoordinates {
11
+ latitude: number;
12
+ longitude: number;
13
+ }
14
+ export declare function getGeoPointCoordinates(value: GeoPointLike): GeoPointCoordinates | undefined;
15
+ export declare function normalizeGeoPoint(value: GeoPointLike): GeoPoint | undefined;
16
+ export declare function formatGeoPoint(value: GeoPointLike, options?: {
17
+ maximumFractionDigits?: number;
18
+ }): string;
19
+ export declare function parseGeoPoint(input: string): {
20
+ point: GeoPoint | null;
21
+ error?: string;
22
+ };
@@ -12,6 +12,7 @@ export * from "./useDebouncedCallback";
12
12
  export * from "./property_utils";
13
13
  export * from "./resolutions";
14
14
  export * from "./permissions";
15
+ export * from "./geopoint";
15
16
  export * from "./icon_list";
16
17
  export * from "./icon_synonyms";
17
18
  export * from "./icons";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Pure predicate deciding whether an in-progress navigation away from an open
3
+ * entity form should be blocked (to warn about unsaved changes).
4
+ *
5
+ * Extracted from FireCMSRoute so the branching can be unit-tested without
6
+ * mounting the router. This is an internal helper — it is intentionally not
7
+ * re-exported from the package index.
8
+ */
9
+ export interface NavigationBlockLocation {
10
+ pathname: string;
11
+ hash: string;
12
+ }
13
+ export declare function shouldBlockEntityNavigation(params: {
14
+ currentLocation: NavigationBlockLocation;
15
+ nextLocation: NavigationBlockLocation;
16
+ /** Path of the currently open entity (basePath + "/" + entityId). */
17
+ entityPath: string;
18
+ /** Collection path the entity form lives under. */
19
+ basePath: string;
20
+ /** Whether the form currently has unsaved modifications. */
21
+ blocked: boolean;
22
+ }): boolean;
@@ -24,10 +24,20 @@ export interface NavigationViewEntityCustomInternal<M extends Record<string, any
24
24
  entityId: string;
25
25
  view: EntityCustomView<M>;
26
26
  }
27
+ /**
28
+ * Note on encoding: `path` arrives from the URL, so any entity id in it is escaped (see
29
+ * `encodeEntityId`). Two chains are threaded through the recursion:
30
+ *
31
+ * - `currentFullPath` carries RAW ids and feeds the `path` / `fullPath` used to address
32
+ * the datasource.
33
+ * - `currentFullUrlPath` carries ESCAPED ids and feeds `fullPath`, which is handed back
34
+ * to `buildUrlCollectionPath` for breadcrumbs and links.
35
+ */
27
36
  export declare function getNavigationEntriesFromPath(props: {
28
37
  path: string;
29
38
  collections: EntityCollection[] | undefined;
30
39
  currentFullPath?: string;
31
40
  currentFullIdPath?: string;
41
+ currentFullUrlPath?: string;
32
42
  contextEntityViews?: EntityCustomView<any>[];
33
43
  }): NavigationViewInternal[];
@@ -3,6 +3,26 @@ export declare function removeInitialAndTrailingSlashes(s: string): string;
3
3
  export declare function removeInitialSlash(s: string): string;
4
4
  export declare function removeTrailingSlash(s: string): string;
5
5
  export declare function addInitialSlash(s: string): string;
6
+ /**
7
+ * Characters that would otherwise be read as structure once an entity id is joined into a
8
+ * path: "/" separates segments, "?" and "#" start the query and hash in a URL, and "%"
9
+ * has to be escaped first so that no escape sequence we introduce can be misread as one
10
+ * that was already part of the id.
11
+ *
12
+ * Entity ids are escaped ONLY inside URL-facing strings — anything handed to
13
+ * `buildUrlCollectionPath` or `navigate`. Every other path string (the `path` field of a
14
+ * navigation entry, datasource paths, `EntityReference.path`) carries raw ids.
15
+ *
16
+ * @group Hooks and utilities
17
+ */
18
+ export declare function encodeEntityId(entityId: string): string;
19
+ /**
20
+ * Inverse of {@link encodeEntityId}. "%25" is undone last, mirroring the encoder, so an id
21
+ * that legitimately contains the text "%2F" survives the round trip.
22
+ *
23
+ * @group Hooks and utilities
24
+ */
25
+ export declare function decodeEntityId(encodedEntityId: string): string;
6
26
  export declare function getLastSegment(path: string): string;
7
27
  export declare function resolveCollectionPathIds(path: string, allCollections: EntityCollection[]): string;
8
28
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@firecms/core",
3
3
  "type": "module",
4
- "version": "3.3.0-canary.1e1cce9",
4
+ "version": "3.3.0-canary.289082f",
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.3.0-canary.1e1cce9",
57
- "@firecms/ui": "^3.3.0-canary.1e1cce9",
56
+ "@firecms/formex": "^3.3.0-canary.289082f",
57
+ "@firecms/ui": "^3.3.0-canary.289082f",
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",
@@ -63,7 +63,7 @@
63
63
  "date-fns": "^3.6.0",
64
64
  "fuse.js": "^7.1.0",
65
65
  "history": "^5.3.0",
66
- "i18next": "^23.16.4",
66
+ "i18next": "^26.3.3",
67
67
  "json-logic-js": "^2.0.5",
68
68
  "markdown-it": "^14.2.0",
69
69
  "markdown-it-ins": "^4.0.0",
@@ -96,7 +96,7 @@
96
96
  "react-transition-group": "^4.4.5",
97
97
  "react-use-measure": "^2.1.7",
98
98
  "react-window": "^1.8.11",
99
- "vite-plugin-static-copy": "3.1.4",
99
+ "vite-plugin-static-copy": "4.1.1",
100
100
  "yup": "^1.7.1"
101
101
  },
102
102
  "peerDependencies": {
@@ -111,7 +111,7 @@
111
111
  "@testing-library/user-event": "^14.6.1",
112
112
  "@types/jest": "^29.5.14",
113
113
  "@types/json-logic-js": "^2.0.8",
114
- "@types/node": "^20.19.17",
114
+ "@types/node": "^26.1.1",
115
115
  "@types/object-hash": "^3.0.6",
116
116
  "@types/react": "^19.2.3",
117
117
  "@types/react-dom": "^19.2.3",
@@ -128,7 +128,7 @@
128
128
  "react-router-dom": "^6.30.2",
129
129
  "ts-jest": "^29.4.5",
130
130
  "ts-node": "^10.9.2",
131
- "tsd": "^0.31.2",
131
+ "tsd": "^0.33.0",
132
132
  "typescript": "^5.9.3",
133
133
  "vite": "^7.2.4"
134
134
  },
@@ -163,6 +163,10 @@
163
163
  "^.+\\.tsx?$": "ts-jest"
164
164
  },
165
165
  "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
166
+ "testPathIgnorePatterns": [
167
+ "/node_modules/",
168
+ "/dist/"
169
+ ],
166
170
  "moduleFileExtensions": [
167
171
  "ts",
168
172
  "tsx",
@@ -172,6 +176,9 @@
172
176
  "node"
173
177
  ],
174
178
  "testEnvironment": "node",
179
+ "setupFiles": [
180
+ "<rootDir>/test/setup_jsdom.js"
181
+ ],
175
182
  "moduleNameMapper": {
176
183
  "\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js"
177
184
  }
@@ -1,5 +1,5 @@
1
1
  import React, { PropsWithChildren, useCallback } from "react";
2
- import { ChevronLeftIcon, cls, defaultBorderMixin, IconButton, MenuIcon, Sheet, Tooltip } from "@firecms/ui";
2
+ import { cls, defaultBorderMixin, IconButton, MenuIcon, Sheet } from "@firecms/ui";
3
3
  import equal from "react-fast-compare"
4
4
  import { useLargeLayout } from "../hooks";
5
5
  import { ErrorBoundary } from "../components";
@@ -80,7 +80,11 @@ export const Scaffold = React.memo<PropsWithChildren<ScaffoldProps>>(
80
80
  const [onHover, setOnHover] = React.useState(false);
81
81
 
82
82
  const setOnHoverTrue = useCallback(() => setOnHover(true), []);
83
- const setOnHoverFalse = useCallback(() => setOnHover(false), []);
83
+ const setOnHoverFalse = useCallback(() => {
84
+ // Don't collapse the drawer while a popover/dropdown is open
85
+ if (typeof document !== "undefined" && document.querySelector("[data-radix-popper-content-wrapper]")) return;
86
+ setOnHover(false);
87
+ }, []);
84
88
 
85
89
  const handleDrawerOpen = useCallback(() => {
86
90
  setDrawerOpen(true);
@@ -96,17 +100,21 @@ export const Scaffold = React.memo<PropsWithChildren<ScaffoldProps>>(
96
100
  } catch { /* ignore */ }
97
101
  }, []);
98
102
 
99
- const computedDrawerOpen: boolean = drawerOpen || Boolean(largeLayout && autoOpenDrawer && onHover);
103
+ // Pinned-open state only; hover is tracked separately so it can *float*
104
+ // over the content instead of pushing it.
105
+ const computedDrawerOpen: boolean = drawerOpen;
106
+ const computedDrawerHovered: boolean = Boolean(largeLayout && onHover);
100
107
 
101
108
  const hasAppBar = Boolean(appBarChildren.length > 0);
102
109
  return (
103
110
  <AppContext.Provider value={{
104
111
  logo,
105
112
  hasDrawer: includeDrawer,
106
- drawerHovered: onHover,
113
+ drawerHovered: computedDrawerHovered,
107
114
  drawerOpen: computedDrawerOpen,
108
115
  closeDrawer: handleDrawerClose,
109
116
  openDrawer: handleDrawerOpen,
117
+ closeHover: setOnHoverFalse,
110
118
  autoOpenDrawer
111
119
  }}>
112
120
  <div
@@ -128,7 +136,7 @@ export const Scaffold = React.memo<PropsWithChildren<ScaffoldProps>>(
128
136
  onMouseMove={setOnHoverTrue}
129
137
  onMouseLeave={setOnHoverFalse}
130
138
  open={computedDrawerOpen}
131
- hovered={onHover}
139
+ hovered={computedDrawerHovered}
132
140
  setDrawerOpen={setDrawerOpen}>
133
141
  {includeDrawer && drawerChildren}
134
142
  </DrawerWrapper>
@@ -177,49 +185,30 @@ function DrawerWrapper(props: {
177
185
  }) {
178
186
 
179
187
  const { t } = useTranslation();
180
- const width = !props.displayed ? 0 : (props.open ? DRAWER_WIDTH : 72);
188
+
189
+ // Layout width is the space the drawer occupies in the flex row; the visual
190
+ // width is how wide the drawer actually renders. When hovered (but not
191
+ // pinned open) the layout width stays collapsed while the visual width
192
+ // expands, so the drawer *floats* over the content instead of pushing it.
193
+ const layoutWidth = !props.displayed ? 0 : (props.open ? DRAWER_WIDTH : 72);
194
+ const visualWidth = !props.displayed ? 0 : ((props.open || props.hovered) ? DRAWER_WIDTH : 72);
195
+ const isFloating = props.hovered && !props.open;
196
+
197
+ const transition = "left 75ms cubic-bezier(0.4, 0, 0.6, 1) 0ms, opacity 75ms cubic-bezier(0.4, 0, 0.6, 1) 0ms, width 75ms cubic-bezier(0.4, 0, 0.6, 1) 0ms";
198
+
181
199
  const innerDrawer = <div
182
- className={"relative h-full no-scrollbar overflow-y-auto overflow-x-hidden"}
200
+ className={cls("h-full overflow-hidden", defaultBorderMixin,
201
+ isFloating
202
+ ? "absolute top-0 left-0 bottom-0 z-30 bg-surface-50 dark:bg-surface-900 shadow-lg border-r"
203
+ : "relative bg-surface-50 dark:bg-surface-900")}
183
204
  style={{
184
- width,
185
- transition: "left 100ms cubic-bezier(0.4, 0, 0.6, 1) 0ms, opacity 100ms cubic-bezier(0.4, 0, 0.6, 1) 0ms, width 100ms cubic-bezier(0.4, 0, 0.6, 1) 0ms"
205
+ width: visualWidth,
206
+ transition
186
207
  }}
187
208
  >
188
-
189
- {!props.open && props.displayed && (
190
- <Tooltip title={t("open_menu")}
191
- side="right"
192
- sideOffset={12}
193
- asChild={true}>
194
- <div
195
- className="ml-2 fixed top-1 left-2 sm:top-2 sm:left-2 !bg-surface-50 dark:!bg-surface-900 rounded-full w-fit z-20">
196
- <IconButton
197
- color="inherit"
198
- aria-label={t("open_menu")}
199
- onClick={() => props.setDrawerOpen(true)}
200
- size="large"
201
- >
202
- <MenuIcon size="small" />
203
- </IconButton>
204
- </div>
205
- </Tooltip>
206
- )}
207
-
208
- <div
209
- className={`z-20 absolute right-0 top-4 ${props.open ? "opacity-100" : "opacity-0 invisible"
210
- } transition-opacity duration-200 ease-in-out`}>
211
- <IconButton
212
- aria-label={t("close_drawer")}
213
- onClick={() => props.setDrawerOpen(false)}
214
- >
215
- <ChevronLeftIcon />
216
- </IconButton>
217
- </div>
218
-
219
209
  <div className={"flex flex-col h-full"}>
220
210
  {props.children}
221
211
  </div>
222
-
223
212
  </div>;
224
213
 
225
214
  const largeLayout = useLargeLayout();
@@ -250,13 +239,14 @@ function DrawerWrapper(props: {
250
239
 
251
240
  return (
252
241
  <div
253
- className="z-20 relative"
242
+ className="z-20 relative flex-shrink-0 overflow-visible"
254
243
  onMouseEnter={props.onMouseEnter}
255
244
  onMouseMove={props.onMouseMove}
256
245
  onMouseLeave={props.onMouseLeave}
257
246
  style={{
258
- width,
259
- transition: "left 100ms cubic-bezier(0.4, 0, 0.6, 1) 0ms, opacity 100ms cubic-bezier(0.4, 0, 0.6, 1) 0ms, width 100ms cubic-bezier(0.4, 0, 0.6, 1) 0ms"
247
+ width: layoutWidth,
248
+ minWidth: layoutWidth,
249
+ transition
260
250
  }}>
261
251
 
262
252
  {innerDrawer}
@@ -10,6 +10,7 @@ export type AppState = {
10
10
  drawerOpen: boolean,
11
11
  openDrawer: () => void,
12
12
  closeDrawer: () => void,
13
+ closeHover?: () => void,
13
14
  autoOpenDrawer?: boolean,
14
15
  logo?: string
15
16
  }
@@ -135,7 +135,7 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
135
135
  const [columns, setColumns] = useState(columnsProp);
136
136
 
137
137
  const tableRef = useRef<HTMLDivElement>(null);
138
- const endReachCallbackThreshold = useRef<number>(0);
138
+ const lastEndReachedDataLength = useRef<number | undefined>(undefined);
139
139
 
140
140
  const debouncedScroll = useDebounceCallback(onScrollProp, 200);
141
141
 
@@ -242,7 +242,7 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
242
242
  }, [filterInput]);
243
243
 
244
244
  const scrollToTop = useCallback(() => {
245
- endReachCallbackThreshold.current = 0;
245
+ lastEndReachedDataLength.current = undefined;
246
246
  if (tableRef.current) {
247
247
  tableRef.current.scrollTo(tableRef.current?.scrollLeft, 0);
248
248
  }
@@ -278,13 +278,24 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
278
278
 
279
279
  const maxScroll = Math.max((data?.length ?? 0) * rowHeight - bounds.height, 0);
280
280
 
281
- const onEndReachedInternal = useCallback((scrollOffset: number) => {
282
- if (onEndReached && (data?.length ?? 0) > 0 && scrollOffset > endReachCallbackThreshold.current + endOffset) {
283
- endReachCallbackThreshold.current = scrollOffset;
281
+ const onEndReachedInternal = useCallback(() => {
282
+ const dataLength = data?.length ?? 0;
283
+
284
+ if (onEndReached && dataLength > 0 && lastEndReachedDataLength.current !== dataLength) {
285
+ lastEndReachedDataLength.current = dataLength;
284
286
  onEndReached();
285
287
  }
286
288
  }, [data?.length, onEndReached]);
287
289
 
290
+ useEffect(() => {
291
+ if (!onEndReached || loading || !data?.length || !bounds.height || !tableRef.current) {
292
+ return;
293
+ }
294
+ if (tableRef.current.scrollHeight <= tableRef.current.clientHeight) {
295
+ onEndReachedInternal();
296
+ }
297
+ }, [bounds.height, data?.length, loading, onEndReached, onEndReachedInternal]);
298
+
288
299
  const onScroll = useCallback(({
289
300
  scrollDirection,
290
301
  scrollOffset,
@@ -302,12 +313,12 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
302
313
  })
303
314
  }
304
315
  if (!scrollUpdateWasRequested && (scrollOffset >= maxScroll - endOffset))
305
- onEndReachedInternal(scrollOffset);
306
- }, [maxScroll, onEndReachedInternal]);
316
+ onEndReachedInternal();
317
+ }, [endOffset, maxScroll, onEndReachedInternal]);
307
318
 
308
319
  const onFilterUpdateInternal = useCallback((column: VirtualTableColumn, filterForProperty?: [VirtualTableWhereFilterOp, any]) => {
309
320
 
310
- endReachCallbackThreshold.current = 0;
321
+ lastEndReachedDataLength.current = undefined;
311
322
  const filter = filterRef.current;
312
323
  let newFilterValue: VirtualTableFilterValues<any> = filter ? { ...filter } : {};
313
324
 
@@ -18,6 +18,7 @@ import {
18
18
  import { useDebouncedData } from "./useDebouncedData";
19
19
  import { ScrollRestorationController } from "./useScrollRestoration";
20
20
  import { isDataTypeFilterable } from "../../util";
21
+ import { decodeEntityId, encodeEntityId } from "../../util/navigation_utils";
21
22
 
22
23
  const DEFAULT_PAGE_SIZE = 50;
23
24
 
@@ -379,7 +380,13 @@ function encodeFilterAndSort(filterValues?: FilterValues<string>, sortBy?: [stri
379
380
  }
380
381
  if (encodedValue !== undefined) {
381
382
  entries[encodeURIComponent(`${key}_op`)] = encodeURIComponent(op);
382
- entries[encodeURIComponent(`${key}_value`)] = encodedValue ? encodeURIComponent(encodedValue.toString()) : "null";
383
+ // Note: check for null/undefined explicitly instead of truthiness,
384
+ // otherwise falsy-but-valid values (boolean `false`, number `0`)
385
+ // would be serialized as "null" and round-trip back as `null`,
386
+ // breaking filters like `archived == false`.
387
+ entries[encodeURIComponent(`${key}_value`)] = encodedValue !== null && encodedValue !== undefined
388
+ ? encodeURIComponent(encodedValue.toString())
389
+ : "null";
383
390
  }
384
391
  }
385
392
  });
@@ -431,7 +438,21 @@ function isDate(dateString: string): boolean {
431
438
  }
432
439
 
433
440
  function encodeRef(val: EntityReference) {
434
- return `ref::${val.path}/${val.id}`;
441
+ return `ref::${val.path}/${encodeEntityId(val.id)}`;
442
+ }
443
+
444
+ /**
445
+ * Split a "path/id" reference on its LAST separator: the path may itself be a
446
+ * subcollection path with several segments, and the id is escaped so it is always exactly
447
+ * one segment.
448
+ */
449
+ function decodeRef(encoded: string): EntityReference {
450
+ const separatorIndex = encoded.lastIndexOf("/");
451
+ if (separatorIndex < 0) return new EntityReference(encoded, "");
452
+ return new EntityReference(
453
+ decodeEntityId(encoded.substring(separatorIndex + 1)),
454
+ encoded.substring(0, separatorIndex)
455
+ );
435
456
  }
436
457
 
437
458
  function decodeString(val: string): EntityReference | Date | string {
@@ -447,8 +468,7 @@ function decodeString(val: string): EntityReference | Date | string {
447
468
  try {
448
469
  parsedFilterVal = JSON.parse(parsedFilterVal, (key, value) => {
449
470
  if (typeof value === "string" && value.startsWith("ref::")) {
450
- const [path, id] = value.substring(5).split("/");
451
- return new EntityReference(id, path);
471
+ return decodeRef(value.substring(5));
452
472
  }
453
473
  return value;
454
474
  });
@@ -458,8 +478,7 @@ function decodeString(val: string): EntityReference | Date | string {
458
478
  }
459
479
 
460
480
  if (typeof parsedFilterVal === "string" && parsedFilterVal.startsWith("ref::")) {
461
- const [path, id] = parsedFilterVal.substring(5).split("/");
462
- return new EntityReference(id, path);
481
+ return decodeRef(parsedFilterVal.substring(5));
463
482
  }
464
483
  return parsedFilterVal;
465
484
  }