@firecms/core 3.2.0 → 3.3.0-canary.7e3431b

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 (193) hide show
  1. package/dist/app/AppBar.d.ts +1 -1
  2. package/dist/app/Drawer.d.ts +1 -1
  3. package/dist/components/AIIcon.d.ts +3 -2
  4. package/dist/components/ArrayContainer.d.ts +3 -3
  5. package/dist/components/CircularProgressCenter.d.ts +2 -1
  6. package/dist/components/ClearFilterSortButton.d.ts +1 -1
  7. package/dist/components/ConfirmationDialog.d.ts +1 -1
  8. package/dist/components/DeleteEntityDialog.d.ts +2 -1
  9. package/dist/components/EntityCollectionTable/EntityCollectionRowActions.d.ts +1 -1
  10. package/dist/components/EntityCollectionTable/EntityCollectionTable.d.ts +2 -1
  11. package/dist/components/EntityCollectionTable/fields/TableReferenceField.d.ts +1 -1
  12. package/dist/components/EntityCollectionTable/fields/TableStorageUpload.d.ts +2 -2
  13. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +1 -1
  14. package/dist/components/EntityCollectionTable/internal/EntityTableCellActions.d.ts +1 -1
  15. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +4 -3
  16. package/dist/components/EntityCollectionView/Board.d.ts +2 -1
  17. package/dist/components/EntityCollectionView/BoardColumnTitle.d.ts +1 -1
  18. package/dist/components/EntityCollectionView/BoardSortableList.d.ts +1 -1
  19. package/dist/components/EntityCollectionView/CollectionDataErrorBanner.d.ts +2 -1
  20. package/dist/components/EntityCollectionView/EntityBoardCard.d.ts +1 -1
  21. package/dist/components/EntityCollectionView/EntityCard.d.ts +2 -1
  22. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +1 -1
  23. package/dist/components/EntityCollectionView/EntityCollectionCardView.d.ts +1 -1
  24. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +2 -1
  25. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +2 -1
  26. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
  27. package/dist/components/EntityCollectionView/ViewModeToggle.d.ts +2 -1
  28. package/dist/components/EntityJsonPreview.d.ts +2 -1
  29. package/dist/components/EntityPreview.d.ts +1 -1
  30. package/dist/components/EntityView.d.ts +2 -1
  31. package/dist/components/ErrorBoundary.d.ts +1 -1
  32. package/dist/components/ErrorTooltip.d.ts +2 -1
  33. package/dist/components/FieldCaption.d.ts +1 -1
  34. package/dist/components/FireCMSLogo.d.ts +1 -1
  35. package/dist/components/HomePage/DefaultHomePage.d.ts +1 -1
  36. package/dist/components/HomePage/FavouritesView.d.ts +1 -1
  37. package/dist/components/HomePage/HomePageDnD.d.ts +4 -4
  38. package/dist/components/HomePage/NavigationCardBinding.d.ts +2 -1
  39. package/dist/components/HomePage/NavigationGroup.d.ts +2 -2
  40. package/dist/components/HomePage/RenameGroupDialog.d.ts +2 -1
  41. package/dist/components/HomePage/SmallNavigationCard.d.ts +1 -1
  42. package/dist/components/LanguageToggle.d.ts +2 -1
  43. package/dist/components/NotFoundPage.d.ts +2 -1
  44. package/dist/components/PropertyCollectionView.d.ts +2 -1
  45. package/dist/components/PropertyIdCopyTooltip.d.ts +2 -2
  46. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +1 -1
  47. package/dist/components/ReferenceWidget.d.ts +2 -1
  48. package/dist/components/SearchIconsView.d.ts +2 -1
  49. package/dist/components/SelectableTable/SelectableTable.d.ts +1 -1
  50. package/dist/components/SelectableTable/filters/BooleanFilterField.d.ts +2 -1
  51. package/dist/components/SelectableTable/filters/DateTimeFilterField.d.ts +2 -1
  52. package/dist/components/SelectableTable/filters/ReferenceFilterField.d.ts +2 -1
  53. package/dist/components/SelectableTable/filters/StringNumberFilterField.d.ts +2 -1
  54. package/dist/components/UnsavedChangesDialog.d.ts +1 -1
  55. package/dist/components/UserDisplay.d.ts +1 -1
  56. package/dist/components/VirtualTable/VirtualTableHeader.d.ts +1 -0
  57. package/dist/components/VirtualTable/VirtualTableHeaderRow.d.ts +2 -1
  58. package/dist/components/VirtualTable/VirtualTableProps.d.ts +6 -1
  59. package/dist/components/VirtualTable/fields/VirtualTableDateField.d.ts +1 -1
  60. package/dist/components/VirtualTable/fields/VirtualTableInput.d.ts +2 -1
  61. package/dist/components/VirtualTable/fields/VirtualTableNumberInput.d.ts +2 -1
  62. package/dist/components/VirtualTable/fields/VirtualTableSelect.d.ts +1 -1
  63. package/dist/components/VirtualTable/fields/VirtualTableSwitch.d.ts +2 -1
  64. package/dist/components/VirtualTable/fields/VirtualTableUserSelect.d.ts +1 -1
  65. package/dist/components/VirtualTable/types.d.ts +1 -0
  66. package/dist/core/DefaultAppBar.d.ts +1 -1
  67. package/dist/core/DefaultDrawer.d.ts +2 -2
  68. package/dist/core/DrawerNavigationGroup.d.ts +1 -1
  69. package/dist/core/DrawerNavigationItem.d.ts +1 -1
  70. package/dist/core/EntityEditView.d.ts +2 -2
  71. package/dist/core/EntityEditViewFormActions.d.ts +2 -1
  72. package/dist/core/EntitySidePanel.d.ts +2 -1
  73. package/dist/core/FireCMS.d.ts +2 -1
  74. package/dist/core/FireCMSRouter.d.ts +1 -1
  75. package/dist/core/SideDialogs.d.ts +1 -1
  76. package/dist/editor/components/SlashCommandMenu.d.ts +2 -1
  77. package/dist/editor/editor.d.ts +1 -1
  78. package/dist/editor/selectors/color-selector.d.ts +1 -1
  79. package/dist/editor/selectors/link-selector.d.ts +1 -1
  80. package/dist/editor/selectors/node-selector.d.ts +1 -1
  81. package/dist/editor/selectors/text-buttons.d.ts +1 -1
  82. package/dist/form/EntityForm.d.ts +1 -1
  83. package/dist/form/EntityFormActions.d.ts +1 -1
  84. package/dist/form/components/CustomIdField.d.ts +2 -1
  85. package/dist/form/components/FieldHelperText.d.ts +1 -1
  86. package/dist/form/components/FormEntry.d.ts +1 -1
  87. package/dist/form/components/FormLayout.d.ts +1 -1
  88. package/dist/form/components/LabelWithIconAndTooltip.d.ts +1 -1
  89. package/dist/form/components/LocalChangesMenu.d.ts +2 -1
  90. package/dist/form/components/StorageItemPreview.d.ts +2 -1
  91. package/dist/form/components/StorageUploadProgress.d.ts +2 -1
  92. package/dist/form/field_bindings/ArrayCustomShapedFieldBinding.d.ts +2 -1
  93. package/dist/form/field_bindings/ArrayOfReferencesFieldBinding.d.ts +2 -1
  94. package/dist/form/field_bindings/BlockFieldBinding.d.ts +2 -1
  95. package/dist/form/field_bindings/DateTimeFieldBinding.d.ts +2 -1
  96. package/dist/form/field_bindings/KeyValueFieldBinding.d.ts +2 -1
  97. package/dist/form/field_bindings/MapFieldBinding.d.ts +2 -1
  98. package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +3 -2
  99. package/dist/form/field_bindings/MultiSelectFieldBinding.d.ts +2 -1
  100. package/dist/form/field_bindings/ReadOnlyFieldBinding.d.ts +2 -1
  101. package/dist/form/field_bindings/ReferenceAsStringFieldBinding.d.ts +2 -1
  102. package/dist/form/field_bindings/ReferenceFieldBinding.d.ts +2 -1
  103. package/dist/form/field_bindings/RepeatFieldBinding.d.ts +2 -1
  104. package/dist/form/field_bindings/SelectFieldBinding.d.ts +2 -1
  105. package/dist/form/field_bindings/StorageUploadFieldBinding.d.ts +3 -2
  106. package/dist/form/field_bindings/SwitchFieldBinding.d.ts +2 -1
  107. package/dist/form/field_bindings/TextFieldBinding.d.ts +2 -1
  108. package/dist/form/field_bindings/UserSelectFieldBinding.d.ts +2 -1
  109. package/dist/i18n/FireCMSi18nProvider.d.ts +2 -2
  110. package/dist/index.d.ts +1 -0
  111. package/dist/index.es.js +24457 -23726
  112. package/dist/index.es.js.map +1 -1
  113. package/dist/index.umd.js +22959 -22228
  114. package/dist/index.umd.js.map +1 -1
  115. package/dist/preview/components/ArrayEnumPreview.d.ts +2 -1
  116. package/dist/preview/components/AsyncPreviewComponent.d.ts +1 -1
  117. package/dist/preview/components/EmptyValue.d.ts +2 -1
  118. package/dist/preview/components/EnumValuesChip.d.ts +1 -1
  119. package/dist/preview/components/ImagePreview.d.ts +2 -1
  120. package/dist/preview/components/ReferencePreview.d.ts +1 -1
  121. package/dist/preview/components/StorageThumbnail.d.ts +1 -1
  122. package/dist/preview/components/UserPreview.d.ts +2 -1
  123. package/dist/preview/property_previews/ArrayOfMapsPreview.d.ts +2 -1
  124. package/dist/preview/property_previews/ArrayOfReferencesPreview.d.ts +1 -1
  125. package/dist/preview/property_previews/ArrayOfStorageComponentsPreview.d.ts +2 -1
  126. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +2 -1
  127. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +2 -1
  128. package/dist/preview/property_previews/ArrayPropertyEnumPreview.d.ts +1 -1
  129. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +2 -1
  130. package/dist/preview/property_previews/MapPropertyPreview.d.ts +3 -2
  131. package/dist/preview/property_previews/SkeletonPropertyComponent.d.ts +5 -4
  132. package/dist/routes/FireCMSRoute.d.ts +2 -1
  133. package/dist/types/collections.d.ts +38 -0
  134. package/dist/types/properties.d.ts +9 -8
  135. package/dist/types/translations.d.ts +23 -0
  136. package/dist/util/index.d.ts +1 -0
  137. package/dist/util/lazy_eager.d.ts +7 -0
  138. package/dist/util/objects.d.ts +1 -0
  139. package/dist/util/property_utils.d.ts +1 -1
  140. package/package.json +5 -5
  141. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +9 -3
  142. package/src/components/EntityCollectionTable/internal/common.tsx +2 -2
  143. package/src/components/EntityCollectionView/EntityCollectionView.tsx +3 -5
  144. package/src/components/EntityJsonPreview.tsx +2 -1
  145. package/src/components/ErrorBoundary.tsx +3 -3
  146. package/src/components/VirtualTable/VirtualTable.tsx +5 -3
  147. package/src/components/VirtualTable/VirtualTableHeader.tsx +9 -8
  148. package/src/components/VirtualTable/VirtualTableHeaderRow.tsx +8 -3
  149. package/src/components/VirtualTable/VirtualTableProps.tsx +7 -1
  150. package/src/components/VirtualTable/types.tsx +1 -0
  151. package/src/core/DrawerNavigationGroup.tsx +1 -1
  152. package/src/core/EntityEditView.tsx +50 -5
  153. package/src/core/EntitySidePanel.tsx +2 -1
  154. package/src/core/field_configs.tsx +4 -2
  155. package/src/editor/editor.tsx +20 -1
  156. package/src/editor/markdown.ts +89 -1
  157. package/src/form/EntityForm.tsx +64 -4
  158. package/src/form/PropertyFieldBinding.tsx +4 -3
  159. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +18 -5
  160. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +18 -5
  161. package/src/form/field_bindings/BlockFieldBinding.tsx +21 -7
  162. package/src/form/field_bindings/DateTimeFieldBinding.tsx +1 -1
  163. package/src/form/field_bindings/KeyValueFieldBinding.tsx +23 -6
  164. package/src/form/field_bindings/MapFieldBinding.tsx +23 -8
  165. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +43 -20
  166. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +15 -1
  167. package/src/form/field_bindings/ReferenceAsStringFieldBinding.tsx +25 -11
  168. package/src/form/field_bindings/ReferenceFieldBinding.tsx +25 -11
  169. package/src/form/field_bindings/RepeatFieldBinding.tsx +18 -5
  170. package/src/form/field_bindings/SelectFieldBinding.tsx +7 -5
  171. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +24 -7
  172. package/src/form/field_bindings/SwitchFieldBinding.tsx +31 -14
  173. package/src/form/field_bindings/TextFieldBinding.tsx +10 -7
  174. package/src/form/field_bindings/UserSelectFieldBinding.tsx +7 -5
  175. package/src/index.ts +1 -0
  176. package/src/internal/useBuildSideEntityController.tsx +1 -1
  177. package/src/locales/de.ts +28 -1
  178. package/src/locales/en.ts +27 -0
  179. package/src/locales/es.ts +28 -1
  180. package/src/locales/fr.ts +28 -1
  181. package/src/locales/hi.ts +28 -1
  182. package/src/locales/it.ts +28 -1
  183. package/src/locales/pt.ts +28 -1
  184. package/src/preview/PropertyPreview.tsx +6 -5
  185. package/src/preview/components/ReferencePreview.tsx +2 -1
  186. package/src/preview/property_previews/MapPropertyPreview.tsx +49 -27
  187. package/src/routes/FireCMSRoute.tsx +63 -54
  188. package/src/types/collections.ts +40 -0
  189. package/src/types/properties.ts +11 -10
  190. package/src/types/translations.ts +27 -0
  191. package/src/util/index.ts +1 -0
  192. package/src/util/lazy_eager.tsx +33 -0
  193. package/src/util/objects.ts +15 -0
