@asteby/metacore-runtime-react 20.1.6 → 21.0.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.
@@ -0,0 +1,195 @@
1
+ // useDynamicRowActions — the single source of truth for what a per-row action
2
+ // (the "…" kebab → Ver / Editar / Eliminar / custom / link) DOES when clicked.
3
+ //
4
+ // Extracted verbatim from DynamicTable so the kanban board's per-card menu
5
+ // behaves IDENTICALLY to a table row (it used to forward the raw action object
6
+ // to the host and silently no-op). Both renderers call the returned
7
+ // `handleInternalAction(actionKey, row)` with the action's STRING key and mount
8
+ // the returned `dialogs` node once in their tree:
9
+ //
10
+ // delete → opens the SDK's own confirm dialog, then DELETEs.
11
+ // view / edit → host `onAction(action, row)` when provided (string
12
+ // contract), else the built-in record dialog.
13
+ // link action → navigate to the action's templated `linkUrl`.
14
+ // custom (fields/confirm/
15
+ // executable) action → opens the ActionModal via the dispatcher.
16
+ // anything else → host `onAction` + refresh (or just refresh).
17
+ //
18
+ // The host `onAction` contract is a STRING action key on purpose — the existing
19
+ // ops `handleAction(action: string, row)` keeps working unchanged.
20
+ import { useCallback, useState } from 'react'
21
+ import { useNavigate } from '@tanstack/react-router'
22
+ import { useTranslation } from 'react-i18next'
23
+ import { toast } from 'sonner'
24
+ import {
25
+ AlertDialog,
26
+ AlertDialogAction,
27
+ AlertDialogCancel,
28
+ AlertDialogContent,
29
+ AlertDialogDescription,
30
+ AlertDialogFooter,
31
+ AlertDialogHeader,
32
+ AlertDialogTitle,
33
+ } from '@asteby/metacore-ui/primitives'
34
+ import { useApi } from './api-context'
35
+ import { ActionModalDispatcher } from './action-modal-dispatcher'
36
+ import { DynamicRecordDialog } from './dialogs/dynamic-record'
37
+ import type { TableMetadata, ActionMetadata } from './types'
38
+
39
+ export interface UseDynamicRowActionsParams {
40
+ /** Model key as registered on the backend (e.g. "issue"). */
41
+ model: string
42
+ /**
43
+ * Data endpoint base. The per-record DELETE hits `<endpoint>/<id>`; when
44
+ * omitted it falls back to `/data/<model>/<id>`. Pass the SAME base the
45
+ * caller lists from (e.g. `/data/<model>/me`) so writes stay org-scoped.
46
+ */
47
+ endpoint?: string
48
+ /** Raw model metadata — used to resolve link/custom action definitions. */
49
+ metadata: TableMetadata | null
50
+ /**
51
+ * Host hook for `view`/`edit` (and any unhandled key). STRING contract:
52
+ * receives the action key, not the action object. When absent, `view`/`edit`
53
+ * fall back to the built-in record dialog.
54
+ */
55
+ onAction?: (action: string, row: any) => void
56
+ /** Called after a successful delete / custom action to refetch the list. */
57
+ onRefresh: () => void
58
+ }
59
+
60
+ export interface DynamicRowActions {
61
+ /** Dispatch a row action by its STRING key. */
62
+ handleInternalAction: (action: string, row: any) => Promise<void>
63
+ /**
64
+ * The delete-confirm dialog + record dialog + action modal this handler
65
+ * drives. Render it ONCE in the consuming component's tree.
66
+ */
67
+ dialogs: React.ReactElement
68
+ }
69
+
70
+ /**
71
+ * Owns the state + dialogs + dispatch logic for per-row actions, shared by
72
+ * DynamicTable and DynamicKanban so both behave identically.
73
+ */
74
+ export function useDynamicRowActions({
75
+ model,
76
+ endpoint,
77
+ metadata,
78
+ onAction,
79
+ onRefresh,
80
+ }: UseDynamicRowActionsParams): DynamicRowActions {
81
+ const { t } = useTranslation()
82
+ const api = useApi()
83
+ const navigate = useNavigate()
84
+
85
+ const [recordDialog, setRecordDialog] = useState<{
86
+ open: boolean
87
+ mode: 'view' | 'edit' | 'create'
88
+ recordId: string | null
89
+ }>({ open: false, mode: 'view', recordId: null })
90
+
91
+ const [rowToDelete, setRowToDelete] = useState<any | null>(null)
92
+ const [isDeleting, setIsDeleting] = useState(false)
93
+
94
+ const [actionModal, setActionModal] = useState<{
95
+ open: boolean
96
+ action: ActionMetadata | null
97
+ record: any | null
98
+ }>({ open: false, action: null, record: null })
99
+
100
+ const handleInternalAction = useCallback(async (action: string, row: any) => {
101
+ if (action === 'delete') { setRowToDelete(row); return }
102
+ if (action === 'view' || action === 'edit') {
103
+ if (onAction) await Promise.resolve(onAction(action, row))
104
+ else setRecordDialog({ open: true, mode: action, recordId: row.id })
105
+ return
106
+ }
107
+ const linkDef = metadata?.actions?.find((a) => a.key === action && a.type === 'link')
108
+ if (linkDef?.linkUrl) {
109
+ const url = linkDef.linkUrl.replace(/\{(\w+)\}/g, (_: string, field: string) => String(row[field] ?? ''))
110
+ navigate({ to: url })
111
+ return
112
+ }
113
+ const actionDef = metadata?.actions?.find((a) => a.key === action)
114
+ if (actionDef && (actionDef.fields?.length || actionDef.confirm || actionDef.executable)) {
115
+ setActionModal({
116
+ open: true,
117
+ action: {
118
+ key: actionDef.key,
119
+ label: actionDef.label,
120
+ icon: actionDef.icon || 'Zap',
121
+ color: actionDef.color,
122
+ confirm: actionDef.confirm,
123
+ confirmMessage: actionDef.confirmMessage,
124
+ fields: actionDef.fields,
125
+ requiresState: actionDef.requiresState,
126
+ executable: actionDef.executable,
127
+ },
128
+ record: row,
129
+ })
130
+ return
131
+ }
132
+ if (onAction) { await Promise.resolve(onAction(action, row)); onRefresh() }
133
+ else onRefresh()
134
+ }, [onAction, onRefresh, metadata, navigate])
135
+
136
+ const confirmDelete = async () => {
137
+ if (!rowToDelete) return
138
+ setIsDeleting(true)
139
+ try {
140
+ const deleteEndpoint = endpoint ? `${endpoint}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}`
141
+ const res = await api.delete(deleteEndpoint)
142
+ if (res.data.success) { toast.success(res.data.message || 'Eliminado correctamente'); onRefresh() }
143
+ else toast.error(res.data.message || 'Error al eliminar')
144
+ } catch (error) {
145
+ console.error('Error al eliminar', error)
146
+ toast.error('Error al eliminar el registro')
147
+ } finally {
148
+ setIsDeleting(false)
149
+ setRowToDelete(null)
150
+ }
151
+ }
152
+
153
+ const dialogs = (
154
+ <>
155
+ <AlertDialog open={!!rowToDelete} onOpenChange={(open: boolean) => !open && setRowToDelete(null)}>
156
+ <AlertDialogContent>
157
+ <AlertDialogHeader>
158
+ <AlertDialogTitle>¿Está absolutamente seguro?</AlertDialogTitle>
159
+ <AlertDialogDescription>Esta acción no se puede deshacer. Esto eliminará permanentemente el registro seleccionado de nuestros servidores.</AlertDialogDescription>
160
+ </AlertDialogHeader>
161
+ <AlertDialogFooter>
162
+ <AlertDialogCancel disabled={isDeleting}>{t('common.cancel')}</AlertDialogCancel>
163
+ <AlertDialogAction onClick={(e: React.MouseEvent) => { e.preventDefault(); confirmDelete() }} className="bg-red-600 hover:bg-red-700" disabled={isDeleting}>
164
+ {isDeleting ? 'Eliminando...' : 'Eliminar'}
165
+ </AlertDialogAction>
166
+ </AlertDialogFooter>
167
+ </AlertDialogContent>
168
+ </AlertDialog>
169
+
170
+ <DynamicRecordDialog
171
+ open={recordDialog.open}
172
+ onOpenChange={(open: boolean) => setRecordDialog((prev) => ({ ...prev, open }))}
173
+ mode={recordDialog.mode}
174
+ model={model}
175
+ recordId={recordDialog.recordId}
176
+ endpoint={endpoint}
177
+ onSaved={onRefresh}
178
+ />
179
+
180
+ {actionModal.action && (
181
+ <ActionModalDispatcher
182
+ open={actionModal.open}
183
+ onOpenChange={(open: boolean) => setActionModal((prev) => ({ ...prev, open }))}
184
+ action={actionModal.action}
185
+ model={model}
186
+ record={actionModal.record}
187
+ endpoint={endpoint}
188
+ onSuccess={onRefresh}
189
+ />
190
+ )}
191
+ </>
192
+ )
193
+
194
+ return { handleInternalAction, dialogs }
195
+ }
@@ -11,7 +11,6 @@
11
11
  // `getDynamicColumns` prop (hosts retain ownership because the rendered
