@asteby/metacore-runtime-react 23.2.0 → 23.4.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 +33 -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 +155 -34
- 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 +13 -72
- 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/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/rich-url.d.ts +119 -0
- package/dist/rich-url.d.ts.map +1 -0
- package/dist/rich-url.js +224 -0
- 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/__tests__/rich-url.test.tsx +196 -0
- package/src/action-modal-dispatcher.tsx +4 -4
- package/src/dialogs/dynamic-record.tsx +244 -48
- package/src/display-value.tsx +134 -0
- package/src/dynamic-columns.tsx +24 -128
- package/src/dynamic-row-actions.tsx +5 -3
- package/src/dynamic-table.tsx +2 -2
- package/src/index.ts +17 -0
- package/src/rich-url.tsx +359 -0
|
@@ -56,6 +56,14 @@ 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 { MediaValue, RichText } from '../rich-url'
|
|
66
|
+
import { generateBadgeStyles } from '@asteby/metacore-ui/lib'
|
|
59
67
|
import { CollectionCell, type ItemField } from '../collection-cell'
|
|
60
68
|
import type { ActionFieldDef, RelationMeta } from '../types'
|
|
61
69
|
import { ImageUrlContext, identityImageUrl, type GetImageUrl } from '../image-url-context'
|
|
@@ -442,6 +450,21 @@ export function isMoneyField(field: FieldDef, value: any): boolean {
|
|
|
442
450
|
)
|
|
443
451
|
}
|
|
444
452
|
|
|
453
|
+
// filterVisibleFields decides which declared fields render in the form for a
|
|
454
|
+
// given mode. `hidden` fields never render. A `readonly` (server/system-
|
|
455
|
+
// generated) field is EXCLUDED on create — the user can't set a value the
|
|
456
|
+
// server will overwrite — but stays visible on edit/view (rendered disabled).
|
|
457
|
+
export function filterVisibleFields(
|
|
458
|
+
fields: FieldDef[] | undefined,
|
|
459
|
+
mode: 'view' | 'edit' | 'create',
|
|
460
|
+
): FieldDef[] {
|
|
461
|
+
return (fields ?? []).filter(f => {
|
|
462
|
+
if (f.hidden) return false
|
|
463
|
+
if (mode === 'create' && f.readonly) return false
|
|
464
|
+
return true
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
|
|
445
468
|
export function DynamicRecordDialog({
|
|
446
469
|
open,
|
|
447
470
|
onOpenChange,
|
|
@@ -557,7 +580,7 @@ export function DynamicRecordDialog({
|
|
|
557
580
|
}
|
|
558
581
|
} catch (err) {
|
|
559
582
|
console.error('[DynamicRecordDialog] load error:', err)
|
|
560
|
-
if (!seed) toast.error('
|
|
583
|
+
if (!seed) toast.error(t('dynamic.load_error', { defaultValue: 'No se pudieron cargar los datos' }))
|
|
561
584
|
} finally {
|
|
562
585
|
if (!cancelled) setLoading(false)
|
|
563
586
|
}
|
|
@@ -662,7 +685,7 @@ export function DynamicRecordDialog({
|
|
|
662
685
|
try {
|
|
663
686
|
if (isCreate && onCreate) {
|
|
664
687
|
const created = await onCreate(formValues)
|
|
665
|
-
toast.success(modalMeta?.messages?.created || 'Registro creado correctamente')
|
|
688
|
+
toast.success(modalMeta?.messages?.created || t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }))
|
|
666
689
|
onSaved?.(created ?? undefined)
|
|
667
690
|
onOpenChange(false)
|
|
668
691
|
return
|
|
@@ -670,7 +693,7 @@ export function DynamicRecordDialog({
|
|
|
670
693
|
|
|
671
694
|
if (!isCreate && recordId && onUpdate) {
|
|
672
695
|
const updated = await onUpdate(String(recordId), formValues)
|
|
673
|
-
toast.success(modalMeta?.messages?.updated || 'Guardado correctamente')
|
|
696
|
+
toast.success(modalMeta?.messages?.updated || t('dynamic.update_success', { defaultValue: 'Guardado correctamente' }))
|
|
674
697
|
onSaved?.(updated ?? undefined)
|
|
675
698
|
onOpenChange(false)
|
|
676
699
|
return
|
|
@@ -693,16 +716,18 @@ export function DynamicRecordDialog({
|
|
|
693
716
|
// endpoint returns a raw English string that would leak into the toast.
|
|
694
717
|
toast.success(
|
|
695
718
|
modalMeta?.messages?.[isCreate ? 'created' : 'updated']
|
|
696
|
-
|| (isCreate
|
|
719
|
+
|| (isCreate
|
|
720
|
+
? t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' })
|
|
721
|
+
: t('dynamic.update_success', { defaultValue: 'Guardado correctamente' })),
|
|
697
722
|
)
|
|
698
723
|
// Hand the persisted record back so callers can auto-select it.
|
|
699
724
|
onSaved?.(res.data?.data ?? res.data ?? undefined)
|
|
700
725
|
onOpenChange(false)
|
|
701
726
|
} else {
|
|
702
|
-
toast.error(res.data?.message || '
|
|
727
|
+
toast.error(res.data?.message || t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }))
|
|
703
728
|
}
|
|
704
729
|
} catch (err: any) {
|
|
705
|
-
toast.error(err?.response?.data?.message || '
|
|
730
|
+
toast.error(err?.response?.data?.message || t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }))
|
|
706
731
|
} finally {
|
|
707
732
|
setSaving(false)
|
|
708
733
|
}
|
|
@@ -716,7 +741,7 @@ export function DynamicRecordDialog({
|
|
|
716
741
|
onOpenChange(false)
|
|
717
742
|
} catch (err: any) {
|
|
718
743
|
console.error('[DynamicRecordDialog] delete error:', err)
|
|
719
|
-
toast.error(err?.response?.data?.message || err?.message || '
|
|
744
|
+
toast.error(err?.response?.data?.message || err?.message || t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }))
|
|
720
745
|
} finally {
|
|
721
746
|
setDeleting(false)
|
|
722
747
|
}
|
|
@@ -724,11 +749,7 @@ export function DynamicRecordDialog({
|
|
|
724
749
|
|
|
725
750
|
const title = modalMeta ? config.getTitle(modalMeta, t) : ''
|
|
726
751
|
|
|
727
|
-
const visibleFields = modalMeta?.fields
|
|
728
|
-
if (f.hidden) return false
|
|
729
|
-
if (isCreate && f.readonly) return false
|
|
730
|
-
return true
|
|
731
|
-
}) ?? []
|
|
752
|
+
const visibleFields = filterVisibleFields(modalMeta?.fields, mode)
|
|
732
753
|
|
|
733
754
|
return (
|
|
734
755
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
@@ -877,19 +898,27 @@ interface FieldRowProps {
|
|
|
877
898
|
}
|
|
878
899
|
|
|
879
900
|
function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
|
|
880
|
-
|
|
901
|
+
// A `readonly` field is server/system-generated (e.g. the GitHub addon's
|
|
902
|
+
// `number`/`github_url`, filled by the API after the outbound create). On
|
|
903
|
+
// CREATE it is excluded from the form entirely (see `visibleFields`); on EDIT
|
|
904
|
+
// it stays visible but is NOT editable — rendered as a disabled, muted input
|
|
905
|
+
// so the user sees its value without being able to change it. View mode keeps
|
|
906
|
+
// the rich read-only renderer.
|
|
907
|
+
const isEditReadonly = mode === 'edit' && !!field.readonly
|
|
881
908
|
|
|
882
909
|
return (
|
|
883
910
|
<div className="flex flex-col gap-1.5">
|
|
884
911
|
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
885
912
|
{field.label}
|
|
886
|
-
{field.required && mode !== 'view' && (
|
|
913
|
+
{field.required && mode !== 'view' && !isEditReadonly && (
|
|
887
914
|
<span className="text-destructive ml-0.5">*</span>
|
|
888
915
|
)}
|
|
889
916
|
</Label>
|
|
890
917
|
|
|
891
|
-
{
|
|
918
|
+
{mode === 'view' ? (
|
|
892
919
|
<ViewValue field={field} value={value} record={record} />
|
|
920
|
+
) : isEditReadonly ? (
|
|
921
|
+
<ReadonlyEditField field={field} value={value} />
|
|
893
922
|
) : (
|
|
894
923
|
<EditField field={field} value={value} onChange={onChange} record={record} />
|
|
895
924
|
)}
|
|
@@ -897,6 +926,24 @@ function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
|
|
|
897
926
|
)
|
|
898
927
|
}
|
|
899
928
|
|
|
929
|
+
// ReadonlyEditField — the edit-mode rendering of a `readonly` (system-generated)
|
|
930
|
+
// field: a disabled, muted input that shows the current value without allowing
|
|
931
|
+
// edits. Booleans render as a disabled switch to match their editable
|
|
932
|
+
// counterpart; everything else renders the formatted display value in a disabled
|
|
933
|
+
// text input.
|
|
934
|
+
export function ReadonlyEditField({ field, value }: { field: FieldDef; value: any }) {
|
|
935
|
+
if (field.type === 'boolean' || typeof value === 'boolean') {
|
|
936
|
+
return (
|
|
937
|
+
<div className="flex items-center gap-2 py-1">
|
|
938
|
+
<Switch checked={!!value} disabled />
|
|
939
|
+
<span className="text-sm text-muted-foreground">{value ? 'Sí' : 'No'}</span>
|
|
940
|
+
</div>
|
|
941
|
+
)
|
|
942
|
+
}
|
|
943
|
+
const display = formatDisplayValue(value, field)
|
|
944
|
+
return <Input value={display === '—' ? '' : display} disabled readOnly className="text-muted-foreground" />
|
|
945
|
+
}
|
|
946
|
+
|
|
900
947
|
// RelationViewValue — read-only FK lead. Resolves the relation's label + image
|
|
901
948
|
// from (1) the sibling object the table served, then (2) the canonical options
|
|
902
949
|
// endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label.
|
|
@@ -975,8 +1022,22 @@ export function ViewValue({
|
|
|
975
1022
|
const timeZone = timeZoneProp ?? ctxTimeZone
|
|
976
1023
|
const currency = currencyProp ?? ctxCurrency
|
|
977
1024
|
|
|
1025
|
+
// Declarative display hint the backend stamps (mirrors the table column's
|
|
1026
|
+
// `cellStyle`). The table renders each cell off `cellStyle ?? type`; the
|
|
1027
|
+
// detail view keys off the SAME resolved renderer so both stay in lock-step
|
|
1028
|
+
// (a `datetime` display on a numeric column, a `url` display on a text
|
|
1029
|
+
// column, a `status`/`badge` pill, …).
|
|
1030
|
+
const renderAs = field.cellStyle ?? field.type
|
|
1031
|
+
|
|
978
1032
|
// created_by / avatar resolver sibling → name (+ avatar) instead of "—".
|
|
979
|
-
if (
|
|
1033
|
+
if (
|
|
1034
|
+
field.type === 'avatar' ||
|
|
1035
|
+
renderAs === 'avatar' ||
|
|
1036
|
+
renderAs === 'creator' ||
|
|
1037
|
+
renderAs === 'user' ||
|
|
1038
|
+
field.key === 'created_by' ||
|
|
1039
|
+
field.key === 'created_by_id'
|
|
1040
|
+
) {
|
|
980
1041
|
const user = createdBySibling(rawValue, record)
|
|
981
1042
|
if (user) {
|
|
982
1043
|
return (
|
|
@@ -1055,16 +1116,25 @@ export function ViewValue({
|
|
|
1055
1116
|
return <IconNameViewValue name={value} />
|
|
1056
1117
|
}
|
|
1057
1118
|
|
|
1058
|
-
|
|
1119
|
+
// URL/link display (matches the table's `url`/`link` cell). Triggers on the
|
|
1120
|
+
// stamped display type — not just the storage `type` — so a text column
|
|
1121
|
+
// carrying `cellStyle:'url'` (e.g. `github_url`) renders as a clickable
|
|
1122
|
+
// external link, opening in a new tab, truncated.
|
|
1123
|
+
if ((renderAs === 'url' || renderAs === 'link') && value) {
|
|
1124
|
+
// Shared media renderer (same as the table cell): an image URL shows a
|
|
1125
|
+
// larger inline thumbnail, a file a chip, else a compact link chip with
|
|
1126
|
+
// a smart label — never the raw 120-char URL.
|
|
1059
1127
|
return (
|
|
1060
|
-
<
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1128
|
+
<div className="py-1">
|
|
1129
|
+
<MediaValue
|
|
1130
|
+
url={String(value)}
|
|
1131
|
+
getImageUrl={getImageUrl}
|
|
1132
|
+
label={field.styleConfig?.label as string | undefined}
|
|
1133
|
+
icon={field.styleConfig?.icon as string | undefined}
|
|
1134
|
+
thumbHeight={200}
|
|
1135
|
+
maxLabelWidth={360}
|
|
1136
|
+
/>
|
|
1137
|
+
</div>
|
|
1068
1138
|
)
|
|
1069
1139
|
}
|
|
1070
1140
|
|
|
@@ -1088,9 +1158,16 @@ export function ViewValue({
|
|
|
1088
1158
|
|
|
1089
1159
|
// Date/datetime/timestamp → tz-aware format. `date` pins to UTC (calendar
|
|
1090
1160
|
// day); instants render in the org timezone with a full-precision tooltip.
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1161
|
+
// Keys off the display type (`cellStyle ?? type`) so a numeric/epoch column
|
|
1162
|
+
// stamped `datetime` (e.g. `synced_at`) formats as a date, never raw digits.
|
|
1163
|
+
if (
|
|
1164
|
+
renderAs === 'date' ||
|
|
1165
|
+
renderAs === 'datetime' ||
|
|
1166
|
+
renderAs === 'timestamp' ||
|
|
1167
|
+
renderAs === 'timestamptz'
|
|
1168
|
+
) {
|
|
1169
|
+
const dateRenderAs = renderAs === 'date' ? 'date' : renderAs
|
|
1170
|
+
const formatted = formatDateCell(value, dateRenderAs, es, timeZone)
|
|
1094
1171
|
if (formatted) {
|
|
1095
1172
|
return (
|
|
1096
1173
|
<p className="text-sm py-1" title={formatted.title}>
|
|
@@ -1101,27 +1178,56 @@ export function ViewValue({
|
|
|
1101
1178
|
return <p className="text-sm py-1 text-muted-foreground">—</p>
|
|
1102
1179
|
}
|
|
1103
1180
|
|
|
1104
|
-
// Enum/option field with served options → colored/iconed
|
|
1105
|
-
//
|
|
1181
|
+
// Enum/option field with served options → the SAME colored/iconed pill the
|
|
1182
|
+
// table renders (shared `OptionBadge`): resolved color, thumbnail/icon and
|
|
1183
|
+
// the localized option label (e.g. "Almacenable" instead of "storable").
|
|
1106
1184
|
const opt = servedOption(field, value)
|
|
1107
1185
|
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
1186
|
return (
|
|
1114
|
-
<
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1187
|
+
<div className="py-1">
|
|
1188
|
+
<OptionBadge
|
|
1189
|
+
option={{
|
|
1190
|
+
value: String(opt.value ?? value ?? ''),
|
|
1191
|
+
label: opt.label,
|
|
1192
|
+
color: opt.color ?? undefined,
|
|
1193
|
+
icon: opt.icon ?? undefined,
|
|
1194
|
+
image: opt.image ?? undefined,
|
|
1195
|
+
}}
|
|
1196
|
+
getImageUrl={getImageUrl}
|
|
1197
|
+
/>
|
|
1198
|
+
</div>
|
|
1122
1199
|
)
|
|
1123
1200
|
}
|
|
1124
1201
|
|
|
1202
|
+
// Array of scalars / label objects (e.g. github `labels`, tags, a
|
|
1203
|
+
// group-badge list) → a row of pills, mirroring the table's `tags` /
|
|
1204
|
+
// `relation-badge-list` cells. Checked before the structured-object branch
|
|
1205
|
+
// so a flat label array never renders as a mini-table.
|
|
1206
|
+
if (
|
|
1207
|
+
Array.isArray(value) &&
|
|
1208
|
+
(renderAs === 'tags' ||
|
|
1209
|
+
renderAs === 'relation-badge-list' ||
|
|
1210
|
+
value.every((v) => v === null || typeof v !== 'object' || 'label' in v || 'name' in v))
|
|
1211
|
+
) {
|
|
1212
|
+
return <BadgeListViewValue items={value} getImageUrl={getImageUrl} />
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Status / badge / select display with no served option list — a bare enum
|
|
1216
|
+
// token (e.g. a kanban `stage` like "backlog"). Render a colored pill with a
|
|
1217
|
+
// semantic/value-derived color and a localized-or-humanized label, matching
|
|
1218
|
+
// the table's `status`/`badge` cells.
|
|
1219
|
+
if (
|
|
1220
|
+
(renderAs === 'status' ||
|
|
1221
|
+
renderAs === 'badge' ||
|
|
1222
|
+
renderAs === 'select' ||
|
|
1223
|
+
renderAs === 'option') &&
|
|
1224
|
+
value !== null &&
|
|
1225
|
+
value !== undefined &&
|
|
1226
|
+
typeof value !== 'object'
|
|
1227
|
+
) {
|
|
1228
|
+
return <StatusBadgeViewValue field={field} value={value} t={t} />
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1125
1231
|
// Structured value (jsonb column, e.g. fiscal_data) with no label/name/title
|
|
1126
1232
|
// to surface — render readable key/value pairs instead of falling through to
|
|
1127
1233
|
// String(value) ("[object Object]").
|
|
@@ -1137,16 +1243,32 @@ export function ViewValue({
|
|
|
1137
1243
|
}
|
|
1138
1244
|
|
|
1139
1245
|
const display = formatDisplayValue(value, field)
|
|
1246
|
+
// Free text may embed URLs (a github body, notes, a long-text field). Turn
|
|
1247
|
+
// them into rich chips / inline thumbnails with the shared linkifier instead
|
|
1248
|
+
// of showing raw URLs — the rest of the text is preserved verbatim.
|
|
1249
|
+
const hasUrl = display !== '—' && /(https?:\/\/|www\.)\S/i.test(display)
|
|
1140
1250
|
|
|
1141
|
-
if (field.type === 'textarea') {
|
|
1251
|
+
if (field.type === 'textarea' || renderAs === 'textarea' || renderAs === 'long-text') {
|
|
1142
1252
|
return (
|
|
1143
|
-
<p className="text-sm whitespace-pre-wrap rounded-md bg-muted/40 p-3 min-h-[60px]">
|
|
1144
|
-
{
|
|
1253
|
+
<p className="text-sm whitespace-pre-wrap rounded-md bg-muted/40 p-3 min-h-[60px] [&_img]:my-1">
|
|
1254
|
+
{hasUrl ? (
|
|
1255
|
+
<RichText text={display} getImageUrl={getImageUrl} imageHeight={200} />
|
|
1256
|
+
) : (
|
|
1257
|
+
display
|
|
1258
|
+
)}
|
|
1145
1259
|
</p>
|
|
1146
1260
|
)
|
|
1147
1261
|
}
|
|
1148
1262
|
|
|
1149
|
-
return
|
|
1263
|
+
return (
|
|
1264
|
+
<p className="text-sm py-1">
|
|
1265
|
+
{hasUrl ? (
|
|
1266
|
+
<RichText text={display} getImageUrl={getImageUrl} imageHeight={160} />
|
|
1267
|
+
) : (
|
|
1268
|
+
display
|
|
1269
|
+
)}
|
|
1270
|
+
</p>
|
|
1271
|
+
)
|
|
1150
1272
|
}
|
|
1151
1273
|
|
|
1152
1274
|
// IconNameViewValue — read view for a column whose value is a lucide icon name
|
|
@@ -1163,6 +1285,79 @@ function IconNameViewValue({ name }: { name: string }) {
|
|
|
1163
1285
|
)
|
|
1164
1286
|
}
|
|
1165
1287
|
|
|
1288
|
+
// StatusBadgeViewValue — a bare enum/status token (no served option list) as a
|
|
1289
|
+
// colored pill: a semantic/value-derived color (same `statusColorFor` the table
|
|
1290
|
+
// uses) plus a localized-or-humanized label. Mirrors the table's `status`/
|
|
1291
|
+
// `badge` cell so a kanban `stage` ("backlog") reads as a colored, translated
|
|
1292
|
+
// badge instead of the raw token.
|
|
1293
|
+
function StatusBadgeViewValue({
|
|
1294
|
+
field,
|
|
1295
|
+
value,
|
|
1296
|
+
t,
|
|
1297
|
+
}: {
|
|
1298
|
+
field: FieldDef
|
|
1299
|
+
value: any
|
|
1300
|
+
t: (key: string, options?: any) => string
|
|
1301
|
+
}) {
|
|
1302
|
+
const isDark = useIsDarkTheme()
|
|
1303
|
+
const token = String(value)
|
|
1304
|
+
// Prefer an explicit per-option color served on the field (metadata.stages /
|
|
1305
|
+
// options), else derive a semantic color from the token.
|
|
1306
|
+
const declared = field.options?.find((o) => String(o.value) === token)
|
|
1307
|
+
const color = declared?.color || statusColorFor(token)
|
|
1308
|
+
// Localized label: the option's already-localized label wins, then a manifest
|
|
1309
|
+
// i18n key matching the raw token, then a humanized fallback.
|
|
1310
|
+
const label =
|
|
1311
|
+
declared?.label ?? t(token, { defaultValue: humanizeToken(token) })
|
|
1312
|
+
return (
|
|
1313
|
+
<div className="py-1">
|
|
1314
|
+
<Badge
|
|
1315
|
+
variant="outline"
|
|
1316
|
+
className="border-0 flex w-fit items-center gap-1"
|
|
1317
|
+
style={generateBadgeStyles(color, { isDark })}
|
|
1318
|
+
>
|
|
1319
|
+
{label}
|
|
1320
|
+
</Badge>
|
|
1321
|
+
</div>
|
|
1322
|
+
)
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// BadgeListViewValue — an array of scalars / label objects as a row of pills
|
|
1326
|
+
// (github `labels`, tags, a group-badge list). Objects carrying a `color` render
|
|
1327
|
+
// as colored `OptionBadge`s; plain strings render as neutral secondary pills.
|
|
1328
|
+
function BadgeListViewValue({
|
|
1329
|
+
items,
|
|
1330
|
+
getImageUrl,
|
|
1331
|
+
}: {
|
|
1332
|
+
items: any[]
|
|
1333
|
+
getImageUrl: GetImageUrl
|
|
1334
|
+
}) {
|
|
1335
|
+
if (!items || items.length === 0) {
|
|
1336
|
+
return <p className="text-sm py-1 text-muted-foreground">—</p>
|
|
1337
|
+
}
|
|
1338
|
+
return (
|
|
1339
|
+
<div className="flex flex-wrap gap-1 py-1">
|
|
1340
|
+
{items.map((item, i) => {
|
|
1341
|
+
if (item !== null && typeof item === 'object') {
|
|
1342
|
+
const opt: DisplayOption = {
|
|
1343
|
+
value: String(item.value ?? item.id ?? item.name ?? item.label ?? i),
|
|
1344
|
+
label: String(item.label ?? item.name ?? item.value ?? ''),
|
|
1345
|
+
color: item.color ?? undefined,
|
|
1346
|
+
icon: item.icon ?? undefined,
|
|
1347
|
+
image: item.image ?? item.avatar ?? undefined,
|
|
1348
|
+
}
|
|
1349
|
+
return <OptionBadge key={i} option={opt} getImageUrl={getImageUrl} />
|
|
1350
|
+
}
|
|
1351
|
+
return (
|
|
1352
|
+
<Badge key={i} variant="secondary" className="w-fit">
|
|
1353
|
+
{String(item)}
|
|
1354
|
+
</Badge>
|
|
1355
|
+
)
|
|
1356
|
+
})}
|
|
1357
|
+
</div>
|
|
1358
|
+
)
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1166
1361
|
// StructuredViewValue renders a jsonb object/array that has no resolvable label.
|
|
1167
1362
|
// It delegates to the shared `CollectionCell` in `'inline'` mode so the detail
|
|
1168
1363
|
// view gets the SAME pro rendering as the table: a declared `item_fields` schema
|
|
@@ -1392,6 +1587,7 @@ export function EditField({ field, value, onChange, record }: {
|
|
|
1392
1587
|
}
|
|
1393
1588
|
|
|
1394
1589
|
function ImageUploadField({ field: _field, value, onChange }: { field: FieldDef; value: any; onChange: (val: any) => void }) {
|
|
1590
|
+
const { t } = useTranslation()
|
|
1395
1591
|
const api = useApi()
|
|
1396
1592
|
const model = useContext(ModelContext)
|
|
1397
1593
|
const getImageUrl = useContext(ImageUrlContext)
|
|
@@ -1411,7 +1607,7 @@ function ImageUploadField({ field: _field, value, onChange }: { field: FieldDef;
|
|
|
1411
1607
|
const url = res.data?.data?.url || res.data?.url
|
|
1412
1608
|
if (url) onChange(url)
|
|
1413
1609
|
} catch {
|
|
1414
|
-
toast.error('
|
|
1610
|
+
toast.error(t('dynamic.image_upload_error', { defaultValue: 'No se pudo subir la imagen' }))
|
|
1415
1611
|
} finally {
|
|
1416
1612
|
setUploading(false)
|
|
1417
1613
|
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
|
+
}
|