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

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.
@@ -25,3 +25,4 @@ export declare function removeEntityFromCache(path: string): void;
25
25
  * Clears the entire in-memory cache and removes all related entities from `localStorage`.
26
26
  */
27
27
  export declare function clearEntityCache(): void;
28
+ export declare function flattenKeys(obj: any, prefix?: string, result?: string[]): string[];
@@ -1,5 +1,6 @@
1
1
  export declare const pick: <T>(obj: T, ...args: any[]) => T;
2
2
  export declare function isObject(item: any): any;
3
+ export declare function isPlainObject(obj: any): boolean;
3
4
  export declare function mergeDeep<T extends Record<any, any>, U extends Record<any, any>>(target: T, source: U, ignoreUndefined?: boolean): T & U;
4
5
  export declare function getValueInPath(o: object | undefined, path: string): any;
5
6
  export declare function removeInPath(o: object, path: string): object | undefined;
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.287",
4
+ "version": "3.0.0-canary.289",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/firecmsco"
@@ -53,9 +53,9 @@
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.287",
57
- "@firecms/formex": "^3.0.0-canary.287",
58
- "@firecms/ui": "^3.0.0-canary.287",
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
59
  "@radix-ui/react-portal": "^1.1.9",
60
60
  "clsx": "^2.1.1",
61
61
  "date-fns": "^3.6.0",
@@ -108,7 +108,7 @@
108
108
  "dist",
109
109
  "src"
110
110
  ],
111
- "gitHead": "c6606ddc2309cbdaeacb3103fbb2bb50a20418ce",
111
+ "gitHead": "ca236e0e8d9a91d1106bc7836b5f8945cdba35e9",
112
112
  "publishConfig": {
113
113
  "access": "public"
114
114
  },
@@ -1,20 +1,9 @@
1
1
  import React, { MouseEvent, useCallback } from "react";
2
2
 
3
3
  import { CollectionSize, Entity, EntityAction, EntityCollection, SelectionController } from "../../types";
4
- import {
5
- Checkbox,
6
- Chip,
7
- cls,
8
- EditIcon,
9
- IconButton,
10
- Menu,
11
- MenuItem,
12
- MoreVertIcon,
13
- Skeleton,
14
- Tooltip
15
- } from "@firecms/ui";
4
+ import { Badge, Checkbox, cls, IconButton, Menu, MenuItem, MoreVertIcon, Skeleton, Tooltip } from "@firecms/ui";
16
5
  import { useFireCMSContext, useLargeLayout } from "../../hooks";
17
- import { getEntityFromCache, hasEntityInCache } from "../../util/entity_cache";
6
+ import { getEntityFromCache } from "../../util/entity_cache";
18
7
  import { getLocalChangesBackup } from "../../util";
19
8
 
