@asteby/metacore-runtime-react 23.2.0 → 23.3.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.
- package/CHANGELOG.md +21 -0
- package/dist/action-modal-dispatcher.js +4 -4
- package/dist/dialogs/dynamic-record.d.ts +5 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +146 -31
- package/dist/display-value.d.ts +56 -0
- package/dist/display-value.d.ts.map +1 -0
- package/dist/display-value.js +74 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +2 -53
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +5 -3
- package/dist/dynamic-table.js +2 -2
- package/package.json +1 -1
- package/src/__tests__/readonly-fields.test.tsx +97 -0
- package/src/__tests__/record-detail-display.test.tsx +113 -0
- package/src/action-modal-dispatcher.tsx +4 -4
- package/src/dialogs/dynamic-record.tsx +217 -40
- package/src/display-value.tsx +134 -0
- package/src/dynamic-columns.tsx +6 -102
- package/src/dynamic-row-actions.tsx +5 -3
- package/src/dynamic-table.tsx +2 -2
|
@@ -56,6 +56,13 @@ import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
|
|
|
56
56
|
import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
|
|
57
57
|
import { humanizeToken } from '../dynamic-columns-helpers'
|
|
58
58
|
import { formatDateCell } from '../dynamic-columns'
|
|
59
|
+
import {
|
|
60
|
+
OptionBadge,
|
|
61
|
+
statusColorFor,
|
|
62
|
+
useIsDarkTheme,
|
|
63
|
+
type DisplayOption,
|
|
64
|
+
} from '../display-value'
|
|
65
|
+
import { generateBadgeStyles } from '@asteby/metacore-ui/lib'
|
|
59
66
|
import { CollectionCell, type ItemField } from '../collection-cell'
|
|
60
67
|
import type { ActionFieldDef, RelationMeta } from '../types'
|
|
61
68
|
import { ImageUrlContext, identityImageUrl, type GetImageUrl } from '../image-url-context'
|
|
@@ -442,6 +449,21 @@ export function isMoneyField(field: FieldDef, value: any): boolean {
|
|
|
442
449
|
)
|
|
443
450
|
}
|
|
444
451
|
|
|
452
|
+
// filterVisibleFields decides which declared fields render in the form for a
|
|
453
|
+
// given mode. `hidden` fields never render. A `readonly` (server/system-
|
|
454
|
+
// generated) field is EXCLUDED on create — the user can't set a value the
|
|
455
|
+
// server will overwrite — but stays visible on edit/view (rendered disabled).
|
|
456
|
+
export function filterVisibleFields(
|
|
457
|
+
fields: FieldDef[] | undefined,
|
|
458
|
+
mode: 'view' | 'edit' | 'create',
|
|
459
|
+
): FieldDef[] {
|
|
460
|
+
return (fields ?? []).filter(f => {
|
|
461
|
+
if (f.hidden) return false
|
|
462
|
+
if (mode === 'create' && f.readonly) return false
|
|
463
|
+
return true
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
|
|
445
467
|
export function DynamicRecordDialog({
|
|
446
468
|
open,
|
|
447
469
|
onOpenChange,
|
|
@@ -557,7 +579,7 @@ export function DynamicRecordDialog({
|
|
|
557
579
|
}
|
|
558
580
|
} catch (err) {
|
|
559
581
|
console.error('[DynamicRecordDialog] load error:', err)
|
|
560
|
-
if (!seed) toast.error('
|
|
582
|
+
if (!seed) toast.error(t('dynamic.load_error', { defaultValue: 'No se pudieron cargar los datos' }))
|
|
561
583
|
} finally {
|
|
562
584
|
if (!cancelled) setLoading(false)
|
|
563
585
|
}
|
|
@@ -662,7 +684,7 @@ export function DynamicRecordDialog({
|
|
|
662
684
|
try {
|
|
663
685
|
if (isCreate && onCreate) {
|
|
664
686
|
const created = await onCreate(formValues)
|
|
665
|
-
toast.success(modalMeta?.messages?.created || 'Registro creado correctamente')
|
|
687
|
+
toast.success(modalMeta?.messages?.created || t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }))
|
|
666
688
|
onSaved?.(created ?? undefined)
|
|
667
689
|
onOpenChange(false)
|
|
668
690
|
return
|
|
@@ -670,7 +692,7 @@ export function DynamicRecordDialog({
|
|
|
670
692
|
|
|
671
693
|
if (!isCreate && recordId && onUpdate) {
|
|
672
694
|
const updated = await onUpdate(String(recordId), formValues)
|
|
673
|
-
toast.success(modalMeta?.messages?.updated || 'Guardado correctamente')
|
|
695
|
+
toast.success(modalMeta?.messages?.updated || t('dynamic.update_success', { defaultValue: 'Guardado correctamente' }))
|
|
674
696
|
onSaved?.(updated ?? undefined)
|
|
675
697
|
onOpenChange(false)
|
|
676
698
|
return
|
|
@@ -693,16 +715,18 @@ export function DynamicRecordDialog({
|
|
|
693
715
|
// endpoint returns a raw English string that would leak into the toast.
|
|
694
716
|
toast.success(
|
|
695
717
|
modalMeta?.messages?.[isCreate ? 'created' : 'updated']
|
|
696
|
-
|| (isCreate
|
|
718
|
+
|| (isCreate
|
|
719
|
+
? t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' })
|
|
720
|
+
: t('dynamic.update_success', { defaultValue: 'Guardado correctamente' })),
|
|
697
721
|
)
|
|
698
722
|
// Hand the persisted record back so callers can auto-select it.
|
|
699
723
|
onSaved?.(res.data?.data ?? res.data ?? undefined)
|
|
700
724
|
onOpenChange(false)
|
|
701
725
|
} else {
|
|
702
|
-
toast.error(res.data?.message || '
|
|
726
|
+
toast.error(res.data?.message || t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }))
|
|
703
727
|
}
|
|
704
728
|
} catch (err: any) {
|
|
705
|
-
toast.error(err?.response?.data?.message || '
|
|
729
|
+
toast.error(err?.response?.data?.message || t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }))
|
|
706
730
|
} finally {
|
|
707
731
|
setSaving(false)
|
|
708
732
|
}
|
|
@@ -716,7 +740,7 @@ export function DynamicRecordDialog({
|
|
|
716
740
|
onOpenChange(false)
|
|
717
741
|
} catch (err: any) {
|
|
718
742
|
console.error('[DynamicRecordDialog] delete error:', err)
|
|
719
|
-
toast.error(err?.response?.data?.message || err?.message || '
|
|
743
|
+
toast.error(err?.response?.data?.message || err?.message || t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }))
|
|
720
744
|
} finally {
|
|
721
745
|
setDeleting(false)
|
|
722
746
|
}
|
|
@@ -724,11 +748,7 @@ export function DynamicRecordDialog({
|
|
|
724
748
|
|
|
725
749
|
const title = modalMeta ? config.getTitle(modalMeta, t) : ''
|
|
726
750
|
|
|
727
|
-
const visibleFields = modalMeta?.fields
|
|
728
|
-
if (f.hidden) return false
|
|
729
|
-
if (isCreate && f.readonly) return false
|
|
730
|
-
return true
|
|
731
|
-
}) ?? []
|
|
751
|
+
const visibleFields = filterVisibleFields(modalMeta?.fields, mode)
|
|
732
752
|
|
|
733
753
|
return (
|
|
734
754
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
@@ -877,19 +897,27 @@ interface FieldRowProps {
|
|
|
877
897
|
}
|
|
878
898
|
|
|
879
899
|
function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
|
|
880
|
-
|
|
900
|
+
// A `readonly` field is server/system-generated (e.g. the GitHub addon's
|
|
901
|
+
// `number`/`github_url`, filled by the API after the outbound create). On
|
|
902
|
+
// CREATE it is excluded from the form entirely (see `visibleFields`); on EDIT
|
|
903
|
+
// it stays visible but is NOT editable — rendered as a disabled, muted input
|
|
904
|
+
// so the user sees its value without being able to change it. View mode keeps
|
|
905
|
+
// the rich read-only renderer.
|
|
906
|
+
const isEditReadonly = mode === 'edit' && !!field.readonly
|
|
881
907
|
|
|
882
908
|
return (
|
|
883
909
|
<div className="flex flex-col gap-1.5">
|
|
884
910
|
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
885
911
|
{field.label}
|
|
886
|
-
{field.required && mode !== 'view' && (
|
|
912
|
+
{field.required && mode !== 'view' && !isEditReadonly && (
|
|
887
913
|
<span className="text-destructive ml-0.5">*</span>
|
|
888
914
|
)}
|
|
889
915
|
</Label>
|
|
890
916
|
|
|
891
|
-
{
|
|
917
|
+
{mode === 'view' ? (
|
|
892
918
|
<ViewValue field={field} value={value} record={record} />
|
|
919
|
+
) : isEditReadonly ? (
|
|
920
|
+
<ReadonlyEditField field={field} value={value} />
|
|
893
921
|
) : (
|
|
894
922
|
<EditField field={field} value={value} onChange={onChange} record={record} />
|
|
895
923
|
)}
|
|
@@ -897,6 +925,24 @@ function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
|
|
|
897
925
|
)
|
|
898
926
|
}
|
|
899
927
|
|
|
928
|
+
// ReadonlyEditField — the edit-mode rendering of a `readonly` (system-generated)
|
|
929
|
+
// field: a disabled, muted input that shows the current value without allowing
|
|
930
|
+
// edits. Booleans render as a disabled switch to match their editable
|
|
931
|
+
// counterpart; everything else renders the formatted display value in a disabled
|
|
932
|
+
// text input.
|
|
933
|
+
export function ReadonlyEditField({ field, value }: { field: FieldDef; value: any }) {
|
|
934
|
+
if (field.type === 'boolean' || typeof value === 'boolean') {
|
|
935
|
+
return (
|
|
936
|
+
<div className="flex items-center gap-2 py-1">
|
|
937
|
+
<Switch checked={!!value} disabled />
|
|
938
|
+
<span className="text-sm text-muted-foreground">{value ? 'Sí' : 'No'}</span>
|
|
939
|
+
</div>
|
|
940
|
+
)
|
|
941
|
+
}
|
|
942
|
+
const display = formatDisplayValue(value, field)
|
|
943
|
+
return <Input value={display === '—' ? '' : display} disabled readOnly className="text-muted-foreground" />
|
|
944
|
+
}
|
|
945
|
+
|
|
900
946
|
// RelationViewValue — read-only FK lead. Resolves the relation's label + image
|
|
901
947
|
// from (1) the sibling object the table served, then (2) the canonical options
|
|
902
948
|
// endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label.
|
|
@@ -975,8 +1021,22 @@ export function ViewValue({
|
|
|
975
1021
|
const timeZone = timeZoneProp ?? ctxTimeZone
|
|
976
1022
|
const currency = currencyProp ?? ctxCurrency
|
|
977
1023
|
|
|
1024
|
+
// Declarative display hint the backend stamps (mirrors the table column's
|
|
1025
|
+
// `cellStyle`). The table renders each cell off `cellStyle ?? type`; the
|
|
1026
|
+
// detail view keys off the SAME resolved renderer so both stay in lock-step
|
|
1027
|
+
// (a `datetime` display on a numeric column, a `url` display on a text
|
|
1028
|
+
// column, a `status`/`badge` pill, …).
|
|
1029
|
+
const renderAs = field.cellStyle ?? field.type
|
|
1030
|
+
|
|
978
1031
|
// created_by / avatar resolver sibling → name (+ avatar) instead of "—".
|
|
979
|
-
if (
|
|
1032
|
+
if (
|
|
1033
|
+
field.type === 'avatar' ||
|
|
1034
|
+
renderAs === 'avatar' ||
|
|
1035
|
+
renderAs === 'creator' ||
|
|
1036
|
+
renderAs === 'user' ||
|
|
1037
|
+
field.key === 'created_by' ||
|
|
1038
|
+
field.key === 'created_by_id'
|
|
1039
|
+
) {
|
|
980
1040
|
const user = createdBySibling(rawValue, record)
|
|
981
1041
|
if (user) {
|
|
982
1042
|
return (
|
|
@@ -1055,15 +1115,22 @@ export function ViewValue({
|
|
|
1055
1115
|
return <IconNameViewValue name={value} />
|
|
1056
1116
|
}
|
|
1057
1117
|
|
|
1058
|
-
|
|
1118
|
+
// URL/link display (matches the table's `url`/`link` cell). Triggers on the
|
|
1119
|
+
// stamped display type — not just the storage `type` — so a text column
|
|
1120
|
+
// carrying `cellStyle:'url'` (e.g. `github_url`) renders as a clickable
|
|
1121
|
+
// external link, opening in a new tab, truncated.
|
|
1122
|
+
if ((renderAs === 'url' || renderAs === 'link') && value) {
|
|
1123
|
+
const urlStr = String(value)
|
|
1124
|
+
const href = /^https?:\/\//i.test(urlStr) ? urlStr : `https://${urlStr}`
|
|
1059
1125
|
return (
|
|
1060
1126
|
<a
|
|
1061
|
-
href={
|
|
1127
|
+
href={href}
|
|
1062
1128
|
target="_blank"
|
|
1063
|
-
rel="noreferrer"
|
|
1064
|
-
className="text-sm text-primary hover:underline
|
|
1129
|
+
rel="noopener noreferrer"
|
|
1130
|
+
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
|
1065
1131
|
>
|
|
1066
|
-
|
|
1132
|
+
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
|
1133
|
+
<span className="truncate max-w-[360px]">{urlStr}</span>
|
|
1067
1134
|
</a>
|
|
1068
1135
|
)
|
|
1069
1136
|
}
|
|
@@ -1088,9 +1155,16 @@ export function ViewValue({
|
|
|
1088
1155
|
|
|
1089
1156
|
// Date/datetime/timestamp → tz-aware format. `date` pins to UTC (calendar
|
|
1090
1157
|
// day); instants render in the org timezone with a full-precision tooltip.
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1158
|
+
// Keys off the display type (`cellStyle ?? type`) so a numeric/epoch column
|
|
1159
|
+
// stamped `datetime` (e.g. `synced_at`) formats as a date, never raw digits.
|
|
1160
|
+
if (
|
|
1161
|
+
renderAs === 'date' ||
|
|
1162
|
+
renderAs === 'datetime' ||
|
|
1163
|
+
renderAs === 'timestamp' ||
|
|
1164
|
+
renderAs === 'timestamptz'
|
|
1165
|
+
) {
|
|
1166
|
+
const dateRenderAs = renderAs === 'date' ? 'date' : renderAs
|
|
1167
|
+
const formatted = formatDateCell(value, dateRenderAs, es, timeZone)
|
|
1094
1168
|
if (formatted) {
|
|
1095
1169
|
return (
|
|
1096
1170
|
<p className="text-sm py-1" title={formatted.title}>
|
|
@@ -1101,27 +1175,56 @@ export function ViewValue({
|
|
|
1101
1175
|
return <p className="text-sm py-1 text-muted-foreground">—</p>
|
|
1102
1176
|
}
|
|
1103
1177
|
|
|
1104
|
-
// Enum/option field with served options → colored/iconed
|
|
1105
|
-
//
|
|
1178
|
+
// Enum/option field with served options → the SAME colored/iconed pill the
|
|
1179
|
+
// table renders (shared `OptionBadge`): resolved color, thumbnail/icon and
|
|
1180
|
+
// the localized option label (e.g. "Almacenable" instead of "storable").
|
|
1106
1181
|
const opt = servedOption(field, value)
|
|
1107
1182
|
if (opt) {
|
|
1108
|
-
const lead: Pick<ResolvedOption, 'image' | 'color' | 'icon'> = {
|
|
1109
|
-
image: opt.image ? getImageUrl(opt.image) : null,
|
|
1110
|
-
color: opt.color ?? null,
|
|
1111
|
-
icon: opt.icon ?? null,
|
|
1112
|
-
}
|
|
1113
1183
|
return (
|
|
1114
|
-
<
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1184
|
+
<div className="py-1">
|
|
1185
|
+
<OptionBadge
|
|
1186
|
+
option={{
|
|
1187
|
+
value: String(opt.value ?? value ?? ''),
|
|
1188
|
+
label: opt.label,
|
|
1189
|
+
color: opt.color ?? undefined,
|
|
1190
|
+
icon: opt.icon ?? undefined,
|
|
1191
|
+
image: opt.image ?? undefined,
|
|
1192
|
+
}}
|
|
1193
|
+
getImageUrl={getImageUrl}
|
|
1194
|
+
/>
|
|
1195
|
+
</div>
|
|
1122
1196
|
)
|
|
1123
1197
|
}
|
|
1124
1198
|
|
|
1199
|
+
// Array of scalars / label objects (e.g. github `labels`, tags, a
|
|
1200
|
+
// group-badge list) → a row of pills, mirroring the table's `tags` /
|
|
1201
|
+
// `relation-badge-list` cells. Checked before the structured-object branch
|
|
1202
|
+
// so a flat label array never renders as a mini-table.
|
|
1203
|
+
if (
|
|
1204
|
+
Array.isArray(value) &&
|
|
1205
|
+
(renderAs === 'tags' ||
|
|
1206
|
+
renderAs === 'relation-badge-list' ||
|
|
1207
|
+
value.every((v) => v === null || typeof v !== 'object' || 'label' in v || 'name' in v))
|
|
1208
|
+
) {
|
|
1209
|
+
return <BadgeListViewValue items={value} getImageUrl={getImageUrl} />
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Status / badge / select display with no served option list — a bare enum
|
|
1213
|
+
// token (e.g. a kanban `stage` like "backlog"). Render a colored pill with a
|
|
1214
|
+
// semantic/value-derived color and a localized-or-humanized label, matching
|
|
1215
|
+
// the table's `status`/`badge` cells.
|
|
1216
|
+
if (
|
|
1217
|
+
(renderAs === 'status' ||
|
|
1218
|
+
renderAs === 'badge' ||
|
|
1219
|
+
renderAs === 'select' ||
|
|
1220
|
+
renderAs === 'option') &&
|
|
1221
|
+
value !== null &&
|
|
1222
|
+
value !== undefined &&
|
|
1223
|
+
typeof value !== 'object'
|
|
1224
|
+
) {
|
|
1225
|
+
return <StatusBadgeViewValue field={field} value={value} t={t} />
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1125
1228
|
// Structured value (jsonb column, e.g. fiscal_data) with no label/name/title
|
|
1126
1229
|
// to surface — render readable key/value pairs instead of falling through to
|
|
1127
1230
|
// String(value) ("[object Object]").
|
|
@@ -1163,6 +1266,79 @@ function IconNameViewValue({ name }: { name: string }) {
|
|
|
1163
1266
|
)
|
|
1164
1267
|
}
|
|
1165
1268
|
|
|
1269
|
+
// StatusBadgeViewValue — a bare enum/status token (no served option list) as a
|
|
1270
|
+
// colored pill: a semantic/value-derived color (same `statusColorFor` the table
|
|
1271
|
+
// uses) plus a localized-or-humanized label. Mirrors the table's `status`/
|
|
1272
|
+
// `badge` cell so a kanban `stage` ("backlog") reads as a colored, translated
|
|
1273
|
+
// badge instead of the raw token.
|
|
1274
|
+
function StatusBadgeViewValue({
|
|
1275
|
+
field,
|
|
1276
|
+
value,
|
|
1277
|
+
t,
|
|
1278
|
+
}: {
|
|
1279
|
+
field: FieldDef
|
|
1280
|
+
value: any
|
|
1281
|
+
t: (key: string, options?: any) => string
|
|
1282
|
+
}) {
|
|
1283
|
+
const isDark = useIsDarkTheme()
|
|
1284
|
+
const token = String(value)
|
|
1285
|
+
// Prefer an explicit per-option color served on the field (metadata.stages /
|
|
1286
|
+
// options), else derive a semantic color from the token.
|
|
1287
|
+
const declared = field.options?.find((o) => String(o.value) === token)
|
|
1288
|
+
const color = declared?.color || statusColorFor(token)
|
|
1289
|
+
// Localized label: the option's already-localized label wins, then a manifest
|
|
1290
|
+
// i18n key matching the raw token, then a humanized fallback.
|
|
1291
|
+
const label =
|
|
1292
|
+
declared?.label ?? t(token, { defaultValue: humanizeToken(token) })
|
|
1293
|
+
return (
|
|
1294
|
+
<div className="py-1">
|
|
1295
|
+
<Badge
|
|
1296
|
+
variant="outline"
|
|
1297
|
+
className="border-0 flex w-fit items-center gap-1"
|
|
1298
|
+
style={generateBadgeStyles(color, { isDark })}
|
|
1299
|
+
>
|
|
1300
|
+
{label}
|
|
1301
|
+
</Badge>
|
|
1302
|
+
</div>
|
|
1303
|
+
)
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// BadgeListViewValue — an array of scalars / label objects as a row of pills
|
|
1307
|
+
// (github `labels`, tags, a group-badge list). Objects carrying a `color` render
|
|
1308
|
+
// as colored `OptionBadge`s; plain strings render as neutral secondary pills.
|
|
1309
|
+
function BadgeListViewValue({
|
|
1310
|
+
items,
|
|
1311
|
+
getImageUrl,
|
|
1312
|
+
}: {
|
|
1313
|
+
items: any[]
|
|
1314
|
+
getImageUrl: GetImageUrl
|
|
1315
|
+
}) {
|
|
1316
|
+
if (!items || items.length === 0) {
|
|
1317
|
+
return <p className="text-sm py-1 text-muted-foreground">—</p>
|
|
1318
|
+
}
|
|
1319
|
+
return (
|
|
1320
|
+
<div className="flex flex-wrap gap-1 py-1">
|
|
1321
|
+
{items.map((item, i) => {
|
|
1322
|
+
if (item !== null && typeof item === 'object') {
|
|
1323
|
+
const opt: DisplayOption = {
|
|
1324
|
+
value: String(item.value ?? item.id ?? item.name ?? item.label ?? i),
|
|
1325
|
+
label: String(item.label ?? item.name ?? item.value ?? ''),
|
|
1326
|
+
color: item.color ?? undefined,
|
|
1327
|
+
icon: item.icon ?? undefined,
|
|
1328
|
+
image: item.image ?? item.avatar ?? undefined,
|
|
1329
|
+
}
|
|
1330
|
+
return <OptionBadge key={i} option={opt} getImageUrl={getImageUrl} />
|
|
1331
|
+
}
|
|
1332
|
+
return (
|
|
1333
|
+
<Badge key={i} variant="secondary" className="w-fit">
|
|
1334
|
+
{String(item)}
|
|
1335
|
+
</Badge>
|
|
1336
|
+
)
|
|
1337
|
+
})}
|
|
1338
|
+
</div>
|
|
1339
|
+
)
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1166
1342
|
// StructuredViewValue renders a jsonb object/array that has no resolvable label.
|
|
1167
1343
|
// It delegates to the shared `CollectionCell` in `'inline'` mode so the detail
|
|
1168
1344
|
// view gets the SAME pro rendering as the table: a declared `item_fields` schema
|
|
@@ -1392,6 +1568,7 @@ export function EditField({ field, value, onChange, record }: {
|
|
|
1392
1568
|
}
|
|
1393
1569
|
|
|
1394
1570
|
function ImageUploadField({ field: _field, value, onChange }: { field: FieldDef; value: any; onChange: (val: any) => void }) {
|
|
1571
|
+
const { t } = useTranslation()
|
|
1395
1572
|
const api = useApi()
|
|
1396
1573
|
const model = useContext(ModelContext)
|
|
1397
1574
|
const getImageUrl = useContext(ImageUrlContext)
|
|
@@ -1411,7 +1588,7 @@ function ImageUploadField({ field: _field, value, onChange }: { field: FieldDef;
|
|
|
1411
1588
|
const url = res.data?.data?.url || res.data?.url
|
|
1412
1589
|
if (url) onChange(url)
|
|
1413
1590
|
} catch {
|
|
1414
|
-
toast.error('
|
|
1591
|
+
toast.error(t('dynamic.image_upload_error', { defaultValue: 'No se pudo subir la imagen' }))
|
|
1415
1592
|
} finally {
|
|
1416
1593
|
setUploading(false)
|
|
1417
1594
|
if (inputRef.current) inputRef.current.value = ''
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared display-value primitives.
|
|
3
|
+
*
|
|
4
|
+
* These are the building blocks the dynamic TABLE (`dynamic-columns.tsx`) uses
|
|
5
|
+
* to render "pro" cells — colored option badges, relation thumbnails, semantic
|
|
6
|
+
* status colors, dark-mode detection. They live here (instead of inline in the
|
|
7
|
+
* table) so the read-only DETAIL DIALOG (`dialogs/dynamic-record.tsx`) can reuse
|
|
8
|
+
* the EXACT same rendering. Ecosystem rule: shared primitives, zero copy-paste —
|
|
9
|
+
* table and dialog must not drift.
|
|
10
|
+
*/
|
|
11
|
+
import React from 'react'
|
|
12
|
+
import { Avatar, AvatarImage, AvatarFallback, Badge } from '@asteby/metacore-ui'
|
|
13
|
+
import {
|
|
14
|
+
generateBadgeStyles,
|
|
15
|
+
getInitials,
|
|
16
|
+
optionColor,
|
|
17
|
+
} from '@asteby/metacore-ui/lib'
|
|
18
|
+
import { DynamicIcon } from './dynamic-icon'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `true` when the host document is in dark mode. Observes the `<html>` class so
|
|
22
|
+
* badge colors re-derive on theme toggle. SSR/no-DOM safe (starts light).
|
|
23
|
+
*/
|
|
24
|
+
export function useIsDarkTheme(): boolean {
|
|
25
|
+
const [isDark, setIsDark] = React.useState(
|
|
26
|
+
() =>
|
|
27
|
+
typeof document !== 'undefined' &&
|
|
28
|
+
document.documentElement.classList.contains('dark')
|
|
29
|
+
)
|
|
30
|
+
React.useEffect(() => {
|
|
31
|
+
if (typeof document === 'undefined') return
|
|
32
|
+
const sync = () =>
|
|
33
|
+
setIsDark(document.documentElement.classList.contains('dark'))
|
|
34
|
+
sync()
|
|
35
|
+
const observer = new MutationObserver(sync)
|
|
36
|
+
observer.observe(document.documentElement, {
|
|
37
|
+
attributes: true,
|
|
38
|
+
attributeFilter: ['class'],
|
|
39
|
+
})
|
|
40
|
+
return () => observer.disconnect()
|
|
41
|
+
}, [])
|
|
42
|
+
return isDark
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Semantic status → badge color. Used by the `status` cell/value when no
|
|
47
|
+
* explicit `options` color is declared. Generic, value-driven mapping.
|
|
48
|
+
*/
|
|
49
|
+
export function statusColorFor(value: string): string {
|
|
50
|
+
const v = value.toLowerCase()
|
|
51
|
+
if (
|
|
52
|
+
['active', 'enabled', 'paid', 'completed', 'done', 'success', 'approved', 'open']
|
|
53
|
+
.includes(v)
|
|
54
|
+
)
|
|
55
|
+
return '#22c55e'
|
|
56
|
+
if (['pending', 'draft', 'processing', 'in_progress', 'review', 'waiting'].includes(v))
|
|
57
|
+
return '#eab308'
|
|
58
|
+
if (
|
|
59
|
+
['inactive', 'disabled', 'cancelled', 'canceled', 'failed', 'rejected', 'error', 'closed']
|
|
60
|
+
.includes(v)
|
|
61
|
+
)
|
|
62
|
+
return '#ef4444'
|
|
63
|
+
return '#6b7280'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Tiny square thumbnail for a resolved relation/option that carries an `image`
|
|
68
|
+
* (brand logo, product photo, customer/user avatar). Uses the same Avatar
|
|
69
|
+
* primitive as the `avatar`/`creator` cells so a broken/loading image
|
|
70
|
+
* gracefully falls back to the record's initials. Sized small (inline style so
|
|
71
|
+
* an addon-arbitrary Tailwind class never gets dropped by a consuming app's
|
|
72
|
+
* class scan). Rendered inline alongside a label — never alone.
|
|
73
|
+
*/
|
|
74
|
+
export const RelationThumbnail: React.FC<{
|
|
75
|
+
src: string
|
|
76
|
+
alt: string
|
|
77
|
+
getImageUrl?: (path: string) => string
|
|
78
|
+
size?: number
|
|
79
|
+
}> = ({ src, alt, getImageUrl, size = 18 }) => (
|
|
80
|
+
<Avatar
|
|
81
|
+
className="shrink-0 rounded-sm ring-1 ring-border/40"
|
|
82
|
+
style={{ width: size, height: size }}
|
|
83
|
+
>
|
|
84
|
+
<AvatarImage
|
|
85
|
+
src={getImageUrl ? getImageUrl(src) : src}
|
|
86
|
+
alt={alt}
|
|
87
|
+
className="object-cover"
|
|
88
|
+
/>
|
|
89
|
+
<AvatarFallback className="rounded-sm bg-primary/10 text-[8px] font-bold text-primary">
|
|
90
|
+
{getInitials(alt)}
|
|
91
|
+
</AvatarFallback>
|
|
92
|
+
</Avatar>
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
export interface DisplayOption {
|
|
96
|
+
value: string
|
|
97
|
+
label: string
|
|
98
|
+
icon?: string
|
|
99
|
+
color?: string
|
|
100
|
+
image?: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Colored option badge — the canonical "pro" pill for a select/status/badge
|
|
105
|
+
* value. Explicit backend `color` wins; otherwise a stable, cohesive color is
|
|
106
|
+
* derived from the option value so "dead" gray badges come alive. Inline style
|
|
107
|
+
* (hex-derived) so it works regardless of the host's tailwind safelist —
|
|
108
|
+
* addon-arbitrary classes aren't in the host scan.
|
|
109
|
+
*/
|
|
110
|
+
export const OptionBadge: React.FC<{
|
|
111
|
+
option: DisplayOption
|
|
112
|
+
getImageUrl?: (path: string) => string
|
|
113
|
+
/** Accepted for call-site compatibility with the table; unused (option.label wins). */
|
|
114
|
+
fallback?: string
|
|
115
|
+
}> = ({ option, getImageUrl }) => {
|
|
116
|
+
const isDark = useIsDarkTheme()
|
|
117
|
+
const colorSource = option.color || optionColor(option.value || option.label)
|
|
118
|
+
const colorStyles = generateBadgeStyles(colorSource, { isDark })
|
|
119
|
+
return (
|
|
120
|
+
<Badge variant="outline" className="flex items-center gap-1 border-0" style={colorStyles}>
|
|
121
|
+
{option.image ? (
|
|
122
|
+
<RelationThumbnail
|
|
123
|
+
src={option.image}
|
|
124
|
+
alt={option.label}
|
|
125
|
+
getImageUrl={getImageUrl}
|
|
126
|
+
size={16}
|
|
127
|
+
/>
|
|
128
|
+
) : (
|
|
129
|
+
option.icon && <DynamicIcon name={option.icon} className="h-3.5 w-3.5" />
|
|
130
|
+
)}
|
|
131
|
+
<span>{option.label}</span>
|
|
132
|
+
</Badge>
|
|
133
|
+
)
|
|
134
|
+
}
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -38,11 +38,16 @@ import {
|
|
|
38
38
|
import {
|
|
39
39
|
generateBadgeStyles,
|
|
40
40
|
getInitials,
|
|
41
|
-
optionColor,
|
|
42
41
|
relationChipStyles,
|
|
43
42
|
} from '@asteby/metacore-ui/lib'
|
|
44
43
|
import { Progress } from './dialogs/_primitives'
|
|
45
44
|
import { humanizeToken } from './dynamic-columns-helpers'
|
|
45
|
+
import {
|
|
46
|
+
OptionBadge,
|
|
47
|
+
RelationThumbnail,
|
|
48
|
+
statusColorFor,
|
|
49
|
+
useIsDarkTheme,
|
|
50
|
+
} from './display-value'
|
|
46
51
|
import { OptionsContext } from './options-context'
|
|
47
52
|
import { DynamicIcon, isLucideIconName } from './dynamic-icon'
|
|
48
53
|
import { CollectionCell } from './collection-cell'
|
|
@@ -158,27 +163,6 @@ export const formatAggregateTotal = (
|
|
|
158
163
|
)
|
|
159
164
|
}
|
|
160
165
|
|
|
161
|
-
/**
|
|
162
|
-
* Semantic status → badge color. Used by the `status` cell when no explicit
|
|
163
|
-
* `options` color is declared. Generic, value-driven mapping.
|
|
164
|
-
*/
|
|
165
|
-
const statusColorFor = (value: string): string => {
|
|
166
|
-
const v = value.toLowerCase()
|
|
167
|
-
if (
|
|
168
|
-
['active', 'enabled', 'paid', 'completed', 'done', 'success', 'approved', 'open']
|
|
169
|
-
.includes(v)
|
|
170
|
-
)
|
|
171
|
-
return '#22c55e'
|
|
172
|
-
if (['pending', 'draft', 'processing', 'in_progress', 'review', 'waiting'].includes(v))
|
|
173
|
-
return '#eab308'
|
|
174
|
-
if (
|
|
175
|
-
['inactive', 'disabled', 'cancelled', 'canceled', 'failed', 'rejected', 'error', 'closed']
|
|
176
|
-
.includes(v)
|
|
177
|
-
)
|
|
178
|
-
return '#ef4444'
|
|
179
|
-
return '#6b7280'
|
|
180
|
-
}
|
|
181
|
-
|
|
182
166
|
/** Copyable monospaced text cell (code/IDs/hashes). */
|
|
183
167
|
const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLength }) => {
|
|
184
168
|
const [copied, setCopied] = React.useState(false)
|
|
@@ -293,26 +277,6 @@ const getValueFromPathVariants = (obj: any, path?: string) => {
|
|
|
293
277
|
return undefined
|
|
294
278
|
}
|
|
295
279
|
|
|
296
|
-
const useIsDarkTheme = () => {
|
|
297
|
-
const [isDark, setIsDark] = React.useState(() =>
|
|
298
|
-
typeof document !== 'undefined' &&
|
|
299
|
-
document.documentElement.classList.contains('dark')
|
|
300
|
-
)
|
|
301
|
-
React.useEffect(() => {
|
|
302
|
-
if (typeof document === 'undefined') return
|
|
303
|
-
const sync = () =>
|
|
304
|
-
setIsDark(document.documentElement.classList.contains('dark'))
|
|
305
|
-
sync()
|
|
306
|
-
const observer = new MutationObserver(sync)
|
|
307
|
-
observer.observe(document.documentElement, {
|
|
308
|
-
attributes: true,
|
|
309
|
-
attributeFilter: ['class'],
|
|
310
|
-
})
|
|
311
|
-
return () => observer.disconnect()
|
|
312
|
-
}, [])
|
|
313
|
-
return isDark
|
|
314
|
-
}
|
|
315
|
-
|
|
316
280
|
const renderRelationBadges = (items: any, col: ColumnDefinition) => {
|
|
317
281
|
if (!Array.isArray(items) || items.length === 0) {
|
|
318
282
|
return <span className="text-muted-foreground">-</span>
|
|
@@ -359,66 +323,6 @@ const renderRelationBadges = (items: any, col: ColumnDefinition) => {
|
|
|
359
323
|
)
|
|
360
324
|
}
|
|
361
325
|
|
|
362
|
-
/**
|
|
363
|
-
* Tiny square thumbnail for a resolved relation/option that carries an `image`
|
|
364
|
-
* (brand logo, product photo, customer/user avatar). Uses the same Avatar
|
|
365
|
-
* primitive as the `avatar`/`creator` cells so a broken/loading image
|
|
366
|
-
* gracefully falls back to the record's initials. Sized small (the box is an
|
|
367
|
-
* inline style so an addon-arbitrary Tailwind class never gets dropped by a
|
|
368
|
-
* consuming app's class scan). Rendered inline alongside a label — never alone.
|
|
369
|
-
*/
|
|
370
|
-
const RelationThumbnail: React.FC<{
|
|
371
|
-
src: string
|
|
372
|
-
alt: string
|
|
373
|
-
getImageUrl?: (path: string) => string
|
|
374
|
-
size?: number
|
|
375
|
-
}> = ({ src, alt, getImageUrl, size = 18 }) => (
|
|
376
|
-
<Avatar
|
|
377
|
-
className="shrink-0 rounded-sm ring-1 ring-border/40"
|
|
378
|
-
style={{ width: size, height: size }}
|
|
379
|
-
>
|
|
380
|
-
<AvatarImage
|
|
381
|
-
src={getImageUrl ? getImageUrl(src) : src}
|
|
382
|
-
alt={alt}
|
|
383
|
-
className="object-cover"
|
|
384
|
-
/>
|
|
385
|
-
<AvatarFallback className="rounded-sm bg-primary/10 text-[8px] font-bold text-primary">
|
|
386
|
-
{getInitials(alt)}
|
|
387
|
-
</AvatarFallback>
|
|
388
|
-
</Avatar>
|
|
389
|
-
)
|
|
390
|
-
|
|
391
|
-
interface OptionBadgeProps {
|
|
392
|
-
option: { value: string; label: string; icon?: string; color?: string; image?: string }
|
|
393
|
-
fallback: string
|
|
394
|
-
getImageUrl?: (path: string) => string
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const OptionBadge: React.FC<OptionBadgeProps> = ({ option, getImageUrl }) => {
|
|
398
|
-
const isDark = useIsDarkTheme()
|
|
399
|
-
// Explicit backend color wins; otherwise derive a stable, cohesive color
|
|
400
|
-
// from the option's value (fallback label) so "dead" gray badges come
|
|
401
|
-
// alive. Inline style (hex-derived) so it works regardless of the host's
|
|
402
|
-
// tailwind safelist — addon-arbitrary classes aren't in the host scan.
|
|
403
|
-
const colorSource = option.color || optionColor(option.value || option.label)
|
|
404
|
-
const colorStyles = generateBadgeStyles(colorSource, { isDark })
|
|
405
|
-
return (
|
|
406
|
-
<Badge variant="outline" className="flex items-center gap-1 border-0" style={colorStyles}>
|
|
407
|
-
{option.image ? (
|
|
408
|
-
<RelationThumbnail
|
|
409
|
-
src={option.image}
|
|
410
|
-
alt={option.label}
|
|
411
|
-
getImageUrl={getImageUrl}
|
|
412
|
-
size={16}
|
|
413
|
-
/>
|
|
414
|
-
) : (
|
|
415
|
-
option.icon && <DynamicIcon name={option.icon} className="h-3.5 w-3.5" />
|
|
416
|
-
)}
|
|
417
|
-
<span>{option.label}</span>
|
|
418
|
-
</Badge>
|
|
419
|
-
)
|
|
420
|
-
}
|
|
421
|
-
|
|
422
326
|
const BadgeWithEndpointOptions: React.FC<{
|
|
423
327
|
endpoint: string
|
|
424
328
|
value: any
|