@asteby/metacore-runtime-react 18.10.2 → 18.12.0

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.
@@ -0,0 +1,298 @@
1
+ /**
2
+ * activity-diff.tsx
3
+ *
4
+ * <ActivityDiff> — renders the field-level diff of a single ActivityEvent.
5
+ *
6
+ * Three visual states driven by `event.action`:
7
+ * - created → green "after" column only (no before)
8
+ * - deleted → red "before" column only (no after)
9
+ * - updated → yellow "before → after" side-by-side per field
10
+ *
11
+ * Consumers pass the declarative `columns` metadata array (same shape as
12
+ * `TableMetadata.columns`) so labels and display types are resolved without
13
+ * any internal fetch. Degrades gracefully when `columns` is empty/absent.
14
+ *
15
+ * Toggle: "Todos los campos / Solo cambios" (with changed-field counter).
16
+ */
17
+
18
+ import * as React from 'react'
19
+ import { ChevronDown, ChevronRight, ArrowRight } from 'lucide-react'
20
+ import { cn } from '@asteby/metacore-ui/lib'
21
+ import { Badge } from '@asteby/metacore-ui/primitives'
22
+ import type { ColumnDefinition } from './types'
23
+ import { ActivityValueRenderer } from './activity-value-renderer'
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Types
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * The canonical activity event shape as produced by the kernel / host backend.
31
+ * Transport-agnostic — the component only reads the fields it needs.
32
+ */
33
+ export interface ActivityEvent {
34
+ id: string
35
+ correlation_id?: string | null
36
+ actor_id?: string | null
37
+ actor_label?: string | null
38
+ addon_key: string
39
+ model: string
40
+ record_id: string
41
+ action: string
42
+ kind?: string | null
43
+ before?: Record<string, unknown> | null
44
+ after?: Record<string, unknown> | null
45
+ /**
46
+ * Explicit diff map produced by the backend. When present, only these keys
47
+ * are "changed" fields. When absent, the diff is derived from before/after.
48
+ */
49
+ changes?: Record<string, { from: unknown; to: unknown }> | null
50
+ summary?: string | null
51
+ occurred_at: string
52
+ }
53
+
54
+ export interface ActivityDiffProps {
55
+ /** The activity event to render. */
56
+ event: ActivityEvent
57
+ /**
58
+ * Column metadata for the model. Used to resolve `col.label` and display
59
+ * type. Pass `TableMetadata.columns` from the host's metadata cache.
60
+ * Optional — field keys are shown raw when absent.
61
+ */
62
+ columns?: ColumnDefinition[]
63
+ /** IANA timezone for datetime cells (org config). */
64
+ timeZone?: string
65
+ /** ISO 4217 currency for money cells (org config). */
66
+ currency?: string
67
+ /** BCP-47 locale. Defaults to 'es'. */
68
+ locale?: string
69
+ /** Class applied to the root element. */
70
+ className?: string
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Helpers
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /** Returns all field keys that appear in the diff. */
78
+ function diffKeys(event: ActivityEvent): string[] {
79
+ if (event.changes && Object.keys(event.changes).length > 0) {
80
+ return Object.keys(event.changes)
81
+ }
82
+ const before = event.before ?? {}
83
+ const after = event.after ?? {}
84
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)])
85
+ // Filter out meta-level keys that are always present and rarely meaningful
86
+ // in a human-readable diff (id, created_at, updated_at, organization_id).
87
+ const META = new Set(['id', 'created_at', 'updated_at', 'organization_id', 'org_id'])
88
+ keys.forEach((k) => { if (META.has(k)) keys.delete(k) })
89
+ return Array.from(keys)
90
+ }
91
+
92
+ /** Returns the set of keys where the value actually changed. */
93
+ function changedKeys(event: ActivityEvent): Set<string> {
94
+ if (event.changes && Object.keys(event.changes).length > 0) {
95
+ return new Set(Object.keys(event.changes))
96
+ }
97
+ const before = event.before ?? {}
98
+ const after = event.after ?? {}
99
+ const changed = new Set<string>()
100
+ const all = new Set([...Object.keys(before), ...Object.keys(after)])
101
+ all.forEach((k) => {
102
+ if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) changed.add(k)
103
+ })
104
+ return changed
105
+ }
106
+
107
+ function resolveColumn(key: string, columns?: ColumnDefinition[]): ColumnDefinition | undefined {
108
+ return columns?.find((c) => c.key === key)
109
+ }
110
+
111
+ function resolveLabel(key: string, columns?: ColumnDefinition[]): string {
112
+ const col = resolveColumn(key, columns)
113
+ if (col?.label) return col.label
114
+ // Humanize snake_case as last resort
115
+ return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
116
+ }
117
+
118
+ function actionVariant(action: string): 'created' | 'updated' | 'deleted' | 'other' {
119
+ const a = action.toLowerCase()
120
+ if (a === 'created' || a === 'create') return 'created'
121
+ if (a === 'deleted' || a === 'delete') return 'deleted'
122
+ if (a === 'updated' || a === 'update') return 'updated'
123
+ return 'other'
124
+ }
125
+
126
+ const VARIANT_BADGE: Record<string, { label: string; className: string }> = {
127
+ created: { label: 'Creado', className: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-900' },
128
+ updated: { label: 'Actualizado', className: 'bg-yellow-50 text-yellow-700 border-yellow-200 dark:bg-yellow-950/30 dark:text-yellow-400 dark:border-yellow-900' },
129
+ deleted: { label: 'Eliminado', className: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/30 dark:text-red-400 dark:border-red-900' },
130
+ other: { label: '', className: 'bg-muted text-muted-foreground border-border' },
131
+ }
132
+
133
+ // Subtle row highlight colors — using inline style so arbitrary values are
134
+ // never dropped by the host's Tailwind class scan.
135
+ const ROW_STYLE = {
136
+ created: { background: 'color-mix(in srgb, #22c55e 6%, transparent)' },
137
+ deleted: { background: 'color-mix(in srgb, #ef4444 6%, transparent)' },
138
+ updated: {},
139
+ other: {},
140
+ } as Record<string, React.CSSProperties>
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Component
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Renders the field-level diff of a single ActivityEvent. Shows each field
148
+ * with its label (from `columns`) and value formatted using the column's
149
+ * display type. Supports a toggle to show only changed fields vs. all fields.
150
+ */
151
+ export const ActivityDiff: React.FC<ActivityDiffProps> = ({
152
+ event,
153
+ columns,
154
+ timeZone,
155
+ currency,
156
+ locale = 'es',
157
+ className,
158
+ }) => {
159
+ const variant = actionVariant(event.action)
160
+ const allKeys = diffKeys(event)
161
+ const changed = changedKeys(event)
162
+ const [showOnlyChanged, setShowOnlyChanged] = React.useState(true)
163
+
164
+ const displayedKeys = showOnlyChanged ? allKeys.filter((k) => changed.has(k)) : allKeys
165
+ const isCreated = variant === 'created'
166
+ const isDeleted = variant === 'deleted'
167
+
168
+ const variantBadge = VARIANT_BADGE[variant] ?? VARIANT_BADGE.other
169
+
170
+ if (allKeys.length === 0 && !event.summary) {
171
+ return (
172
+ <div className={cn('text-sm text-muted-foreground italic py-1', className)}>
173
+ Sin campos registrados.
174
+ </div>
175
+ )
176
+ }
177
+
178
+ return (
179
+ <div className={cn('space-y-2', className)}>
180
+ {/* Header: action badge + field count + toggle */}
181
+ <div className="flex items-center gap-2 flex-wrap">
182
+ <Badge variant="outline" className={cn('text-xs font-medium px-2 py-0.5', variantBadge.className)}>
183
+ {variantBadge.label || event.action}
184
+ </Badge>
185
+ {changed.size > 0 && variant === 'updated' && (
186
+ <span className="text-xs text-muted-foreground">
187
+ {changed.size} campo{changed.size !== 1 ? 's' : ''} modificado{changed.size !== 1 ? 's' : ''}
188
+ </span>
189
+ )}
190
+ {allKeys.length > 0 && variant === 'updated' && (
191
+ <button
192
+ type="button"
193
+ onClick={() => setShowOnlyChanged((v) => !v)}
194
+ className="ml-auto text-xs text-primary hover:underline"
195
+ >
196
+ {showOnlyChanged ? `Ver todos (${allKeys.length})` : 'Solo cambios'}
197
+ </button>
198
+ )}
199
+ </div>
200
+
201
+ {/* Summary line (if backend provided one) */}
202
+ {event.summary && (
203
+ <p className="text-sm text-muted-foreground italic">{event.summary}</p>
204
+ )}
205
+
206
+ {/* Diff table */}
207
+ {displayedKeys.length > 0 && (
208
+ <div className="rounded-lg border border-border/60 overflow-hidden text-sm">
209
+ {/* Column headers */}
210
+ <div className="grid grid-cols-[1fr_1fr_1fr] border-b border-border/40 bg-muted/40 px-3 py-1.5 text-xs font-medium text-muted-foreground">
211
+ <span>Campo</span>
212
+ {!isCreated && <span>{isDeleted ? 'Valor' : 'Antes'}</span>}
213
+ {isCreated && <span></span>}
214
+ {!isDeleted && <span>{isCreated ? 'Valor' : 'Después'}</span>}
215
+ {isDeleted && <span></span>}
216
+ </div>
217
+
218
+ {displayedKeys.map((key, idx) => {
219
+ const col = resolveColumn(key, columns)
220
+ const label = resolveLabel(key, columns)
221
+ const isChanged = changed.has(key)
222
+
223
+ let fromVal: unknown
224
+ let toVal: unknown
225
+
226
+ if (event.changes?.[key]) {
227
+ fromVal = event.changes[key].from
228
+ toVal = event.changes[key].to
229
+ } else {
230
+ fromVal = event.before?.[key]
231
+ toVal = event.after?.[key]
232
+ }
233
+
234
+ const rowStyle = isCreated
235
+ ? ROW_STYLE.created
236
+ : isDeleted
237
+ ? ROW_STYLE.deleted
238
+ : isChanged
239
+ ? {}
240
+ : {}
241
+
242
+ return (
243
+ <div
244
+ key={key}
245
+ style={rowStyle}
246
+ className={cn(
247
+ 'grid grid-cols-[1fr_1fr_1fr] items-start px-3 py-2 gap-x-2',
248
+ idx !== displayedKeys.length - 1 && 'border-b border-border/30',
249
+ isChanged && variant === 'updated' && 'bg-yellow-50/40 dark:bg-yellow-950/10',
250
+ )}
251
+ >
252
+ {/* Field label */}
253
+ <span className="text-xs font-medium text-foreground/70 pt-0.5 truncate" title={label}>
254
+ {label}
255
+ </span>
256
+
257
+ {/* Before value (or value for deleted/created) */}
258
+ {!isCreated ? (
259
+ <span className={cn(isDeleted ? 'col-span-2' : '')}>
260
+ <ActivityValueRenderer
261
+ value={isDeleted ? fromVal : fromVal}
262
+ col={col}
263
+ timeZone={timeZone}
264
+ currency={currency}
265
+ locale={locale}
266
+ />
267
+ </span>
268
+ ) : (
269
+ <span />
270
+ )}
271
+
272
+ {/* After value */}
273
+ {!isDeleted ? (
274
+ <span className={cn(isCreated ? 'col-span-2' : '')}>
275
+ {isChanged && variant === 'updated' && (
276
+ <span className="inline-flex items-center gap-1 align-middle mr-1">
277
+ <ArrowRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
278
+ </span>
279
+ )}
280
+ <ActivityValueRenderer
281
+ value={isCreated ? toVal : toVal}
282
+ col={col}
283
+ timeZone={timeZone}
284
+ currency={currency}
285
+ locale={locale}
286
+ />
287
+ </span>
288
+ ) : (
289
+ <span />
290
+ )}
291
+ </div>
292
+ )
293
+ })}
294
+ </div>
295
+ )}
296
+ </div>
297
+ )
298
+ }