@firecms/core 3.1.0 → 3.2.0-canary.4c3b8f2

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 (191) hide show
  1. package/dist/components/EntityCollectionView/CollectionDataErrorBanner.d.ts +4 -0
  2. package/dist/components/ErrorBoundary.d.ts +3 -1
  3. package/dist/components/HomePage/DefaultHomePage.d.ts +0 -1
  4. package/dist/components/LanguageToggle.d.ts +1 -0
  5. package/dist/components/UnsavedChangesDialog.d.ts +1 -0
  6. package/dist/components/index.d.ts +1 -0
  7. package/dist/core/DrawerNavigationGroup.d.ts +2 -2
  8. package/dist/editor/components/SlashCommandMenu.d.ts +6 -0
  9. package/dist/editor/components/editor-bubble-item.d.ts +8 -0
  10. package/dist/editor/components/editor-bubble.d.ts +8 -0
  11. package/dist/editor/components/image-bubble.d.ts +5 -0
  12. package/dist/editor/components/index.d.ts +16 -0
  13. package/dist/editor/components/table-bubble.d.ts +5 -0
  14. package/dist/editor/editor.d.ts +30 -0
  15. package/dist/editor/extensions/HighlightDecorationExtension.d.ts +24 -0
  16. package/dist/editor/extensions/Image/index.d.ts +6 -0
  17. package/dist/editor/extensions/Image.d.ts +6 -0
  18. package/dist/editor/extensions/TextLoadingDecorationExtension.d.ts +16 -0
  19. package/dist/editor/extensions/clipboard.d.ts +7 -0
  20. package/dist/editor/extensions/custom-keymap.d.ts +1 -0
  21. package/dist/editor/extensions/drag-and-drop.d.ts +9 -0
  22. package/dist/editor/hooks/useProseMirror.d.ts +13 -0
  23. package/dist/editor/hooks/useProseMirrorContext.d.ts +9 -0
  24. package/dist/editor/index.d.ts +2 -0
  25. package/dist/editor/markdown.d.ts +5 -0
  26. package/dist/editor/nodeViews/ImageComponent.d.ts +3 -0
  27. package/dist/editor/nodeViews/ReactNodeView.d.ts +29 -0
  28. package/dist/editor/nodeViews/TaskItemComponent.d.ts +3 -0
  29. package/dist/editor/nodeViews/index.d.ts +6 -0
  30. package/dist/editor/plugins/index.d.ts +2 -0
  31. package/dist/editor/plugins/inputrules.d.ts +6 -0
  32. package/dist/editor/plugins/placeholderPlugin.d.ts +3 -0
  33. package/dist/editor/plugins/slashCommandPlugin.d.ts +12 -0
  34. package/dist/editor/schema.d.ts +2 -0
  35. package/dist/editor/selectors/ai-selector.d.ts +0 -0
  36. package/dist/editor/selectors/color-selector.d.ts +10 -0
  37. package/dist/editor/selectors/link-selector.d.ts +8 -0
  38. package/dist/editor/selectors/node-selector.d.ts +15 -0
  39. package/dist/editor/selectors/text-buttons.d.ts +1 -0
  40. package/dist/editor/types.d.ts +5 -0
  41. package/dist/editor/useProseMirror.d.ts +16 -0
  42. package/dist/editor/utils/prosemirror-utils.d.ts +6 -0
  43. package/dist/editor/utils/remove_classes.d.ts +1 -0
  44. package/dist/editor/utils/useDebouncedCallback.d.ts +1 -0
  45. package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +1 -1
  46. package/dist/hooks/index.d.ts +1 -0
  47. package/dist/hooks/useBuildNavigationController.d.ts +0 -1
  48. package/dist/hooks/useCollapsedGroups.d.ts +3 -3
  49. package/dist/hooks/useTranslation.d.ts +17 -0
  50. package/dist/i18n/FireCMSi18nProvider.d.ts +33 -0
  51. package/dist/index.d.ts +4 -0
  52. package/dist/index.es.js +12898 -2265
  53. package/dist/index.es.js.map +1 -1
  54. package/dist/index.umd.js +12877 -2264
  55. package/dist/index.umd.js.map +1 -1
  56. package/dist/locales/de.d.ts +2 -0
  57. package/dist/locales/en.d.ts +10 -0
  58. package/dist/locales/es.d.ts +10 -0
  59. package/dist/locales/fr.d.ts +2 -0
  60. package/dist/locales/hi.d.ts +2 -0
  61. package/dist/locales/it.d.ts +2 -0
  62. package/dist/locales/pt.d.ts +7 -0
  63. package/dist/types/customization_controller.d.ts +2 -1
  64. package/dist/types/firecms.d.ts +2 -1
  65. package/dist/types/index.d.ts +1 -0
  66. package/dist/types/navigation.d.ts +2 -2
  67. package/dist/types/plugins.d.ts +7 -0
  68. package/dist/types/storage.d.ts +1 -0
  69. package/dist/types/translations.d.ts +646 -0
  70. package/dist/util/useStorageUploadController.d.ts +10 -1
  71. package/package.json +45 -9
  72. package/src/app/Scaffold.tsx +7 -5
  73. package/src/components/AIIcon.tsx +3 -1
  74. package/src/components/ArrayContainer.tsx +6 -4
  75. package/src/components/ClearFilterSortButton.tsx +6 -3
  76. package/src/components/ConfirmationDialog.tsx +4 -2
  77. package/src/components/DeleteEntityDialog.tsx +10 -7
  78. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +6 -3
  79. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +3 -1
  80. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +3 -2
  81. package/src/components/EntityCollectionView/BoardSortableList.tsx +3 -1
  82. package/src/components/EntityCollectionView/CollectionDataErrorBanner.tsx +43 -0
  83. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +16 -43
  84. package/src/components/EntityCollectionView/EntityCollectionCardView.tsx +17 -25
  85. package/src/components/EntityCollectionView/EntityCollectionView.tsx +26 -18
  86. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +4 -3
  87. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -2
  88. package/src/components/EntityCollectionView/FiltersDialog.tsx +8 -5
  89. package/src/components/EntityCollectionView/ViewModeToggle.tsx +11 -8
  90. package/src/components/EntityView.tsx +3 -2
  91. package/src/components/ErrorBoundary.tsx +27 -15
  92. package/src/components/HomePage/DefaultHomePage.tsx +19 -13
  93. package/src/components/HomePage/HomePageDnD.tsx +3 -1
  94. package/src/components/HomePage/NavigationGroup.tsx +3 -1
  95. package/src/components/HomePage/RenameGroupDialog.tsx +15 -13
  96. package/src/components/LanguageToggle.tsx +66 -0
  97. package/src/components/NotFoundPage.tsx +5 -3
  98. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +9 -7
  99. package/src/components/ReferenceWidget.tsx +3 -2
  100. package/src/components/SearchIconsView.tsx +3 -1
  101. package/src/components/SelectableTable/filters/DateTimeFilterField.tsx +11 -0
  102. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +15 -2
  103. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +11 -0
  104. package/src/components/UnsavedChangesDialog.tsx +6 -4
  105. package/src/components/VirtualTable/VirtualTable.performance.test.tsx +1 -0
  106. package/src/components/VirtualTable/VirtualTableHeader.tsx +12 -10
  107. package/src/components/common/default_entity_actions.tsx +4 -0
  108. package/src/components/common/useDataSourceTableController.tsx +12 -4
  109. package/src/components/index.tsx +1 -0
  110. package/src/core/DefaultAppBar.tsx +14 -10
  111. package/src/core/DefaultDrawer.tsx +8 -2
  112. package/src/core/DrawerNavigationGroup.tsx +5 -3
  113. package/src/core/EntityEditView.tsx +4 -3
  114. package/src/core/EntityEditViewFormActions.tsx +24 -17
  115. package/src/core/EntitySidePanel.tsx +6 -5
  116. package/src/core/FireCMS.tsx +33 -6
  117. package/src/editor/components/SlashCommandMenu.tsx +516 -0
  118. package/src/editor/components/editor-bubble-item.tsx +32 -0
  119. package/src/editor/components/editor-bubble.tsx +118 -0
  120. package/src/editor/components/image-bubble.tsx +156 -0
  121. package/src/editor/components/index.ts +14 -0
  122. package/src/editor/components/table-bubble.tsx +165 -0
  123. package/src/editor/editor.tsx +455 -0
  124. package/src/editor/extensions/HighlightDecorationExtension.ts +114 -0
  125. package/src/editor/extensions/Image/index.ts +133 -0
  126. package/src/editor/extensions/Image.ts +159 -0
  127. package/src/editor/extensions/TextLoadingDecorationExtension.tsx +107 -0
  128. package/src/editor/extensions/clipboard.ts +72 -0
  129. package/src/editor/extensions/custom-keymap.ts +24 -0
  130. package/src/editor/extensions/drag-and-drop.tsx +480 -0
  131. package/src/editor/hooks/useProseMirror.ts +124 -0
  132. package/src/editor/hooks/useProseMirrorContext.ts +15 -0
  133. package/src/editor/index.ts +2 -0
  134. package/src/editor/markdown.ts +172 -0
  135. package/src/editor/nodeViews/ImageComponent.tsx +20 -0
  136. package/src/editor/nodeViews/ReactNodeView.tsx +89 -0
  137. package/src/editor/nodeViews/TaskItemComponent.tsx +29 -0
  138. package/src/editor/nodeViews/index.ts +35 -0
  139. package/src/editor/plugins/index.ts +58 -0
  140. package/src/editor/plugins/inputrules.ts +82 -0
  141. package/src/editor/plugins/placeholderPlugin.ts +55 -0
  142. package/src/editor/plugins/slashCommandPlugin.ts +61 -0
  143. package/src/editor/schema.ts +240 -0
  144. package/src/editor/selectors/ai-selector.tsx +111 -0
  145. package/src/editor/selectors/color-selector.tsx +200 -0
  146. package/src/editor/selectors/link-selector.tsx +118 -0
  147. package/src/editor/selectors/node-selector.tsx +157 -0
  148. package/src/editor/selectors/text-buttons.tsx +86 -0
  149. package/src/editor/types.ts +6 -0
  150. package/src/editor/useProseMirror.ts +126 -0
  151. package/src/editor/utils/prosemirror-utils.ts +108 -0
  152. package/src/editor/utils/remove_classes.ts +17 -0
  153. package/src/editor/utils/useDebouncedCallback.ts +25 -0
  154. package/src/form/EntityForm.tsx +16 -3
  155. package/src/form/EntityFormActions.tsx +19 -12
  156. package/src/form/PropertyFieldBinding.tsx +3 -2
  157. package/src/form/components/LocalChangesMenu.tsx +13 -13
  158. package/src/form/components/StorageItemPreview.tsx +3 -2
  159. package/src/form/components/StorageUploadProgress.tsx +18 -3
  160. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +4 -4
  161. package/src/form/field_bindings/BlockFieldBinding.tsx +5 -2
  162. package/src/form/field_bindings/KeyValueFieldBinding.tsx +23 -18
  163. package/src/form/field_bindings/MapFieldBinding.tsx +4 -3
  164. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +33 -19
  165. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -1
  166. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +4 -3
  167. package/src/hooks/index.tsx +1 -0
  168. package/src/hooks/useBuildNavigationController.tsx +45 -18
  169. package/src/hooks/useCollapsedGroups.ts +7 -6
  170. package/src/hooks/useTranslation.ts +31 -0
  171. package/src/i18n/FireCMSi18nProvider.tsx +160 -0
  172. package/src/index.ts +4 -0
  173. package/src/internal/useBuildSideEntityController.tsx +22 -20
  174. package/src/locales/de.ts +691 -0
  175. package/src/locales/en.ts +703 -0
  176. package/src/locales/es.ts +703 -0
  177. package/src/locales/fr.ts +691 -0
  178. package/src/locales/hi.ts +691 -0
  179. package/src/locales/it.ts +691 -0
  180. package/src/locales/pt.ts +700 -0
  181. package/src/preview/components/UrlComponentPreview.tsx +4 -2
  182. package/src/preview/components/UserPreview.tsx +3 -1
  183. package/src/types/customization_controller.tsx +2 -1
  184. package/src/types/firecms.tsx +2 -1
  185. package/src/types/index.ts +1 -0
  186. package/src/types/navigation.ts +2 -2
  187. package/src/types/plugins.tsx +8 -0
  188. package/src/types/properties.ts +1 -0
  189. package/src/types/storage.ts +2 -1
  190. package/src/types/translations.ts +725 -0
  191. package/src/util/useStorageUploadController.tsx +23 -29
