@asteby/metacore-runtime-react 23.5.1 → 23.7.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.
@@ -52,6 +52,7 @@ import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-f
52
52
  import { DynamicRelations } from '../dynamic-relations'
53
53
  import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
54
54
  import { getFieldRef } from '../dynamic-form-schema'
55
+ import { FieldCell } from '../field-grid'
55
56
  import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
56
57
  import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
57
58
  import { humanizeToken } from '../dynamic-columns-helpers'
@@ -767,18 +768,22 @@ export function DynamicRecordDialog({
767
768
  <ImageUrlContext.Provider value={getImageUrl}>
768
769
  <TimeZoneContext.Provider value={timeZone}>
769
770
  <CurrencyContext.Provider value={currency}>
771
+ {/* The grid IS the form element (the footer submit
772
+ button targets it by id). FieldCell gives each
773
+ cell `min-w-0` so a long select/input value can't
774
+ blow the two columns past the dialog width. */}
770
775
  <form
771
776
  id="dynamic-record-form"
772
777
  onSubmit={handleSubmit}
773
- className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4"
778
+ className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"
774
779
  >
775
780
  {visibleFields.map(field => {
776
- const isFullWidth = field.type === 'textarea'
781
+ const isFullWidth =
782
+ field.type === 'textarea' ||
783
+ field.widget === 'textarea' ||
784
+ field.widget === 'richtext'
777
785
  return (
778
- <div
779
- key={field.key}
780
- className={isFullWidth ? 'sm:col-span-2' : ''}
781
- >
786
+ <FieldCell key={field.key} fullWidth={isFullWidth}>
782
787
  <FieldRow
783
788
  field={field}
784
789
  record={record}
@@ -788,12 +793,12 @@ export function DynamicRecordDialog({
788
793
  setFormValues((prev: Record<string, any>) => ({ ...prev, [field.key]: val }))
789
794
  }
790
795
  />
791
- </div>
796
+ </FieldCell>
792
797
  )
793
798
  })}
794
799
 
795
800
  {record?.external_url && (
796
- <div className="sm:col-span-2">
801
+ <div className="sm:col-span-2 min-w-0">
797
802
  <a
798
803
  href={record.external_url}
799
804
  target="_blank"
@@ -807,6 +812,7 @@ export function DynamicRecordDialog({
807
812
  )}
808
813
  </form>
809
814
 
815
+
810
816
  {/* Child records (line items, etc.) for declared relations.
811
817
  View = strictly read-only; edit = add/edit/delete. */}
812
818
  {!isCreate && record && relations.length > 0 && (
@@ -79,6 +79,24 @@ import {
79
79
  import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
80
80
  import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
81
81
  import { useApi } from './api-context'
82
+ import {
83
+ useStageAutomations,
84
+ StageAutomationsButton,
85
+ type StageAutomation,
86
+ type NewStageAutomation,
87
+ } from './stage-automations'
88
+ import {
89
+ useCustomStages,
90
+ splitCustomStages,
91
+ mergeLaneStages,
92
+ resolveSmartLanes,
93
+ AddStageColumn,
94
+ CustomStageLaneMenu,
95
+ CustomStageDialog,
96
+ CustomStageDeleteDialog,
97
+ SmartLane,
98
+ type CustomStage,
99
+ } from './custom-stages'
82
100
  import { useDynamicFilters } from './use-dynamic-filters'
83
101
  import {
84
102
  FilterChipsRow,
@@ -453,6 +471,30 @@ export function DynamicKanban({
453
471
  const api = useApi()
454
472
  const isDark = useIsDarkTheme()
455
473
 
474
+ // Stage automations (Bitrix-style per-lane rules). Degrades to no-op when
475
+ // the host has no `/stage-automations` endpoint — the ⚡ affordance hides.
476
+ const automations = useStageAutomations(model)
477
+
478
+ // Custom stages (Bitrix-style user-defined columns). Degrades to no-op when
479
+ // the host has no `/custom-stages` endpoint — the "+ Agregar etapa" column
480
+ // and lane menus simply don't render.
481
+ const customStages = useCustomStages(model)
482
+ // Dialog state: create/edit a stage, and the delete confirmation.
483
+ const [stageDialogOpen, setStageDialogOpen] = useState(false)
484
+ const [editingStage, setEditingStage] = useState<CustomStage | null>(null)
485
+ const [deletingStage, setDeletingStage] = useState<CustomStage | null>(null)
486
+ const openCreateStage = useCallback(() => {
487
+ setEditingStage(null)
488
+ setStageDialogOpen(true)
489
+ }, [])
490
+ const openEditStage = useCallback((s: CustomStage) => {
491
+ setEditingStage(s)
492
+ setStageDialogOpen(true)
493
+ }, [])
494
+ const openDeleteStage = useCallback((s: CustomStage) => {
495
+ setDeletingStage(s)
496
+ }, [])
497
+
456
498
  const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache()
457
499
  const cachedMeta = getMetadata(model)
458
500
 
@@ -747,10 +789,34 @@ export function DynamicKanban({
747
789
  [],
748
790
  )
749
791
 
750
- const stages = useMemo(
792
+ const declaredStages = useMemo(
751
793
  () => (metadata ? deriveStages(metadata) : []),
752
794
  [metadata],
753
795
  )
796
+ // The kernel merges custom real stages into `metadata.stages` (custom: true)
797
+ // and serves smart lanes in `metadata.smart_lanes` — the metadata is the
798
+ // painting source (ops #704). The CRUD list (`customStages.stages`) only
799
+ // backs the management dialog: it carries the `id`/filters the metadata
800
+ // omits, so we still split it to build `customByKey` (and to fall back when
801
+ // the metadata hasn't caught up yet).
802
+ const { laneStages: customLaneStages, smartStages: crudSmartStages } =
803
+ useMemo(() => splitCustomStages(customStages.stages), [customStages.stages])
804
+ const { lanes: stages, customByKey } = useMemo(
805
+ () => mergeLaneStages(declaredStages, customLaneStages),
806
+ [declaredStages, customLaneStages],
807
+ )
808
+ // Smart lanes to paint: metadata first, folding in CRUD ids by key.
809
+ const smartStages = useMemo(
810
+ () =>
811
+ resolveSmartLanes(metadata?.smart_lanes, crudSmartStages, model),
812
+ [metadata?.smart_lanes, crudSmartStages, model],
813
+ )
814
+ // Position a newly created stage after every existing lane (real + smart).
815
+ const nextStagePosition = useMemo(() => {
816
+ const positions = customStages.stages.map((s) => s.position ?? 0)
817
+ const base = stages.length + smartStages.length
818
+ return Math.max(base, ...positions, 0) + 1
819
+ }, [customStages.stages, stages.length, smartStages.length])
754
820
  const transitions = metadata?.transitions
755
821
 
756
822
  const grouped = useMemo(
@@ -1075,6 +1141,22 @@ export function DynamicKanban({
1075
1141
  isDark={isDark}
1076
1142
  dimmed={!!activeId && !droppableAllowed}
1077
1143
  disabled={!!activeId && !droppableAllowed}
1144
+ model={model}
1145
+ columns={metadata?.columns ?? []}
1146
+ automationsAvailable={
1147
+ automations.available && stage.key !== UNASSIGNED_LANE
1148
+ }
1149
+ automationRules={automations.byStage.get(stage.key) ?? []}
1150
+ onAutomationCreate={automations.create}
1151
+ onAutomationUpdate={automations.update}
1152
+ onAutomationRemove={automations.remove}
1153
+ customStage={
1154
+ customStages.available
1155
+ ? customByKey.get(stage.key)
1156
+ : undefined
1157
+ }
1158
+ onEditStage={openEditStage}
1159
+ onDeleteStage={openDeleteStage}
1078
1160
  >
1079
1161
  {loadingData && cards.length === 0 ? (
1080
1162
  <>
@@ -1104,6 +1186,42 @@ export function DynamicKanban({
1104
1186
  </KanbanLane>
1105
1187
  )
1106
1188
  })}
1189
+
1190
+ {/* Smart (virtual) lanes — each runs its own filtered query and
1191
+ renders read-only cards (no drag target). */}
1192
+ {smartStages.map((smart) => (
1193
+ <SmartLane
1194
+ key={`smart-${smart.key}`}
1195
+ stage={smart}
1196
+ model={model}
1197
+ endpoint={endpoint}
1198
+ defaultFilters={defaultFilters}
1199
+ pageSize={pageSize}
1200
+ isDark={isDark}
1201
+ refreshTrigger={refreshTrigger}
1202
+ onEdit={openEditStage}
1203
+ onDelete={openDeleteStage}
1204
+ renderCard={(card) => (
1205
+ <KanbanCard
1206
+ card={card}
1207
+ titleCol={titleCol}
1208
+ fieldCols={fieldCols}
1209
+ actions={rowActions}
1210
+ locale={i18n.language}
1211
+ timeZone={timeZone}
1212
+ currency={currency}
1213
+ onClick={onCardClick}
1214
+ onAction={handleInternalAction}
1215
+ draggable={false}
1216
+ />
1217
+ )}
1218
+ />
1219
+ ))}
1220
+
1221
+ {/* "+ Agregar etapa" — only when the host wired /custom-stages. */}
1222
+ {customStages.available && (
1223
+ <AddStageColumn onClick={openCreateStage} />
1224
+ )}
1107
1225
  </div>
1108
1226
 
1109
1227
  <DragOverlay>
@@ -1121,6 +1239,35 @@ export function DynamicKanban({
1121
1239
 
1122
1240
  {rowActionDialogs}
1123
1241
  </DndContext>
1242
+
1243
+ {customStages.available && (
1244
+ <>
1245
+ <CustomStageDialog
1246
+ open={stageDialogOpen}
1247
+ onOpenChange={setStageDialogOpen}
1248
+ model={model}
1249
+ columns={metadata?.columns ?? []}
1250
+ initial={editingStage}
1251
+ nextPosition={nextStagePosition}
1252
+ onCreate={customStages.create}
1253
+ onUpdate={customStages.update}
1254
+ />
1255
+ <CustomStageDeleteDialog
1256
+ open={!!deletingStage}
1257
+ onOpenChange={(o) => {
1258
+ if (!o) setDeletingStage(null)
1259
+ }}
1260
+ stage={deletingStage}
1261
+ reassignTargets={stages.map((s) => ({
1262
+ key: s.key,
1263
+ label: s.label,
1264
+ }))}
1265
+ onConfirm={(s, reassignTo) =>
1266
+ customStages.remove(s.id, reassignTo)
1267
+ }
1268
+ />
1269
+ </>
1270
+ )}
1124
1271
  </div>
1125
1272
  )
1126
1273
  }
@@ -1243,6 +1390,22 @@ interface KanbanLaneProps {
1243
1390
  isDark: boolean
1244
1391
  dimmed: boolean
1245
1392
  disabled: boolean
1393
+ /** Model key + columns for the stage-automations editor. */
1394
+ model: string
1395
+ columns: ColumnDefinition[]
1396
+ /** Whether the ⚡ automations affordance should render for this lane. */
1397
+ automationsAvailable: boolean
1398
+ automationRules: StageAutomation[]
1399
+ onAutomationCreate: (draft: NewStageAutomation) => Promise<void>
1400
+ onAutomationUpdate: (
1401
+ id: StageAutomation['id'],
1402
+ patch: Partial<StageAutomation>,
1403
+ ) => Promise<void>
1404
+ onAutomationRemove: (id: StageAutomation['id']) => Promise<void>
1405
+ /** When set, this lane is a user-defined custom stage → show ⋮ Editar/Eliminar. */
1406
+ customStage?: CustomStage
1407
+ onEditStage?: (stage: CustomStage) => void
1408
+ onDeleteStage?: (stage: CustomStage) => void
1246
1409
  children: React.ReactNode
1247
1410
  }
1248
1411
 
@@ -1261,6 +1424,16 @@ function KanbanLane({
1261
1424
  isDark,
1262
1425
  dimmed,
1263
1426
  disabled,
1427
+ model,
1428
+ columns,
1429
+ automationsAvailable,
1430
+ automationRules,
1431
+ onAutomationCreate,
1432
+ onAutomationUpdate,
1433
+ onAutomationRemove,
1434
+ customStage,
1435
+ onEditStage,
1436
+ onDeleteStage,
1264
1437
  children,
1265
1438
  }: KanbanLaneProps) {
1266
1439
  const { t } = useTranslation()
@@ -1359,6 +1532,25 @@ function KanbanLane({
1359
1532
  value={funnelValue}
1360
1533
  onChange={onFunnelChange}
1361
1534
  />
1535
+ {automationsAvailable && (
1536
+ <StageAutomationsButton
1537
+ model={model}
1538
+ stageKey={stage.key}
1539
+ stageLabel={stage.label}
1540
+ columns={columns}
1541
+ rules={automationRules}
1542
+ onCreate={onAutomationCreate}
1543
+ onUpdate={onAutomationUpdate}
1544
+ onRemove={onAutomationRemove}
1545
+ />
1546
+ )}
1547
+ {customStage && onEditStage && onDeleteStage && (
1548
+ <CustomStageLaneMenu
1549
+ stage={customStage}
1550
+ onEdit={onEditStage}
1551
+ onDelete={onDeleteStage}
1552
+ />
1553
+ )}
1362
1554
  </div>
1363
1555
  </div>
1364
1556
  {searchOpen && (
@@ -1590,6 +1782,8 @@ interface KanbanCardProps {
1590
1782
  onClick?: (row: any) => void
1591
1783
  /** STRING contract — dispatch the action by its key (see useDynamicRowActions). */
1592
1784
  onAction: (actionKey: string, record: any) => void
1785
+ /** When false the card is static (no drag) — used by read-only smart lanes. */
1786
+ draggable?: boolean
1593
1787
  }
1594
1788
 
1595
1789
  function KanbanCard({
@@ -1602,19 +1796,23 @@ function KanbanCard({
1602
1796
  currency,
1603
1797
  onClick,
1604
1798
  onAction,
1799
+ draggable = true,
1605
1800
  }: KanbanCardProps) {
1606
1801
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
1607
1802
  id: String(card.id),
1803
+ disabled: !draggable,
1608
1804
  })
1609
1805
 
1610
1806
  const visibleActions = actions.filter((a) => isRowActionVisible(a, card))
1611
1807
 
1612
1808
  return (
1613
1809
  <Card
1614
- ref={setNodeRef}
1615
- {...attributes}
1616
- {...listeners}
1617
- className="w-full min-w-0 cursor-grab active:cursor-grabbing border-border/70 shadow-sm"
1810
+ ref={draggable ? setNodeRef : undefined}
1811
+ {...(draggable ? attributes : {})}
1812
+ {...(draggable ? listeners : {})}
1813
+ className={`w-full min-w-0 border-border/70 shadow-sm ${
1814
+ draggable ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'
1815
+ }`}
1618
1816
  style={{ opacity: isDragging ? 0.4 : 1 }}
1619
1817
  onClick={() => onClick?.(card)}
1620
1818
  data-card-id={String(card.id)}
@@ -0,0 +1,57 @@
1
+ // FieldGrid — the shared, responsive form layout for the SDK's declarative
2
+ // modals (the automatic CRUD create/edit dialog and the placement:create /
3
+ // generic action modal). One source of truth so both read identically: short
4
+ // scalar fields (inputs, selects, dates) flow in two columns when the width
5
+ // allows, textareas / line-items / long-form widgets span the full row, and
6
+ // everything collapses to a single column on phones.
7
+ //
8
+ // The load-bearing detail is `min-w-0` on every cell: CSS grid tracks default
9
+ // to `minmax(auto, 1fr)`, so a child with a wide intrinsic minimum (a Select
10
+ // with a long option, an Input with a long value) pushes its column past the
11
+ // container and the whole dialog grows a horizontal scrollbar. `min-w-0` lets
12
+ // the cell shrink below its content and keeps the modal within its width.
13
+ import type { ReactNode } from 'react'
14
+ import { Label } from '@asteby/metacore-ui/primitives'
15
+ import { cn } from '@asteby/metacore-ui/lib'
16
+
17
+ export function FieldGrid({ children, className }: { children: ReactNode; className?: string }) {
18
+ return (
19
+ <div className={cn('grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2', className)}>
20
+ {children}
21
+ </div>
22
+ )
23
+ }
24
+
25
+ export function FieldCell({
26
+ children,
27
+ fullWidth,
28
+ className,
29
+ }: {
30
+ children: ReactNode
31
+ /** Span both columns — textareas, line-items grids, embedded relations. */
32
+ fullWidth?: boolean
33
+ className?: string
34
+ }) {
35
+ return (
36
+ <div className={cn('flex min-w-0 flex-col gap-1.5', fullWidth && 'sm:col-span-2', className)}>
37
+ {children}
38
+ </div>
39
+ )
40
+ }
41
+
42
+ export function FieldLabel({
43
+ htmlFor,
44
+ required,
45
+ children,
46
+ }: {
47
+ htmlFor?: string
48
+ required?: boolean
49
+ children: ReactNode
50
+ }) {
51
+ return (
52
+ <Label htmlFor={htmlFor} className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
53
+ {children}
54
+ {required && <span className="ml-0.5 text-destructive">*</span>}
55
+ </Label>
56
+ )
57
+ }
package/src/index.ts CHANGED
@@ -16,6 +16,44 @@ export {
16
16
  selectCardColumns,
17
17
  UNASSIGNED_LANE,
18
18
  } from './dynamic-kanban'
19
+ export {
20
+ useStageAutomations,
21
+ StageAutomationsButton,
22
+ isTagColumn,
23
+ automationFieldOptions,
24
+ groupAutomationsByStage,
25
+ activeAutomationCount,
26
+ type StageAutomation,
27
+ type StageAutomationAction,
28
+ type StageAutomationActionType,
29
+ type NewStageAutomation,
30
+ type UseStageAutomationsResult,
31
+ type StageAutomationsButtonProps,
32
+ } from './stage-automations'
33
+ export {
34
+ useCustomStages,
35
+ splitCustomStages,
36
+ mergeLaneStages,
37
+ resolveSmartLanes,
38
+ smartLaneParams,
39
+ customStageFilterFields,
40
+ isCustomStageDraftValid,
41
+ slugifyStageKey,
42
+ emptyCustomStageFilter,
43
+ AddStageColumn,
44
+ CustomStageLaneMenu,
45
+ CustomStageDialog,
46
+ CustomStageDeleteDialog,
47
+ SmartLane,
48
+ CUSTOM_STAGE_COLORS,
49
+ CUSTOM_STAGE_FILTER_OPS,
50
+ type CustomStage,
51
+ type NewCustomStage,
52
+ type CustomStageType,
53
+ type CustomStageFilter,
54
+ type CustomStageFilterOp,
55
+ type UseCustomStagesResult,
56
+ } from './custom-stages'
19
57
  export {
20
58
  DynamicView,
21
59
  resolveViewRenderer,
@@ -29,6 +67,7 @@ export {
29
67
  type UseDynamicFiltersResult,
30
68
  } from './use-dynamic-filters'
31
69
  export * from './dynamic-form'
70
+ export { FieldGrid, FieldCell, FieldLabel } from './field-grid'
32
71
  export {
33
72
  ActionModalDispatcher,
34
73
  type ActionModalProps,