12
12
  // column cells are tightly coupled to their design system).
13
13
  import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
14
- import { useNavigate } from '@tanstack/react-router'
15
14
  import { useTranslation } from 'react-i18next'
16
15
  import { format } from 'date-fns'
17
16
  import type { DateRange } from 'react-day-picker'
@@ -68,11 +67,10 @@ import { useApi, useCurrentBranch } from './api-context'
68
67
  import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
69
68
  import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
70
69
  import { OptionsContext } from './options-context'
71
- import { ActionModalDispatcher } from './action-modal-dispatcher'
72
- import type { TableMetadata, ApiResponse, ActionMetadata } from './types'
70
+ import type { TableMetadata, ApiResponse } from './types'
73
71
  import { getSearchableColumnKeys } from './column-visibility'
74
72
  import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context'
75
- import { DynamicRecordDialog } from './dialogs/dynamic-record'
73
+ import { useDynamicRowActions } from './dynamic-row-actions'
76
74
  import { ExportDialog } from './dialogs/export'
77
75
  import { ImportDialog } from './dialogs/import'
78
76
 
@@ -131,7 +129,6 @@ export function DynamicTable({
131
129
  const { t, i18n } = useTranslation()
132
130
  const api = useApi()
133
131
  const currentBranch = useCurrentBranch()
134
- const navigate = useNavigate()
135
132
 
136
133
  const prevBranchId = useRef(currentBranch?.id)
137
134
 
@@ -147,24 +144,9 @@ export function DynamicTable({
147
144
  const [loadingData, setLoadingData] = useState(true)
148
145
  const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
149
146
 
150
- const [recordDialog, setRecordDialog] = useState<{
151
- open: boolean
152
- mode: 'view' | 'edit' | 'create'
153
- recordId: string | null
154
- }>({ open: false, mode: 'view', recordId: null })
155
-
156
- const [rowToDelete, setRowToDelete] = useState<any | null>(null)
157
- const [isDeleting, setIsDeleting] = useState(false)
158
-
159
147
  const [exportOpen, setExportOpen] = useState(false)
160
148
  const [importOpen, setImportOpen] = useState(false)
161
149
 
162
- const [actionModal, setActionModal] = useState<{
163
- open: boolean
164
- action: ActionMetadata | null
165
- record: any | null
166
- }>({ open: false, action: null, record: null })
167
-
168
150
  const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
169
151
  const [isBulkDeleting, setIsBulkDeleting] = useState(false)
170
152
  const [bulkDeleteProgress, setBulkDeleteProgress] = useState(0)
@@ -506,58 +488,15 @@ export function DynamicTable({
506
488
 
507
489
  const handleRefresh = useCallback(() => { fetchData(); fetchAggregates() }, [fetchData, fetchAggregates])
508
490
 
509
- const handleInternalAction = useCallback(async (action: string, row: any) => {
510
- if (action === 'delete') { setRowToDelete(row); return }
511
- if (action === 'view' || action === 'edit') {
512
- if (onAction) await Promise.resolve(onAction(action, row))
513
- else setRecordDialog({ open: true, mode: action, recordId: row.id })
514
- return
515
- }
516
- const linkDef = metadata?.actions?.find((a) => a.key === action && a.type === 'link')
517
- if (linkDef?.linkUrl) {
518
- const url = linkDef.linkUrl.replace(/\{(\w+)\}/g, (_: string, field: string) => String(row[field] ?? ''))
519
- navigate({ to: url })
520
- return
521
- }
522
- const actionDef = metadata?.actions?.find((a) => a.key === action)
523
- if (actionDef && (actionDef.fields?.length || actionDef.confirm || actionDef.executable)) {
524
- setActionModal({
525
- open: true,
526
- action: {
527
- key: actionDef.key,
528
- label: actionDef.label,
529
- icon: actionDef.icon || 'Zap',
530
- color: actionDef.color,
531
- confirm: actionDef.confirm,
532
- confirmMessage: actionDef.confirmMessage,
533
- fields: actionDef.fields,
534
- requiresState: actionDef.requiresState,
535
- executable: actionDef.executable,
536
- },
537
- record: row,
538
- })
539
- return
540
- }
541
- if (onAction) { await Promise.resolve(onAction(action, row)); handleRefresh() }
542
- else handleRefresh()
543
- }, [onAction, handleRefresh, metadata, navigate])
544
-
545
- const confirmDelete = async () => {
546
- if (!rowToDelete) return
547
- setIsDeleting(true)
548
- try {
549
- const deleteEndpoint = endpoint ? `${endpoint}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}`
550
- const res = await api.delete(deleteEndpoint)
551
- if (res.data.success) { toast.success(res.data.message || 'Eliminado correctamente'); handleRefresh() }
552
- else toast.error(res.data.message || 'Error al eliminar')
553
- } catch (error) {
554
- console.error('Error al eliminar', error)
555
- toast.error('Error al eliminar el registro')
556
- } finally {
557
- setIsDeleting(false)
558
- setRowToDelete(null)
559
- }
560
- }
491
+ // Per-row action dispatch (view/edit/delete/link/custom) + its dialogs live
492
+ // in the shared hook so DynamicKanban's card menu behaves identically.
493
+ const { handleInternalAction, dialogs: rowActionDialogs } = useDynamicRowActions({
494
+ model,
495
+ endpoint,
496
+ metadata,
497
+ onAction,
498
+ onRefresh: handleRefresh,
499
+ })
561
500
 
562
501
  const confirmBulkDelete = async () => {
563
502
  const selectedRows = table.getFilteredSelectedRowModel().rows
@@ -974,20 +913,7 @@ export function DynamicTable({
974
913
  </div>
975
914
  </div>
976
915
 
977
- <AlertDialog open={!!rowToDelete} onOpenChange={(open: boolean) => !open && setRowToDelete(null)}>
978
- <AlertDialogContent>
979
- <AlertDialogHeader>
980
- <AlertDialogTitle>¿Está absolutamente seguro?</AlertDialogTitle>
981
- <AlertDialogDescription>Esta acción no se puede deshacer. Esto eliminará permanentemente el registro seleccionado de nuestros servidores.</AlertDialogDescription>
982
- </AlertDialogHeader>
983
- <AlertDialogFooter>
984
- <AlertDialogCancel disabled={isDeleting}>{t('common.cancel')}</AlertDialogCancel>
985
- <AlertDialogAction onClick={(e: React.MouseEvent) => { e.preventDefault(); confirmDelete() }} className="bg-red-600 hover:bg-red-700" disabled={isDeleting}>
986
- {isDeleting ? 'Eliminando...' : 'Eliminar'}
987
- </AlertDialogAction>
988
- </AlertDialogFooter>
989
- </AlertDialogContent>
990
- </AlertDialog>
916
+ {rowActionDialogs}
991
917
 
992
918
  <AlertDialog open={showBulkDeleteConfirm} onOpenChange={(open: boolean) => !open && !isBulkDeleting && setShowBulkDeleteConfirm(false)}>
993
919
  <AlertDialogContent>
@@ -1013,33 +939,12 @@ export function DynamicTable({
1013
939
  </AlertDialogContent>
1014
940
  </AlertDialog>
1015
941
 
1016
- <DynamicRecordDialog
1017
- open={recordDialog.open}
1018
- onOpenChange={(open: boolean) => setRecordDialog((prev) => ({ ...prev, open }))}
1019
- mode={recordDialog.mode}
1020
- model={model}
1021
- recordId={recordDialog.recordId}
1022
- endpoint={endpoint}
1023
- onSaved={handleRefresh}
1024
- />
1025
-
1026
942
  {viewMetadata?.canExport && (
1027
943
  <ExportDialog open={exportOpen} onOpenChange={setExportOpen} model={model} metadata={metadata} currentFilters={buildFilterParams()} hasActiveFilters={hasActiveFilters} />
1028
944
  )}
1029
945
  {viewMetadata?.canImport && (
1030
946
  <ImportDialog open={importOpen} onOpenChange={setImportOpen} model={model} metadata={metadata} onImported={handleRefresh} />
1031
947
  )}
1032
- {actionModal.action && (
1033
- <ActionModalDispatcher
1034
- open={actionModal.open}
1035
- onOpenChange={(open: boolean) => setActionModal((prev) => ({ ...prev, open }))}
1036
- action={actionModal.action}
1037
- model={model}
1038
- record={actionModal.record}
1039
- endpoint={endpoint}
1040
- onSuccess={handleRefresh}
1041
- />
1042
- )}
1043
948
  <DataTableBulkActions table={table} entityName="registro">
1044
949
  <Button variant="destructive" size="sm" className="h-8" onClick={() => setShowBulkDeleteConfirm(true)}>
1045
950
  <Trash2 className="h-4 w-4 mr-1.5" /> Eliminar
package/src/index.ts CHANGED
@@ -23,6 +23,11 @@ export {
23
23
  resolveActiveView,
24
24
  type DynamicViewProps,
25
25
  } from './dynamic-view'
26
+ export {
27
+ useDynamicFilters,
28
+ type UseDynamicFiltersOptions,
29
+ type UseDynamicFiltersResult,
30
+ } from './use-dynamic-filters'
26
31
  export * from './dynamic-form'
27
32
  export {
28
33
  ActionModalDispatcher,
@@ -53,9 +58,15 @@ export {
53
58
  capabilityForActionKey,
54
59
  modelCapability,
55
60
  gateTableMetadata,
61
+ resolveRowActions,
56
62
  type CanFn,
57
63
  type PermissionsProviderProps,
58
64
  } from './permissions-context'
65
+ export {
66
+ useDynamicRowActions,
67
+ type UseDynamicRowActionsParams,
68
+ type DynamicRowActions,
69
+ } from './dynamic-row-actions'
59
70
  export {
60
71
  PermissionsManager,
61
72
  moduleActionCapability,
@@ -156,3 +156,42 @@ export function gateTableMetadata(
156
156
  metadata.canCreate === undefined ? undefined : metadata.canCreate && allowed('create'),
157
157
  }
158
158
  }
159
+
160
+ /**
161
+ * Resolves the per-row (`placement: 'row'`) action list for a model exactly the
162
+ * way DynamicTable's action column does, so the kanban card menu shows the SAME
163
+ * actions:
164
+ * - when a <PermissionsProvider> is active, the metadata is run through
165
+ * `gateTableMetadata` first (filters by capability + materializes the CRUD
166
+ * trio), otherwise the raw metadata is used.
167
+ * - explicit actions win; absent them, the implicit View/Edit/Delete trio is
168
+ * materialized when `enableCRUDActions` is on.
169
+ * - table/create-placement actions are stripped (they belong to the toolbar).
170
+ *
171
+ * Pure. `tx` resolves the trio's i18n labels (defaults to the Spanish fallbacks).
172
+ */
173
+ export function resolveRowActions(
174
+ metadata: TableMetadata,
175
+ model: string,
176
+ can: CanFn,
177
+ permissionsActive: boolean,
178
+ tx: (i18nKey: string, fallback: string) => string = (_k, fallback) => fallback,
179
+ ): ActionDefinition[] {
180
+ const gated = permissionsActive ? gateTableMetadata(metadata, model, can, tx) : metadata
181
+ const explicit = gated.actions ?? []
182
+ const hasExplicit = (gated.hasActions ?? explicit.length > 0) && explicit.length > 0
183
+ const base: ActionDefinition[] = hasExplicit
184
+ ? explicit
185
+ : gated.enableCRUDActions
186
+ ? DEFAULT_TRIO.map(
187
+ (a) =>
188
+ ({
189
+ key: a.key,
190
+ name: a.key,
191
+ label: tx(a.i18nKey, a.fallback),
192
+ icon: a.icon,
193
+ }) as ActionDefinition,
194
+ )
195
+ : []
196
+ return base.filter((a) => ((a.placement ?? 'row') as string) === 'row')
197
+ }