20
9
  /**
@@ -82,7 +71,7 @@ export const EntityCollectionRowActions = function EntityCollectionRowActions({
82
71
  const uncollapsedActions = actions.filter(a => a.collapsed === false);
83
72
  const enableLocalChangesBackup = collection ? getLocalChangesBackup(collection) : false;
84
73
  const hasDraft = enableLocalChangesBackup ? getEntityFromCache(fullPath + "/" + entity.id) : false;
85
-
74
+ const iconSize = largeLayout && (size === "m" || size === "l" || size == "xl") ? "medium" : "small";
86
75
  return (
87
76
  <div
88
77
  className={cls(
@@ -102,37 +91,50 @@ export const EntityCollectionRowActions = function EntityCollectionRowActions({
102
91
  {(hasActions || selectionEnabled) &&
103
92
  <div className="w-34 flex justify-center">
104
93
 
105
- {uncollapsedActions.map((action, index) => (
106
- <Tooltip key={index}
107
- title={action.name}
108
- asChild={true}>
109
- <IconButton
110
- onClick={(event: MouseEvent) => {
111
- event.stopPropagation();
112
- action.onClick({
113
- view: "collection",
114
- entity,
115
- fullPath,
116
- fullIdPath,
117
- collection,
118
- context,
119
- selectionController,
120
- highlightEntity,
121
- unhighlightEntity,
122
- onCollectionChange,
123
- openEntityMode: openEntityMode ?? collection?.openEntityMode
124
- });
125
- }}
126
- size={largeLayout ? "medium" : "small"}>
127
- {action.icon}
128
- </IconButton>
129
- </Tooltip>
130
- ))}
94
+ {uncollapsedActions.map((action, index) => {
95
+ const isEditAction = action.key === "edit";
96
+ const tooltip = isEditAction && hasDraft ? "Local unsaved changes" : action.name;
97
+
98
+ let iconButton = <IconButton
99
+ onClick={(event: MouseEvent) => {
100
+ event.stopPropagation();
101
+ action.onClick({
102
+ view: "collection",
103
+ entity,
104
+ fullPath,
105
+ fullIdPath,
106
+ collection,
107
+ context,
108
+ selectionController,
109
+ highlightEntity,
110
+ unhighlightEntity,
111
+ onCollectionChange,
112
+ openEntityMode: openEntityMode ?? collection?.openEntityMode
113
+ });
114
+ }}
115
+ size={iconSize}>
116
+ {action.icon}
117
+ </IconButton>;
118
+ if (isEditAction && hasDraft) {
119
+ iconButton = (
120
+ <Badge color={"warning"}>
121
+ {iconButton}
122
+ </Badge>
123
+ );
124
+ }
125
+ return (
126
+ <Tooltip key={index}
127
+ title={tooltip}
128
+ asChild={true}>
129
+ {iconButton}
130
+ </Tooltip>
131
+ );
132
+ })}
131
133
 
132
134
  {hasCollapsedActions &&
133
135
  <Menu
134
136
  trigger={<IconButton
135
- size={largeLayout ? "medium" : "small"}>
137
+ size={iconSize}>
136
138
  <MoreVertIcon/>
137
139
  </IconButton>}>
138
140
  {collapsedActions.map((action, index) => (
@@ -164,7 +166,7 @@ export const EntityCollectionRowActions = function EntityCollectionRowActions({
164
166
  {selectionEnabled &&
165
167
  <Tooltip title={`Select ${entity.id}`}>
166
168
  <Checkbox
167
- size={largeLayout ? "medium" : "small"}
169
+ size={iconSize}
168
170
  checked={Boolean(isSelected)}
169
171
  onCheckedChange={onCheckedChange}
170
172
  />
@@ -178,11 +180,6 @@ export const EntityCollectionRowActions = function EntityCollectionRowActions({
178
180
  onClick={(event) => {
179
181
  event.stopPropagation();
180
182
  }}>
181
- {hasDraft && <Tooltip title={"Local unsaved changes"} className={"inline"}>
182
- <Chip colorScheme={"orangeDarker"} className={"p-0.5"}>
183
- <EditIcon size={12}/>
184
- </Chip>
185
- </Tooltip>}
186
183
  <span className="min-w-0 truncate text-center">
187
184
  {entity
188
185
  ? entity.id
@@ -1,11 +1,11 @@
1
1
  import React, { useMemo } from "react";
2
- import { PropertyPreview } from "../preview";
3
2
  import { Entity, EntityCollection, ResolvedEntityCollection, ResolvedProperties } from "../types";
4
3
  import { resolveCollection } from "../util";
5
- import { cls, defaultBorderMixin, IconButton, OpenInNewIcon } from "@firecms/ui";
4
+ import { cls, defaultBorderMixin, IconButton, OpenInNewIcon, Typography } from "@firecms/ui";
6
5
  import { CustomizationController } from "../types/customization_controller";
7
6
  import { useCustomizationController } from "../hooks/useCustomizationController";
8
7
  import { useAuthController } from "../hooks";
8
+ import { PropertyCollectionView } from "./PropertyCollectionView";
9
9
 
10
10
  /**
11
11
  * @group Components
@@ -40,47 +40,36 @@ export function EntityView<M extends Record<string, any>>(
40
40
 
41
41
  return (
42
42
  <div className={"w-full " + className}>
43
- <div className={"w-full mb-4"}>
44
- <div className={cls(defaultBorderMixin, "flex justify-between py-2 border-b last:border-b-0")}>
45
- <div className="flex items-center w-1/4">
46
- <span className="pl-2 text-sm text-surface-600">Id</span>
43
+ <div className={"w-full mb-4 p-4"}>
44
+
45
+ <div className={`grid grid-cols-12 gap-x-4 py-4 items-start border-b ${defaultBorderMixin}`}>
46
+ <div className="col-span-4 pr-2">
47
+ <Typography variant="caption"
48
+ color={"secondary"}
49
+ component={"span"}
50
+ className="break-words">
51
+ Id
52
+ </Typography>
47
53
  </div>
48
- <div
49
- className="flex-grow p-2 ml-2 w-3/4 text-surface-900 dark:text-white min-h-[56px] flex items-center">
50
- <span className="flex-grow mr-2">{entity.id}</span>
51
- {customizationController?.entityLinkBuilder &&
52
- <a href={customizationController.entityLinkBuilder({ entity })}
53
- rel="noopener noreferrer"
54
- target="_blank">
55
- <IconButton>
56
- <OpenInNewIcon
57
- size={"small"}/>
58
- </IconButton>
59
- </a>}
54
+ <div className="col-span-8">
55
+ <div
56
+ className="flex-grow text-surface-900 dark:text-white flex items-center">
57
+ <span className="flex-grow mr-2">{entity.id}</span>
58
+ {customizationController?.entityLinkBuilder &&
59
+ <a href={customizationController.entityLinkBuilder({ entity })}
60
+ rel="noopener noreferrer"
61
+ target="_blank">
62
+ <IconButton>
63
+ <OpenInNewIcon
64
+ size={"small"}/>
65
+ </IconButton>
66
+ </a>}
67
+ </div>
60
68
  </div>
61
69
  </div>
62
- {Object.entries(properties)
63
- .map(([key, property]) => {
64
- const value = entity.values?.[key];
65
- return (
66
- <div
67
- key={`reference_previews_${key}`}
68
- className={cls(defaultBorderMixin, "flex justify-between py-2 border-b last:border-b-0")}>
69
- <div className="flex items-center w-1/4">
70
- <span className="pl-2 text-sm text-surface-600">{property.name}</span>
71
- </div>
72
- <div
73
- className="flex-grow p-2 ml-2 w-3/4 text-surface-900 dark:text-white min-h-[56px] flex items-center">
74
- <PropertyPreview
75
- propertyKey={key}
76
- value={value}
77
- // entity={entity}
78
- property={property}
79
- size={"medium"}/>
80
- </div>
81
- </div>
82
- )
83
- })}
70
+
71
+ <PropertyCollectionView data={entity.values} properties={properties} size={"medium"}/>
72
+
84
73
  </div>
85
74
  </div>
86
75
  );
@@ -0,0 +1,329 @@
1
+ import React from "react";
2
+ import { defaultBorderMixin, Typography } from "@firecms/ui";
3
+ import { PreviewSize, PropertyPreview } from "../preview";
4
+ import { ResolvedProperties, ResolvedProperty } from "../types";
5
+ import { getValueInPath } from "../util";
6
+
7
+ /**
8
+ * Build a readable label for a path and resolve the property
9
+ * Supports map and array (including arrays of maps)
10
+ */
11
+ export function buildPropertyLabelAndGetProperty(
12
+ properties: ResolvedProperties,
13
+ key: string
14
+ ): { label: string; property: ResolvedProperty | undefined } {
15
+ if (!key) return {
16
+ label: "",
17
+ property: undefined
18
+ };
19
+
20
+ // Parse "a[0].b.c[2]" -> ["a", 0, "b", "c", 2]
21
+ const segments: (string | number)[] = [];
22
+ const re = /([^[.\]]+)|\[(\d+)\]/g;
23
+ let m: RegExpExecArray | null;
24
+ while ((m = re.exec(key)) !== null) {
25
+ if (m[1] !== undefined) segments.push(m[1]);
26
+ else if (m[2] !== undefined) segments.push(Number(m[2]));
27
+ }
28
+
29
+ let currentProps: ResolvedProperties | undefined = properties;
30
+ let currentProp: ResolvedProperty | undefined;
31
+ let lastLabel = "";
32
+
33
+ const getArrayOfProp = (p?: ResolvedProperty): ResolvedProperty | undefined => {
34
+ if (!p || p.dataType !== "array") return undefined;
35
+ return Array.isArray(p.of) ? (p.of[0] as ResolvedProperty) : (p.of as ResolvedProperty | undefined);
36
+ };
37
+
38
+ for (const seg of segments) {
39
+ if (typeof seg === "number") {
40
+ // Last segment label should be the index itself
41
+ lastLabel = `[${seg}]`;
42
+
43
+ // Move schema context into the array element
44
+ if (currentProp?.dataType === "array") {
45
+ currentProp = getArrayOfProp(currentProp);
46
+ if (currentProp?.dataType === "map" && currentProp.properties) {
47
+ currentProps = currentProp.properties as ResolvedProperties;
48
+ } else {
49
+ currentProps = undefined;
50
+ }
51
+ } else {
52
+ // Index without array schema context
53
+ currentProp = undefined;
54
+ currentProps = undefined;
55
+ }
56
+ continue;
57
+ }
58
+
59
+ // seg is a string key
60
+ if (currentProps && (currentProps as any)[seg]) {
61
+ const nextProp = (currentProps as any)[seg] as ResolvedProperty;
62
+ currentProp = nextProp;
63
+ // Last segment label should be the property name (or the raw key)
64
+ lastLabel = nextProp.name || String(seg);
65
+
66
+ if (nextProp.dataType === "map" && nextProp.properties) {
67
+ currentProps = nextProp.properties as ResolvedProperties;
68
+ } else if (nextProp.dataType === "array") {
69
+ // Keep array prop; the next segment (index) will step into its element schema
70
+ currentProps = undefined;
71
+ } else {
72
+ currentProps = undefined;
73
+ }
74
+ } else {
75
+ // Unknown key or no schema context
76
+ currentProp = undefined;
77
+ currentProps = undefined;
78
+ lastLabel = String(seg);
79
+ }
80
+ }
81
+
82
+ return {
83
+ label: lastLabel,
84
+ property: currentProp
85
+ };
86
+ }
87
+
88
+ const pathEndsWithIndex = (p: string) => /\[\d+\]$/.test(p);
89
+
90
+ /**
91
+ * Improved simple layout for nested changes:
92
+ * - Map or Array-of-Map -> section header + indented rows
93
+ * - Leaf or Array-of-Primitives -> single row with label and value
94
+ */
95
+ export const PropertyCollectionView = ({
96
+ data,
97
+ properties,
98
+ baseKey = "",
99
+ suppressHeader = false,
100
+ size = "small"
101
+ }: {
102
+ data: any;
103
+ properties: ResolvedProperties;
104
+ baseKey?: string;
105
+ suppressHeader?: boolean;
106
+ size?: PreviewSize;
107
+ }) => {
108
+
109
+ const isTopLevel = !!baseKey && !baseKey.includes(".") && !baseKey.includes("[");
110
+
111
+ // Arrays
112
+ if (Array.isArray(data)) {
113
+ const {
114
+ label: arrayLabel,
115
+ property
116
+ } = baseKey
117
+ ? buildPropertyLabelAndGetProperty(properties, baseKey)
118
+ : {
119
+ label: "",
120
+ property: undefined as ResolvedProperty | undefined
121
+ };
122
+
123
+ const ofProp = property?.dataType === "array"
124
+ ? (Array.isArray(property.of) ? property.of[0] : property.of) as ResolvedProperty | undefined
125
+ : undefined;
126
+
127
+ const isArrayOfMaps = ofProp?.dataType === "map";
128
+ const isArrayOfPrimitives = property?.dataType === "array" && ofProp && ofProp.dataType !== "map";
129
+
130
+ // Array of primitives -> single row
131
+ if (baseKey && property && isArrayOfPrimitives) {
132
+ return (
133
+ <div
134
+ className={`grid grid-cols-12 gap-x-4 ${isTopLevel ? "py-4" : "py-2"} items-start ${isTopLevel ? `border-b ${defaultBorderMixin}` : ""}`}>
135
+ <div className="col-span-4 pr-2">
136
+ <Typography variant="caption"
137
+ color={"secondary"}
138
+ component={"span"}
139
+ className="break-words">
140
+ {arrayLabel}
141
+ </Typography>
142
+ </div>
143
+ <div className="col-span-8">
144
+ <PropertyPreview propertyKey={baseKey}
145
+ value={data}
146
+ property={property}
147
+ size={size}/>
148
+ </div>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ // Array of maps or unknown -> array header + combined item header (MapName [index]) then content
154
+ return (
155
+ <div className={`${isTopLevel ? "py-4" : "py-1"} ${isTopLevel ? `border-b ${defaultBorderMixin}` : ""}`}>
156
+ {baseKey && arrayLabel && !suppressHeader && (
157
+ <Typography variant="caption"
158
+ color={"secondary"}
159
+ component={"span"}>
160
+ {arrayLabel}
161
+ </Typography>
162
+ )}
163
+ <div className={baseKey ? `pl-4 mt-1 border-l ${defaultBorderMixin}` : ""}>
164
+ {data.map((item, index) => {
165
+ if (item === null || item === undefined) return null;
166
+ const currentKey = baseKey ? `${baseKey}[${index}]` : `[${index}]`;
167
+
168
+ // Combined header text
169
+ const itemHeader = isArrayOfMaps && ofProp?.name
170
+ ? `${ofProp.name} [${index}]`
171
+ : `[${index}]`;
172
+
173
+ return (
174
+ <div key={currentKey} className="py-1">
175
+ <Typography variant="caption"
176
+ color={"secondary"}
177
+ component={"span"}>
178
+ {itemHeader}
179
+ </Typography>
180
+ <div className={`pl-4 mt-1 border-l ${defaultBorderMixin}`}>
181
+ <PropertyCollectionView
182
+ data={item}
183
+ properties={properties}
184
+ baseKey={currentKey}
185
+ suppressHeader={true} // don’t repeat the inner map header
186
+ size={size}
187
+ />
188
+ </div>
189
+ </div>
190
+ );
191
+ })}
192
+ </div>
193
+ </div>
194
+ );
195
+ }
196
+
197
+ // Objects (maps or plain objects)
198
+ if (typeof data === "object" && data !== null) {
199
+ const {
200
+ label,
201
+ property
202
+ } = baseKey
203
+ ? buildPropertyLabelAndGetProperty(properties, baseKey)
204
+ : {
205
+ label: "",
206
+ property: undefined as ResolvedProperty | undefined
207
+ };
208
+
209
+ // Non-map leaf-like object -> single row
210
+ if (baseKey && (!property || property.dataType !== "map" || !property.properties)) {
211
+ if (!property) return null;
212
+ return (
213
+ <div
214
+ className={`grid grid-cols-12 gap-x-4 ${isTopLevel ? "py-4" : "py-2"} items-start ${isTopLevel ? `border-b ${defaultBorderMixin}` : ""}`}>
215
+ <div className="col-span-4 pr-2">
216
+ <Typography variant="caption"
217
+ color={"secondary"}
218
+ component={"span"}
219
+ className="break-words">
220
+ {label}
221
+ </Typography>
222
+ </div>
223
+ <div className="col-span-8">
224
+ <PropertyPreview propertyKey={baseKey}
225
+ value={data}
226
+ property={property}
227
+ size={size}/>
228
+ </div>
229
+ </div>
230
+ );
231
+ }
232
+
233
+ // Map with defined properties -> show map header only if not suppressed
234
+ const showMapHeader =
235
+ baseKey &&
236
+ !suppressHeader &&
237
+ property?.dataType === "map" &&
238
+ (property.name || !pathEndsWithIndex(baseKey));
239
+
240
+ const headerText = property?.name || label;
241
+
242
+ return (
243
+ <div className={`${isTopLevel ? "py-4" : "py-1"} ${isTopLevel ? `border-b ${defaultBorderMixin}` : ""}`}>
244
+ {showMapHeader && (
245
+ <Typography variant="caption"
246
+ color={"secondary"}
247
+ component={"span"}
248
+ >
249
+ {headerText}
250
+ </Typography>
251
+ )}
252
+ <div className={baseKey ? `pl-4 mt-1 border-l ${defaultBorderMixin}` : ""}>
253
+ {Object.entries(data).map(([key, value]) => {
254
+ if (value === null || value === undefined) return null;
255
+ const currentKey = baseKey ? `${baseKey}.${key}` : key;
256
+ return (
257
+ <PropertyCollectionView
258
+ key={currentKey}
259
+ data={value}
260
+ properties={properties}
261
+ baseKey={currentKey}
262
+ size={size}
263
+ />
264
+ );
265
+ })}
266
+ </div>
267
+ </div>
268
+ );
269
+ }
270
+
271
+ // Primitives
272
+ if (baseKey) {
273
+ const {
274
+ label,
275
+ property
276
+ } = buildPropertyLabelAndGetProperty(properties, baseKey);
277
+ if (!property) return null;
278
+ return (
279
+ <div
280
+ className={`grid grid-cols-12 gap-x-4 ${isTopLevel ? "py-4" : "py-2"} items-start ${isTopLevel ? `border-b ${defaultBorderMixin}` : ""}`}>
281
+ <div className="col-span-4 pr-2">
282
+ <Typography variant="caption"
283
+ color={"secondary"}
284
+ component={"span"}
285
+ className="break-words">
286
+ {label}
287
+ </Typography>
288
+ </div>
289
+ <div className="col-span-8">
290
+ <PropertyPreview propertyKey={baseKey}
291
+ value={data}
292
+ property={property}
293
+ size={size}/>
294
+ </div>
295
+ </div>
296
+ );
297
+ }
298
+
299
+ return null;
300
+ };
301
+
302
+ export function buildDataFromPaths(values: object, paths: string[]): object {
303
+ const result = {};
304
+ paths.forEach(path => {
305
+ const value = getValueInPath(values, path);
306
+ if (value === undefined) return;
307
+
308
+ // lodash.set would be perfect here
309
+ const segments = path.replace(/\[(\d+)\]/g, ".$1").split(".");
310
+ let current: any = result;
311
+ segments.forEach((segment, index) => {
312
+ if (index === segments.length - 1) {
313
+ current[segment] = value;
314
+ } else {
315
+ const nextSegment = segments[index + 1];
316
+ const isNextAnIndex = /^\d+$/.test(nextSegment);
317
+ if (!current[segment]) {
318
+ if (isNextAnIndex) {
319
+ current[segment] = [];
320
+ } else {
321
+ current[segment] = {};
322
+ }
323
+ }
324
+ current = current[segment];
325
+ }
326
+ });
327
+ });
328
+ return result;
329
+ }