@firecms/core 3.4.0-canary.f6a889a → 3.4.0

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 (123) hide show
  1. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
  2. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  3. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  4. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +8 -0
  5. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  7. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  8. package/dist/components/ErrorBoundary.d.ts +1 -3
  9. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  10. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  11. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  12. package/dist/components/common/table_url_params.d.ts +24 -0
  13. package/dist/components/common/useDataSourceTableController.d.ts +9 -1
  14. package/dist/components/common/useTableSearchHelper.d.ts +9 -1
  15. package/dist/core/EntityEditView.d.ts +9 -1
  16. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  17. package/dist/form/EntityForm.d.ts +8 -1
  18. package/dist/form/EntityFormActions.d.ts +2 -0
  19. package/dist/hooks/data/delete.d.ts +1 -1
  20. package/dist/hooks/data/save.d.ts +1 -1
  21. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  22. package/dist/hooks/data/useEntityFetch.d.ts +9 -1
  23. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  24. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  25. package/dist/index.es.js +1753 -1170
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/index.umd.js +1751 -1168
  28. package/dist/index.umd.js.map +1 -1
  29. package/dist/types/collections.d.ts +14 -0
  30. package/dist/types/datasource.d.ts +67 -4
  31. package/dist/types/entities.d.ts +21 -1
  32. package/dist/types/entity_actions.d.ts +6 -0
  33. package/dist/types/entity_callbacks.d.ts +21 -0
  34. package/dist/types/navigation.d.ts +34 -5
  35. package/dist/types/plugins.d.ts +13 -0
  36. package/dist/types/properties.d.ts +17 -1
  37. package/dist/types/side_entity_controller.d.ts +7 -0
  38. package/dist/types/translations.d.ts +6 -0
  39. package/dist/util/__tests__/collections.test.d.ts +1 -0
  40. package/dist/util/__tests__/urls.test.d.ts +1 -0
  41. package/dist/util/collections.d.ts +1 -1
  42. package/dist/util/entity_cache.d.ts +11 -0
  43. package/dist/util/index.d.ts +1 -0
  44. package/dist/util/navigation_from_path.d.ts +7 -0
  45. package/dist/util/navigation_utils.d.ts +87 -3
  46. package/dist/util/parent_references_from_path.d.ts +16 -0
  47. package/dist/util/paths.d.ts +28 -0
  48. package/dist/util/permissions.d.ts +10 -4
  49. package/dist/util/urls.d.ts +22 -0
  50. package/package.json +4 -4
  51. package/src/components/DeleteEntityDialog.tsx +2 -0
  52. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  53. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +1 -0
  54. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  55. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  56. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  57. package/src/components/EntityCollectionView/EntityCollectionView.tsx +66 -15
  58. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  59. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  60. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  61. package/src/components/EntityPreview.tsx +4 -1
  62. package/src/components/ErrorBoundary.tsx +12 -18
  63. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  64. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  65. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  66. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  67. package/src/components/common/default_entity_actions.tsx +22 -2
  68. package/src/components/common/table_url_params.ts +172 -0
  69. package/src/components/common/useDataSourceTableController.tsx +86 -179
  70. package/src/components/common/useTableSearchHelper.ts +26 -2
  71. package/src/contexts/SnackbarProvider.tsx +14 -1
  72. package/src/core/EntityEditView.tsx +20 -3
  73. package/src/core/EntityEditViewFormActions.tsx +3 -2
  74. package/src/core/EntitySidePanel.tsx +8 -4
  75. package/src/form/EntityForm.tsx +31 -10
  76. package/src/form/EntityFormActions.tsx +2 -0
  77. package/src/hooks/data/delete.ts +8 -0
  78. package/src/hooks/data/save.ts +6 -1
  79. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  80. package/src/hooks/data/useEntityFetch.tsx +35 -4
  81. package/src/hooks/useBuildNavigationController.tsx +23 -8
  82. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  83. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  84. package/src/i18n/FireCMSi18nProvider.tsx +48 -4
  85. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  86. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  87. package/src/internal/useBuildDataSource.ts +37 -15
  88. package/src/internal/useBuildSideEntityController.tsx +19 -5
  89. package/src/locales/de.ts +5 -0
  90. package/src/locales/en.ts +5 -0
  91. package/src/locales/es.ts +5 -0
  92. package/src/locales/fr.ts +5 -0
  93. package/src/locales/hi.ts +5 -0
  94. package/src/locales/it.ts +5 -0
  95. package/src/locales/pl.ts +5 -0
  96. package/src/locales/pt.ts +5 -0
  97. package/src/preview/components/ReferencePreview.tsx +7 -3
  98. package/src/preview/components/StorageThumbnail.tsx +24 -2
  99. package/src/routes/FireCMSRoute.tsx +11 -3
  100. package/src/types/collections.ts +16 -0
  101. package/src/types/datasource.ts +67 -4
  102. package/src/types/entities.ts +24 -1
  103. package/src/types/entity_actions.tsx +6 -0
  104. package/src/types/entity_callbacks.ts +24 -0
  105. package/src/types/navigation.ts +35 -5
  106. package/src/types/plugins.tsx +13 -0
  107. package/src/types/properties.ts +17 -0
  108. package/src/types/side_entity_controller.tsx +7 -0
  109. package/src/types/translations.ts +6 -0
  110. package/src/util/__tests__/collections.test.ts +32 -0
  111. package/src/util/__tests__/urls.test.ts +131 -0
  112. package/src/util/collections.ts +1 -1
  113. package/src/util/entities.ts +3 -1
  114. package/src/util/entity_cache.ts +18 -0
  115. package/src/util/index.ts +1 -0
  116. package/src/util/navigation_from_path.ts +25 -2
  117. package/src/util/navigation_utils.ts +208 -2
  118. package/src/util/parent_references_from_path.ts +42 -3
  119. package/src/util/paths.ts +40 -3
  120. package/src/util/permissions.ts +22 -10
  121. package/src/util/previews.ts +8 -1
  122. package/src/util/resolutions.ts +8 -0
  123. package/src/util/urls.ts +52 -0