@@ -34,7 +34,6 @@ import { getParentReferencesFromPath } from "../util/parent_references_from_path
34
34
  const DEFAULT_BASE_PATH = "/";
35
35
  const DEFAULT_COLLECTION_PATH = "/c";
36
36
 
37
- export const NAVIGATION_DEFAULT_GROUP_NAME = "Views";
38
37
  export const NAVIGATION_ADMIN_GROUP_NAME = "Admin";
39
38
 
40
39
  export type BuildNavigationContextProps<EC extends EntityCollection, USER extends User> = {
@@ -189,7 +188,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
189
188
  path: pathKey,
190
189
  collection,
191
190
  description: collection.description?.trim(),
192
- group: groupName ?? NAVIGATION_DEFAULT_GROUP_NAME
191
+ group: groupName
193
192
  });
194
193
  return acc;
195
194
  }, [] as NavigationEntry[]),
@@ -217,7 +216,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
217
216
  path: view.path,
218
217
  view,
219
218
  description: view.description?.trim(),
220
- group: groupName ?? NAVIGATION_DEFAULT_GROUP_NAME
219
+ group: groupName
221
220
  });
222
221
  return acc;
223
222
  }, [] as NavigationEntry[]),
@@ -242,7 +241,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
242
241
  }, [] as NavigationEntry[])
