@asteby/metacore-runtime-react 34.1.0 → 35.1.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +7 -86
  2. package/dist/action-modal-dispatcher.d.ts +4 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +102 -23
  5. package/dist/addon-loader.d.ts +1 -1
  6. package/dist/addon-loader.d.ts.map +1 -1
  7. package/dist/addon-loader.js +8 -1
  8. package/dist/color-picker-field.d.ts +13 -0
  9. package/dist/color-picker-field.d.ts.map +1 -0
  10. package/dist/color-picker-field.js +181 -0
  11. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  12. package/dist/dialogs/dynamic-record.js +99 -61
  13. package/dist/dynamic-columns.d.ts.map +1 -1
  14. package/dist/dynamic-columns.js +23 -8
  15. package/dist/dynamic-form.d.ts +1 -0
  16. package/dist/dynamic-form.d.ts.map +1 -1
  17. package/dist/dynamic-form.js +3 -1
  18. package/dist/dynamic-select-field.d.ts.map +1 -1
  19. package/dist/dynamic-select-field.js +6 -3
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/permissions-manager.d.ts +5 -0
  24. package/dist/permissions-manager.d.ts.map +1 -1
  25. package/dist/permissions-manager.js +67 -23
  26. package/dist/use-print-document.d.ts +10 -1
  27. package/dist/use-print-document.d.ts.map +1 -1
  28. package/dist/use-print-document.js +28 -1
  29. package/package.json +1 -1
  30. package/src/__tests__/color-picker-field.test.tsx +18 -0
  31. package/src/__tests__/filename-from-disposition.test.ts +30 -0
  32. package/src/__tests__/prefill-scalar-from-record.test.ts +37 -0
  33. package/src/__tests__/record-detail-display.test.tsx +30 -0
  34. package/src/action-modal-dispatcher.tsx +110 -22
  35. package/src/addon-loader.tsx +7 -1
  36. package/src/color-picker-field.tsx +296 -0
  37. package/src/dialogs/dynamic-record.tsx +139 -65
  38. package/src/dynamic-columns.tsx +28 -12
  39. package/src/dynamic-form.tsx +9 -1
  40. package/src/dynamic-select-field.tsx +7 -3
  41. package/src/index.ts +2 -0
  42. package/src/permissions-manager.tsx +98 -39
  43. package/src/use-print-document.ts +38 -2
