@firecms/core 3.0.0-canary.289 → 3.0.0-canary.290

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.
@@ -67,6 +67,10 @@ export interface FieldProps<T extends CMSType = any, CustomProps = any, M extend
67
67
  * Is this field part of an array
68
68
  */
69
69
  partOfArray?: boolean;
70
+ /**
71
+ * Is this field part of a block
72
+ */
73
+ partOfBlock?: boolean;
70
74
  /**
71
75
  * Display the child properties directly, without being wrapped in an
72
76
  * extendable panel. Note that this will also hide the title of this property.
@@ -178,6 +182,10 @@ export interface PropertyFieldBindingProps<T extends CMSType, M extends Record<s
178
182
  * Is this field part of an array
179
183
  */
180
184
  partOfArray?: boolean;
185
+ /**
186
+ * Is this field part of a block
187
+ */
188
+ partOfBlock?: boolean;
181
189
  /**
182
190
  * Display the child properties directly, without being wrapped in an
183
191
  * extendable panel. Note that this will also hide the title of this property.
@@ -102,6 +102,10 @@ export interface BaseProperty<T extends CMSType, CustomProps = any> {
102
102
  /**
103
103
  * Should this property be editable. If set to true, the user will be able to modify the property and
104
104
  * save the new config. The saved config will then become the source of truth.
105
+ * Defaults to `true.
106
+ * This props is only useful when you are using the collection editor to modify collection
107
+ * configurations from the CMS itself. You can also use the `editable` prop in the
108
+ * `EntityCollection` interface to disable the edition of all properties in a collection.
105
109
  */
106
110
  editable?: boolean;
107
111
  /**
@@ -617,8 +621,15 @@ export type StorageConfig = {
617
621
  /**
618
622
  * Use client side image compression and resizing
619
623
  * Will only be applied to these MIME types: image/jpeg, image/png and image/webp
624
+ * @deprecated Use `imageResize` instead
620
625
  */
621
- imageCompression?: ImageCompression;
626
+ imageCompression?: ImageResize;
627
+ /**
628
+ * Advanced image resizing and cropping configuration.
629
+ * Applied before upload to optimize storage and bandwidth.
630
+ * Only applies to image MIME types: image/jpeg, image/png, image/webp
631
+ */
632
+ imageResize?: ImageResize;
622
633
  /**
623
634
  * Specific metadata set in your uploaded file.
624
635
  * For the default Firebase implementation, the values passed here are of type
@@ -730,17 +741,32 @@ export type PreviewType = "image" | "video" | "audio" | "file";
730
741
  * @group Entity properties
731
742
  */
732
743
  export type FileType = "image/*" | "video/*" | "audio/*" | "application/*" | "text/*" | "font/*" | string;