243
242
  ];
244
243
 
245
- const groupOrderValue = (groupName?: string): number => {
244
+ const groupOrderValue = (groupName?: string | null): number => {
246
245
  if (groupName === NAVIGATION_ADMIN_GROUP_NAME) return 1;
247
246
  return 0; // Other groups
248
247
  };
@@ -266,7 +265,10 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
266
265
 
267
266
  const collectedGroupsFromEntries = navigationEntries
268
267
  .map(e => e.group)
269
- .filter(Boolean) as string[];
268
+ .filter((g): g is string => g !== null && Boolean(g));
269
+
270
+ // Check if there are any ungrouped entries
271
+ const hasUngroupedEntries = navigationEntries.some(e => e.group === null && e.type !== "admin");
270
272
 
271
273
  // Preserve order from finalNavigationGroupMappings (persisted order)
272
274
  const groupsFromMappings = finalNavigationGroupMappings.map(g => g.name);
@@ -284,7 +286,12 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
284
286
  const uniqueGroupsArray = [...new Set(allDefinedGroups)];
285
287
  const adminGroups = uniqueGroupsArray.filter(g => g === NAVIGATION_ADMIN_GROUP_NAME);
286
288
  const nonAdminGroups = uniqueGroupsArray.filter(g => g !== NAVIGATION_ADMIN_GROUP_NAME);
287
- const uniqueGroups = [...nonAdminGroups, ...adminGroups];
289
+ // Place null (ungrouped) first if there are ungrouped entries
290
+ const uniqueGroups: (string | null)[] = [
291
+ ...(hasUngroupedEntries ? [null] : []),
292
+ ...nonAdminGroups,
293
+ ...adminGroups
294
+ ];
288
295
 
289
296
  return {
290
297
  allowDragAndDrop: plugins?.some(plugin => plugin.homePage?.allowDragAndDrop) ?? false,
@@ -487,8 +494,10 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
487
494
 
488
495
  const urlPathToDataPath = useCallback((path: string): string => {
489
496
  const decodedPath = decodeURIComponent(path);
490
- if (decodedPath.startsWith(fullCollectionPath))
491
- return decodedPath.replace(fullCollectionPath, "");
497
+ const withoutHash = decodedPath.split("#")[0];
498
+ const cleanPath = withoutHash.split("?")[0];
499
+ if (cleanPath.startsWith(fullCollectionPath))
500
+ return cleanPath.replace(fullCollectionPath, "");
492
501
  throw Error("Expected path starting with " + fullCollectionPath);
493
502
  }, [fullCollectionPath]);
494
503
 
@@ -565,9 +574,27 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
565
574
  }
566
575
 
567
576
  function encodePath(input: string) {
568
- return encodeURIComponent(removeInitialAndTrailingSlashes(input))
569
- .replaceAll("%2F", "/")
570
- .replaceAll("%23", "#");
577
+ const cleanInput = removeInitialAndTrailingSlashes(input);
578
+ const [pathPart, rest] = cleanInput.split("?", 2);
579
+
580
+ let encodedPath = encodeURIComponent(pathPart).replaceAll("%2F", "/");
581
+ let result = encodedPath;
582
+
583
+ if (rest !== undefined) {
584
+ const [searchPart, hashPart] = rest.split("#", 2);
585
+ result += `?${searchPart}`;
586
+ if (hashPart !== undefined) {
587
+ result += `#${hashPart}`;
588
+ }
589
+ } else {
590
+ const [pathOnly, hashOnly] = cleanInput.split("#", 2);
591
+ if (hashOnly !== undefined) {
592
+ encodedPath = encodeURIComponent(pathOnly).replaceAll("%2F", "/");
593
+ result = `${encodedPath}#${hashOnly}`;
594
+ }
595
+ }
596
+
597
+ return result;
571
598
  }
572
599
 
573
600
  function filterOutNotAllowedCollections(resolvedCollections: EntityCollection[], authController: AuthController<User>): EntityCollection[] {
@@ -662,12 +689,12 @@ async function resolveCMSViews(
662
689
  return resolvedViews;
663
690
  }
664
691
 
665
- function getGroup(collectionOrView: EntityCollection<any, any> | CMSView) {
692
+ function getGroup(collectionOrView: EntityCollection<any, any> | CMSView): string | null {
666
693
  const trimmed = collectionOrView.group?.trim();
667
694
  if (!trimmed || trimmed === "") {
668
- return NAVIGATION_DEFAULT_GROUP_NAME;
695
+ return null;
669
696
  }
670
- return trimmed ?? NAVIGATION_DEFAULT_GROUP_NAME;
697
+ return trimmed;
671
698
  }
672
699
 
673
700
  function areCollectionListsEqual(a: EntityCollection[], b: EntityCollection[]) {
@@ -803,7 +830,7 @@ function computeNavigationGroups({
803
830
  (collections ?? []).forEach(collection => {
804
831
  const entry = collection.id ?? collection.path;
805
832
  if (!assignedEntries.has(entry)) {
806
- const groupName = getGroup(collection);
833
+ const groupName = getGroup(collection) ?? "__default__";
807
834
  if (!unassignedGroupMap[groupName]) unassignedGroupMap[groupName] = [];
808
835
  unassignedGroupMap[groupName].push(entry);
809
836
  }
@@ -813,7 +840,7 @@ function computeNavigationGroups({
813
840
  (views ?? []).forEach(view => {
814
841
  const entry = view.path;
815
842
  if (!assignedEntries.has(entry)) {
816
- const groupName = getGroup(view);
843
+ const groupName = getGroup(view) ?? "__default__";
817
844
  if (!unassignedGroupMap[groupName]) unassignedGroupMap[groupName] = [];
818
845
  unassignedGroupMap[groupName].push(entry);
819
846
  }
@@ -841,7 +868,7 @@ function computeNavigationGroups({
841
868
 
842
869
  // Add collections
843
870
  (collections ?? []).forEach(collection => {
844
- const name = getGroup(collection);
871
+ const name = getGroup(collection) ?? "__default__";
845
872
  const entry = collection.id ?? collection.path;
846
873
  if (!groupMap[name]) groupMap[name] = [];
847
874
  groupMap[name].push(entry);
@@ -849,7 +876,7 @@ function computeNavigationGroups({
849
876
 
850
877
  // Add views
851
878
  (views ?? []).forEach(view => {
852
- const name = getGroup(view);
879
+ const name = getGroup(view) ?? "__default__";
853
880
  const entry = view.path;
854
881
  if (!groupMap[name]) groupMap[name] = [];
855
882
  groupMap[name].push(entry);
@@ -10,7 +10,7 @@ const STORAGE_KEY_PREFIX = "firecms-collapsed-groups";
10
10
  * @param groupNames - Array of group names to track
11
11
  * @param namespace - Namespace for localStorage key (e.g., "home", "drawer") to allow independent state
12
12
  */
13
- export function useCollapsedGroups(groupNames: string[], namespace: string = "default") {
13
+ export function useCollapsedGroups(groupNames: (string | null)[], namespace: string = "default") {
14
14
  const storageKey = `${STORAGE_KEY_PREFIX}-${namespace}`;
15
15
 
16
16
  // Load collapsed groups from localStorage on mount
@@ -37,7 +37,7 @@ export function useCollapsedGroups(groupNames: string[], namespace: string = "de
37
37
  // Only clean up if we have actual groups loaded (avoid cleaning up during initial load)
38
38
  if (groupNames.length === 0) return;
39
39
 
40
- const currentGroupNames = new Set(groupNames);
40
+ const currentGroupNames = new Set(groupNames.map(g => g ?? "__default__"));
41
41
 
42
42
  setCollapsedGroups(prev => {
43
43
  const cleaned = Object.fromEntries(
@@ -56,12 +56,13 @@ export function useCollapsedGroups(groupNames: string[], namespace: string = "de
56
56
  });
57
57
  }, [groupNames]);
58
58
 
59
- const isGroupCollapsed = useCallback((name: string) => {
60
- return !!collapsedGroups[name];
59
+ const isGroupCollapsed = useCallback((name?: string | null) => {
60
+ return !!collapsedGroups[name ?? "__default__"];
61
61
  }, [collapsedGroups]);
62
62
 
63
- const toggleGroupCollapsed = useCallback((name: string) => {
64
- setCollapsedGroups(prev => ({ ...prev, [name]: !prev[name] }));
63
+ const toggleGroupCollapsed = useCallback((name?: string | null) => {
64
+ const key = name ?? "__default__";
65
+ setCollapsedGroups(prev => ({ ...prev, [key]: !prev[key] }));
65
66
  }, []);
66
67
 
67
68
  return {
@@ -0,0 +1,31 @@
1
+ import { useTranslation as useI18nTranslation } from "react-i18next";
2
+
3
+ const FIRECMS_NS = "firecms_core";
4
+
5
+ /**
6
+ * Internal hook for translating FireCMS UI strings.
7
+ *
8
+ * Uses the `firecms_core` i18next namespace that is initialised by
9
+ * `FireCMSi18nProvider`. Do NOT use `react-i18next` directly in internal
10
+ * components — always go through this hook so the namespace is consistent.
11
+ *
12
+ * @example
13
+ * const { t } = useTranslation();
14
+ * <Button>{t("save")}</Button>
15
+ *
16
+ * @internal
17
+ */
18
+ export function useTranslation() {
19
+ const { t, i18n } = useI18nTranslation(FIRECMS_NS);
20
+
21
+ /**
22
+ * Typed translation function scoped to FirecmsTranslations keys.
23
+ * Also supports i18next interpolation variables, e.g.
24
+ * t("add_to_field", { fieldName: "Tags" })
25
+ * t("error_deleting", { message: err.message })
26
+ */
27
+ const typedT = (key: string, vars?: Record<string, string>): string =>
28
+ t(key, vars) as string;
29
+
30
+ return { t: typedT, i18n };
31
+ }
@@ -0,0 +1,160 @@
1
+ import React, { PropsWithChildren, useEffect, useRef } from "react";
2
+ import i18next, { i18n } from "i18next";
3
+ import { I18nextProvider, initReactI18next } from "react-i18next";
4
+ import { en } from "../locales/en";
5
+ import { es } from "../locales/es";
6
+ import { de } from "../locales/de";
7
+ import { fr } from "../locales/fr";
8
+ import { it } from "../locales/it";
9
+ import { hi } from "../locales/hi";
10
+ import { pt } from "../locales/pt";
11
+ import { FireCMSTranslations } from "../types/translations";
12
+
13
+ const FIRECMS_NS = "firecms_core";
14
+
15
+ export const FIRECMS_LOCALE_STORAGE_KEY = "firecms_locale";
16
+
17
+ /** DeepPartial helper — allows partial overrides at any nesting level */
18
+ type DeepPartial<T> = T extends object
19
+ ? { [K in keyof T]?: DeepPartial<T[K]> }
20
+ : T;
21
+
22
+ export interface FireCMSi18nProviderProps {
23
+ /** BCP-47 locale tag, e.g. "en", "es", "fr". Defaults to "en". */
24
+ locale?: string;
25
+ /**
26
+ * Override or extend any FireCMS UI string, keyed by locale.
27
+ *
28
+ * @example
29
+ * translations={{
30
+ * en: { save: "Publish" },
31
+ * es: { save: "Publicar", discard: "Descartar" }
32
+ * }}
33
+ */
34
+ translations?: {
35
+ [locale: string]: DeepPartial<FireCMSTranslations>;
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Initialises a dedicated i18next instance for FireCMS's internal UI strings.
41
+ *
42
+ * This instance is isolated from any app-level i18next configuration the
43
+ * consumer may have. Mount this at the top of the FireCMS component tree.
44
+ *
45
+ * @internal
46
+ */
47
+ export function FireCMSi18nProvider({
48
+ locale = "en",
49
+ translations,
50
+ children
51
+ }: PropsWithChildren<FireCMSi18nProviderProps>) {
52
+ const i18nRef = useRef<i18n | null>(null);
53
+ const [ready, setReady] = React.useState(false);
54
+
55
+ if (!i18nRef.current) {
56
+ const instance = i18next.createInstance();
57
+
58
+ // Build the initial resources: English baseline + any consumer overrides
59
+ const resources = buildResources(translations);
60
+
61
+ let initialLocale = locale;
62
+ if (typeof window !== "undefined") {
63
+ const stored = localStorage.getItem(FIRECMS_LOCALE_STORAGE_KEY);
64
+ if (stored) initialLocale = stored;
65
+ }
66
+
67
+ instance
68
+ .use(initReactI18next)
69
+ .init({
70
+ lng: initialLocale,
71
+ fallbackLng: "en",
72
+ ns: [FIRECMS_NS],
73
+ defaultNS: FIRECMS_NS,
74
+ resources,
75
+ interpolation: {
76
+ // React already escapes — don't double-escape
77
+ escapeValue: false,
78
+ },
79
+ }, () => {
80
+ setReady(true);
81
+ });
82
+
83
+ instance.on("languageChanged", (lng) => {
84
+ if (typeof window !== "undefined") {
85
+ localStorage.setItem(FIRECMS_LOCALE_STORAGE_KEY, lng);
86
+ }
87
+ });
88
+
89
+ i18nRef.current = instance;
90
+ }
91
+
92
+ // When `locale` prop changes, switch language on the existing instance
93
+ // ONLY if the user hasn't explicitly set a preference
94
+ useEffect(() => {
95
+ if (i18nRef.current && i18nRef.current.language !== locale) {
96
+ const hasUserPreference = typeof window !== "undefined" && Boolean(localStorage.getItem(FIRECMS_LOCALE_STORAGE_KEY));
97
+ if (!hasUserPreference) {
98
+ i18nRef.current.changeLanguage(locale);
99
+ }
100
+ }
101
+ }, [locale]);
102
+
103
+ // When consumer translations prop changes, update the resource bundles
104
+ useEffect(() => {
105
+ if (!i18nRef.current) return;
106
+ const resources = buildResources(translations);
107
+ for (const [lang, bundle] of Object.entries(resources)) {
108
+ i18nRef.current.addResourceBundle(
109
+ lang,
110
+ FIRECMS_NS,
111
+ bundle[FIRECMS_NS],
112
+ true, // deep merge
113
+ true // overwrite existing keys
114
+ );
115
+ }
116
+ }, [translations]);
117
+
118
+ if (!ready || !i18nRef.current) return null;
119
+
120
+ return (
121
+ <I18nextProvider i18n={i18nRef.current}>
122
+ {children}
123
+ </I18nextProvider>
124
+ );
125
+ }
126
+
127
+ /**
128
+ * Build an i18next resources object from the English baseline plus any
129
+ * consumer-provided overrides.
130
+ */
131
+ function buildResources(
132
+ translations?: { [locale: string]: DeepPartial<FireCMSTranslations> }
133
+ ): Record<string, Record<string, object>> {
134
+ const resources: Record<string, Record<string, object>> = {
135
+ en: { [FIRECMS_NS]: { ...en } },
136
+ es: { [FIRECMS_NS]: { ...es } },
137
+ de: { [FIRECMS_NS]: { ...de } },
138
+ fr: { [FIRECMS_NS]: { ...fr } },
139
+ it: { [FIRECMS_NS]: { ...it } },
140
+ hi: { [FIRECMS_NS]: { ...hi } },
141
+ pt: { [FIRECMS_NS]: { ...pt } },
142
+ };
143
+
144
+ if (!translations) return resources;
145
+
146
+ for (const [lang, overrides] of Object.entries(translations)) {
147
+ if (!resources[lang]) {
148
+ // For non-English/Spanish locales, start from English as the fallback base
149
+ resources[lang] = { [FIRECMS_NS]: { ...en } };
150
+ }
151
+ // Merge consumer overrides (shallow merge is enough since translations
152
+ // is a flat record — deepMerge option in addResourceBundle handles deeper)
153
+ resources[lang][FIRECMS_NS] = {
154
+ ...resources[lang][FIRECMS_NS],
155
+ ...overrides,
156
+ };
157
+ }
158
+
159
+ return resources;
160
+ }
package/src/index.ts CHANGED
@@ -7,3 +7,7 @@ export * from "./hooks";
7
7
  export * from "./components";
8
8
  export * from "./util";
9
9
  export * from "./contexts";
10
+ export * from "./i18n/FireCMSi18nProvider";
11
+ export * from "./locales/en";
12
+ export * from "./locales/es";
13
+ export * from "./editor";
@@ -99,8 +99,8 @@ function getNestedPropertiesDepth(property: ResolvedProperty, accumulator: numbe
99
99
  }
100
100
 
101
101
  export const useBuildSideEntityController = (navigation: NavigationController,
102
- sideDialogsController: SideDialogsController,
103
- authController: AuthController
102
+ sideDialogsController: SideDialogsController,
103
+ authController: AuthController
104
104
  ): SideEntityController => {
105
105
 
106
106
  const location = useLocation();
@@ -121,9 +121,9 @@ export const useBuildSideEntityController = (navigation: NavigationController,
121
121
  for (let i = 0; i < panelsFromUrl.length; i++) {
122
122
  const props = panelsFromUrl[i];
123
123
  if (i === 0)
124
- sideDialogsController.replace(propsToSidePanel(props, navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, smallLayout, customizationController, authController));
124
+ sideDialogsController.replace(propsToSidePanel(props, navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, smallLayout, customizationController, authController, location.search));
125
125
  else
126
- sideDialogsController.open(propsToSidePanel(props, navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, smallLayout, customizationController, authController))
126
+ sideDialogsController.open(propsToSidePanel(props, navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, smallLayout, customizationController, authController, location.search))
127
127
  }
128
128
  }
129
129
  initialised.current = true;
@@ -143,7 +143,7 @@ export const useBuildSideEntityController = (navigation: NavigationController,
143
143
  return;
144
144
  }
145
145
  const lastPanel = panelsFromUrl[panelsFromUrl.length - 1];
146
- const panelProps = propsToSidePanel(lastPanel, navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, smallLayout, customizationController, authController);
146
+ const panelProps = propsToSidePanel(lastPanel, navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, smallLayout, customizationController, authController, location.search);
147
147
  const lastCurrentPanel = currentPanelKeys.length > 0 ? currentPanelKeys[currentPanelKeys.length - 1] : undefined;
148
148
  if (!lastCurrentPanel || lastCurrentPanel !== panelProps.key) {
149
149
  sideDialogsController.replace(panelProps);
@@ -156,7 +156,7 @@ export const useBuildSideEntityController = (navigation: NavigationController,
156
156
  useEffect(() => {
157
157
  const updatedSidePanels = sideDialogsController.sidePanels.map(sidePanelProps => {
158
158
  if (sidePanelProps.additional) {
159
- return propsToSidePanel(sidePanelProps.additional, navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, smallLayout, customizationController, authController);
159
+ return propsToSidePanel(sidePanelProps.additional, navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, smallLayout, customizationController, authController, location.search);
160
160
  }
161
161
  return sidePanelProps;
162
162
  });
@@ -183,17 +183,18 @@ export const useBuildSideEntityController = (navigation: NavigationController,
183
183
 
184
184
  sideDialogsController.open(
185
185
  propsToSidePanel({
186
- selectedTab: defaultSelectedView,
187
- ...props
188
- },
186
+ selectedTab: defaultSelectedView,
187
+ ...props
188
+ },
189
189
  navigation.buildUrlCollectionPath,
190
190
  navigation.resolveIdsFrom,
191
191
  smallLayout,
192
192
  customizationController,
193
- authController
193
+ authController,
194
+ location.search
194
195
  ));
195
196
 
196
- }, [sideDialogsController, navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, smallLayout, authController.user]);
197
+ }, [sideDialogsController, navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, smallLayout, authController.user, location.search]);
197
198
 
198
199
  const replace = useCallback((props: EntitySidePanelProps<any>) => {
199
200
 
@@ -201,9 +202,9 @@ export const useBuildSideEntityController = (navigation: NavigationController,
201
202
  throw Error("If you want to copy an entity you need to provide an entityId");
202
203
  }
203
204
 
204
- sideDialogsController.replace(propsToSidePanel(props, navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, smallLayout, customizationController, authController));
205
+ sideDialogsController.replace(propsToSidePanel(props, navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, smallLayout, customizationController, authController, location.search));
205
206
 
206
- }, [navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, sideDialogsController, smallLayout, authController.user]);
207
+ }, [navigation.buildUrlCollectionPath, navigation.resolveIdsFrom, sideDialogsController, smallLayout, authController.user, location.search]);
207
208
 
208
209
  return {
209
210
  close,
@@ -265,18 +266,19 @@ export function buildSidePanelsFromUrl(path: string, collections: EntityCollecti
265
266
  }
266
267
 
267
268
  const propsToSidePanel = (props: EntitySidePanelProps,
268
- buildUrlCollectionPath: (path: string) => string,
269
- resolveIdsFrom: (pathWithAliases: string) => string,
270
- smallLayout: boolean,
271
- customizationController: CustomizationController,
272
- authController: AuthController
269
+ buildUrlCollectionPath: (path: string) => string,
270
+ resolveIdsFrom: (pathWithAliases: string) => string,
271
+ smallLayout: boolean,
272
+ customizationController: CustomizationController,
273
+ authController: AuthController,
274
+ locationSearch: string
273
275
  ): SideDialogPanelProps => {
274
276
 
275
277
  const collectionPath = removeInitialAndTrailingSlashes(props.path);
276
278
 
277
279
  const urlPath = props.entityId
278
- ? buildUrlCollectionPath(`${collectionPath}/${props.entityId}${props.selectedTab ? "/" + props.selectedTab : ""}#${SIDE_URL_HASH}`)
279
- : buildUrlCollectionPath(`${collectionPath}#${NEW_URL_HASH}`);
280
+ ? buildUrlCollectionPath(`${collectionPath}/${props.entityId}${props.selectedTab ? "/" + props.selectedTab : ""}${locationSearch}#${SIDE_URL_HASH}`)
281
+ : buildUrlCollectionPath(`${collectionPath}${locationSearch}#${NEW_URL_HASH}`);
280
282
 
281
283
  const resolvedPanelProps: EntitySidePanelProps<any> = {
282
284
  ...props,