@@ -26,7 +26,7 @@ import {
26
26
  useFireCMSContext,
27
27
  useLargeLayout
28
28
  } from "../hooks";
29
- import { CircularProgress, cls, CodeIcon, defaultBorderMixin, Tab, Tabs, Typography } from "@firecms/ui";
29
+ import { CircularProgress, cls, CodeIcon, defaultBorderMixin, Tab, Tabs, Typography, Menu, MenuItem, ExpandMoreIcon } from "@firecms/ui";
30
30
  import { getEntityFromMemoryCache } from "../util/entity_cache";
31
31
  import { EntityForm, EntityFormProps } from "../form";
32
32
  import { EntityEditViewFormActions } from "./EntityEditViewFormActions";
@@ -230,6 +230,10 @@ export function EntityEditViewInner<M extends Record<string, any>>({
230
230
  const includeJsonView = collection.includeJsonView === undefined ? true : collection.includeJsonView;
231
231
  const hasAdditionalViews = customViewsCount > 0 || subcollectionsCount > 0 || includeJsonView;
232
232
 
233
+ const groupedViews = useMemo(() => {
234
+ return (collection.viewGroups ?? []).flatMap(g => g.views);
235
+ }, [collection.viewGroups]);
236
+
233
237
  const {
234
238
  resolvedEntityViews,
235
239
  selectedEntityView,
@@ -419,16 +423,18 @@ export function EntityEditViewInner<M extends Record<string, any>>({
419
423
  Builder={selectedSecondaryForm?.Builder}
420
424
  />;
421
425
 
422
- const subcollectionTabs = subcollections && subcollections.map((subcollection) =>
426
+ const subcollectionTabs = subcollections && subcollections
427
+ .filter(sub => !groupedViews.includes(sub.id ?? sub.path))
428
+ .map((subcollection) =>
423
429
  <Tab
424
430
  className="text-sm min-w-[120px]"
425
- value={subcollection.id}
431
+ value={subcollection.id ?? subcollection.path}
426
432
  key={`entity_detail_collection_tab_${subcollection.name}`}>
427
433
  {subcollection.name}
428
434
  </Tab>
429
435
  );
430
436
 
431
- const customViewTabsStart = resolvedEntityViews.filter(view => view.position === "start")
437
+ const customViewTabsStart = resolvedEntityViews.filter(view => view.position === "start" && !groupedViews.includes(view.key))
432
438
  .map((view) =>
433
439
  <Tab
434
440
  className={!view.tabComponent ? "text-sm min-w-[120px]" : undefined}
@@ -437,7 +443,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
437
443
  {view.tabComponent ?? view.name}
438
444
  </Tab>
439
445
  );
440
- const customViewTabsEnd = resolvedEntityViews.filter(view => !view.position || view.position === "end")
446
+ const customViewTabsEnd = resolvedEntityViews.filter(view => (!view.position || view.position === "end") && !groupedViews.includes(view.key))
441
447
  .map((view) =>
442
448
  <Tab
443
449
  className={!view.tabComponent ? "text-sm min-w-[120px]" : undefined}
@@ -447,6 +453,43 @@ export function EntityEditViewInner<M extends Record<string, any>>({
447
453
  </Tab>
448
454
  );
449
455
 
456
+ const viewGroupMenus = collection.viewGroups?.map(group => {
457
+ const isActive = group.views.includes(selectedTab);
458
+ return (
459
+ <Menu
460
+ key={`view_group_${group.name}`}
461
+ trigger={
462
+ <button
463
+ type="button"
464
+ className={cls(
465
+ "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-white transition-all",
466
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-surface-400 focus-visible:ring-offset-2",
467
+ "disabled:pointer-events-none disabled:opacity-50",
468
+ isActive ? "bg-white text-surface-900 dark:bg-surface-950 dark:text-surface-50" : "text-surface-600 dark:text-surface-400 hover:bg-surface-200 dark:hover:bg-surface-800"
469
+ )}
470
+ >
471
+ {group.name}
472
+ <ExpandMoreIcon className="ml-1 -mr-1" size="small" />
473
+ </button>
474
+ }>
475
+ {group.views.map(viewId => {
476
+ const subcollection = subcollections.find(s => (s.id ?? s.path) === viewId);
477
+ const customView = resolvedEntityViews.find(v => v.key === viewId);
478
+ const name = subcollection?.name ?? customView?.name ?? viewId;
479
+ return (
480
+ <MenuItem
481
+ key={`view_group_${group.name}_${viewId}`}
482
+ onClick={() => onSideTabClick(viewId)}
483
+ className={selectedTab === viewId ? "bg-surface-accent-100 dark:bg-surface-accent-900" : ""}
484
+ >
485
+ {name}
486
+ </MenuItem>
487
+ );
488
+ })}
489
+ </Menu>
490
+ );
491
+ });
492
+
450
493
  const shouldShowTopBar = Boolean(barActions) || hasAdditionalViews;
451
494
 
452
495
  let result = <div className="relative flex flex-col h-full w-full bg-white dark:bg-surface-900">
@@ -494,6 +537,8 @@ export function EntityEditViewInner<M extends Record<string, any>>({
494
537
 
495
538
  {customViewTabsEnd}
496
539
 
540
+ {viewGroupMenus}
541
+
497
542
  {subcollectionTabs}
498
543
  </Tabs>}
499
544
  </div>}
@@ -78,7 +78,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
78
78
  return navigationController.getParentCollectionIds(path);
79
79
  }, [navigationController, path]);
80
80
 
81
- const collection = navigationController.getCollection(fullIdPath ?? path) ?? props.collection;
81
+ const collection = props.collection ?? navigationController.getCollection(fullIdPath ?? path);
82
82
 
83
83
  useEffect(() => {
84
84
  function beforeunload(e: any) {
@@ -112,6 +112,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
112
112
  return (
113
113
  <>
114
114
  <ErrorBoundary>
115
+
115
116
  <EntityEditView
116
117
  {...props}
117
118
  fullIdPath={fullIdPath}
@@ -8,16 +8,18 @@ import {
8
8
  DateTimeFieldBinding,
9
9
  KeyValueFieldBinding,
10
10
  MapFieldBinding,
11
- MarkdownEditorFieldBinding,
12
11
  MultiSelectFieldBinding,
13
12
  ReferenceAsStringFieldBinding,
14
13
  ReferenceFieldBinding,
15
14
  RepeatFieldBinding,
16
15
  SelectFieldBinding,
17
- StorageUploadFieldBinding,
18
16
  SwitchFieldBinding,
19
17
  TextFieldBinding
20
18
  } from "../form";
19
+ import { lazyEager } from "../util/lazy_eager";
20
+
21
+ const MarkdownEditorFieldBinding = lazyEager<typeof import("../form/field_bindings/MarkdownEditorFieldBinding")["MarkdownEditorFieldBinding"]>(() => import("../form/field_bindings/MarkdownEditorFieldBinding"), "MarkdownEditorFieldBinding");
22
+ const StorageUploadFieldBinding = lazyEager<typeof import("../form/field_bindings/StorageUploadFieldBinding")["StorageUploadFieldBinding"]>(() => import("../form/field_bindings/StorageUploadFieldBinding"), "StorageUploadFieldBinding");
21
23
  import { isPropertyBuilder, mergeDeep } from "../util";
22
24
 
23
25
  import {
@@ -148,8 +148,26 @@ export const FireCMSEditor = ({
148
148
  });
149
149
 
150
150
  const doc = state?.doc;
151
+ const mountedRef = useRef(false);
152
+
153
+ // Enable flushing after the initial render cycle completes.
154
+ // ProseMirror initialization (including trailingNodePlugin and
155
+ // appendTransaction) runs synchronously during mount, so any doc
156
+ // change after the first effect cycle is from user interaction.
157
+ useEffect(() => {
158
+ const raf = requestAnimationFrame(() => {
159
+ mountedRef.current = true;
160
+ });
161
+ return () => cancelAnimationFrame(raf);
162
+ }, []);
163
+
151
164
  useEffect(() => {
152
165
  if (!state) return;
166
+ // Skip flush until after mount — the round-trip through
167
+ // parse → ProseMirror → serialize is not idempotent and would
168
+ // produce subtly different markdown on init, making the form
169
+ // dirty without user interaction.
170
+ if (!mountedRef.current) return;
153
171
  const timeout = setTimeout(() => {
154
172
  flushChanges(state);
155
173
  }, 250);
@@ -160,6 +178,7 @@ export const FireCMSEditor = ({
160
178
  if (!view) return;
161
179
  const dom = view.dom;
162
180
  const handleBlur = () => {
181
+ if (!mountedRef.current) return;
163
182
  flushChanges(view.state);
164
183
  };
165
184
  dom.addEventListener("blur", handleBlur);
@@ -236,7 +255,7 @@ export const FireCMSEditor = ({
236
255
  onChange={handleMarkdownChange as any}
237
256
  onBlur={handleMarkdownBlur as any}
238
257
  className={cls(
239
- "w-full h-full min-h-[300px] p-12 bg-transparent resize-none font-mono focus:ring-0",
258
+ "w-full min-h-[300px] p-12 bg-transparent resize-none font-mono focus:ring-0 focus:outline-none outline-none",
240
259
  proseClass
241
260
  )}
242
261
  style={{
@@ -49,6 +49,66 @@ const md = markdownIt({ html: false })
49
49
  .use(markdownItMark)
50
50
  .use(markdownItIns);
51
51
 
52
+ // Override the escape rule so that `\` before a newline is kept as literal
53
+ // text instead of being silently consumed as a hardbreak. The default
54
+ // markdown-it behaviour strips the backslash and produces a <br>, which
55
+ // causes users to lose visible `\` characters in their content.
56
+ md.inline.ruler.at("escape", function escapeOverride(state: any, silent: boolean): boolean {
57
+ let pos = state.pos;
58
+ const max = state.posMax;
59
+
60
+ if (state.src.charCodeAt(pos) !== 0x5C /* \ */) return false;
61
+ pos++;
62
+
63
+ if (pos >= max) return false;
64
+
65
+ const ch1 = state.src.charCodeAt(pos);
66
+
67
+ // KEY CHANGE: when `\` is followed by a newline, output the backslash as
68
+ // literal text and let the normal softbreak handling deal with the newline.
69
+ if (ch1 === 0x0A) {
70
+ if (!silent) {
71
+ state.pending += "\\";
72
+ }
73
+ state.pos = pos; // leave the newline for softbreak to handle
74
+ return true;
75
+ }
76
+
77
+ // For escaped ASCII punctuation, output the character without the backslash
78
+ // (standard markdown escape behaviour: `\*` → `*`).
79
+ let escapedStr = state.src[pos];
80
+ // Handle surrogate pairs
81
+ if (ch1 >= 0xD800 && ch1 <= 0xDBFF && pos + 1 < max) {
82
+ const ch2 = state.src.charCodeAt(pos + 1);
83
+ if (ch2 >= 0xDC00 && ch2 <= 0xDFFF) {
84
+ escapedStr += state.src[pos + 1];
85
+ pos++;
86
+ }
87
+ }
88
+
89
+ const origStr = "\\" + escapedStr;
90
+
91
+ if (!silent) {
92
+ // Check if the character is an ASCII punctuation that
93
+ // markdown-it considers escapable (codes < 256 in its lookup table).
94
+ const isEscapable = ch1 < 256 && /[\\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-]/.test(String.fromCharCode(ch1));
95
+ const token = state.push("text_special", "", 0);
96
+ if (isEscapable) {
97
+ token.content = escapedStr;
98
+ } else {
99
+ token.content = origStr;
100
+ }
101
+ token.markup = origStr;
102
+ token.info = "escape";
103
+ }
104
+ state.pos = pos + 1;
105
+ return true;
106
+ });
107
+
108
+ // Also disable the newline rule which redundantly converts `\` + newline
109
+ // to hardbreaks via a separate code path.
110
+ md.inline.ruler.disable(["newline"]);
111
+
52
112
  // Unwrap images from paragraphs so they can be parsed as block nodes by ProseMirror
53
113
  md.core.ruler.after("inline", "image-to-block", (state: any) => {
54
114
  const tokens = state.tokens;
@@ -145,7 +205,35 @@ export const markdownSerializer = new MarkdownSerializer(
145
205
  },
146
206
  table_row() {},
147
207
  table_cell() {},
148
- table_header() {}
208
+ table_header() {},
209
+ // Custom text serializer: since our parser override keeps `\` as
210
+ // literal text (instead of consuming it), we must avoid the default
211
+ // esc() from double-escaping it. We escape all standard markdown
212
+ // specials *except* the backslash itself.
213
+ text(state: any, node: any) {
214
+ const escaped = node.text.replace(/[`*~\[\]_]/g, (m: string, i: number) => {
215
+ // Don't escape mid-word underscores (same logic as default esc)
216
+ if (m === "_" && i > 0 && i + 1 < node.text.length
217
+ && /\w/.test(node.text[i - 1]) && /\w/.test(node.text[i + 1])) {
218
+ return m;
219
+ }
220
+ return "\\" + m;
221
+ });
222
+ // Handle start-of-line patterns that could be parsed as block syntax
223
+ const lines = escaped.split("\n");
224
+ for (let i = 0; i < lines.length; i++) {
225
+ state.write();
226
+ let line = lines[i];
227
+ if (state.atBlockStart || i > 0) {
228
+ line = line
229
+ .replace(/^(\+[ ]|[-*>])/, "\\$&")
230
+ .replace(/^(\s*)(#{1,6})(\s|$)/, '$1\\$2$3')
231
+ .replace(/^(\s*\d+)\.\s/, "$1\\. ");
232
+ }
233
+ state.out += line;
234
+ if (i !== lines.length - 1) state.out += "\n";
235
+ }
236
+ }
149
237
  },
150
238
  {
151
239
  ...defaultMarkdownSerializer.marks,
@@ -125,6 +125,35 @@ export function extractTouchedValues(values: any, touched: Record<string, boolea
125
125
  return acc;
126
126
  }
127
127
 
128
+ /**
129
+ * Recursively removes empty plain objects `{}` and empty arrays `[]` from a value tree.
130
+ * This prevents ghost containers created by `setIn` intermediate path construction
131
+ * (e.g. `{ address: {} }` when only `address.city` was touched but value is undefined)
132
+ * from falsely triggering the unsaved local changes indicator.
133
+ */
134
+ function removeEmptyContainers(obj: any): any {
135
+ if (Array.isArray(obj)) {
136
+ const cleaned = obj.map(removeEmptyContainers);
137
+ // Keep arrays even if they contain only nulls/undefined — that's intentional data
138
+ return cleaned;
139
+ }
140
+ if (obj && typeof obj === "object" && Object.getPrototypeOf(obj) === Object.prototype) {
141
+ const result: Record<string, any> = {};
142
+ for (const key of Object.keys(obj)) {
143
+ const cleaned = removeEmptyContainers(obj[key]);
144
+ // Skip empty plain objects
145
+ if (cleaned && typeof cleaned === "object" && !Array.isArray(cleaned)
146
+ && Object.getPrototypeOf(cleaned) === Object.prototype
147
+ && Object.keys(cleaned).length === 0) {
148
+ continue;
149
+ }
150
+ result[key] = cleaned;
151
+ }
152
+ return result;
153
+ }
154
+ return obj;
155
+ }
156
+
128
157
  export function getChanges<T extends object>(source: Partial<T>, comparison: Partial<T>): Partial<T> {
129
158
  const changes: Partial<T> = {};
130
159
 
@@ -251,7 +280,7 @@ export function EntityForm<M extends Record<string, any>>({
251
280
  const context = useFireCMSContext();
252
281
  const analyticsController = useAnalyticsController();
253
282
 
254
- const [underlyingChanges] = useState<Partial<EntityValues<M>>>({});
283
+ const [underlyingChanges, setUnderlyingChanges] = useState<Partial<EntityValues<M>>>({});
255
284
 
256
285
  const [customIdLoading, setCustomIdLoading] = useState<boolean>(false);
257
286
 
@@ -332,7 +361,15 @@ export function EntityForm<M extends Record<string, any>>({
332
361
  return [initialValues, initialDirty];
333
362
  }, [autoApplyLocalChanges, localChangesDataRaw, baseInitialValues, initialDirtyValues]);
334
363
 
335
- const hasLocalChanges = !localChangesCleared && localChangesDataRaw && Object.keys(localChangesDataRaw).length > 0;
364
+ const hasLocalChanges = useMemo(() => {
365
+ if (localChangesCleared || !localChangesDataRaw || Object.keys(localChangesDataRaw).length === 0) {
366
+ return false;
367
+ }
368
+ // Compare cached values against entity values to check for real differences
369
+ const entityValues = entity?.values ?? {};
370
+ const realChanges = getChanges(localChangesDataRaw as Partial<M>, entityValues as Partial<M>);
371
+ return Object.keys(realChanges).length > 0;
372
+ }, [localChangesCleared, localChangesDataRaw, entity?.values]);
336
373
 
337
374
  const formex: FormexController<M> = formexProp ?? useCreateFormex<M>({
338
375
  initialValues: initialValues as M,
@@ -352,8 +389,10 @@ export function EntityForm<M extends Record<string, any>>({
352
389
  onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
353
390
  const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
354
391
  if (controller.dirty) {
355
- const touchedValues = extractTouchedValues(values, controller.touched);
356
- saveEntityToCache(key, touchedValues);
392
+ const touchedValues = removeEmptyContainers(extractTouchedValues(values, controller.touched));
393
+ if (touchedValues && Object.keys(touchedValues).length > 0) {
394
+ saveEntityToCache(key, touchedValues);
395
+ }
357
396
  }
358
397
  },
359
398
  validation: (values) => {
@@ -666,6 +705,27 @@ export function EntityForm<M extends Record<string, any>>({
666
705
 
667
706
  useOnAutoSave(autoSave, formex, lastSavedValues, save);
668
707
 
708
+ // Detect external changes to the entity (e.g. from onSnapshot after Admin SDK writes)
709
+ const prevEntityValuesRef = useRef<EntityValues<M> | undefined>(entity?.values);
710
+ useEffect(() => {
711
+ if (!entity?.values || status !== "existing") return;
712
+ const prev = prevEntityValuesRef.current;
713
+ prevEntityValuesRef.current = entity.values;
714
+ if (prev && !equal(prev, entity.values)) {
715
+ // Compute the diff between the old and new entity values
716
+ const changes: Partial<EntityValues<M>> = {};
717
+ const allKeys = new Set([...Object.keys(prev), ...Object.keys(entity.values)]);
718
+ for (const key of allKeys) {
719
+ if (!equal((prev as any)[key], (entity.values as any)[key])) {
720
+ (changes as any)[key] = (entity.values as any)[key];
721
+ }
722
+ }
723
+ if (Object.keys(changes).length > 0) {
724
+ setUnderlyingChanges(changes);
725
+ }
726
+ }
727
+ }, [entity?.values, status]);
728
+
669
729
  useEffect(() => {
670
730
  if (!autoSave && !formex.isSubmitting && underlyingChanges && entity) {
671
731
  // we update the form fields from the Firestore data
@@ -20,7 +20,7 @@ import { isHidden, isPropertyBuilder, isReadOnly, resolveProperty } from "../uti
20
20
  import { useAuthController, useCustomizationController, useTranslation } from "../hooks";
21
21
  import { Typography } from "@firecms/ui";
22
22
  import { getFieldConfig, getFieldId } from "../core";
23
- import { ErrorBoundary } from "../components";
23
+ import { ErrorBoundary, CircularProgressCenter } from "../components";
24
24
 
25
25
  /**
26
26
  * This component renders a form field creating the corresponding configuration
@@ -287,8 +287,9 @@ function FieldInternal<T extends CMSType, CustomProps, M extends Record<string,
287
287
 
288
288
  return (
289
289
  <ErrorBoundary>
290
-
291
- <UsedComponent {...cmsFieldProps} />
290
+ <React.Suspense fallback={<CircularProgressCenter />}>
291
+ <UsedComponent {...cmsFieldProps} />
292
+ </React.Suspense>
292
293
 
293
294
  {underlyingValueHasChanged && !isSubmitting &&
294
295
  <Typography variant={"caption"} className={"ml-3.5"}>
@@ -2,7 +2,7 @@ import React from "react";
2
2
  import { FieldProps } from "../../types";
3
3
  import { FieldHelperText, LabelWithIconAndTooltip } from "../components";
4
4
  import { PropertyFieldBinding } from "../PropertyFieldBinding";
5
- import { ExpandablePanel, Typography } from "@firecms/ui";
5
+ import { ExpandablePanel, IconButton, CloseIcon } from "@firecms/ui";
6
6
  import { getArrayResolvedProperties, getIconForProperty, isReadOnly } from "../../util";
7
7
  import { useClearRestoreValue } from "../useClearRestoreValue";
8
8
  import { useAuthController } from "../../hooks";
@@ -50,15 +50,28 @@ export function ArrayCustomShapedFieldBinding<T extends Array<any>>({
50
50
  setValue
51
51
  });
52
52
 
53
- const title = (<>
53
+ const title = (<div className="flex items-center w-full">
54
54
  <LabelWithIconAndTooltip
55
55
  propertyKey={propertyKey}
56
56
  icon={getIconForProperty(property, "small")}
57
57
  required={property.validation?.required}
58
58
  title={property.name}
59
- className={"h-8 flex-grow text-text-secondary dark:text-text-secondary-dark"}/>
60
- {Array.isArray(value) && <Typography variant={"caption"} className={"px-4"}>({value.length})</Typography>}
61
- </>);
59
+ className={"text-text-secondary dark:text-text-secondary-dark"}/>
60
+ {Array.isArray(value) && <span className={"text-sm text-text-secondary dark:text-text-secondary-dark ml-1"}>({value.length})</span>}
61
+ <div className="flex-grow"/>
62
+ {(property.nullable || property.clearable) && !disabled && (
63
+ <IconButton
64
+ size="small"
65
+ onClick={(e) => {
66
+ e.stopPropagation();
67
+ e.preventDefault();
68
+ setValue(null);
69
+ }}
70
+ >
71
+ <CloseIcon size={"small"}/>
72
+ </IconButton>
73
+ )}
74
+ </div>);
62
75
 
63
76
  const body = resolvedProperties.map((childProperty, index) => {
64
77
  const thisDisabled = isReadOnly(childProperty) || Boolean(childProperty.disabled);
@@ -5,7 +5,7 @@ import { FieldHelperText, LabelWithIconAndTooltip } from "../components";
5
5
  import { ArrayContainer, ArrayEntryParams, ErrorView } from "../../components";
6
6
  import { getIconForProperty, getReferenceFrom } from "../../util";
7
7
  import { useNavigationController, useReferenceDialog, useTranslation } from "../../hooks";
8
- import { Button, cls, EditIcon, ExpandablePanel, fieldBackgroundMixin, Typography } from "@firecms/ui";
8
+ import { Button, cls, EditIcon, ExpandablePanel, fieldBackgroundMixin, Typography, IconButton, CloseIcon } from "@firecms/ui";
9
9
  import { useClearRestoreValue } from "../useClearRestoreValue";
10
10
 
11
11
  type ArrayOfReferencesFieldProps = FieldProps<EntityReference[]>;
@@ -100,15 +100,28 @@ export function ArrayOfReferencesFieldBinding({
100
100
  );
101
101
  }, [ofProperty.path, ofProperty.previewProperties, value]);
102
102
 
103
- const title = (<>
103
+ const title = (<div className="flex items-center w-full">
104
104
  <LabelWithIconAndTooltip
105
105
  propertyKey={propertyKey}
106
106
  icon={getIconForProperty(property, "small")}
107
107
  required={property.validation?.required}
108
108
  title={property.name}
109
- className={"h-8 flex flex-grow text-text-secondary dark:text-text-secondary-dark"}/>
110
- {Array.isArray(value) && <Typography variant={"caption"} className={"px-4"}>({value.length})</Typography>}
111
- </>);
109
+ className={"text-text-secondary dark:text-text-secondary-dark"}/>
110
+ {Array.isArray(value) && <span className={"text-sm text-text-secondary dark:text-text-secondary-dark ml-1"}>({value.length})</span>}
111
+ <div className="flex-grow"/>
112
+ {(property.nullable || property.clearable) && !disabled && (
113
+ <IconButton
114
+ size="small"
115
+ onClick={(e) => {
116
+ e.stopPropagation();
117
+ e.preventDefault();
118
+ setValue(null);
119
+ }}
120
+ >
121
+ <CloseIcon size={"small"}/>
122
+ </IconButton>
123
+ )}
124
+ </div>);
112
125
 
113
126
  const body = <>
114
127
  {!collection && <ErrorView
@@ -8,7 +8,7 @@ import { EnumValuesChip } from "../../preview";
8
8
  import { FieldProps, FormContext, PropertyFieldBindingProps, PropertyOrBuilder } from "../../types";
9
9
  import { getDefaultValueFor, getIconForProperty, mergeDeep, } from "../../util";
10
10
  import { DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "../../util/common";
11
- import { cls, ExpandablePanel, paperMixin, Select, SelectItem, Typography } from "@firecms/ui";
11
+ import { cls, ExpandablePanel, paperMixin, Select, SelectItem, Typography, IconButton, CloseIcon } from "@firecms/ui";
12
12
  import { useClearRestoreValue } from "../useClearRestoreValue";
13
13
  import { ArrayContainer, ArrayEntryParams } from "../../components";
14
14
  import { useTranslation } from "../../hooks/useTranslation";
@@ -74,12 +74,26 @@ export function BlockFieldBinding<T extends Array<any>>({
74
74
  };
75
75
 
76
76
  const title = (
77
- <LabelWithIconAndTooltip
78
- propertyKey={propertyKey}
79
- icon={getIconForProperty(property, "small")}
80
- required={property.validation?.required}
81
- title={property.name}
82
- className={"text-text-secondary dark:text-text-secondary-dark"}/>
77
+ <div className="flex items-center w-full">
78
+ <LabelWithIconAndTooltip
79
+ propertyKey={propertyKey}
80
+ icon={getIconForProperty(property, "small")}
81
+ required={property.validation?.required}
82
+ title={property.name}
83
+ className={"text-text-secondary dark:text-text-secondary-dark flex-grow"}/>
84
+ {(property.nullable || property.clearable) && !disabled && (
85
+ <IconButton
86
+ size="small"
87
+ onClick={(e) => {
88
+ e.stopPropagation();
89
+ e.preventDefault();
90
+ setValue(null);
91
+ }}
92
+ >
93
+ <CloseIcon size={"small"}/>
94
+ </IconButton>
95
+ )}
96
+ </div>
83
97
  );
84
98
 
85
99
  const firstOneOfKey = Object.keys(property.oneOf.properties)[0];
@@ -48,7 +48,7 @@ export function DateTimeFieldBinding({
48
48
  onChange={(dateValue) => setValue(dateValue)}
49
49
  size={"large"}
50
50
  mode={property.mode}
51
- clearable={property.clearable}
51
+ clearable={property.nullable || property.clearable}
52
52
  locale={locale}
53
53
  timezone={property.timezone}
54
54
  error={showError}
@@ -13,6 +13,7 @@ import {
13
13
  defaultBorderMixin,
14
14
  ExpandablePanel,
15
15
  IconButton,
16
+ CloseIcon,
16
17
  Menu,
17
18
  MenuItem,
18
19
  RemoveIcon,
@@ -64,12 +65,28 @@ export function KeyValueFieldBinding({
64
65
  initialValue={initialValues}
65
66
  fieldName={property.name ?? propertyKey}/>;
66
67
 
67
- const title = <LabelWithIconAndTooltip
68
- propertyKey={propertyKey}
69
- icon={getIconForProperty(property, "small")}
70
- required={property.validation?.required}
71
- title={property.name}
72
- className={"text-text-secondary dark:text-text-secondary-dark"}/>;
68
+ const title = (
69
+ <div className="flex items-center w-full">
70
+ <LabelWithIconAndTooltip
71
+ propertyKey={propertyKey}
72
+ icon={getIconForProperty(property, "small")}
73
+ required={property.validation?.required}
74
+ title={property.name}
75
+ className={"text-text-secondary dark:text-text-secondary-dark flex-grow"}/>
76
+ {(property.nullable || property.clearable) && !disabled && (
77
+ <IconButton
78
+ size="small"
79
+ onClick={(e) => {
80
+ e.stopPropagation();
81
+ e.preventDefault();
82
+ setValue(null);
83
+ }}
84
+ >
85
+ <CloseIcon size={"small"}/>
86
+ </IconButton>
87
+ )}
88
+ </div>
89
+ );
73
90
 
74
91
  return (
75
92
  <>
@@ -6,7 +6,7 @@ import { getIconForProperty, isHidden, isReadOnly, pick } from "../../util";
6
6
  import { FieldHelperText, LabelWithIconAndTooltip } from "../components";
7
7
  import { FormEntry } from "../components/FormEntry";
8
8
  import { PropertyFieldBinding } from "../PropertyFieldBinding";
9
- import { cls, ExpandablePanel, InputLabel, Select, SelectItem } from "@firecms/ui";
9
+ import { cls, ExpandablePanel, InputLabel, Select, SelectItem, IconButton, CloseIcon } from "@firecms/ui";
10
10
  import { useTranslation } from "../../hooks";
11
11
 
12
12
  /**
@@ -28,7 +28,8 @@ export function MapFieldBinding({
28
28
  includeDescription,
29
29
  autoFocus,
30
30
  context,
31
- onPropertyChange
31
+ onPropertyChange,
32
+ setValue
32
33
  }: FieldProps<Record<string, any>>) {
33
34
 
34
35
  const pickOnlySomeKeys = property.pickOnlySomeKeys || false;
@@ -108,12 +109,26 @@ export function MapFieldBinding({
108
109
  }}
109
110
  className={property.widthPercentage !== undefined ? "mt-8" : undefined}
110
111
  innerClassName={"px-2 md:px-4 pb-2 md:pb-4 pt-1 md:pt-2 bg-white dark:bg-surface-900"}
111
- title={<LabelWithIconAndTooltip
112
- propertyKey={propertyKey}
113
- icon={getIconForProperty(property, "small")}
114
- required={property.validation?.required}
115
- title={property.name}
116
- className={"text-text-secondary dark:text-text-secondary-dark"} />}>
112
+ title={<div className="flex items-center w-full">
113
+ <LabelWithIconAndTooltip
114
+ propertyKey={propertyKey}
115
+ icon={getIconForProperty(property, "small")}
116
+ required={property.validation?.required}
117
+ title={property.name}
118
+ className={"text-text-secondary dark:text-text-secondary-dark flex-grow"} />
119
+ {(property.nullable || property.clearable) && !disabled && (
120
+ <IconButton
121
+ size="small"
122
+ onClick={(e) => {
123
+ e.stopPropagation();
124
+ e.preventDefault();
125
+ setValue(null);
126
+ }}
127
+ >
128
+ <CloseIcon size={"small"}/>
129
+ </IconButton>
130
+ )}
131
+ </div>}>
117
132
  {mapFormView}
118
133
  </ExpandablePanel>}
119
134