733
- export interface ImageCompression {
744
+ export interface ImageResize {
745
+ /**
746
+ * Maximum width in pixels. Image will be scaled down proportionally if wider.
747
+ */
748
+ maxWidth?: number;
734
749
  /**
735
- * New image max height (ratio is preserved)
750
+ * Maximum height in pixels. Image will be scaled down proportionally if taller.
736
751
  */
737
752
  maxHeight?: number;
738
753
  /**
739
- * New image max width (ratio is preserved)
754
+ * Resize mode determines how the image fits within maxWidth/maxHeight bounds.
755
+ * - `contain`: Scale down to fit within bounds, preserving aspect ratio (default)
756
+ * - `cover`: Scale to fill bounds, preserving aspect ratio (may crop)
740
757
  */
741
- maxWidth?: number;
758
+ mode?: 'contain' | 'cover';
759
+ /**
760
+ * Output format for the resized image.
761
+ * - `original`: Keep the original format (default)
762
+ * - `jpeg`: Convert to JPEG
763
+ * - `png`: Convert to PNG
764
+ * - `webp`: Convert to WebP
765
+ */
766
+ format?: 'original' | 'jpeg' | 'png' | 'webp';
742
767
  /**
743
- * A number between 0 and 100. Used for the JPEG compression.(if no compress is needed, just set it to 100)
768
+ * Quality for lossy formats (JPEG, WebP). Number between 0 and 100.
769
+ * Higher is better quality but larger file size. Defaults to 80.
744
770
  */
745
771
  quality?: number;
746
772
  }
@@ -1,3 +1,2 @@
1
- import { Properties, PropertiesOrBuilders } from "../types";
1
+ import { Properties } from "../types";
2
2
  export declare function makePropertiesEditable(properties: Properties): Properties<any>;
3
- export declare function makePropertiesNonEditable(properties: PropertiesOrBuilders): PropertiesOrBuilders;
@@ -31,6 +31,7 @@ export declare function useStorageUploadController<M extends object>({ entityId,
31
31
  fileNameBuilder: (file: File) => Promise<string>;
32
32
  storagePathBuilder: (file: File) => string;
33
33
  onFileUploadComplete: (uploadedPath: string, entry: StorageFieldItem, metadata?: any) => Promise<void>;
34
+ onFileUploadError: (entry: StorageFieldItem) => void;
34
35
  onFilesAdded: (acceptedFiles: File[]) => Promise<void>;
35
36
  multipleFilesSupported: boolean;
36
37
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@firecms/core",
3
3
  "type": "module",
4
- "version": "3.0.0-canary.289",
4
+ "version": "3.0.0-canary.290",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/firecmsco"
@@ -53,11 +53,12 @@
53
53
  "@dnd-kit/core": "^6.3.1",
54
54
  "@dnd-kit/modifiers": "^9.0.0",
55
55
  "@dnd-kit/sortable": "^10.0.0",
56
- "@firecms/editor": "^3.0.0-canary.289",
57
- "@firecms/formex": "^3.0.0-canary.289",
58
- "@firecms/ui": "^3.0.0-canary.289",
59
- "@radix-ui/react-portal": "^1.1.9",
56
+ "@firecms/editor": "^3.0.0-canary.290",
57
+ "@firecms/formex": "^3.0.0-canary.290",
58
+ "@firecms/ui": "^3.0.0-canary.290",
59
+ "@radix-ui/react-portal": "^1.1.10",
60
60
  "clsx": "^2.1.1",
61
+ "compressorjs": "^1.2.1",
61
62
  "date-fns": "^3.6.0",
62
63
  "fuse.js": "^7.1.0",
63
64
  "history": "^5.3.0",
@@ -67,11 +68,10 @@
67
68
  "prism-react-renderer": "^2.4.1",
68
69
  "react-dropzone": "^14.3.8",
69
70
  "react-fast-compare": "^3.2.2",
70
- "react-image-file-resizer": "^0.4.8",
71
71
  "react-transition-group": "^4.4.5",
72
72
  "react-use-measure": "^2.1.7",
73
73
  "react-window": "^1.8.11",
74
- "vite-plugin-static-copy": "3.1.2",
74
+ "vite-plugin-static-copy": "3.1.4",
75
75
  "yup": "^0.32.11"
76
76
  },
77
77
  "peerDependencies": {
@@ -81,7 +81,7 @@
81
81
  "react-router-dom": "^6.28.0"
82
82
  },
83
83
  "devDependencies": {
84
- "@jest/globals": "^30.1.2",
84
+ "@jest/globals": "^30.2.0",
85
85
  "@testing-library/react": "^16.3.0",
86
86
  "@testing-library/user-event": "^14.6.1",
87
87
  "@types/jest": "^29.5.14",
@@ -93,22 +93,22 @@
93
93
  "@vitejs/plugin-react": "^4.7.0",
94
94
  "babel-plugin-react-compiler": "^19.0.0-beta-af1b7da-20250417",
95
95
  "cross-env": "^7.0.3",
96
- "eslint-plugin-react-compiler": "^19.0.0-beta-af1b7da-20250417",
96
+ "eslint-plugin-react-compiler": "^19.1.0-rc.2",
97
97
  "jest": "^29.7.0",
98
98
  "npm-run-all": "^4.1.5",
99
- "react-router": "^6.30.1",
100
- "react-router-dom": "^6.30.1",
101
- "ts-jest": "^29.4.3",
99
+ "react-router": "^6.30.2",
100
+ "react-router-dom": "^6.30.2",
101
+ "ts-jest": "^29.4.5",
102
102
  "ts-node": "^10.9.2",
103
103
  "tsd": "^0.31.2",
104
- "typescript": "^5.9.2",
105
- "vite": "^7.1.6"
104
+ "typescript": "^5.9.3",
105
+ "vite": "^7.2.4"
106
106
  },
107
107
  "files": [
108
108
  "dist",
109
109
  "src"
110
110
  ],
111
- "gitHead": "ca236e0e8d9a91d1106bc7836b5f8945cdba35e9",
111
+ "gitHead": "80538231810a133e5b3cba8a8d4b47ec6e5619f8",
112
112
  "publishConfig": {
113
113
  "access": "public"
114
114
  },
@@ -376,6 +376,11 @@ export const EntityCollectionView = React.memo(
376
376
  console.error("Save failure");
377
377
  console.error(e);
378
378
  setError(e);
379
+ },
380
+ onPreSaveHookError: (e: Error) => {
381
+ console.error("Pre-save hook error");
382
+ console.error(e);
383
+ setError(e);
379
384
  }
380
385
  });