@@ -22,11 +22,14 @@ import {
22
22
  } from "../types";
23
23
  import {
24
24
  applyPermissionsFunctionIfEmpty,
25
+ collectionSegmentsFrom,
26
+ fullPathToCollectionSegments,
25
27
  getCollectionByPathOrId,
26
28
  mergeDeep,
27
29
  removeFunctions,
28
30
  removeInitialAndTrailingSlashes,
29
31
  resolveCollectionPathIds,
32
+ resolveCollectionPathSegments,
30
33
  resolvePermissions
31
34
  } from "../util";
32
35
  import { getParentReferencesFromPath } from "../util/parent_references_from_path";
@@ -405,13 +408,14 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
405
408
 
406
409
  const getCollection = useCallback((
407
410
  idOrPath: string,
408
- includeUserOverride = false
411
+ includeUserOverride = false,
412
+ pathSegments?: string[]
409
413
  ): EC | undefined => {
410
414
  const collections = collectionsRef.current;
411
415
  if (!collections)
412
416
  return undefined;
413
417
 
414
- const baseCollection = getCollectionByPathOrId(removeInitialAndTrailingSlashes(idOrPath), collections);
418
+ const baseCollection = getCollectionByPathOrId(removeInitialAndTrailingSlashes(idOrPath), collections, pathSegments);
415
419
 
416
420
  const userOverride = includeUserOverride ? userConfigPersistence?.getCollectionConfig(idOrPath) : undefined;
417
421
  let overriddenCollection = baseCollection;
@@ -505,23 +509,33 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
505
509
  throw Error("Expected path starting with " + fullCollectionPath);
506
510
  }, [fullCollectionPath]);
507
511
 
