@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,371 @@
1
+ /**
2
+ * activity-value-renderer.tsx
3
+ *
4
+ * Pure, transport-agnostic value renderer for Activity / Time Machine diffs.
5
+ * Reuses the same display-type logic as dynamic-columns.tsx (currency, status,
6
+ * date, boolean, badge, relation, url, tags, color, number, percent) so the
7
+ * diff cells and the table cells are always consistent.
8
+ *
9
+ * Kept in its own module so it has no dependency on tanstack-table or the
10
+ * column-factory machinery — only React + metacore-ui primitives.
11
+ */
12
+
13
+ import * as React from 'react'
14
+ import * as icons from 'lucide-react'
15
+ import { es, enUS } from 'date-fns/locale'
16
+ import {
17
+ Badge,
18
+ Avatar,
19
+ AvatarImage,
20
+ AvatarFallback,
21
+ } from '@asteby/metacore-ui/primitives'
22
+ import { generateBadgeStyles, getInitials, optionColor, relationChipStyles } from '@asteby/metacore-ui/lib'
23
+ import type { ColumnDefinition } from './types'
24
+ import { formatDateCell } from './dynamic-columns'
25
+ import { humanizeToken } from './dynamic-columns-helpers'
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Internal helpers (mirror dynamic-columns.tsx private helpers)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const styleCfg = (col: ColumnDefinition, ...keys: string[]): any => {
32
+ const cfg = col.styleConfig
33
+ if (!cfg) return undefined
34
+ for (const k of keys) {
35
+ if (cfg[k] !== undefined && cfg[k] !== null) return cfg[k]
36
+ }
37
+ return undefined
38
+ }
39
+
40
+ const formatNumber = (value: number, opts: Intl.NumberFormatOptions, locale?: string) =>
41
+ new Intl.NumberFormat(locale || undefined, opts).format(value)
42
+
43
+ const statusColorFor = (value: string): string => {
44
+ const v = value.toLowerCase()
45
+ if (['active', 'enabled', 'paid', 'completed', 'done', 'success', 'approved', 'open'].includes(v))
46
+ return '#22c55e'
47
+ if (['pending', 'draft', 'processing', 'in_progress', 'review', 'waiting'].includes(v))
48
+ return '#eab308'
49
+ if (['inactive', 'disabled', 'cancelled', 'canceled', 'failed', 'rejected', 'error', 'closed'].includes(v))
50
+ return '#ef4444'
51
+ return '#6b7280'
52
+ }
53
+
54
+ const useIsDarkTheme = () => {
55
+ const [isDark, setIsDark] = React.useState(() =>
56
+ typeof document !== 'undefined' && document.documentElement.classList.contains('dark'),
57
+ )
58
+ React.useEffect(() => {
59
+ if (typeof document === 'undefined') return
60
+ const sync = () => setIsDark(document.documentElement.classList.contains('dark'))
61
+ const observer = new MutationObserver(sync)
62
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
63
+ return () => observer.disconnect()
64
+ }, [])
65
+ return isDark
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Public API
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export interface ActivityValueRendererProps {
73
+ /** The raw value to display (from before/after/changes). */
74
+ value: unknown
75
+ /** Column metadata for display formatting. Optional — falls back to string. */
76
+ col?: ColumnDefinition
77
+ /** IANA timezone (org config) for datetime cells. */
78
+ timeZone?: string
79
+ /** ISO 4217 currency for money cells. */
80
+ currency?: string
81
+ /** BCP-47 locale tag (e.g. 'es', 'en'). Defaults to 'es'. */
82
+ locale?: string
83
+ }
84
+
85
+ /**
86
+ * Renders a single field value from an activity event using the same display
87
+ * type logic as `defaultGetDynamicColumns`. Pass a `ColumnDefinition` to get
88
+ * rich formatting (currency, status badge, date, boolean, etc.); without it
89
+ * the component falls back to a plain string representation.
90
+ */
91
+ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
92
+ value,
93
+ col,
94
+ timeZone,
95
+ currency,
96
+ locale = 'es',
97
+ }) => {
98
+ const isDark = useIsDarkTheme()
99
+ const dateLocale = locale === 'en' ? enUS : es
100
+
101
+ // null / undefined → dash
102
+ if (value === null || value === undefined || value === '') {
103
+ return <span className="text-muted-foreground">—</span>
104
+ }
105
+
106
+ // No column metadata → plain string
107
+ if (!col) {
108
+ if (typeof value === 'object') {
109
+ return (
110
+ <span className="text-muted-foreground text-xs font-mono">
111
+ {JSON.stringify(value)}
112
+ </span>
113
+ )
114
+ }
115
+ return <span className="font-medium text-sm">{String(value)}</span>
116
+ }
117
+
118
+ const renderAs = col.cellStyle ?? col.type
119
+
120
+ // -----------------------------------------------------------------------
121
+ // Badge / Status / Select / Option
122
+ // -----------------------------------------------------------------------
123
+
124
+ if (renderAs === 'badge' || renderAs === 'status' || renderAs === 'select' || renderAs === 'option') {
125
+ const sv = String(value)
126
+ const option = col.options?.find((o) => o.value === sv)
127
+ if (option) {
128
+ const colorSource = option.color || optionColor(option.value || option.label)
129
+ const styles = generateBadgeStyles(colorSource, { isDark })
130
+ return (
131
+ <Badge variant="outline" className="border-0" style={styles}>
132
+ {option.label}
133
+ </Badge>
134
+ )
135
+ }
136
+ if (renderAs === 'status') {
137
+ const styles = generateBadgeStyles(statusColorFor(sv), { isDark })
138
+ return (
139
+ <Badge variant="outline" className="border-0" style={styles}>
140
+ {humanizeToken(sv)}
141
+ </Badge>
142
+ )
143
+ }
144
+ return <Badge variant="outline">{humanizeToken(sv)}</Badge>
145
+ }
146
+
147
+ // -----------------------------------------------------------------------
148
+ // Date / Datetime / Timestamp
149
+ // -----------------------------------------------------------------------
150
+
151
+ if (['date', 'datetime', 'timestamp', 'timestamptz'].includes(renderAs ?? '')) {
152
+ const formatted = formatDateCell(value, renderAs, dateLocale, timeZone)
153
+ if (!formatted) return <span className="text-muted-foreground">—</span>
154
+ return (
155
+ <span
156
+ className="inline-flex items-center gap-1 text-sm text-muted-foreground"
157
+ title={formatted.title}
158
+ >
159
+ <icons.Calendar className="h-3 w-3 opacity-60" />
160
+ {formatted.display}
161
+ </span>
162
+ )
163
+ }
164
+
165
+ // -----------------------------------------------------------------------
166
+ // Boolean
167
+ // -----------------------------------------------------------------------
168
+
169
+ if (renderAs === 'boolean') {
170
+ return (
171
+ <span className="inline-flex items-center gap-1">
172
+ {value ? (
173
+ <icons.Check className="h-3.5 w-3.5 text-green-500" />
174
+ ) : (
175
+ <icons.Minus className="h-3.5 w-3.5 text-muted-foreground" />
176
+ )}
177
+ <span className="text-sm text-muted-foreground">{value ? 'Sí' : 'No'}</span>
178
+ </span>
179
+ )
180
+ }
181
+
182
+ // -----------------------------------------------------------------------
183
+ // Currency
184
+ // -----------------------------------------------------------------------
185
+
186
+ if (renderAs === 'currency') {
187
+ const num = typeof value === 'number' ? value : Number(value)
188
+ if (isNaN(num)) return <span className="text-muted-foreground">—</span>
189
+ const decimals = styleCfg(col, 'decimals') ?? 2
190
+ const curr = styleCfg(col, 'currency') || currency || 'USD'
191
+ return (
192
+ <span className="font-medium tabular-nums text-sm">
193
+ {formatNumber(num, { style: 'currency', currency: curr, minimumFractionDigits: decimals, maximumFractionDigits: decimals }, locale)}
194
+ </span>
195
+ )
196
+ }
197
+
198
+ // -----------------------------------------------------------------------
199
+ // Number / Percent / Progress
200
+ // -----------------------------------------------------------------------
201
+
202
+ if (renderAs === 'number') {
203
+ const num = typeof value === 'number' ? value : Number(value)
204
+ if (isNaN(num)) return <span className="text-muted-foreground">—</span>
205
+ const decimals = styleCfg(col, 'decimals')
206
+ return (
207
+ <span className="font-medium tabular-nums text-sm">
208
+ {formatNumber(
209
+ num,
210
+ decimals !== undefined ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } : {},
211
+ locale,
212
+ )}
213
+ </span>
214
+ )
215
+ }
216
+
217
+ if (renderAs === 'percent' || renderAs === 'progress') {
218
+ const num = typeof value === 'number' ? value : Number(value)
219
+ if (isNaN(num)) return <span className="text-muted-foreground">—</span>
220
+ return (
221
+ <span className="font-medium tabular-nums text-sm text-muted-foreground">
222
+ {Math.round(Math.max(0, Math.min(100, num)))}%
223
+ </span>
224
+ )
225
+ }
226
+
227
+ // -----------------------------------------------------------------------
228
+ // Tags
229
+ // -----------------------------------------------------------------------
230
+
231
+ if (renderAs === 'tags') {
232
+ const list: string[] = Array.isArray(value)
233
+ ? value.map(String)
234
+ : String(value).split(',').map((s) => s.trim()).filter(Boolean)
235
+ if (list.length === 0) return <span className="text-muted-foreground">—</span>
236
+ return (
237
+ <span className="inline-flex flex-wrap gap-1">
238
+ {list.map((tag, i) => (
239
+ <Badge key={i} variant="secondary" className="px-1.5 py-0 text-[10px]">
240
+ {tag}
241
+ </Badge>
242
+ ))}
243
+ </span>
244
+ )
245
+ }
246
+
247
+ // -----------------------------------------------------------------------
248
+ // Color
249
+ // -----------------------------------------------------------------------
250
+
251
+ if (renderAs === 'color') {
252
+ const hex = String(value)
253
+ return (
254
+ <span className="inline-flex items-center gap-1.5">
255
+ <span className="h-3.5 w-3.5 rounded border border-border/60 shrink-0" style={{ background: hex }} />
256
+ <code className="font-mono text-xs text-muted-foreground">{hex}</code>
257
+ </span>
258
+ )
259
+ }
260
+
261
+ // -----------------------------------------------------------------------
262
+ // URL / Link
263
+ // -----------------------------------------------------------------------
264
+
265
+ if (renderAs === 'url' || renderAs === 'link') {
266
+ const urlStr = String(value)
267
+ const href = /^https?:\/\//i.test(urlStr) ? urlStr : `https://${urlStr}`
268
+ let label: string
269
+ try { label = new URL(href).hostname } catch { label = urlStr }
270
+ return (
271
+ <a
272
+ href={href}
273
+ target="_blank"
274
+ rel="noopener noreferrer"
275
+ className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
276
+ onClick={(e) => e.stopPropagation()}
277
+ >
278
+ <icons.ExternalLink className="h-3 w-3 shrink-0" />
279
+ <span className="truncate" style={{ maxWidth: 200 }}>{label}</span>
280
+ </a>
281
+ )
282
+ }
283
+
284
+ // -----------------------------------------------------------------------
285
+ // Email
286
+ // -----------------------------------------------------------------------
287
+
288
+ if (renderAs === 'email') {
289
+ const email = String(value)
290
+ return (
291
+ <a
292
+ href={`mailto:${email}`}
293
+ className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
294
+ onClick={(e) => e.stopPropagation()}
295
+ >
296
+ <icons.Mail className="h-3 w-3 shrink-0" />
297
+ <span className="truncate" style={{ maxWidth: 200 }}>{email}</span>
298
+ </a>
299
+ )
300
+ }
301
+
302
+ // -----------------------------------------------------------------------
303
+ // Relation chip (FK / reference)
304
+ // -----------------------------------------------------------------------
305
+
306
+ if (renderAs === 'relation' || renderAs === 'reference' || col.ref) {
307
+ const sv = String(value)
308
+ const chipStyles = relationChipStyles(sv, { isDark })
309
+ return (
310
+ <span
311
+ className="inline-flex items-center rounded-md px-2 py-0.5 text-sm font-medium"
312
+ style={{ ...chipStyles, maxWidth: 180 }}
313
+ title={sv}
314
+ >
315
+ <span className="truncate">{sv}</span>
316
+ </span>
317
+ )
318
+ }
319
+
320
+ // -----------------------------------------------------------------------
321
+ // Creator / User / Avatar — these carry an object (resolved by the backend);
322
+ // in a diff snapshot the value is likely a string (name/email) or the object.
323
+ // -----------------------------------------------------------------------
324
+
325
+ if (renderAs === 'creator' || renderAs === 'user' || renderAs === 'avatar' || renderAs === 'search') {
326
+ const name =
327
+ typeof value === 'object' && value !== null
328
+ ? String((value as any).name ?? (value as any).label ?? JSON.stringify(value))
329
+ : String(value)
330
+ return (
331
+ <span className="inline-flex items-center gap-1.5">
332
+ <Avatar className="h-5 w-5 rounded-full">
333
+ <AvatarImage src="" alt={name} />
334
+ <AvatarFallback className="text-[8px] font-bold bg-primary/10 text-primary">
335
+ {getInitials(name)}
336
+ </AvatarFallback>
337
+ </Avatar>
338
+ <span className="text-sm font-medium truncate" style={{ maxWidth: 180 }}>{name}</span>
339
+ </span>
340
+ )
341
+ }
342
+
343
+ // -----------------------------------------------------------------------
344
+ // Code / truncate-text / phone
345
+ // -----------------------------------------------------------------------
346
+
347
+ if (renderAs === 'code' || renderAs === 'truncate-text') {
348
+ const sv = String(value)
349
+ const maxLength = styleCfg(col, 'max_length', 'maxLength')
350
+ const display = maxLength && sv.length > maxLength ? `${sv.slice(0, maxLength)}…` : sv
351
+ return <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{display}</code>
352
+ }
353
+
354
+ // -----------------------------------------------------------------------
355
+ // Generic object fallback
356
+ // -----------------------------------------------------------------------
357
+
358
+ if (typeof value === 'object') {
359
+ return (
360
+ <span className="text-muted-foreground text-xs font-mono">
361
+ {JSON.stringify(value)}
362
+ </span>
363
+ )
364
+ }
365
+
366
+ // -----------------------------------------------------------------------
367
+ // Plain text fallback
368
+ // -----------------------------------------------------------------------
369
+
370
+ return <span className="font-medium text-sm">{String(value)}</span>
371
+ }
@@ -75,12 +75,19 @@ import { DynamicRecordDialog } from './dialogs/dynamic-record'
75
75
  import { ExportDialog } from './dialogs/export'