381
386
 
@@ -1,8 +1,12 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import Fuse from "fuse.js";
3
3
  import { Container, SearchBar } from "@firecms/ui";
4
- import { useCustomizationController, useFireCMSContext, useNavigationController } from "../../hooks";
5
- import { useCollapsedGroups } from "../../hooks/useCollapsedGroups";
4
+ import {
5
+ useCollapsedGroups,
6
+ useCustomizationController,
7
+ useFireCMSContext,
8
+ useNavigationController
9
+ } from "../../hooks";
6
10
  import {
7
11
  CMSAnalyticsEvent,
8
12
  NavigationEntry,
@@ -143,7 +147,6 @@ export function DefaultHomePage({
143
147
  allProcessed = allProcessed.filter(
144
148
  (g) =>
145
149
  g.entries.length ||
146
- groupOrderFromNavController.includes(g.name) ||
147
150
  (g.name === DEFAULT_GROUP_NAME && hasPluginAdditionalCards)
148
151
  );
149
152
  }
@@ -177,6 +180,7 @@ export function DefaultHomePage({
177
180
  const persistNavigationGroups = (
178
181
  latest: { name: string; entries: NavigationEntry[] }[]
179
182
  ) => {
183
+ // Map ALL groups including "Views"
180
184
  const draggable: NavigationGroupMapping[] = latest.map((g) => ({
181
185
  name: g.name,
182
186
  entries: g.entries.map((e) => e.path)
@@ -221,13 +225,14 @@ export function DefaultHomePage({
221
225
  dialogOpenForGroup,
222
226
  setDialogOpenForGroup,
223
227
  handleRenameGroup,
228
+ handleDialogClose,
224
229
  isHoveringNewGroupDropZone,
225
230
  setIsHoveringNewGroupDropZone
226
231
  } = useHomePageDnd({
227
232
  items,
228
233
  setItems: updateItems,
229
234
  disabled: !allowDragAndDrop || performingSearch,
230
- onPersist: persistNavigationGroups, // ——► persistence here
235
+ onPersist: persistNavigationGroups,
231
236
  onGroupMoved: (g) =>
232
237
  context.analyticsController?.onAnalyticsEvent?.("home_move_group", {
233
238
  name: g
@@ -355,7 +360,7 @@ export function DefaultHomePage({
355
360
  items={containers}
356
361
  strategy={verticalListSortingStrategy}
357
362
  >
358
- {items.map((groupData) => {
363
+ {items.map((groupData, groupIndex) => {
359
364
  const groupKey = groupData.name;
360
365
  const entriesInGroup = groupData.entries;
361
366
 
@@ -378,14 +383,13 @@ export function DefaultHomePage({
378
383
 
379
384
  if (
380
385
  entriesInGroup.length === 0 &&
381
- (AdditionalCards.length === 0 || performingSearch) &&
382
- !groupOrderFromNavController.includes(groupKey)
386
+ (AdditionalCards.length === 0 || performingSearch)
383
387
  )
384
388
  return null;
385
389
 
386
390
  return (
387
391
  <SortableNavigationGroup
388
- key={groupKey}
392
+ key={`group-${groupIndex}`}
389
393
  groupName={groupKey}
390
394
  disabled={dndDisabled}
391
395
  >
@@ -557,7 +561,7 @@ export function DefaultHomePage({
557
561
  existingGroupNames={items
558
562
  .map((g) => g.name)
559
563
  .filter((n) => n !== dialogOpenForGroup)}
560
- onClose={() => setDialogOpenForGroup(null)}
564
+ onClose={handleDialogClose}
561
565
  onRename={(newName) => {
562
566
  handleRenameGroup(dialogOpenForGroup, newName);
563
567
  }}
@@ -163,13 +163,9 @@ export function SortableNavigationGroup({
163
163
  );
164
164
  }
165
165
 
166
- /* ─────────────────────────────────────────────────────────── */
167
- /* Main DnD hook */
168
-
169
- /* ─────────────────────────────────────────────────────────── */
170
166
  export function useHomePageDnd({
171
- items: dndItems,
172
- setItems: setDndItems,
167
+ items,
168
+ setItems,
173
169
  disabled,
174
170
  onCardMovedBetweenGroups,
175
171
  onGroupMoved,
@@ -191,6 +187,9 @@ export function useHomePageDnd({
191
187
  onPersist?: (latest: { name: string; entries: NavigationEntry[] }[]) => void;
192
188
  }) {
193
189
  /* ---------------- local state ---------------- */
190
+ const dndItems = items;
191
+ const setDndItems = setItems;
192
+
194
193
  const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
195
194
  const [activeIsGroup, setActiveIsGroup] = useState(false);
196
195
  const [currentDraggingGroupId, setCurrentDraggingGroupId] =
@@ -200,6 +199,15 @@ export function useHomePageDnd({
200
199
  const [dialogOpenForGroup, setDialogOpenForGroup] = useState<string | null>(null);
201
200
  const [isHoveringNewGroupDropZone, setIsHoveringNewGroupDropZone] =
202
201
  useState(false);
202
+ const [pendingNewGroupName, setPendingNewGroupName] = useState<string | null>(null);
203
+ const [stateBeforeNewGroup, setStateBeforeNewGroup] = useState<
204
+ { name: string; entries: NavigationEntry[] }[] | null
205
+ >(null);
206
+
207
+ /* store the original state before any drag modifications */
208
+ const preDragItemsRef = useRef<
209
+ { name: string; entries: NavigationEntry[] }[] | null
210
+ >(null);
203
211
 
204
212
  /* store interim state for cross-group moves */
205
213
  const interimItemsRef = useRef<
@@ -321,6 +329,9 @@ export function useHomePageDnd({
321
329
  setDndKitActiveNode(active);
322
330
  if (disabled) return;
323
331
 
332
+ // Capture the original state before any drag modifications
333
+ preDragItemsRef.current = cloneItemsForDnd(dndItems);
334
+
324
335
  const isGroup = dndItems.some((g) => g.name === active.id);
325
336
  if (!active.data.current) active.data.current = {};
326
337
  active.data.current.type = isGroup ? "group" : "item";
@@ -349,18 +360,35 @@ export function useHomePageDnd({
349
360
 
350
361
  if (overCont && activeCont !== overCont) {
351
362
  recentlyMovedToNewContainer.current = true;
352
- const newState = cloneItemsForDnd(dndItems);
353
- const srcIdx = newState.findIndex((g) => g.name === activeCont);
354
- const tgtIdx = newState.findIndex((g) => g.name === overCont);
355
- if (srcIdx === -1 || tgtIdx === -1) return;
356
- const src = newState[srcIdx];
357
- const tgt = newState[tgtIdx];
358
- const idxInSrc = src.entries.findIndex((e) => e.url === activeIdNow);
359
- if (idxInSrc === -1) return;
360
- const [moved] = src.entries.splice(idxInSrc, 1);
361
- tgt.entries.push(moved);
362
- interimItemsRef.current = newState;
363
- setDndItems(newState);
363
+ lastOverId.current = overIdNow;
364
+
365
+ // Update state for visual feedback during drag
366
+ setDndItems((current) => {
367
+ const newState = cloneItemsForDnd(current);
368
+ const srcIdx = newState.findIndex((g) => g.name === activeCont);
369
+ const tgtIdx = newState.findIndex((g) => g.name === overCont);
370
+ if (srcIdx === -1 || tgtIdx === -1) return current;
371
+ const src = newState[srcIdx];
372
+ const tgt = newState[tgtIdx];
373
+ const idxInSrc = src.entries.findIndex((e) => e.url === activeIdNow);
374
+ if (idxInSrc === -1) return current;
375
+ const [moved] = src.entries.splice(idxInSrc, 1);
376
+
377
+ // Calculate insertion position - SAME logic as handleDragEnd
378
+ const overIsContainer = overIdNow === overCont;
379
+ if (overIsContainer) {
380
+ tgt.entries.push(moved);
381
+ } else {
382
+ const overIdx = tgt.entries.findIndex((e) => e.url === overIdNow);
383
+ if (overIdx !== -1) {
384
+ tgt.entries.splice(overIdx, 0, moved);
385
+ } else {
386
+ tgt.entries.push(moved);
387
+ }
388
+ }
389
+
390
+ return newState;
391
+ });
364
392
  } else if (activeCont === overCont) {
365
393
  recentlyMovedToNewContainer.current = false;
366
394
  }
@@ -396,11 +424,26 @@ export function useHomePageDnd({
396
424
  }
397
425
  /* ─── card move ─── */
398
426
  else {
399
- const activeCont = findDndContainer(activeIdNow);
427
+ // CRITICAL: Find source container from ORIGINAL pre-drag state, not current (potentially stale) dndItems
428
+ const findContainerInState = (id: string, state: { name: string; entries: NavigationEntry[] }[]): string | undefined => {
429
+ const group = state.find((g) => g.name === id);
430
+ if (group) return group.name;
431
+ for (const g of state) {
432
+ if (g.entries.some((e) => e.url === id)) return g.name;
433
+ }
434
+ return undefined;
435
+ };
436
+
437
+ const sourceState = preDragItemsRef.current || dndItems;
438
+ const activeCont = findContainerInState(activeIdNow as string, sourceState);
439
+ const overCont = findDndContainer(overIdNow);
400
440
 
401
441
  /* drop on new-group zone */
402
442
  if (overIdNow === "new-group-drop-zone") {
403
443
  if (activeCont) {
444
+ // Save current state before making changes
445
+ setStateBeforeNewGroup(cloneItemsForDnd(dndItems));
446
+
404
447
  const newState = cloneItemsForDnd(dndItems);
405
448
  const srcIdx = newState.findIndex((g) => g.name === activeCont);
406
449
  if (srcIdx !== -1) {
@@ -421,8 +464,10 @@ export function useHomePageDnd({
421
464
  name: tentative,
422
465
  entries: [dragged]
423
466
  });
467
+
468
+ // Update local state but DON'T persist yet
424
469
  setDndItems(newState);
425
- onPersist?.(newState);
470
+ setPendingNewGroupName(tentative);
426
471
  setDialogOpenForGroup(tentative);
427
472
  onNewGroupDrop?.();
428
473
  }
@@ -459,18 +504,59 @@ export function useHomePageDnd({
459
504
  onPersist?.(newState);
460
505
  }
461
506
  }
462
- } else if (
463
- recentlyMovedToNewContainer.current &&
464
- interimItemsRef.current
465
- ) {
466
- onPersist?.(interimItemsRef.current);
467
- }
507
+ } else if (overCont && activeCont !== overCont) {
508
+ // Card moved between different groups - use CLEAN pre-drag state
509
+ const finalState = cloneItemsForDnd(sourceState);
468
510
 
469
- onCardMovedBetweenGroups?.(
470
- dndItems
471
- .flatMap((g) => g.entries)
472
- .find((e) => e.url === activeIdNow)!
473
- );
511
+ // Find target container from clean state too
512
+ const finalOverId = lastOverId.current || overIdNow;
513
+ const cleanOverCont = findContainerInState(finalOverId as string, sourceState) || overCont;
514
+
515
+ const srcIdx = finalState.findIndex((g) => g.name === activeCont);
516
+ const tgtIdx = finalState.findIndex((g) => g.name === cleanOverCont);
517
+
518
+ if (srcIdx !== -1 && tgtIdx !== -1) {
519
+ const src = finalState[srcIdx];
520
+ const tgt = finalState[tgtIdx];
521
+ const idxInSrc = src.entries.findIndex((e) => e.url === activeIdNow);
522
+
523
+ if (idxInSrc !== -1) {
524
+ // Remove from source
525
+ const [moved] = src.entries.splice(idxInSrc, 1);
526
+
527
+ // Calculate insertion position in target
528
+ const overIsContainer = finalOverId === cleanOverCont;
529
+ if (overIsContainer) {
530
+ tgt.entries.push(moved);
531
+ } else {
532
+ const overIdx = tgt.entries.findIndex((e) => e.url === finalOverId);
533
+ if (overIdx !== -1) {
534
+ tgt.entries.splice(overIdx, 0, moved);
535
+ } else {
536
+ tgt.entries.push(moved);
537
+ }
538
+ }
539
+
540
+ // Remove empty source group if needed
541
+ if (src.entries.length === 0) {
542
+ finalState.splice(srcIdx, 1);
543
+ }
544
+
545
+ setDndItems(finalState);
546
+ onPersist?.(finalState);
547
+
548
+ onCardMovedBetweenGroups?.(moved);
549
+ }
550
+ }
551
+ } else if (recentlyMovedToNewContainer.current) {
552
+ // This shouldn't happen but log it for debugging
553
+ console.error("Move between containers detected but conditions not met", {
554
+ activeCont,
555
+ overCont,
556
+ activeIdNow,
557
+ overIdNow
558
+ });
559
+ }
474
560
  }
475
561
  }
476
562
 
@@ -501,9 +587,29 @@ export function useHomePageDnd({
501
587
  ...updated[idx],
502
588
  name: newName
503
589
  };
504
- onPersist?.(updated); // <- ensure rename is saved
590
+
591
+ // Persist after successful rename
592
+ onPersist?.(updated);
505
593
  return updated;
506
594
  });
595
+
596
+ // Clear all pending state
597
+ setPendingNewGroupName(null);
598
+ setStateBeforeNewGroup(null);
599
+ setDialogOpenForGroup(null);
600
+ };
601
+
602
+ /* Handle dialog close without renaming */
603
+ const handleDialogClose = () => {
604
+ // If there's a pending new group that wasn't renamed, restore previous state
605
+ if (pendingNewGroupName && dialogOpenForGroup === pendingNewGroupName && stateBeforeNewGroup) {
606
+ // Restore the state from before the new group was created
607
+ setDndItems(stateBeforeNewGroup);
608
+ }
609
+
610
+ // Clear all pending state
611
+ setPendingNewGroupName(null);
612
+ setStateBeforeNewGroup(null);
507
613
  setDialogOpenForGroup(null);
508
614
  };
509
615
 
@@ -535,15 +641,12 @@ export function useHomePageDnd({
535
641
  dialogOpenForGroup,
536
642
  setDialogOpenForGroup,
537
643
  handleRenameGroup,
644
+ handleDialogClose,
538
645
  isHoveringNewGroupDropZone,
539
646
  setIsHoveringNewGroupDropZone
540
647
  };
541
648
  }
542
649
 
543
- /* ─────────────────────────────────────────────────────────── */
544
- /* New-group drop-zone component */
545
-
546
- /* ─────────────────────────────────────────────────────────── */
547
650
  export function NewGroupDropZone({
548
651
  disabled,
549
652
  setIsHovering
@@ -588,8 +691,7 @@ export function NewGroupDropZone({
588
691
  isOver
589
692
  ? "bg-surface-accent-100 dark:bg-surface-accent-800 border-surface-300 dark:border-surface-600"
590
693
  : "bg-surface-50 dark:bg-surface-900 border-surface-200 dark:border-surface-700"
591
- )}
592
- >
694
+ )}>
593
695
  <div className="text-center p-4">
594
696
  <span className="block font-medium text-sm">
595
697
  Drop here to create a new group
@@ -131,7 +131,7 @@ export const PropertyCollectionView = ({
131
131
  if (baseKey && property && isArrayOfPrimitives) {
132
132
  return (
133
133
  <div
134
- className={`grid grid-cols-12 gap-x-4 ${isTopLevel ? "py-4" : "py-2"} items-start ${isTopLevel ? `border-b ${defaultBorderMixin}` : ""}`}>
134
+ className={`grid grid-cols-12 gap-x-4 ${isTopLevel ? "py-4" : "py-2"} items-start ${isTopLevel ? `border-b ${defaultBorderMixin} last:border-b-0` : ""}`}>
135
135
  <div className="col-span-4 pr-2">
136
136
  <Typography variant="caption"
137
137
  color={"secondary"}
@@ -152,7 +152,7 @@ export const PropertyCollectionView = ({
152
152
 
153
153
  // Array of maps or unknown -> array header + combined item header (MapName [index]) then content
154
154
  return (
155
- <div className={`${isTopLevel ? "py-4" : "py-1"} ${isTopLevel ? `border-b ${defaultBorderMixin}` : ""}`}>
155
+ <div className={`${isTopLevel ? "py-4" : "py-1"} ${isTopLevel ? `border-b ${defaultBorderMixin} last:border-b-0` : ""}`}>
156
156
  {baseKey && arrayLabel && !suppressHeader && (
157
157
  <Typography variant="caption"
158
158
  color={"secondary"}
@@ -211,7 +211,7 @@ export const PropertyCollectionView = ({
211
211
  if (!property) return null;
212
212
  return (
213
213
  <div
214
- className={`grid grid-cols-12 gap-x-4 ${isTopLevel ? "py-4" : "py-2"} items-start ${isTopLevel ? `border-b ${defaultBorderMixin}` : ""}`}>
214
+ className={`grid grid-cols-12 gap-x-4 ${isTopLevel ? "py-4" : "py-2"} items-start ${isTopLevel ? `border-b ${defaultBorderMixin} last:border-b-0` : ""}`}>
215
215
  <div className="col-span-4 pr-2">
216
216
  <Typography variant="caption"
217
217
  color={"secondary"}
@@ -240,7 +240,7 @@ export const PropertyCollectionView = ({
240
240
  const headerText = property?.name || label;
241
241
 
242
242
  return (
243
- <div className={`${isTopLevel ? "py-4" : "py-1"} ${isTopLevel ? `border-b ${defaultBorderMixin}` : ""}`}>
243
+ <div className={`${isTopLevel ? "py-4" : "py-1"} ${isTopLevel ? `border-b ${defaultBorderMixin} last:border-b-0` : ""}`}>
244
244
  {showMapHeader && (
245
245
  <Typography variant="caption"
246
246
  color={"secondary"}
@@ -277,7 +277,7 @@ export const PropertyCollectionView = ({
277
277
  if (!property) return null;
278
278
  return (
279
279
  <div
280
- className={`grid grid-cols-12 gap-x-4 ${isTopLevel ? "py-4" : "py-2"} items-start ${isTopLevel ? `border-b ${defaultBorderMixin}` : ""}`}>
280
+ className={`grid grid-cols-12 gap-x-4 ${isTopLevel ? "py-4" : "py-2"} items-start ${isTopLevel ? `border-b ${defaultBorderMixin} last:border-b-0` : ""}`}>
281
281
  <div className="col-span-4 pr-2">
282
282
  <Typography variant="caption"
283
283
  color={"secondary"}
@@ -327,15 +327,3 @@ function createFilterField({
327
327
  );
328
328
  }
329
329
 
330
- function filterableProperty(property: ResolvedProperty, partOfArray = false): boolean {
331
- if (partOfArray) {
332
- return ["string", "number", "date", "reference"].includes(property.dataType);
333
- }
334
- if (property.dataType === "array") {
335
- if (property.of)
336
- return filterableProperty(property.of, true);
337
- else
338
- return false;
339
- }
340
- return ["string", "number", "boolean", "date", "reference", "array"].includes(property.dataType);
341
- }
@@ -55,7 +55,7 @@ export function DateTimeFilterField({
55
55
  }
56
56
 
57
57
  setOperation(op);
58
- setInternalValue(newValue === null ? undefined : newValue);
58
+ setInternalValue(newValue);
59
59
 
60
60
  const hasNewValue = newValue !== null && Array.isArray(newValue)
61
61
  ? newValue.length > 0
@@ -96,6 +96,7 @@ export function DateTimeFilterField({
96
96
  mode={mode}
97
97
  size={"large"}
98
98
  locale={locale}
99
+ disabled={internalValue === null}
99
100
  value={internalValue ?? undefined}
100
101
  onChange={(dateValue: Date | null) => {
101
102
  updateFilter(operation, dateValue === null ? undefined : dateValue);
@@ -139,7 +139,6 @@ export function StringNumberFilterField({
139
139
  updateFilter(operation, dataType === "number" ? parseInt(value as string) : value as string)
140
140
  }}
141
141
  endAdornment={internalValue && <IconButton
142
- className="absolute right-2 top-3"
143
142
  onClick={(e) => updateFilter(operation, undefined)}>
144
143
  <CloseIcon/>
145
144
  </IconButton>}
@@ -857,13 +857,13 @@ export function EntityForm<M extends Record<string, any>>({
857
857
  />}
858
858
 
859
859
  {formex.dirty
860
- ? <Tooltip title={"There are local unsaved changes"}>
861
- <Chip size={"small"} colorScheme={"orangeDarker"}>
860
+ ? <Tooltip title={"This form has been modified"}>
861
+ <Chip size={"small"} className={"py-1"} colorScheme={"orangeDarker"}>
862
862
  <EditIcon size={"smallest"}/>
863
863
  </Chip>
864
864
  </Tooltip>
865
865
  : <Tooltip title={"The current form is in sync with the database"}>
866
- <Chip size={"small"}>
866
+ <Chip size={"small"} className={"py-1"} >
867
867
  <CheckIcon size={"smallest"}/>
868
868
  </Chip>
869
869
  </Tooltip>}