@asteby/metacore-runtime-react 23.6.0 → 23.7.1

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 && (
@@ -85,6 +85,18 @@ import {
85
85
  type StageAutomation,
86
86
  type NewStageAutomation,
87
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'
88
100
  import { useDynamicFilters } from './use-dynamic-filters'
89
101
  import {
90
102
  FilterChipsRow,
@@ -463,6 +475,26 @@ export function DynamicKanban({
463
475
  // the host has no `/stage-automations` endpoint — the ⚡ affordance hides.
464
476
  const automations = useStageAutomations(model)
465
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
+
466
498
  const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache()
467
499
  const cachedMeta = getMetadata(model)
468
500
 
@@ -757,10 +789,34 @@ export function DynamicKanban({
757
789
  [],
758
790
  )
759
791
 
760
- const stages = useMemo(
792
+ const declaredStages = useMemo(
761
793
  () => (metadata ? deriveStages(metadata) : []),
762
794
  [metadata],
763
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])
764
820
  const transitions = metadata?.transitions
765
821
 
766
822
  const grouped = useMemo(
@@ -1094,6 +1150,13 @@ export function DynamicKanban({
1094
1150
  onAutomationCreate={automations.create}
1095
1151
  onAutomationUpdate={automations.update}
1096
1152
  onAutomationRemove={automations.remove}
1153
+ customStage={
1154
+ customStages.available
1155
+ ? customByKey.get(stage.key)
1156
+ : undefined
1157
+ }
1158
+ onEditStage={openEditStage}
1159
+ onDeleteStage={openDeleteStage}
1097
1160
  >
1098
1161
  {loadingData && cards.length === 0 ? (
1099
1162
  <>
@@ -1123,6 +1186,42 @@ export function DynamicKanban({
1123
1186
  </KanbanLane>
1124
1187
  )
1125
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
+ )}
1126
1225
  </div>
1127
1226
 
1128
1227
  <DragOverlay>
@@ -1140,6 +1239,35 @@ export function DynamicKanban({
1140
1239
 
1141
1240
  {rowActionDialogs}
1142
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
+ )}
1143
1271
  </div>
1144
1272
  )
1145
1273
  }
@@ -1274,6 +1402,10 @@ interface KanbanLaneProps {
1274
1402
  patch: Partial<StageAutomation>,
1275
1403
  ) => Promise<void>
1276
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
1277
1409
  children: React.ReactNode
1278
1410
  }
1279
1411
 
@@ -1299,6 +1431,9 @@ function KanbanLane({
1299
1431
  onAutomationCreate,
1300
1432
  onAutomationUpdate,
1301
1433
  onAutomationRemove,
1434
+ customStage,
1435
+ onEditStage,
1436
+ onDeleteStage,
1302
1437
  children,
1303
1438
  }: KanbanLaneProps) {
1304
1439
  const { t } = useTranslation()
@@ -1409,6 +1544,13 @@ function KanbanLane({
1409
1544
  onRemove={onAutomationRemove}
1410
1545
  />
1411
1546
  )}
1547
+ {customStage && onEditStage && onDeleteStage && (
1548
+ <CustomStageLaneMenu
1549
+ stage={customStage}
1550
+ onEdit={onEditStage}
1551
+ onDelete={onDeleteStage}
1552
+ />
1553
+ )}
1412
1554
  </div>
1413
1555
  </div>
1414
1556
  {searchOpen && (
@@ -1640,6 +1782,8 @@ interface KanbanCardProps {
1640
1782
  onClick?: (row: any) => void
1641
1783
  /** STRING contract — dispatch the action by its key (see useDynamicRowActions). */
1642
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
1643
1787
  }
1644
1788
 
1645
1789
  function KanbanCard({
@@ -1652,19 +1796,23 @@ function KanbanCard({
1652
1796
  currency,
1653
1797
  onClick,
1654
1798
  onAction,
1799
+ draggable = true,
1655
1800
  }: KanbanCardProps) {
1656
1801
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
1657
1802
  id: String(card.id),
1803
+ disabled: !draggable,
1658
1804
  })
1659
1805
 
1660
1806
  const visibleActions = actions.filter((a) => isRowActionVisible(a, card))
1661
1807
 
1662
1808
  return (
1663
1809
  <Card
1664
- ref={setNodeRef}
1665
- {...attributes}
1666
- {...listeners}
1667
- 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
+ }`}
1668
1816
  style={{ opacity: isDragging ? 0.4 : 1 }}
1669
1817
  onClick={() => onClick?.(card)}
1670
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
@@ -30,6 +30,30 @@ export {
30
30
  type UseStageAutomationsResult,
31
31
  type StageAutomationsButtonProps,
32
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'
33
57
  export {
34
58
  DynamicView,
35
59
  resolveViewRenderer,
@@ -43,6 +67,7 @@ export {
43
67
  type UseDynamicFiltersResult,
44
68
  } from './use-dynamic-filters'
45
69
  export * from './dynamic-form'
70
+ export { FieldGrid, FieldCell, FieldLabel } from './field-grid'
46
71
  export {
47
72
  ActionModalDispatcher,
48
73
  type ActionModalProps,
@@ -18,6 +18,7 @@
18
18
  // it internally, and bespoke host pages (e.g. ops `/m/$model`) mount it directly
19
19
  // next to their own toolbar. Hosts never reimplement action-button plumbing.
20
20
  import { useEffect, useMemo, useState } from 'react'
21
+ import { useTranslation } from 'react-i18next'
21
22
  import { Button } from '@asteby/metacore-ui/primitives'
22
23
  import { useApi } from './api-context'
23
24
  import { useMetadataCache } from './metadata-cache'
@@ -109,6 +110,7 @@ export function ModelActionToolbar({
109
110
  onChange,
110
111
  className,
111
112
  }: ModelActionToolbarProps) {
113
+ const { t } = useTranslation()
112
114
  const all = useModelActions(model, placements, actions)
113
115
  // Capability gating — always-true without a <PermissionsProvider>. Custom
114
116
  // table/create actions map onto `lowercase(model).<action_key>`.
@@ -135,7 +137,13 @@ export function ModelActionToolbar({
135
137
  style={a.color && !isCreate ? { borderColor: a.color, color: a.color } : undefined}
136
138
  >
137
139
  <DynamicIcon name={a.icon || (isCreate ? 'Plus' : 'Zap')} className="mr-2 h-4 w-4" />
138
- {a.label}
140
+ {/* `a.label` is an addon-contributed i18n key (e.g.
141
+ "integration_github.action.create_issue.label"); the addon's
142
+ locale bundle loads asynchronously, so translate at render — a
143
+ bare `{a.label}` prints the raw key until (and after) the bundle
144
+ lands because nothing re-derives it. defaultValue keeps an
145
+ already-localized label untouched. */}
146
+ {t(a.label, { defaultValue: a.label })}
139
147
  </Button>
140
148
  )
141
149
  })}
package/src/types.ts CHANGED
@@ -43,6 +43,13 @@ export interface TableMetadata {
43
43
  * keys as the kernel serves them.
44
44
  */
45
45
  stages?: StageMeta[]
46
+ /**
47
+ * Virtual "smart" lanes (ops #704): read-only board columns defined by a set
48
+ * of filters rather than a stored stage value. When present the kanban paints
49
+ * one lane per entry (querying the list with the lane's filters) after the
50
+ * real stages. Purely additive — absent on hosts without custom stages.
51
+ */
52
+ smart_lanes?: SmartLaneMeta[]
46
53
  /**
47
54
  * Allowed stage transitions (RFC §1.1). When present, the kanban only lets a
48
55
  * card drop into a lane reachable from its current stage; disallowed lanes
@@ -64,6 +71,25 @@ export interface StageMeta {
64
71
  color?: string
65
72
  order?: number
66
73
  is_final?: boolean
74
+ /**
75
+ * True when the kernel merged a user-defined custom stage into `stages[]`
76
+ * (ops #704). Behaves as a normal droppable lane; the SDK just grows an
77
+ * Editar/Eliminar menu on it.
78
+ */
79
+ custom?: boolean
80
+ }
81
+
82
+ /**
83
+ * A virtual "smart" lane (ops #704) served in `TableMetadata.smart_lanes`. It's
84
+ * defined by `filters` (never a stored stage value), so the board paints it by
85
+ * querying the list with those conditions. Read-only — not a drop target.
86
+ */
87
+ export interface SmartLaneMeta {
88
+ key: string
89
+ label: string
90
+ color?: string
91
+ order?: number
92
+ filters: { field: string; op: string; value: string }[]
67
93
  }
68
94
 
69
95
  /** Allowed `from → to` stage transition (RFC §1.1). `'*'` is a wildcard. */