76
76
  import { ImportDialog } from './dialogs/import'
77
77
 
78
- interface DynamicTableProps {
78
+ export interface DynamicTableProps {
79
79
  model: string
80
80
  endpoint?: string
81
81
  enableUrlSync?: boolean
82
82
  hiddenColumns?: string[]
83
83
  onAction?: (action: string, row: any) => void
84
+ /**
85
+ * Called when the user clicks anywhere on a data row (not on a checkbox,
86
+ * action button, or interactive element inside the cell). When provided,
87
+ * each row becomes focusable (cursor-pointer). Absent → rows are not
88
+ * clickable and the behaviour is unchanged.
89
+ */
90
+ onRowClick?: (row: any) => void
84
91
  refreshTrigger?: any
85
92
  defaultFilters?: Record<string, any>
86
93
  extraColumns?: ColumnDef<any>[]
@@ -112,6 +119,7 @@ export function DynamicTable({
112
119
  enableUrlSync = true,
113
120
  hiddenColumns = [],
114
121
  onAction,
122
+ onRowClick,
115
123
  refreshTrigger,
116
124
  defaultFilters,
117
125
  extraColumns = [],
@@ -781,14 +789,21 @@ export function DynamicTable({
781
789
  ) : table.getRowModel().rows?.length ? (
782
790
  <>
783
791
  {table.getRowModel().rows.map((row: Row<any>) => (
784
- <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
792
+ <TableRow
793
+ key={row.id}
794
+ data-state={row.getIsSelected() && 'selected'}
795
+ className={cn(onRowClick && 'cursor-pointer')}
796
+ onClick={onRowClick ? () => onRowClick(row.original) : undefined}
797
+ >
785
798
  {row.getVisibleCells().map((cell: Cell<any, unknown>) => {
786
799
  const isActionsColumn = cell.column.id === 'actions'
800
+ const isSelectColumn = cell.column.id === 'select'
787
801
  return (
788
802
  <TableCell
789
803
  key={cell.id}
790
804
  style={cell.column.columnDef.size ? { width: cell.column.columnDef.size } : undefined}
791
805
  className={cn('py-2', isActionsColumn && 'sticky right-0 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]')}
806
+ onClick={(isActionsColumn || isSelectColumn) ? (e: React.MouseEvent) => e.stopPropagation() : undefined}
792
807
  >
793
808
  {flexRender(cell.column.columnDef.cell, cell.getContext())}
794
809
  </TableCell>
@@ -888,7 +903,8 @@ export function DynamicTable({
888
903
  <div
889
904
  key={row.id}
890
905
  data-state={row.getIsSelected() && 'selected'}
891
- className='flex flex-col gap-1.5 rounded-lg border bg-card p-3 data-[state=selected]:border-primary/40'
906
+ className={cn('flex flex-col gap-1.5 rounded-lg border bg-card p-3 data-[state=selected]:border-primary/40', onRowClick && 'cursor-pointer')}
907
+ onClick={onRowClick ? () => onRowClick(row.original) : undefined}
892
908
  >
893
909
  {dataCells.map((cell: Cell<any, unknown>) => {
894
910
  const header = cell.column.columnDef.header
@@ -903,7 +919,10 @@ export function DynamicTable({
903
919
  )
904
920
  })}
905
921
  {actionsCell && (
906
- <div className='flex justify-end border-t pt-2'>
922
+ <div
923
+ className='flex justify-end border-t pt-2'
924
+ onClick={onRowClick ? (e: React.MouseEvent) => e.stopPropagation() : undefined}
925
+ >
907
926
  {flexRender(actionsCell.column.columnDef.cell, actionsCell.getContext())}
908
927
  </div>
909
928
  )}
package/src/index.ts CHANGED
@@ -132,3 +132,20 @@ export {
132
132
  type OrgConfigBridge,
133
133
  } from './use-org-config-bridge'
134
134
  export { registerValidator } from './dynamic-form-schema'
135
+ export {
136
+ ActivityValueRenderer,
137
+ type ActivityValueRendererProps,
138
+ } from './activity-value-renderer'
139
+ export {
140
+ ActivityDiff,
141
+ type ActivityEvent,
142
+ type ActivityDiffProps,
143
+ } from './activity-diff'
144
+ export {
145
+ RecordHistory,
146
+ type RecordHistoryProps,
147
+ } from './record-history'
148
+ export {
149
+ ActivityTimeline,
150
+ type ActivityTimelineProps,
151
+ } from './activity-timeline'