508
- const resolveIdsFrom = useCallback((path: string): string => {
512
+ const resolveIdsFrom = useCallback((path: string, pathSegments?: string[]): string => {
509
513
  const collections = collectionsRef.current ?? [];
510
- return resolveCollectionPathIds(path, collections);
514
+ return resolveCollectionPathIds(path, collections, pathSegments);
511
515
  }, []);
512
516
 
513
- const getAllParentReferencesForPath = useCallback((path: string): EntityReference[] => {
517
+ const resolveSegmentsFrom = useCallback((pathSegments: string[]): string[] => {
518
+ const collections = collectionsRef.current ?? [];
519
+ return resolveCollectionPathSegments(pathSegments, collections);
520
+ }, []);
521
+
522
+ const getAllParentReferencesForPath = useCallback((path: string, pathSegments?: string[]): EntityReference[] => {
514
523
  const collections = collectionsRef.current ?? [];
515
524
  return getParentReferencesFromPath({
516
525
  path,
526
+ pathSegments,
517
527
  collections
518
528
  });
519
529
  }, []);
520
530
 
521
- const getParentCollectionIds = useCallback((path: string): string[] => {
531
+ const getParentCollectionIds = useCallback((path: string, pathSegments?: string[]): string[] => {
522
532
 
523
- const strings = path.split("/");
524
- const oddPathSegments = strings.filter((_, i) => i % 2 === 0);
533
+ // Positional filtering only picks out the collection segments while no entity id
534
+ // contains "/"; with one, every following segment shifts. Given the real boundaries
535
+ // the same rule applies correctly; without them this is the historical reading.
536
+ const oddPathSegments = pathSegments
537
+ ? collectionSegmentsFrom(pathSegments)
538
+ : fullPathToCollectionSegments(path);
525
539
  oddPathSegments.pop();
526
540
 
527
541
  const result: string[][] = [];
@@ -567,6 +581,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
567
581
  urlPathToDataPath,
568
582
  buildUrlCollectionPath,
569
583
  resolveIdsFrom,
584
+ resolveSegmentsFrom,
570
585
  topLevelNavigation,
571
586
  refreshNavigation,
572
587
  getParentReferencesFromPath: getAllParentReferencesForPath,
@@ -80,12 +80,14 @@ export function resolveNavigationFrom<M extends Record<string, any>, USER extend
80
80
  if (entry.type === "collection") {
81
81
  return Promise.resolve(entry);
82
82
  } else if (entry.type === "entity") {
83
- const collection = navigation.getCollection(entry.path);
83
+ const collection = navigation.getCollection(entry.path, false, entry.pathSegments);
84
84
  if (!collection) {
85
85
  throw Error(`No collection defined in the navigation for the entity with path ${entry.path}`);
86
86
  }
87
87
  return dataSource.fetchEntity({
88
88
  path: entry.path,
89
+ // The navigation entry already knows the real segment boundaries.
90
+ pathSegments: entry.pathSegments,
89
91
  entityId: entry.entityId,
90
92
  collection
91
93
  })
@@ -74,6 +74,10 @@ export function useValidateAuthenticator<USER extends User = any>
74
74
 
75
75
  if (authenticator instanceof Function && delegateUser && !equal(checkedUserRef.current?.uid, delegateUser.uid)) {
76
76
  setAuthLoading(true);
77
+ // Reset the error of any previous authentication attempt. Otherwise, a
78
+ // rejected user would keep `canAccessMainView` to false for every user
79
+ // logging in afterwards, locking them out of the CMS until a page reload.
80
+ setNotAllowedError(false);
77
81
  try {
78
82
  const allowed = await authenticator({
79
83
  user: delegateUser,
@@ -1,6 +1,6 @@
1
- import React, { PropsWithChildren, useEffect, useRef } from "react";
1
+ import React, { PropsWithChildren, useContext, useEffect, useMemo, useRef } from "react";
2
2
  import i18next, { i18n } from "i18next";
3
- import { I18nextProvider, initReactI18next } from "react-i18next";
3
+ import { I18nContext, I18nextProvider, initReactI18next } from "react-i18next";
4
4
  import { en } from "../locales/en";
5
5
  import { es } from "../locales/es";
6
6
  import { de } from "../locales/de";
@@ -50,16 +50,48 @@ export function FireCMSi18nProvider({
50
50
  translations,
51
51
  children
52
52
  }: PropsWithChildren<FireCMSi18nProviderProps>) {
53
+
54
+ // A FireCMS i18next instance already in context means this provider is nested —
55
+ // e.g. FireCMSCloudApp mounts one inside the one the host app already mounted.
56
+ // A second instance built only from the defaults would shadow the parent's and
57
+ // silently drop every translation the host registered, leaving the host's own
58
+ // strings to render as raw keys.
59
+ //
60
+ // So the nested instance is *seeded* from the parent's resources rather than
61
+ // sharing them. Sharing one mutable instance was the obvious alternative and is
62
+ // wrong: bundles added by one provider outlive it, so opening one project's app
63
+ // and then switching to another left the first project's translations answering
64
+ // for keys the second never defined. Copying keeps each provider's strings to
65
+ // itself, which is also how this behaved before nesting was handled at all.
66
+ const parentI18n = useContext(I18nContext)?.i18n;
67
+ const parentInstance = parentI18n?.hasResourceBundle?.("en", FIRECMS_NS)
68
+ ? parentI18n
69
+ : undefined;
70
+
53
71
  const i18nRef = useRef<i18n | null>(null);
54
72
  const [ready, setReady] = React.useState(false);
55
73
 
56
74
  if (!i18nRef.current) {
57
75
  const instance = i18next.createInstance();
58
76
 
59
- // Build the initial resources: English baseline + any consumer overrides
77
+ // English baseline + this provider's overrides, with anything the parent
78
+ // already resolved layered in underneath the overrides.
60
79
  const resources = buildResources(translations);
80
+ if (parentInstance) {
81
+ const inherited: Record<string, any> = parentInstance.services?.resourceStore?.data ?? {};
82
+ for (const [lang, namespaces] of Object.entries(inherited)) {
83
+ const bundle = (namespaces as any)?.[FIRECMS_NS];
84
+ if (!bundle) continue;
85
+ resources[lang] = {
86
+ [FIRECMS_NS]: {
87
+ ...bundle,
88
+ ...(translations?.[lang] ?? {})
89
+ }
90
+ };
91
+ }
92
+ }
61
93
 
62
- let initialLocale = locale;
94
+ let initialLocale = parentInstance?.language ?? locale;
63
95
  if (typeof window !== "undefined") {
64
96
  const stored = localStorage.getItem(FIRECMS_LOCALE_STORAGE_KEY);
65
97
  if (stored) initialLocale = stored;
@@ -90,6 +122,18 @@ export function FireCMSi18nProvider({
90
122
  i18nRef.current = instance;
91
123
  }
92
124
 
125
+ // Follow the parent's language, so a switch made outside this provider is not
126
+ // stranded on the other side of the boundary.
127
+ useEffect(() => {
128
+ if (!parentInstance || !i18nRef.current) return;
129
+ const instance = i18nRef.current;
130
+ const follow = (lng: string) => {
131
+ if (instance.language !== lng) instance.changeLanguage(lng);
132
+ };
133
+ parentInstance.on("languageChanged", follow);
134
+ return () => { parentInstance.off("languageChanged", follow); };
135
+ }, [parentInstance]);
136
+
93
137
  // When `locale` prop changes, switch language on the existing instance
94
138
  // ONLY if the user hasn't explicitly set a preference
95
139
  useEffect(() => {
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from "react";
5
+ import { render, screen } from "@testing-library/react";
6
+ import { useTranslation } from "react-i18next";
7
+ import { FireCMSi18nProvider } from "../FireCMSi18nProvider";
8
+
9
+ /** Reads a key through the same channel application code uses. */
10
+ function Str({ k }: { k: string }) {
11
+ const { t } = useTranslation("firecms_core");
12
+ return <span data-testid={k}>{t(k)}</span>;
13
+ }
14
+
15
+ const HOST_TRANSLATIONS = {
16
+ en: {
17
+ host_only_key: "Explore your Firestore data",
18
+ save: "Publish" // an override of a real core key
19
+ }
20
+ } as any;
21
+
22
+ describe("FireCMSi18nProvider", () => {
23
+
24
+ beforeEach(() => {
25
+ window.localStorage.clear();
26
+ });
27
+
28
+ it("resolves its own translations", () => {
29
+ render(
30
+ <FireCMSi18nProvider translations={HOST_TRANSLATIONS}>
31
+ <Str k={"host_only_key"}/>
32
+ </FireCMSi18nProvider>
33
+ );
34
+ expect(screen.getByTestId("host_only_key").textContent).toBe("Explore your Firestore data");
35
+ });
36
+
37
+ it("keeps the host's translations when a nested provider is mounted without any", () => {
38
+ // This is the FireCMSCloudApp case: it mounts its own provider with
39
+ // `translations={appConfig?.translations}`, which is undefined for every
40
+ // project that has not deployed custom code. A nested provider that built its
41
+ // own isolated instance would shadow the host's and render the raw key.
42
+ render(
43
+ <FireCMSi18nProvider translations={HOST_TRANSLATIONS}>
44
+ <FireCMSi18nProvider translations={undefined}>
45
+ <Str k={"host_only_key"}/>
46
+ </FireCMSi18nProvider>
47
+ </FireCMSi18nProvider>
48
+ );
49
+ expect(screen.getByTestId("host_only_key").textContent).toBe("Explore your Firestore data");
50
+ expect(screen.getByTestId("host_only_key").textContent).not.toBe("host_only_key");
51
+ });
52
+
53
+ it("does not let a nested provider clobber a host override", () => {
54
+ render(
55
+ <FireCMSi18nProvider translations={HOST_TRANSLATIONS}>
56
+ <FireCMSi18nProvider translations={undefined}>
57
+ <Str k={"save"}/>
58
+ </FireCMSi18nProvider>
59
+ </FireCMSi18nProvider>
60
+ );
61
+ expect(screen.getByTestId("save").textContent).toBe("Publish");
62
+ });
63
+
64
+ it("lets a nested provider add its own translations on top of the host's", () => {
65
+ render(
66
+ <FireCMSi18nProvider translations={HOST_TRANSLATIONS}>
67
+ <FireCMSi18nProvider translations={{ en: { nested_key: "From the nested provider" } } as any}>
68
+ <>
69
+ <Str k={"host_only_key"}/>
70
+ <Str k={"nested_key"}/>
71
+ </>
72
+ </FireCMSi18nProvider>
73
+ </FireCMSi18nProvider>
74
+ );
75
+ expect(screen.getByTestId("host_only_key").textContent).toBe("Explore your Firestore data");
76
+ expect(screen.getByTestId("nested_key").textContent).toBe("From the nested provider");
77
+ });
78
+
79
+ it("still serves the built-in core strings through a nested provider", () => {
80
+ render(
81
+ <FireCMSi18nProvider>
82
+ <FireCMSi18nProvider translations={undefined}>
83
+ <Str k={"discard"}/>
84
+ </FireCMSi18nProvider>
85
+ </FireCMSi18nProvider>
86
+ );
87
+ const el = screen.getByTestId("discard");
88
+ expect(el.textContent).not.toBe("discard");
89
+ expect(el.textContent?.length).toBeGreaterThan(0);
90
+ });
91
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from "react";
5
+ import { render, screen } from "@testing-library/react";
6
+ import { useTranslation } from "react-i18next";
7
+ import { FireCMSi18nProvider } from "../FireCMSi18nProvider";
8
+
9
+ function Str({ k }: { k: string }) {
10
+ const { t } = useTranslation("firecms_core");
11
+ return <span data-testid={k}>{t(k)}</span>;
12
+ }
13
+
14
+ const host = { en: { host_key: "Host" } } as any;
15
+ const projectA = { en: { only_in_a: "From project A" } } as any;
16
+ const projectB = { en: { only_in_b: "From project B" } } as any;
17
+
18
+ describe("nested provider lifecycle", () => {
19
+ beforeEach(() => window.localStorage.clear());
20
+
21
+ it("does not carry one project's translations into the next", () => {
22
+ // The real shape of a project switch: App.tsx's provider stays mounted while
23
+ // FireCMSCloudApp's inner one is torn down and rebuilt with another project's
24
+ // translations. Both share the outer i18next instance now, so anything the
25
+ // first project added could outlive it.
26
+ const { rerender } = render(
27
+ <FireCMSi18nProvider translations={host}>
28
+ <FireCMSi18nProvider key={"a"} translations={projectA}>
29
+ <Str k={"only_in_a"}/>
30
+ </FireCMSi18nProvider>
31
+ </FireCMSi18nProvider>
32
+ );
33
+ expect(screen.getByTestId("only_in_a").textContent).toBe("From project A");
34
+
35
+ rerender(
36
+ <FireCMSi18nProvider translations={host}>
37
+ <FireCMSi18nProvider key={"b"} translations={projectB}>
38
+ <>
39
+ <Str k={"only_in_b"}/>
40
+ <Str k={"only_in_a"}/>
41
+ </>
42
+ </FireCMSi18nProvider>
43
+ </FireCMSi18nProvider>
44
+ );
45
+
46
+ expect(screen.getByTestId("only_in_b").textContent).toBe("From project B");
47
+ expect(screen.getByTestId("only_in_a").textContent).toBe("only_in_a");
48
+ });
49
+
50
+ it("keeps the host's own translations across the switch", () => {
51
+ const { rerender } = render(
52
+ <FireCMSi18nProvider translations={host}>
53
+ <FireCMSi18nProvider key={"a"} translations={projectA}>
54
+ <Str k={"host_key"}/>
55
+ </FireCMSi18nProvider>
56
+ </FireCMSi18nProvider>
57
+ );
58
+ rerender(
59
+ <FireCMSi18nProvider translations={host}>
60
+ <FireCMSi18nProvider key={"b"} translations={projectB}>
61
+ <Str k={"host_key"}/>
62
+ </FireCMSi18nProvider>
63
+ </FireCMSi18nProvider>
64
+ );
65
+ expect(screen.getByTestId("host_key").textContent).toBe("Host");
66
+ });
67
+ });
@@ -55,6 +55,7 @@ export function useBuildDataSource({
55
55
  */
56
56
  fetchCollection: useCallback(<M extends Record<string, any>>({
57
57
  path,
58
+ pathSegments,
58
59
  collection,
59
60
  filter,
60
61
  limit,
@@ -67,6 +68,7 @@ export function useBuildDataSource({
67
68
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
68
69
  return usedDelegate.fetchCollection<M>({
69
70
  path,
71
+ pathSegments,
70
72
  filter,
71
73
  limit,
72
74
  startAfter,
@@ -97,6 +99,7 @@ export function useBuildDataSource({
97
99
  ? useCallback(<M extends Record<string, any>>(
98
100
  {
99
101
  path,
102
+ pathSegments,
100
103
  collection: collectionProp,
101
104
  filter,
102
105
  limit,
@@ -109,7 +112,7 @@ export function useBuildDataSource({
109
112
  }: ListenCollectionProps<M>
110
113
  ): () => void => {
111
114
 
112
- const collection = collectionProp ?? navigationController.getCollection(path);
115
+ const collection = collectionProp ?? navigationController.getCollection(path, false, pathSegments);
113
116
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
114
117
 
115
118
  if (!usedDelegate.listenCollection)
@@ -117,6 +120,7 @@ export function useBuildDataSource({
117
120
 
118
121
  return usedDelegate.listenCollection<M>({
119
122
  path,
123
+ pathSegments,
120
124
  filter,
121
125
  limit,
122
126
  startAfter,
@@ -139,6 +143,7 @@ export function useBuildDataSource({
139
143
  */
140
144
  fetchEntity: useCallback(<M extends Record<string, any>>({
141
145
  path,
146
+ pathSegments,
142
147
  entityId,
143
148
  collection
144
149
  }: FetchEntityProps<M>
@@ -146,6 +151,7 @@ export function useBuildDataSource({
146
151
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
147
152
  return usedDelegate.fetchEntity({
148
153
  path,
154
+ pathSegments,
149
155
  entityId,
150
156
  collection
151
157
  });
@@ -165,6 +171,7 @@ export function useBuildDataSource({
165
171
  ? useCallback(<M extends Record<string, any>>(
166
172
  {
167
173
  path,
174
+ pathSegments,
168
175
  entityId,
169
176
  collection,
170
177
  onUpdate,
@@ -177,6 +184,7 @@ export function useBuildDataSource({
177
184
 
178
185
  return usedDelegate.listenEntity<M>({
179
186
  path,
187
+ pathSegments,
180
188
  entityId,
181
189
  onUpdate,
182
190
  onError,
@@ -198,13 +206,14 @@ export function useBuildDataSource({
198
206
  saveEntity: useCallback(async <M extends Record<string, any>>(
199
207
  {
200
208
  path,
209
+ pathSegments,
201
210
  entityId,
202
211
  values,
203
212
  collection: collectionProp,
204
213
  status
205
214
  }: SaveEntityProps<M>): Promise<Entity<M>> => {
206
215
 
207
- const collection = collectionProp ?? navigationController.getCollection(path);
216
+ const collection = collectionProp ?? navigationController.getCollection(path, false, pathSegments);
208
217
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
209
218
 
210
219
  const resolvedCollection = collection
@@ -244,6 +253,7 @@ export function useBuildDataSource({
244
253
  try {
245
254
  const entities = await usedDelegate.fetchCollection({
246
255
  path,
256
+ pathSegments,
247
257
  orderBy: orderProperty,
248
258
  order: "asc",
249
259
  limit: 1,
@@ -270,14 +280,21 @@ export function useBuildDataSource({
270
280
 
271
281
  return usedDelegate.saveEntity({
272
282
  path,
283
+ pathSegments,
273
284
  collection,
274
285
  entityId,
275
286
  values: finalValues,
276
287
  status
277
288
  }).then((res) => {
289
+ // Keep everything the delegate resolved — `databaseId`, and `pathSegments` in
290
+ // particular — so a just-saved entity is as resolvable as a fetched one.
291
+ // Only the values are re-mapped back to the CMS model.
278
292
  return {
279
- id: res.id,
280
- path: res.path,
293
+ ...res,
294
+ // A delegate that predates `pathSegments` returns none; the segments we
295
+ // just saved with describe exactly this entity's collection, so they are
296
+ // the authoritative answer rather than a guess.
297
+ pathSegments: res.pathSegments ?? pathSegments,
281
298
  values: usedDelegate.delegateToCMSModel(finalValues)
282
299
  } as Entity<M>;
283
300
  });
@@ -292,12 +309,15 @@ export function useBuildDataSource({
292
309
  deleteEntity: useCallback(<M extends Record<string, any>>(
293
310
  {
294
311
  entity,
312
+ pathSegments,
295
313
  collection
296
314
  }: DeleteEntityProps<M>
297
315
  ): Promise<void> => {
298
316
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
299
317
  return usedDelegate.deleteEntity({
300
318
  entity,
319
+ // Falls back to what the entity carries from the fetch that produced it.
320
+ pathSegments: pathSegments ?? entity.pathSegments,
301
321
  collection
302
322
  });
303
323
  }, [delegate.deleteEntity]),
@@ -317,33 +337,32 @@ export function useBuildDataSource({
317
337
  name: string,
318
338
  value: any,
319
339
  entityId?: string,
320
- collection?: EntityCollection
340
+ collection?: EntityCollection,
341
+ pathSegments?: string[]
321
342
  ): Promise<boolean> => {
322
343
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
323
- return usedDelegate.checkUniqueField(path, name, value, entityId, collection);
344
+ return usedDelegate.checkUniqueField(path, name, value, entityId, collection, pathSegments);
324
345
  }, [delegate.checkUniqueField]),
325
346
 
326
- generateEntityId: useCallback((path: string, collection: EntityCollection): string => {
347
+ generateEntityId: useCallback((path: string, collection: EntityCollection, pathSegments?: string[]): string => {
327
348
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
328
- return usedDelegate.generateEntityId(path, collection);
349
+ return usedDelegate.generateEntityId(path, collection, pathSegments);
329
350
  }, [delegate.generateEntityId]),
330
351
 
352
+ // Typed with the declared props rather than an inline duplicate: the duplicate is
353
+ // free to drift from the interface, and a field it forgets is dropped in silence.
331
354
  countEntities: delegate.countEntities ? async ({
332
355
  path,
356
+ pathSegments,
333
357
  collection,
334
358
  filter,
335
359
  order,
336
360
  orderBy
337
- }: {
338
- path: string,
339
- collection: EntityCollection<any>,
340
- filter?: FilterValues<Extract<keyof any, string>>,
341
- orderBy?: string,
342
- order?: "desc" | "asc",
343
- }): Promise<number> => {
361
+ }: FetchCollectionProps<any>): Promise<number> => {
344
362
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
345
363
  return usedDelegate.countEntities!({
346
364
  path,
365
+ pathSegments,
347
366
  filter,
348
367
  orderBy,
349
368
  order,
@@ -353,11 +372,13 @@ export function useBuildDataSource({
353
372
 
354
373
  isFilterCombinationValid: useCallback(({
355
374
  path,
375
+ pathSegments,
356
376
  databaseId,
357
377
  filterValues,
358
378
  sortBy
359
379
  }: {
360
380
  path: string,
381
+ pathSegments?: string[],
361
382
  databaseId?: string,
362
383
  filterValues: FilterValues<any>,
363
384
  sortBy?: [string, "asc" | "desc"]
@@ -367,6 +388,7 @@ export function useBuildDataSource({
367
388
  return delegate.isFilterCombinationValid(
368
389
  {
369
390
  path,
391
+ pathSegments,
370
392
  databaseId,
371
393
  filterValues,
372
394
  sortBy
@@ -224,12 +224,14 @@ export function buildSidePanelsFromUrl(path: string, collections: EntityCollecti
224
224
  let sidePanel: EntitySidePanelProps<any> | undefined = undefined;
225
225
  let lastCollectionPath = "";
226
226
  let lastCollectionId: string | undefined = undefined;
227
+ let lastCollectionSegments: string[] | undefined = undefined;
227
228
  for (let i = 0; i < navigationViewsForPath.length; i++) {
228
229
  const navigationEntry = navigationViewsForPath[i];
229
230
 
230
231
  if (navigationEntry.type === "collection") {
231
232
  lastCollectionPath = navigationEntry.path;
232
233
  lastCollectionId = navigationEntry.collection.id;
234
+ lastCollectionSegments = navigationEntry.pathSegments;
233
235
  }
234
236
 
235
237
  const previousEntry = navigationViewsForPath[i - 1];
@@ -237,6 +239,7 @@ export function buildSidePanelsFromUrl(path: string, collections: EntityCollecti
237
239
  sidePanel = {
238
240
  path: navigationEntry.path,
239
241
  fullIdPath: navigationEntry.fullIdPath,
242
+ pathSegments: navigationEntry.pathSegments,
240
243
  entityId: navigationEntry.entityId,
241
244
  copy: false,
242
245
  width: navigationEntry.parentCollection?.sideDialogWidth
@@ -259,6 +262,9 @@ export function buildSidePanelsFromUrl(path: string, collections: EntityCollecti
259
262
  sidePanel = {
260
263
  path: lastCollectionPath,
261
264
  fullIdPath: lastCollectionId,
265
+ // A new entity still needs unambiguous segments: it may be created inside a
266
+ // subcollection whose parent id contains "/".
267
+ pathSegments: lastCollectionSegments,
262
268
  copy: false
263
269
  }
264
270
  }
@@ -275,11 +281,17 @@ const propsToSidePanel = (props: EntitySidePanelProps,
275
281
  locationSearch: string
276
282
  ): SideDialogPanelProps => {
277
283
 
278
- const collectionPath = removeInitialAndTrailingSlashes(props.path);
284
+ // The URL is the ESCAPED representation of the chain — it is what `buildSidePanelsFromUrl`
285
+ // and `FireCMSRoute` parse back on reload, on a deep link, and on every pathname change.
286
+ // `props.path` is the RAW datasource path, so a parent entity id containing "/" reads back
287
+ // there as extra collection/entity hops and resolves to a different entity entirely.
288
+ // `fullIdPath` carries the same chain with its ids already escaped, which is what it is
289
+ // threaded alongside `path` for.
290
+ const urlChain = removeInitialAndTrailingSlashes(props.fullIdPath ?? props.path);
279
291
 
280
292
  const urlPath = props.entityId
281
- ? buildUrlCollectionPath(`${collectionPath}/${encodeEntityId(props.entityId)}${props.selectedTab ? "/" + props.selectedTab : ""}${locationSearch}#${SIDE_URL_HASH}`)
282
- : buildUrlCollectionPath(`${collectionPath}${locationSearch}#${NEW_URL_HASH}`);
293
+ ? buildUrlCollectionPath(`${urlChain}/${encodeEntityId(props.entityId)}${props.selectedTab ? "/" + props.selectedTab : ""}${locationSearch}#${SIDE_URL_HASH}`)
294
+ : buildUrlCollectionPath(`${urlChain}${locationSearch}#${NEW_URL_HASH}`);
283
295
 
284
296
  const resolvedPanelProps: EntitySidePanelProps<any> = {
285
297
  ...props,
@@ -288,10 +300,12 @@ const propsToSidePanel = (props: EntitySidePanelProps,
288
300
 
289
301
  const entityViewWidth = getEntityViewWidth(props, smallLayout, customizationController, authController);
290
302
  return {
291
- key: `${props.path}/${props.entityId}`,
303
+ // Built from the escaped chain so that two different chains which happen to flatten
304
+ // to the same raw string are still two different panels.
305
+ key: `${urlChain}/${props.entityId ? encodeEntityId(props.entityId) : ""}`,
292
306
  component: undefined, // Lazy render in SideDialogs for better performance
293
307
  urlPath: urlPath,
294
- parentUrlPath: buildUrlCollectionPath(collectionPath),
308
+ parentUrlPath: buildUrlCollectionPath(urlChain),
295
309
  width: entityViewWidth,
296
310
  onClose: props.onClose,
297
311
  additional: resolvedPanelProps
package/src/locales/de.ts CHANGED
@@ -389,6 +389,7 @@ export const de: FireCMSTranslations = {
389
389
  download: "Herunterladen",
390
390
  large_number_of_documents: "Diese Sammlung hat eine große Anzahl von Dokumenten ({{count}}).",
391
391
  include_undefined_values: "Undefinierte Werte einschließen",
392
+ export_apply_filter_sort: "Nur Ergebnisse exportieren, die dem aktuellen Filter/der aktuellen Sortierung entsprechen",
392
393
  submit: "Einreichen",
393
394
 
394
395
  no_filterable_properties: "Keine filterbaren Eigenschaften verfügbar",
@@ -586,6 +587,10 @@ export const de: FireCMSTranslations = {
586
587
  order_property_not_found: "Eigenschaft \"{{property}}\" existiert nicht oder ist keine Zahlen-Eigenschaft. Bitte wählen Sie eine gültige Eigenschaft oder löschen Sie die Auswahl.",
587
588
  no_number_properties: "Keine Zahlen-Eigenschaften gefunden. Fügen Sie eine Zahlen-Eigenschaft hinzu, um die Sortierung zu aktivieren.",
588
589
  order_property_description: "Wählen Sie eine Zahlen-Eigenschaft, um die Reihenfolge der Elemente beizubehalten",
590
+ image_property: "Bild-Eigenschaft",
591
+ image_property_not_found: "Eigenschaft \"{{property}}\" existiert nicht oder ist keine Bild-Eigenschaft. Bitte wählen Sie eine gültige Eigenschaft oder löschen Sie die Auswahl.",
592
+ no_image_properties: "Keine Bild-Eigenschaften gefunden. Fügen Sie eine Speicher- oder Bild-URL-Eigenschaft hinzu, um sie als Vorschaubild zu verwenden.",
593
+ image_property_description: "Wählen Sie die Eigenschaft, die als Vorschaubild in den Ansichten Karten und Kanban verwendet wird. Wenn nicht festgelegt, wird automatisch die erste passende Bild-Eigenschaft verwendet.",
589
594
  display_settings: "Anzeigeeinstellungen",
590
595
  default_row_size: "Standard-Zeilengröße",
591
596
  side_dialog_width: "Seitendialog-Breite",
package/src/locales/en.ts CHANGED
@@ -397,6 +397,7 @@ export const en: FireCMSTranslations = {
397
397
  download: "Download",
398
398
  large_number_of_documents: "This collections has a large number of documents ({{count}}).",
399
399
  include_undefined_values: "Include undefined values",
400
+ export_apply_filter_sort: "Only export results matching the current filter/sort",
400
401
  submit: "Submit",
401
402
 
402
403
  no_filterable_properties: "No filterable properties available",
@@ -598,6 +599,10 @@ export const en: FireCMSTranslations = {
598
599
  order_property_not_found: "Property \"{{property}}\" does not exist or is not a number property. Please select a valid property or clear the selection.",
599
600
  no_number_properties: "No number properties found. Add a number property to enable ordering.",
600
601
  order_property_description: "Select a number property to persist the order of items",
602
+ image_property: "Image Property",
603
+ image_property_not_found: "Property \"{{property}}\" does not exist or is not an image property. Please select a valid property or clear the selection.",
604
+ no_image_properties: "No image properties found. Add a storage or URL image property to use as a thumbnail.",
605
+ image_property_description: "Select the property used as the thumbnail image in Cards and Kanban view modes. If not set, the first matching image property will be used automatically.",
601
606
  display_settings: "Display settings",
602
607
  default_row_size: "Default row size",
603
608
  side_dialog_width: "Side dialog width",