@firecms/core 3.0.0-canary.241 → 3.0.0-canary.244

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.
@@ -1,7 +1,6 @@
1
- import React, { useCallback } from "react";
1
+ import React, { useCallback, useState } from "react";
2
2
 
3
3
  import {
4
- ArrayProperty,
5
4
  FieldProps,
6
5
  PropertyOrBuilder,
7
6
  ResolvedArrayProperty,
@@ -14,7 +13,23 @@ import { FieldHelperText, LabelWithIconAndTooltip } from "../components";
14
13
 
15
14
  import { getIconForProperty, isReadOnly, resolveProperty } from "../../util";
16
15
  import { useAuthController, useSnackbarController, useStorageSource } from "../../hooks";
17
- import { DragDropContext, Draggable, Droppable } from "@hello-pangea/dnd";
16
+ import {
17
+ closestCenter,
18
+ DndContext,
19
+ DragEndEvent,
20
+ DragStartEvent,
21
+ KeyboardSensor,
22
+ PointerSensor,
23
+ useSensor,
24
+ useSensors
25
+ } from "@dnd-kit/core";
26
+ import {
27
+ horizontalListSortingStrategy,
28
+ SortableContext,
29
+ sortableKeyboardCoordinates,
30
+ useSortable
31
+ } from "@dnd-kit/sortable";
32
+ import { CSS } from "@dnd-kit/utilities";
18
33
  import { StorageFieldItem, useStorageUploadController } from "../../util/useStorageUploadController";
19
34
  import { StorageUploadProgress } from "../components/StorageUploadProgress";
20
35
  import { StorageItemPreview } from "../components/StorageItemPreview";
@@ -36,13 +51,6 @@ const rejectDropClasses = "transition-colors duration-200 ease-[cubic-bezier(0,0
36
51
 
37
52
  type StorageUploadFieldProps = FieldProps<string | string[]>;
38
53
 
39
- /**
40
- * Field that allows to upload files to Google Cloud Storage.
41
- *
42
- * This is one of the internal components that get mapped natively inside forms
43
- * and tables to the specified properties.
44
- * @group Form fields
45
- */
46
54
  export function StorageUploadFieldBinding({
47
55
  propertyKey,
48
56
  value,
@@ -132,13 +140,104 @@ export function StorageUploadFieldBinding({
132
140
  );
133
141
  }
134
142
 
143
+ interface SortableStorageItemProps {
144
+ id: number;
145
+ entry: StorageFieldItem;
146
+ property: ResolvedStringProperty;
147
+ name: string;
148
+ metadata?: Record<string, unknown>;
149
+ storagePathBuilder: (file: File) => string;
150
+ onFileUploadComplete: (uploadedPath: string, entry: StorageFieldItem, fileMetadata?: any) => Promise<void>;
151
+ onClear: (clearedStoragePathOrDownloadUrl: string) => void;
152
+ disabled: boolean;
153
+ isSortable: boolean; // Kept for consistency, though dnd-kit handles sortability via context
154
+ }
155
+
156
+ function SortableStorageItem({
157
+ id,
158
+ entry,
159
+ property,
160
+ name,
161
+ metadata,
162
+ storagePathBuilder,
163
+ onFileUploadComplete,
164
+ onClear,
165
+ disabled,
166
+ isSortable // This prop might be redundant if SortableContext is always used for multiple items
167
+ }: SortableStorageItemProps) {
168
+
169
+ const {
170
+ attributes,
171
+ listeners,
172
+ setNodeRef,
173
+ transform,
174
+ transition,
175
+ isDragging
176
+ } = useSortable({ id });
177
+
178
+ const style: React.CSSProperties = {
179
+ transform: CSS.Transform.toString(transform),
180
+ transition,
181
+ zIndex: isDragging ? 100 : undefined, // Higher z-index when dragging
182
+ opacity: isDragging ? 0.8 : 1 // Slight opacity for dragged item
183
+ };
184
+
185
+ const getImageSizeNumber = (previewSize: PreviewSize): number => {
186
+ switch (previewSize) {
187
+ case "small":
188
+ return 40;
189
+ case "medium":
190
+ return 118; // As per original logic for multiple items
191
+ case "large":
192
+ return 220; // As per original logic for single item
193
+ default:
194
+ return 118;
195
+ }
196
+ };
197
+
198
+ let child: React.ReactNode;
199
+ if (entry.storagePathOrDownloadUrl) {
200
+ child = (
201
+ <StorageItemPreview
202
+ name={`storage_preview_${entry.storagePathOrDownloadUrl}`}
203
+ property={property}
204
+ disabled={disabled}
205
+ value={entry.storagePathOrDownloadUrl}
206
+ onRemove={() => onClear(entry.storagePathOrDownloadUrl!)}
207
+ size={entry.size}/>
208
+ );
209
+ } else if (entry.file) {
210
+ child = (
211
+ <StorageUploadProgress
212
+ entry={entry}
213
+ metadata={metadata}
214
+ storagePath={storagePathBuilder(entry.file)}
215
+ onFileUploadComplete={onFileUploadComplete}
216
+ imageSize={getImageSizeNumber(entry.size)}
217
+ simple={false}
218
+ />
219
+ );
220
+ }
221
+
222
+ return (
223
+ <div
224
+ ref={setNodeRef}
225
+ style={style}
226
+ {...attributes}
227
+ {...listeners}
228
+ className={cls("rounded-md m-1")} // Added margin for spacing between items
229
+ tabIndex={-1}
230
+ >
231
+ {child}
232
+ </div>
233
+ );
234
+ }
235
+
135
236
  function FileDropComponent({
136
237
  storage,
137
238
  disabled,
138
- isDraggingOver,
139
239
  onFilesAdded,
140
240
  multipleFilesSupported,
141
- droppableProvided,
142
241
  autoFocus,
143
242
  internalValue,
144
243
  property,
@@ -146,26 +245,24 @@ function FileDropComponent({
146
245
  metadata,
147
246
  storagePathBuilder,
148
247
  onFileUploadComplete,
149
- size,
150
248
  name,
151
- helpText
249
+ helpText,
250
+ isDndItemDragging // New prop to disable dropzone when internal D&D is active
152
251
  }: {
153
252
  storage: StorageConfig,
154
253
  disabled: boolean,
155
- isDraggingOver: boolean,
156
- droppableProvided: any,
157
- onFilesAdded: (acceptedFiles: File[]) => void,
254
+ onFilesAdded: (acceptedFiles: File[]) => Promise<void>, // useStorageUploadController returns Promise<void>
158
255
  multipleFilesSupported: boolean,
159
256
  autoFocus: boolean,
160
257
  internalValue: StorageFieldItem[],
161
258
  property: ResolvedStringProperty,
162
259
  onClear: (clearedStoragePathOrDownloadUrl: string) => void,
163
- metadata: any,
260
+ metadata?: any,
164
261
  storagePathBuilder: (file: File) => string,
165
262
  onFileUploadComplete: (uploadedPath: string, entry: StorageFieldItem, fileMetadata?: any) => Promise<void>,
166
- size: PreviewSize,
167
263
  name: string,
168
- helpText: string
264
+ helpText: string,
265
+ isDndItemDragging?: boolean
169
266
  }) {
170
267
 
171
268
  const snackbarContext = useSnackbarController();
@@ -173,16 +270,19 @@ function FileDropComponent({
173
270
  const {
174
271
  getRootProps,
175
272
  getInputProps,
176
- isDragActive,
273
+ isDragActive, // This is for files dragged from OS
177
274
  isDragAccept,
178
275
  isDragReject
179
276
  } = useDropzone({
180
- accept: storage.acceptedFiles ? storage.acceptedFiles.map(e => ({ [e]: [] })).reduce((a, b) => ({ ...a, ...b }), {}) : undefined,
181
- disabled: disabled || isDraggingOver,
277
+ accept: storage.acceptedFiles ? storage.acceptedFiles.reduce((acc, ext) => ({
278
+ ...acc,
279
+ [ext]: []
280
+ }), {}) : undefined,
281
+ disabled: disabled || isDndItemDragging, // Disable if form field is disabled OR an internal item is being dragged
182
282
  noDragEventsBubbling: true,
183
283
  maxSize: storage.maxSize,
184
284
  onDrop: onFilesAdded,
185
- onDropRejected: (fileRejections, event) => {
285
+ onDropRejected: (fileRejections) => {
186
286
  for (const fileRejection of fileRejections) {
187
287
  for (const error of fileRejection.errors) {
188
288
  console.error("Error uploading file: ", error);
@@ -211,79 +311,44 @@ function FileDropComponent({
211
311
  disabled ? fieldBackgroundDisabledMixin : fieldBackgroundHoverMixin,
212
312
  disabled ? "text-surface-accent-600 dark:text-surface-accent-500" : "",
213
313
  dropZoneClasses,
214
- multipleFilesSupported && internalValue.length ? "" : "flex",
314
+ multipleFilesSupported && internalValue.length === 0 && "flex", // Keep flex for empty state centering
215
315
  {
216
316
  [nonActiveDropClasses]: !isDragActive,
217
- [activeDropClasses]: isDragActive,
218
- [rejectDropClasses]: isDragReject,
219
- [acceptDropClasses]: isDragAccept,
220
- [disabledClasses]: disabled
317
+ [activeDropClasses]: isDragActive, // OS file drag active
318
+ [rejectDropClasses]: isDragReject, // OS file drag reject
319
+ [acceptDropClasses]: isDragAccept, // OS file drag accept
320
+ [disabledClasses]: disabled || isDndItemDragging // Visually disable if internal drag
221
321
  })}
222
322
  >
223
323
  <div
224
- {...droppableProvided.droppableProps}
225
- ref={droppableProvided.innerRef}
226
324
  className={cls("flex items-center p-1 no-scrollbar",
227
- multipleFilesSupported && internalValue.length ? "overflow-auto" : "",
228
- multipleFilesSupported && internalValue.length ? "min-h-[180px]" : "min-h-[250px]"
325
+ multipleFilesSupported && internalValue.length ? "flex-row overflow-x-auto" : "flex-col", // flex-col for single or empty
326
+ internalValue.length === 0 && "min-h-[250px] justify-center", // Centering for empty dropzone
327
+ multipleFilesSupported && internalValue.length > 0 && "min-h-[180px]", // Min height for multiple items
328
+ !multipleFilesSupported && internalValue.length > 0 && "min-h-[250px]" // Min height for single item
229
329
  )}
230
330
  >
231
-
232
331
  <input
233
332
  autoFocus={autoFocus}
234
333
  {...getInputProps()} />
235
334
 
236
- {internalValue.map((entry, index) => {
237
- let child: any;
238
- if (entry.storagePathOrDownloadUrl) {
239
- child = (
240
- <StorageItemPreview
241
- name={`storage_preview_${entry.storagePathOrDownloadUrl}`}
242
- property={property}
243
- disabled={disabled}
244
- value={entry.storagePathOrDownloadUrl}
245
- onRemove={onClear}
246
- size={entry.size}/>
247
- );
248
- } else if (entry.file) {
249
- child = (
250
- <StorageUploadProgress
251
- entry={entry}
252
- metadata={metadata}
253
- storagePath={storagePathBuilder(entry.file)}
254
- onFileUploadComplete={onFileUploadComplete}
255
- imageSize={size === "large" ? 220 : 118}
256
- simple={false}
257
- />
258
- );
259
- }
260
-
261
- return (
262
- <Draggable
263
- key={`array_field_${name}_${entry.id}`}
264
- draggableId={`array_field_${name}_${entry.id}`}
265
- index={index}>
266
- {(provided, snapshot) => (
267
- <div
268
- tabIndex={-1}
269
- ref={provided.innerRef}
270
- {...provided.draggableProps}
271
- {...provided.dragHandleProps}
272
- className={cls("rounded-md")}
273
- style={{
274
- ...provided.draggableProps.style
275
- }}
276
- >
277
- {child}
278
- </div>
279
- )}
280
- </Draggable>
281
- );
282
- })
283
- }
284
-
285
- {droppableProvided.placeholder}
286
-
335
+ {internalValue.map((entry) => (
336
+ <SortableStorageItem
337
+ key={entry.id}
338
+ id={entry.id}
339
+ entry={entry}
340
+ property={property}
341
+ name={name}
342
+ metadata={metadata}
343
+ storagePathBuilder={storagePathBuilder}
344
+ onFileUploadComplete={onFileUploadComplete}
345
+ onClear={onClear}
346
+ disabled={disabled}
347
+ isSortable={multipleFilesSupported}
348
+ />
349
+ ))}
350
+
351
+ {/* Placeholder for empty dropzone text is handled by the outer Typography */}
287
352
  </div>
288
353
 
289
354
  <div
@@ -294,7 +359,6 @@ function FileDropComponent({
294
359
  {helpText}
295
360
  </Typography>
296
361
  </div>
297
-
298
362
  </div>
299
363
  );
300
364
  }
@@ -309,7 +373,7 @@ export interface StorageUploadProps {
309
373
  autoFocus: boolean;
310
374
  disabled: boolean;
311
375
  storage: StorageConfig;
312
- onFilesAdded: (acceptedFiles: File[]) => void;
376
+ onFilesAdded: (acceptedFiles: File[]) => Promise<void>; // Updated from useStorageUploadController
313
377
  storagePathBuilder: (file: File) => string;
314
378
  onFileUploadComplete: (uploadedPath: string, entry: StorageFieldItem, fileMetadata?: any) => Promise<void>;
315
379
  }
@@ -317,7 +381,7 @@ export interface StorageUploadProps {
317
381
  export function StorageUpload({
318
382
  property,
319
383
  name,
320
- value,
384
+ value, // This is internalValue from useStorageUploadController
321
385
  setInternalValue,
322
386
  onChange,
323
387
  multipleFilesSupported,
@@ -344,10 +408,10 @@ export function StorageUpload({
344
408
  }
345
409
 
346
410
  const metadata: Record<string, unknown> | undefined = storage?.metadata;
347
- const size = multipleFilesSupported ? "medium" : "large";
411
+ const [isDndItemDragging, setIsDndItemDragging] = useState(false);
348
412
 
349
413
  const moveItem = useCallback((fromIndex: number, toIndex: number) => {
350
- if (!multipleFilesSupported) return;
414
+ if (!multipleFilesSupported || fromIndex === toIndex) return;
351
415
  const newValue = [...value];
352
416
  const item = newValue[fromIndex];
353
417
  newValue.splice(fromIndex, 1);
@@ -359,84 +423,88 @@ export function StorageUpload({
359
423
  onChange(fieldValue);
360
424
  }, [multipleFilesSupported, onChange, setInternalValue, value]);
361
425
 
362
- const onDragEnd = useCallback((result: any) => {
363
- // dropped outside the list
364
- if (!result.destination) {
365
- return;
366
- }
367
-
368
- moveItem(result.source.index, result.destination.index);
426
+ const sensors = useSensors(
427
+ useSensor(PointerSensor, {
428
+ activationConstraint: {
429
+ distance: 5, // Start dragging after 5px movement
430
+ },
431
+ }),
432
+ useSensor(KeyboardSensor, {
433
+ coordinateGetter: sortableKeyboardCoordinates,
434
+ })
435
+ );
369
436
 
370
- }, [moveItem])
437
+ const handleDragStart = useCallback((event: DragStartEvent) => {
438
+ setIsDndItemDragging(true);
439
+ }, []);
440
+
441
+ const handleDragEnd = useCallback((event: DragEndEvent) => {
442
+ setIsDndItemDragging(false);
443
+ const {
444
+ active,
445
+ over
446
+ } = event;
447
+ if (over && active.id !== over.id) {
448
+ const oldIndex = value.findIndex(item => item.id === active.id);
449
+ const newIndex = value.findIndex(item => item.id === over.id);
450
+ if (oldIndex !== -1 && newIndex !== -1) {
451
+ moveItem(oldIndex, newIndex);
452
+ }
453
+ }
454
+ }, [value, moveItem]);
371
455
 
372
456
  const onClear = useCallback((clearedStoragePathOrDownloadUrl: string) => {
457
+ let newValue: StorageFieldItem[];
373
458
  if (multipleFilesSupported) {
374
- const newValue: StorageFieldItem[] = value.filter(v => v.storagePathOrDownloadUrl !== clearedStoragePathOrDownloadUrl);
459
+ newValue = value.filter(v => v.storagePathOrDownloadUrl !== clearedStoragePathOrDownloadUrl);
375
460
  onChange(newValue.filter(v => !!v.storagePathOrDownloadUrl).map(v => v.storagePathOrDownloadUrl as string));
376
- setInternalValue(newValue);
377
461
  } else {
462
+ newValue = [];
378
463
  onChange(null);
379
- setInternalValue([]);
380
464
  }
381
- }, [value, multipleFilesSupported, onChange]);
465
+ setInternalValue(newValue);
466
+ }, [value, multipleFilesSupported, onChange, setInternalValue]);
382
467
 
383
468
  const helpText = multipleFilesSupported
384
- ? "Drag 'n' drop some files here, or click to select files"
469
+ ? "Drag 'n' drop some files here, or click to select files. Drag to reorder."
385
470
  : "Drag 'n' drop a file here, or click to select one";
386
471
 
387
472
  const renderProperty: ResolvedStringProperty = multipleFilesSupported
388
- ? (property as ArrayProperty<string[]>).of as ResolvedStringProperty
473
+ ? (property as ResolvedArrayProperty<string[]>).of as ResolvedStringProperty
389
474
  : property as ResolvedStringProperty;
390
475
 
391
- return (
392
- <DragDropContext onDragEnd={onDragEnd}>
393
- <Droppable
394
- droppableId={`droppable_${name}`}
395
- direction="horizontal"
396
- renderClone={(provided, snapshot, rubric) => {
397
- const entry = value[rubric.source.index];
398
- return (
399
- <div
400
- ref={provided.innerRef}
401
- {...provided.draggableProps}
402
- {...provided.dragHandleProps}
403
- style={
404
- provided.draggableProps.style
405
- }
406
- className="rounded"
407
- >
408
- <StorageItemPreview
409
- name={`storage_preview_${entry.storagePathOrDownloadUrl}`}
410
- placeholder={true}
411
- property={renderProperty}
412
- disabled={true}
413
- value={entry.storagePathOrDownloadUrl as string}
414
- onRemove={onClear}
415
- size={entry.size}/>
416
- </div>
417
- );
418
- }}
419
- >
420
- {(provided, snapshot) => {
421
- return <FileDropComponent storage={storage}
422
- disabled={disabled}
423
- isDraggingOver={snapshot.isDraggingOver}
424
- droppableProvided={provided}
425
- onFilesAdded={onFilesAdded}
426
- multipleFilesSupported={multipleFilesSupported}
427
- autoFocus={autoFocus}
428
- internalValue={value}
429
- property={renderProperty}
430
- onClear={onClear}
431
- metadata={metadata}
432
- storagePathBuilder={storagePathBuilder}
433
- onFileUploadComplete={onFileUploadComplete}
434
- size={size}
435
- name={name}
436
- helpText={helpText}/>
437
- }}
438
- </Droppable>
439
- </DragDropContext>
440
- );
476
+ const fileDropProps = {
477
+ storage,
478
+ disabled,
479
+ onFilesAdded,
480
+ multipleFilesSupported,
481
+ autoFocus,
482
+ internalValue: value, // Pass current internalValue
483
+ property: renderProperty,
484
+ onClear,
485
+ metadata,
486
+ storagePathBuilder,
487
+ onFileUploadComplete,
488
+ name,
489
+ helpText,
490
+ isDndItemDragging // Pass this down
491
+ };
441
492
 
493
+ if (multipleFilesSupported) {
494
+ return (
495
+ <DndContext
496
+ sensors={sensors}
497
+ collisionDetection={closestCenter}
498
+ onDragStart={handleDragStart}
499
+ onDragEnd={handleDragEnd}
500
+ >
501
+ <SortableContext items={value.map(v => v.id)} strategy={horizontalListSortingStrategy}>
502
+ <FileDropComponent {...fileDropProps} />
503
+ </SortableContext>
504
+ </DndContext>
505
+ );
506
+ } else {
507
+ // For single file, no D&D context is needed
508
+ return <FileDropComponent {...fileDropProps} isDndItemDragging={false}/>;
509
+ }
442
510
  }
@@ -45,7 +45,7 @@ export function StringPropertyPreview({
45
45
  if (!value) return <></>;
46
46
  const lines = value.split("\n");
47
47
  return value && value.includes("\n")
48
- ? <div className={cls("overflow-x-scroll", size === "small" ? "text-sm" : "")}>
48
+ ? <div className={cls("overflow-x-scroll overflow-hidden", size === "small" ? "text-sm" : "")}>
49
49
  {lines.map((str, index) =>
50
50
  <React.Fragment key={`string_preview_${index}`}>
51
51
  <span>{str}</span>
@@ -577,6 +577,11 @@ export interface EntityCustomViewParams<M extends Record<string, any> = any> {
577
577
  * Use the form context to access the form state and methods
578
578
  */
579
579
  formContext: FormContext;
580
+
581
+ /**
582
+ * If this is a subcollection, this is the path of the parent collections
583
+ */
584
+ parentCollectionIds?: string[];
580
585
  }
581
586
 
582
587
  export type InferCollectionType<S extends EntityCollection> = S extends EntityCollection<infer M> ? M : never;