@@ -44,9 +44,10 @@ import {
44
44
  import { Progress } from './dialogs/_primitives'
45
45
  import { humanizeToken } from './dynamic-columns-helpers'
46
46
  import { objectLabel } from './dynamic-relation-helpers'
47
- import { ImageStack,
47
+ import {
48
48
  OptionBadge,
49
49
  RelationThumbnail,
50
+ ImageStack,
50
51
  statusColorFor,
51
52
  useIsDarkTheme,
52
53
  } from './display-value'
@@ -603,6 +604,10 @@ export const resolveRelationSubtitle = (col: ColumnDefinition, row: any): string
603
604
  * carries an `image`. Falls back to the raw id when no sibling was resolved, and
604
605
  * to an empty marker when there is no value at all. Domain-agnostic: works for
605
606
  * every `belongs_to` column (category, supplier, brand, …) without per-addon code.
607
+ *
608
+ * When `stack` is true (column `display: "image_stack"`), the landscape mark
609
+ * sits ON TOP of the label — wide logos fit without cropping and the cell
610
+ * stays readable in dense tables.
606
611
  */
607
612
  const RelationCell: React.FC<{
608
613
  col: ColumnDefinition
@@ -944,7 +949,15 @@ export function makeDefaultGetDynamicColumns(
944
949
  }
945
950
 
946
951
  // Landscape stack: wide image ON TOP, label UNDERNEATH.
952
+ // Declared via `display: "image_stack"` on an image column
953
+ // or on a belongs_to FK whose sibling carries a logo/photo
954
+ // (brand marks, product cards). Fits logos that are wider
955
+ // than tall without cropping into a square thumb.
947
956
  if (renderAs === 'image_stack') {
957
+ // FK relation (brand_id → brands) OR any column that
958
+ // already resolved a sibling with an image — stack it.
959
+ // Don't require `col.ref` alone: enrichment sometimes
960
+ // leaves type=text while cellStyle carries image_stack.
948
961
  const looksRelation =
949
962
  !!col.ref ||
950
963
  (typeof col.key === 'string' &&
@@ -1332,30 +1345,33 @@ export function makeDefaultGetDynamicColumns(
1332
1345
  )
1333
1346
  }
1334
1347
 
1335
- case 'image_stack': {
1348
+ case 'image': {
1336
1349
  const imageValue =
1337
1350
  value ||
1338
1351
  (Array.isArray(row.original.media)
1339
1352
  ? row.original.media.find((m: any) => m.type === 'image')?.url
1340
1353
  : null)
1354
+ return <ImageCell value={imageValue} getImageUrl={getImageUrl} />
1355
+ }
1356
+
1357
+ case 'image_stack': {
1358
+ // Defensive: normally handled above before the
1359
+ // switch; kept so a late `type: image_stack` without
1360
+ // cellStyle still stacks.
1361
+ const labelField = styleCfg(col, 'label_field', 'labelField')
1362
+ const caption = labelField
1363
+ ? String(getNestedValue(row.original, labelField) ?? '')
1364
+ : undefined
1341
1365
  return (
1342
1366
  <ImageCell
1343
- value={imageValue}
1367
+ value={value}
1344
1368
  getImageUrl={getImageUrl}
1369
+ label={caption || undefined}
1345
1370
  stack
1346
1371
  />
1347
1372
  )
1348
1373
  }
1349
1374
 
1350
- case 'image': {
1351
- const imageValue =
1352
- value ||
1353
- (Array.isArray(row.original.media)
1354
- ? row.original.media.find((m: any) => m.type === 'image')?.url
1355
- : null)
1356
- return <ImageCell value={imageValue} getImageUrl={getImageUrl} />
1357
- }
1358
-
1359
1375
  default: {
1360
1376
  if (typeof value === 'object' && value !== null) {
1361
1377
  return (
@@ -35,6 +35,7 @@ import { DynamicSelectField } from './dynamic-select-field'
35
35
  import { DynamicDateField } from './dynamic-date-field'
36
36
  import { UploadField } from './upload-field'
37
37
  import { IconPickerField } from './icon-picker-field'
38
+ import { ColorPickerField } from './color-picker-field'
38
39
 
39
40
  export { buildZodSchema, resolveWidget }
40
41
  export { DynamicLineItems } from './dynamic-line-items'
@@ -42,6 +43,7 @@ export { DynamicSelectField } from './dynamic-select-field'
42
43
  export { DynamicDateField } from './dynamic-date-field'
43
44
  export { UploadField } from './upload-field'
44
45
  export { IconPickerField } from './icon-picker-field'
46
+ export { ColorPickerField, DEFAULT_ROLE_COLOR, normalizeHex } from './color-picker-field'
45
47
 
46
48
  export interface DynamicFormProps {
47
49
  fields: ActionFieldDef[]
@@ -399,7 +401,13 @@ function FieldRenderer({
399
401
  // / future MDX editor pick it up without breaking the contract.
400
402
  return <Textarea id={field.key} data-widget="richtext" value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
401
403
  case 'color':
402
- return <Input id={field.key} type="color" value={value || '#000000'} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} />
404
+ return (
405
+ <ColorPickerField
406
+ aria-label={field.label || field.key}
407
+ value={typeof value === 'string' ? value : ''}
408
+ onChange={onChange}
409
+ />
410
+ )
403
411
  case 'select':
404
412
  return (
405
413
  <Select value={value || ''} onValueChange={onChange}>
@@ -22,6 +22,7 @@
22
22
  // value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
23
23
  // case — start empty and never hit this.
24
24
  import { useEffect, useRef, useState } from 'react'
25
+ import { useTranslation } from 'react-i18next'
25
26
  import {
26
27
  Badge,
27
28
  Button,
@@ -205,6 +206,9 @@ export function DynamicSelectField({
205
206
  descriptionAsBadge = false,
206
207
  hideCreate = false,
207
208
  }: DynamicSelectFieldProps) {
209
+ const { t } = useTranslation()
210
+ const ph = (fallback: string) =>
211
+ field.placeholder ? t(field.placeholder, { defaultValue: field.placeholder }) : fallback
208
212
  const [open, setOpen] = useState(false)
209
213
  const [search, setSearch] = useState('')
210
214
  const [scanOpen, setScanOpen] = useState(false)
@@ -353,7 +357,7 @@ export function DynamicSelectField({
353
357
  <span className={'min-w-0 flex-1 truncate ' + (selectedOption ? '' : 'text-muted-foreground')}>
354
358
  {/* Never flash the raw id: until the eager fetch resolves the
355
359
  option, show a loading hint instead of String(value). */}
356
- {selectedOption?.label ?? (loading ? 'Cargando…' : field.placeholder || '—')}
360
+ {selectedOption?.label ?? (loading ? 'Cargando…' : ph('—'))}
357
361
  </span>
358
362
  </span>
359
363
  </Button>
@@ -386,7 +390,7 @@ export function DynamicSelectField({
386
390
  <span className={'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground')}>
387
391
  {blockedByDependency
388
392
  ? (dependsHint || DEFAULT_DEPENDS_HINT)
389
- : selectedLabel || field.placeholder || 'Buscar…'}
393
+ : selectedLabel || ph('Buscar…')}
390
394
  </span>
391
395
  {descriptionAsBadge && selectedOption?.description ? (
392
396
  <Badge variant="secondary" className="shrink-0 font-normal tabular-nums">
@@ -406,7 +410,7 @@ export function DynamicSelectField({
406
410
  >
407
411
  <Command shouldFilter={false}>
408
412
  <CommandInput
409
- placeholder={field.placeholder || 'Buscar…'}
413
+ placeholder={ph('Buscar…')}
410
414
  value={search}
411
415
  onValueChange={setSearch}
412
416
  />
package/src/index.ts CHANGED
@@ -178,6 +178,7 @@ export {
178
178
  type UseDynamicRowActionsParams,
179
179
  type DynamicRowActions,
180
180
  } from './dynamic-row-actions'
181
+ export { ColorPickerField, DEFAULT_ROLE_COLOR, normalizeHex } from './color-picker-field'
181
182
  export {
182
183
  PermissionsManager,
183
184
  moduleActionCapability,
@@ -185,6 +186,7 @@ export {
185
186
  grantedCountForModule,
186
187
  capabilitySetsEqual,
187
188
  defaultActionIcon,
189
+ suggestRoleIcon,
188
190
  normalizeCatalogGroups,
189
191
  flattenGroups,
190
192
  filterModuleGroups,
@@ -79,6 +79,9 @@ import {
79
79
  CollapsibleTrigger,
80
80
  } from '@asteby/metacore-ui/primitives'
81
81
  import { DynamicIcon } from './dynamic-icon'
82
+ import { IconPickerField } from './icon-picker-field'
83
+ import { ColorPickerField, DEFAULT_ROLE_COLOR } from './color-picker-field'
84
+ import type { ActionFieldDef } from './types'
82
85
 
83
86
  // ---------------------------------------------------------------------------
84
87
  // Types (mirror of `GET /api/permissions/modules` + the host sidebar nav)
@@ -183,12 +186,15 @@ export interface RoleDef {
183
186
  label?: string
184
187
  /** Accent color (hex) for the role chip. */
185
188
  color?: string
189
+ /** Lucide PascalCase name (or image path) for the role chip. */
190
+ icon?: string
186
191
  }
187
192
 
188
193
  export interface RoleInput {
189
194
  name: string
190
195
  label?: string
191
196
  color?: string
197
+ icon?: string
192
198
  }
193
199
 
194
200
  export interface PermissionsManagerProps {
@@ -330,17 +336,28 @@ function slugify(label: string): string {
330
336
  .replace(/^_+|_+$/g, '')
331
337
  }
332
338
 
333
- const ROLE_COLORS = [
334
- '#ef4444',
335
- '#f97316',
336
- '#eab308',
337
- '#22c55e',
338
- '#06b6d4',
339
- '#3b82f6',
340
- '#8b5cf6',
341
- '#ec4899',
342
- '#6b7280',
343
- ]
339
+ /** Suggest a Lucide icon from the role label (create-dialog default). */
340
+ export function suggestRoleIcon(label: string): string {
341
+ const s = fold(label)
342
+ const rules: Array<[RegExp, string]> = [
343
+ [/cajer|cash|teller|bank/, 'Banknote'],
344
+ [/vend|seller|sales|shop|mostrador/, 'ShoppingBag'],
345
+ [/mecanic|technic|wrench|taller/, 'Wrench'],
346
+ [/jefe|lead|manager|gerente|supervisor/, 'HardHat'],
347
+ [/almacen|ware|stock|invent/, 'Warehouse'],
348
+ [/compr|purch|buyer/, 'Truck'],
349
+ [/contad|account|ledger/, 'Calculator'],
350
+ [/nomina|payroll|sueldo/, 'Wallet'],
351
+ [/rh|hr|human|emplead|people/, 'Users'],
352
+ [/fleet|flota|vehic/, 'Car'],
353
+ [/admin|dueño|owner/, 'ShieldCheck'],
354
+ [/view|observa|lectura|read/, 'Eye'],
355
+ ]
356
+ for (const [re, icon] of rules) {
357
+ if (re.test(s)) return icon
358
+ }
359
+ return 'Shield'
360
+ }
344
361
 
345
362
  /**
346
363
  * Normalize whatever `loadModules` returned into the canonical grouped shape.
@@ -548,8 +565,16 @@ export function PermissionsManager({
548
565
  mode: 'create' | 'edit'
549
566
  label: string
550
567
  color: string
568
+ icon: string
551
569
  grantAll: boolean
552
- }>({ open: false, mode: 'create', label: '', color: ROLE_COLORS[5], grantAll: false })
570
+ }>({
571
+ open: false,
572
+ mode: 'create',
573
+ label: '',
574
+ color: DEFAULT_ROLE_COLOR,
575
+ icon: 'Shield',
576
+ grantAll: false,
577
+ })
553
578
  const [roleSaving, setRoleSaving] = React.useState(false)
554
579
  const [deleteOpen, setDeleteOpen] = React.useState(false)
555
580
  const [deleting, setDeleting] = React.useState(false)
@@ -711,6 +736,7 @@ export function PermissionsManager({
711
736
  name: slugify(label),
712
737
  label,
713
738
  color: roleDialog.color,
739
+ icon: roleDialog.icon || suggestRoleIcon(label),
714
740
  })
715
741
  const rs = await loadRoles()
716
742
  setRoles(rs)
@@ -732,6 +758,7 @@ export function PermissionsManager({
732
758
  name: activeRole.name,
733
759
  label,
734
760
  color: roleDialog.color,
761
+ icon: roleDialog.icon || suggestRoleIcon(label),
735
762
  })
736
763
  await refreshRoles(activeRole.id)
737
764
  toast.success('Rol actualizado')
@@ -769,7 +796,8 @@ export function PermissionsManager({
769
796
  open: true,
770
797
  mode: 'edit',
771
798
  label: activeRole.label || activeRole.name,
772
- color: activeRole.color || ROLE_COLORS[5],
799
+ color: activeRole.color || DEFAULT_ROLE_COLOR,
800
+ icon: activeRole.icon || suggestRoleIcon(activeRole.label || activeRole.name),
773
801
  grantAll: false,
774
802
  })
775
803
  }
@@ -843,7 +871,8 @@ export function PermissionsManager({
843
871
  open: true,
844
872
  mode: 'create',
845
873
  label: '',
846
- color: ROLE_COLORS[5],
874
+ color: DEFAULT_ROLE_COLOR,
875
+ icon: 'Shield',
847
876
  grantAll: false,
848
877
  })
849
878
  }
@@ -901,12 +930,18 @@ export function PermissionsManager({
901
930
  <span className="flex min-w-0 items-center gap-2">
902
931
  {activeRole && (
903
932
  <span
904
- className="h-2.5 w-2.5 shrink-0 rounded-full"
933
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md"
905
934
  style={{
906
- background: activeRole.color || '#6b7280',
935
+ background: `${activeRole.color || '#64748b'}22`,
936
+ color: activeRole.color || '#64748b',
907
937
  }}
908
938
  aria-hidden="true"
909
- />
939
+ >
940
+ <DynamicIcon
941
+ name={activeRole.icon || 'Shield'}
942
+ className="h-3.5 w-3.5"
943
+ />
944
+ </span>
910
945
  )}
911
946
  <span className="truncate">
912
947
  {activeRole
@@ -933,12 +968,18 @@ export function PermissionsManager({
933
968
  }}
934
969
  >
935
970
  <span
936
- className="mr-2 h-2 w-2 shrink-0 rounded-full"
971
+ className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md"
937
972
  style={{
938
- background: role.color || '#6b7280',
973
+ background: `${role.color || '#64748b'}22`,
974
+ color: role.color || '#64748b',
939
975
  }}
940
976
  aria-hidden="true"
941
- />
977
+ >
978
+ <DynamicIcon
979
+ name={role.icon || 'Shield'}
980
+ className="h-3.5 w-3.5"
981
+ />
982
+ </span>
942
983
  <span className="truncate">
943
984
  {role.label || role.name}
944
985
  </span>
@@ -1224,30 +1265,48 @@ export function PermissionsManager({
1224
1265
  id="pm-role-name"
1225
1266
  value={roleDialog.label}
1226
1267
  placeholder="Ej. Cajero"
1227
- onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
1228
- setRoleDialog((d) => ({ ...d, label: e.target.value }))
1268
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
1269
+ const label = e.target.value
1270
+ setRoleDialog((d) => ({
1271
+ ...d,
1272
+ label,
1273
+ icon:
1274
+ d.icon === suggestRoleIcon(d.label) ||
1275
+ d.icon === 'Shield' ||
1276
+ !d.icon
1277
+ ? suggestRoleIcon(label)
1278
+ : d.icon,
1279
+ }))
1280
+ }}
1281
+ />
1282
+ </div>
1283
+ <div className="flex flex-col gap-2">
1284
+ <Label>Ícono</Label>
1285
+ <IconPickerField
1286
+ field={
1287
+ {
1288
+ key: 'icon',
1289
+ label: 'Ícono',
1290
+ type: 'text',
1291
+ widget: 'icon',
1292
+ } as ActionFieldDef
1293
+ }
1294
+ value={roleDialog.icon}
1295
+ onChange={(v) =>
1296
+ setRoleDialog((d) => ({
1297
+ ...d,
1298
+ icon: typeof v === 'string' && v ? v : 'Shield',
1299
+ }))
1229
1300
  }
1230
1301
  />
1231
1302
  </div>
1232
1303
  <div className="flex flex-col gap-2">
1233
1304
  <Label>Color</Label>
1234
- <div className="flex flex-wrap gap-2">
1235
- {ROLE_COLORS.map((c) => (
1236
- <button
1237
- key={c}
1238
- type="button"
1239
- aria-label={`Color ${c}`}
1240
- onClick={() => setRoleDialog((d) => ({ ...d, color: c }))}
1241
- className={cn(
1242
- 'h-7 w-7 rounded-full border-2 transition-transform',
1243
- roleDialog.color === c
1244
- ? 'scale-110 border-foreground'
1245
- : 'border-transparent hover:scale-105',
1246
- )}
1247
- style={{ background: c }}
1248
- />
1249
- ))}
1250
- </div>
1305
+ <ColorPickerField
1306
+ aria-label="Color del rol"
1307
+ value={roleDialog.color}
1308
+ onChange={(color) => setRoleDialog((d) => ({ ...d, color }))}
1309
+ />
1251
1310
  </div>
1252
1311
  {roleDialog.mode === 'create' && (
1253
1312
  <label className="flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-sm hover:bg-muted/40">
@@ -33,10 +33,36 @@ export interface PrintDocumentArgs {
33
33
  * open → open the PDF in a new tab (user prints from the viewer).
34
34
  */
35
35
  mode?: 'print' | 'download' | 'open'
36
- /** Filename for the download mode (defaults to "<key>.pdf"). */
36
+ /**
37
+ * Hint filename for download mode. Prefer leaving this unset: the server
38
+ * expands `{{record.*}}` into Content-Disposition. A raw template string
39
+ * (e.g. "cfdi-{{record.number}}.pdf") must NOT be used as a.download —
40
+ * that is how downloads end up literally named with mustache braces.
41
+ */
37
42
  filename?: string
38
43
  }
39
44
 
45
+ /** Parse filename from Content-Disposition (RFC 5987 / quoted). */
46
+ export function filenameFromContentDisposition(header: string | undefined | null): string | undefined {
47
+ if (!header) return undefined
48
+ const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header)
49
+ if (star?.[1]) {
50
+ try {
51
+ return decodeURIComponent(star[1].trim().replace(/^"|"$/g, ''))
52
+ } catch {
53
+ return star[1].trim().replace(/^"|"$/g, '')
54
+ }
55
+ }
56
+ const plain = /filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i.exec(header)
57
+ const raw = (plain?.[1] ?? plain?.[2] ?? '').trim()
58
+ return raw || undefined
59
+ }
60
+
61
+ /** True when a caller passed an unexpanded mustache template as filename. */
62
+ export function looksLikeFilenameTemplate(name: string | undefined): boolean {
63
+ return !!name && /\{\{/.test(name)
64
+ }
65
+
40
66
  /**
41
67
  * Returns a `printDocument(args)` callback. Resolves once the PDF has been
42
68
  * fetched and the browser action (print/download/open) has been kicked off;
@@ -65,9 +91,19 @@ export function usePrintDocument() {
65
91
  const cleanup = () => setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
66
92
 
67
93
  if (mode === 'download') {
94
+ const headers = (res as { headers?: Record<string, string> }).headers || {}
95
+ const fromHeader =
96
+ filenameFromContentDisposition(
97
+ headers['content-disposition'] || headers['Content-Disposition'],
98
+ ) || undefined
99
+ // Prefer server-expanded name; never use a raw {{record.*}} template.
100
+ const downloadName =
101
+ fromHeader ||
102
+ (!looksLikeFilenameTemplate(filename) ? filename : undefined) ||
103
+ `${key}.pdf`
68
104
  const a = document.createElement('a')
69
105
  a.href = blobUrl
70
- a.download = filename || `${key}.pdf`
106
+ a.download = downloadName
71
107
  document.body.appendChild(a)
72
108
  a.click()
73
109
  a.remove()