@asteby/metacore-runtime-react 42.0.0 → 44.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/action-modal-dispatcher.d.ts +2 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +74 -8
  5. package/dist/api-context.d.ts.map +1 -1
  6. package/dist/api-context.js +6 -4
  7. package/dist/dynamic-icon.d.ts.map +1 -1
  8. package/dist/dynamic-icon.js +9 -13
  9. package/dist/dynamic-kanban.d.ts +83 -6
  10. package/dist/dynamic-kanban.d.ts.map +1 -1
  11. package/dist/dynamic-kanban.js +647 -146
  12. package/dist/dynamic-row-actions.d.ts.map +1 -1
  13. package/dist/dynamic-row-actions.js +11 -15
  14. package/dist/dynamic-table.d.ts +1 -1
  15. package/dist/dynamic-table.d.ts.map +1 -1
  16. package/dist/dynamic-table.js +14 -1
  17. package/dist/federated-action-loader.d.ts +18 -0
  18. package/dist/federated-action-loader.d.ts.map +1 -0
  19. package/dist/federated-action-loader.js +34 -0
  20. package/dist/index.d.ts +4 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +4 -2
  23. package/dist/license/license-gate.d.ts +0 -21
  24. package/dist/license/license-gate.d.ts.map +1 -1
  25. package/dist/license/license-gate.js +5 -2
  26. package/dist/model-action-toolbar.d.ts.map +1 -1
  27. package/dist/model-action-toolbar.js +3 -12
  28. package/dist/portal-drag-overlay.d.ts +5 -0
  29. package/dist/portal-drag-overlay.d.ts.map +1 -0
  30. package/dist/portal-drag-overlay.js +8 -0
  31. package/dist/query-batch.d.ts.map +1 -1
  32. package/dist/query-batch.js +6 -3
  33. package/dist/sidebar-layout-manager.js +3 -2
  34. package/dist/types.d.ts +4 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/use-flip-animation.d.ts +6 -0
  37. package/dist/use-flip-animation.d.ts.map +1 -1
  38. package/dist/use-flip-animation.js +11 -4
  39. package/package.json +6 -5
  40. package/src/__tests__/action-modal-missing-custom.test.tsx +12 -4
  41. package/src/__tests__/dynamic-icon-slot.test.tsx +50 -0
  42. package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +2 -1
  43. package/src/__tests__/dynamic-kanban-pipelines-pro.test.tsx +388 -0
  44. package/src/__tests__/dynamic-kanban.test.tsx +15 -2
  45. package/src/__tests__/federated-action-modal.test.tsx +315 -0
  46. package/src/__tests__/image-cell-lucide.test.tsx +7 -5
  47. package/src/action-modal-dispatcher.tsx +102 -10
  48. package/src/api-context.tsx +7 -5
  49. package/src/dynamic-icon.tsx +12 -16
  50. package/src/dynamic-kanban.tsx +950 -262
  51. package/src/dynamic-row-actions.tsx +11 -15
  52. package/src/dynamic-table.tsx +16 -2
  53. package/src/federated-action-loader.ts +40 -0
  54. package/src/index.ts +8 -0
  55. package/src/license/license-gate.tsx +6 -2
  56. package/src/model-action-toolbar.tsx +3 -12
  57. package/src/portal-drag-overlay.tsx +20 -0
  58. package/src/query-batch.ts +6 -3
  59. package/src/sidebar-layout-manager.tsx +3 -3
  60. package/src/types.ts +4 -0
  61. package/src/use-flip-animation.ts +16 -3
@@ -11,8 +11,8 @@
11
11
  // view / edit → host `onAction(action, row)` when provided (string
12
12
  // contract), else the built-in record dialog.
13
13
  // link action → navigate to the action's templated `linkUrl`.
14
- // custom (fields/confirm/
15
- // executable) action → opens the ActionModal via the dispatcher.
14
+ // custom (modal/fields/
15
+ // confirm/executable) → opens the ActionModal via the dispatcher.
16
16
  // anything else → host `onAction` + refresh (or just refresh).
17
17
  //
18
18
  // The host `onAction` contract is a STRING action key on purpose — the existing
@@ -120,24 +120,20 @@ export function useDynamicRowActions({
120
120
  return
121
121
  }
122
122
  const actionDef = metadata?.actions?.find((a) => a.key === action)
123
- // Open the dispatcher when there is ANY UI surface (fields, wizard
124
- // steps, confirm bool, confirmMessage, or host `executable`). A
125
- // confirm_message without confirm:true used to fall through / open and
126
- // then render null — derive confirm so the ConfirmActionDialog path wins.
127
- if (actionDef && (actionDef.fields?.length || actionDef.steps?.length || actionDef.confirm || actionDef.confirmMessage || actionDef.executable)) {
123
+ // Open the dispatcher when there is ANY UI surface (federated `modal`,
124
+ // fields, wizard steps, confirm bool, confirmMessage, or host
125
+ // `executable`). A confirm_message without confirm:true used to fall
126
+ // through / open and then render null — derive confirm so the
127
+ // ConfirmActionDialog path wins. Pass the WHOLE definition: a
128
+ // cherry-picked copy dropped `modal` and the dispatcher fell back to
129
+ // the generic confirm while the remote was still loading.
130
+ if (actionDef && (actionDef.modal || actionDef.fields?.length || actionDef.steps?.length || actionDef.confirm || actionDef.confirmMessage || actionDef.executable)) {
128
131
  setActionModal({
129
132
  open: true,
130
133
  action: {
131
- key: actionDef.key,
132
- label: actionDef.label,
134
+ ...actionDef,
133
135
  icon: actionDef.icon || 'Zap',
134
- color: actionDef.color,
135
136
  confirm: !!(actionDef.confirm || actionDef.confirmMessage),
136
- confirmMessage: actionDef.confirmMessage,
137
- fields: actionDef.fields,
138
- steps: actionDef.steps,
139
- requiresState: actionDef.requiresState,
140
- executable: actionDef.executable,
141
137
  },
142
138
  record: row,
143
139
  })
@@ -12,6 +12,7 @@
12
12
  // column cells are tightly coupled to their design system).
13
13
  import { useEffect, useState, useMemo, useCallback, useRef, type MouseEvent } from 'react'
14
14
  import { useTranslation } from 'react-i18next'
15
+ import { useTimeZone, useCurrency } from './org-runtime-context'
15
16
  import { format } from 'date-fns'
16
17
  import type { DateRange } from 'react-day-picker'
17
18
  import { useVirtualizer } from '@tanstack/react-virtual'
@@ -35,6 +36,7 @@ import {
35
36
  useReactTable,
36
37
  } from '@tanstack/react-table'
37
38
  import { cn } from '@asteby/metacore-ui/lib'
39
+ import { preloadGlyphs } from '@asteby/metacore-ui/icons'
38
40
  import {
39
41
  Table,
40
42
  TableBody,
@@ -282,12 +284,18 @@ export function DynamicTable({
282
284
  defaultFilters,
283
285
  extraColumns = [],
284
286
  getDynamicColumns = defaultGetDynamicColumns,
285
- timeZone,
286
- currency,
287
+ timeZone: timeZoneProp,
288
+ currency: currencyProp,
287
289
  pagination: paginationMode,
288
290
  infiniteScroll: infiniteScrollProp = false,
289
291
  virtualizeRows,
290
292
  }: DynamicTableProps) {
293
+ // The org's timezone/currency: an explicit prop wins, else the app-wide
294
+ // OrgRuntimeProvider (without it, money fell back to USD).
295
+ const orgTimeZone = useTimeZone()
296
+ const orgCurrency = useCurrency()
297
+ const timeZone = timeZoneProp ?? orgTimeZone
298
+ const currency = currencyProp ?? orgCurrency
291
299
  // The explicit `pagination` prop wins; the legacy `infiniteScroll` boolean
292
300
  // still selects the mode when `pagination` is absent (back-compat).
293
301
  const infiniteScroll = paginationMode ? paginationMode === 'infinite' : infiniteScrollProp
@@ -754,6 +762,12 @@ export function DynamicTable({
754
762
  return gateTableMetadata(metadata, model, can, (key, fallback) => t(key, { defaultValue: fallback }))
755
763
  }, [metadata, permissionsActive, can, model, t])
756
764
 
765
+ // Row-action menus mount their icons on open: load them with the
766
+ // metadata so the first open draws every glyph at once.
767
+ useEffect(() => {
768
+ void preloadGlyphs((metadata?.actions ?? []).map((a) => a.icon))
769
+ }, [metadata])
770
+
757
771
  // Importing is offered when the host opts in explicitly (`canImport`) or,
758
772
  // when it says nothing, whenever the kernel served an import spec for the
759
773
  // model — which it does for every model with at least one importable form
@@ -0,0 +1,40 @@
1
+ // Host hook that loads the federated remote behind an action's `modal` slug.
2
+ //
3
+ // The SDK does not know how the host resolves addon remotes (Module Federation
4
+ // manifests, fibers, lazy bundles). When ActionModalDispatcher opens an action
5
+ // that declares `modal` and no component is registered yet, it asks the host to
6
+ // load it through this hook and waits for the registry to report the component.
7
+ // The dispatcher re-invokes the loader while it waits, so a loader installed
8
+ // after the first click still gets called.
9
+
10
+ export type FederatedActionLoader = (req: {
11
+ model: string
12
+ actionKey: string
13
+ modal?: string
14
+ }) => void | Promise<void>
15
+
16
+ let loader: FederatedActionLoader | null = null
17
+
18
+ /** Install (or clear with `null`) the host's federated action loader. */
19
+ export function setFederatedActionLoader(fn: FederatedActionLoader | null): void {
20
+ loader = fn
21
+ }
22
+
23
+ /**
24
+ * Ask the host to load the remote for (model, actionKey). No-op without a
25
+ * loader; loader errors are logged, never thrown — the dispatcher keeps waiting
26
+ * until its timeout and then shows the explicit error.
27
+ */
28
+ export function requestFederatedAction(req: { model: string; actionKey: string; modal?: string }): void {
29
+ if (!loader) return
30
+ try {
31
+ const pending = loader(req)
32
+ if (pending && typeof (pending as Promise<void>).catch === 'function') {
33
+ ;(pending as Promise<void>).catch((err) => {
34
+ console.warn('[metacore] federated action loader failed', req, err)
35
+ })
36
+ }
37
+ } catch (err) {
38
+ console.warn('[metacore] federated action loader failed', req, err)
39
+ }
40
+ }
package/src/index.ts CHANGED
@@ -57,9 +57,15 @@ export {
57
57
  groupByStage,
58
58
  isTransitionAllowed,
59
59
  applyOptimisticMove,
60
+ applyStageOverrides,
61
+ laneDeltasFromOverrides,
60
62
  selectCardColumns,
63
+ visibleCardFields,
64
+ isEmptyCardValue,
65
+ nextLaneCoordinates,
61
66
  UNASSIGNED_LANE,
62
67
  } from './dynamic-kanban'
68
+ export { PortalDragOverlay, type PortalDragOverlayProps } from './portal-drag-overlay'
63
69
  export {
64
70
  useStageAutomations,
65
71
  StageAutomationsButton,
@@ -168,8 +174,10 @@ export * from './form-layout-ui'
168
174
  export { FieldGrid, FieldCell, FieldLabel } from './field-grid'
169
175
  export {
170
176
  ActionModalDispatcher,
177
+ FEDERATED_ACTION_MODAL_TIMEOUT_MS,
171
178
  type ActionModalProps,
172
179
  } from './action-modal-dispatcher'
180
+ export { setFederatedActionLoader, type FederatedActionLoader } from './federated-action-loader'
173
181
  export {
174
182
  ModelActionToolbar,
175
183
  useModelActions,
@@ -19,6 +19,7 @@
19
19
  * INDEPENDIENTE del kernel: no importa su cliente ni fija su versión. Solo
20
20
  * consume `LicenseState` y una promesa `onActivate`.
21
21
  */
22
+ import { createPortal } from 'react-dom'
22
23
  import { useState, type ReactNode } from 'react'
23
24
  import { useTranslation } from 'react-i18next'
24
25
  import { ShieldAlert, Clock, Loader2 } from 'lucide-react'
@@ -165,7 +166,9 @@ function LicenseGateModal({
165
166
  }
166
167
  }
167
168
 
168
- return (
169
+ // In <body>: mounted inside a glass panel (backdrop-filter), a fixed veil
170
+ // would cover only that panel.
171
+ return createPortal(
169
172
  <div
170
173
  role="dialog"
171
174
  aria-modal="true"
@@ -300,6 +303,7 @@ function LicenseGateModal({
300
303
  )}
301
304
  </div>
302
305
  </div>
303
- </div>
306
+ </div>,
307
+ document.body,
304
308
  )
305
309
  }
@@ -83,18 +83,9 @@ export function humanizeActionLabel(label: string): string {
83
83
  }
84
84
 
85
85
  function toActionMetadata(a: ActionDefinition): ActionMetadata {
86
- return {
87
- key: a.key,
88
- label: a.label,
89
- icon: a.icon || 'Zap',
90
- color: a.color,
91
- confirm: a.confirm,
92
- confirmMessage: a.confirmMessage,
93
- fields: a.fields,
94
- requiresState: a.requiresState,
95
- executable: a.executable,
96
- placement: a.placement,
97
- }
86
+ // Spread, don't cherry-pick: a hand-picked copy dropped `modal`/`steps`
87
+ // and the dispatcher fell back to generics for federated/wizard actions.
88
+ return { ...a, icon: a.icon || 'Zap' }
98
89
  }
99
90
 
100
91
  /**
@@ -0,0 +1,20 @@
1
+ // PortalDragOverlay — dnd-kit's <DragOverlay> rendered into <body>.
2
+ //
3
+ // The overlay is `position: fixed` at the viewport coordinates of the item
4
+ // being dragged. Any ancestor with a transform, filter or backdrop-filter
5
+ // becomes the containing block of a fixed descendant, so inside such an
6
+ // ancestor (a glass panel from @asteby/metacore-theme/glass.css, an animated
7
+ // dialog) the lifted item shows up shifted by that ancestor's offset instead of
8
+ // under the pointer. Rendering it into <body> keeps the viewport as its
9
+ // containing block wherever the board or list is mounted. React context (the
10
+ // DndContext) still reaches it through the portal.
11
+ import * as React from 'react'
12
+ import { createPortal } from 'react-dom'
13
+ import { DragOverlay } from '@dnd-kit/core'
14
+
15
+ export type PortalDragOverlayProps = React.ComponentProps<typeof DragOverlay>
16
+
17
+ export function PortalDragOverlay(props: PortalDragOverlayProps) {
18
+ if (typeof document === 'undefined') return null
19
+ return createPortal(<DragOverlay {...props} />, document.body)
20
+ }
@@ -377,8 +377,11 @@ function batchForbidden(message?: string): boolean {
377
377
  * or options read. Anything the batch refuses falls back to the original GET.
378
378
  */
379
379
  export async function batchGet(api: ApiClient, url: string, config?: { params?: Record<string, unknown> }) {
380
+ // The fallback GET gets exactly the arguments the caller passed: no
381
+ // trailing `undefined` config reaching the host client.
382
+ const plainGet = () => (config === undefined ? api.get(url) : api.get(url, config))
380
383
  const token = tokenForGet(url, config?.params)
381
- if (!token) return api.get(url, config)
384
+ if (!token) return plainGet()
382
385
  try {
383
386
  const part = await loadQueryPart(api, token)
384
387
  if (!part.success) {
@@ -391,13 +394,13 @@ export async function batchGet(api: ApiClient, url: string, config?: { params?:
391
394
  err.response = { status: 403 }
392
395
  throw err
393
396
  }
394
- return api.get(url, config)
397
+ return plainGet()
395
398
  }
396
399
  return { data: { success: true, data: part.data, meta: part.meta } }
397
400
  } catch (err) {
398
401
  const status = (err as { status?: number; response?: { status?: number } })?.status
399
402
  ?? (err as { response?: { status?: number } })?.response?.status
400
403
  if (status === 403) throw err
401
- return api.get(url, config)
404
+ return plainGet()
402
405
  }
403
406
  }
@@ -5,7 +5,6 @@
5
5
  import * as React from 'react'
6
6
  import {
7
7
  DndContext,
8
- DragOverlay,
9
8
  PointerSensor,
10
9
  closestCenter,
11
10
  useSensor,
@@ -20,6 +19,7 @@ import {
20
19
  verticalListSortingStrategy,
21
20
  } from '@dnd-kit/sortable'
22
21
  import { CSS } from '@dnd-kit/utilities'
22
+ import { PortalDragOverlay } from './portal-drag-overlay'
23
23
  import {
24
24
  ChevronDown,
25
25
  ChevronRight,
@@ -733,13 +733,13 @@ export function SidebarLayoutManager({
733
733
  ) : (
734
734
  renderList(tree, [])
735
735
  )}
736
- <DragOverlay>
736
+ <PortalDragOverlay>
737
737
  {activeLabel ? (
738
738
  <div className="bg-background border-border rounded-md border px-3 py-1.5 text-sm shadow-lg">
739
739
  {activeLabel}
740
740
  </div>
741
741
  ) : null}
742
- </DragOverlay>
742
+ </PortalDragOverlay>
743
743
  </DndContext>
744
744
 
745
745
  <div className="mt-3 flex gap-2 px-2">
package/src/types.ts CHANGED
@@ -671,6 +671,8 @@ export interface ActionDefinition {
671
671
  steps?: ActionStep[]
672
672
  requiresState?: string[]
673
673
  executable?: boolean
674
+ /** Federated modal slug "<addon_key>.<action_key>" (manifest `modal`). */
675
+ modal?: string
674
676
  /**
675
677
  * Where the host surfaces the trigger. Mirrors manifest/v3 Action.placement.
676
678
  * "row" (default) — per-row table action.
@@ -719,5 +721,7 @@ export interface ActionMetadata {
719
721
  steps?: ActionStep[]
720
722
  requiresState?: string[]
721
723
  executable?: boolean
724
+ /** Federated modal slug "<addon_key>.<action_key>"; see ActionModalDispatcher. */
725
+ modal?: string
722
726
  placement?: 'row' | 'table' | 'create'
723
727
  }
@@ -23,6 +23,12 @@ export interface UseFlipAnimationOptions {
23
23
  easing?: MotionEasing | string
24
24
  /** Off switch; the hook also stays still under prefers-reduced-motion. */
25
25
  disabled?: boolean
26
+ /**
27
+ * Leave one element still on this change while still tracking where it is
28
+ * (e.g. the card a drag overlay is already carrying into place). Default:
29
+ * animate everything.
30
+ */
31
+ shouldAnimate?: (el: HTMLElement, key: string) => boolean
26
32
  }
27
33
 
28
34
  const DEFAULT_SELECTOR = '[data-flip-key]'
@@ -54,12 +60,13 @@ export function useFlipAnimation(
54
60
  duration = 'moderate',
55
61
  easing = 'standard',
56
62
  disabled = false,
63
+ shouldAnimate,
57
64
  } = options
58
65
  const positionsRef = useRef<Positions | null>(null)
59
66
  const triggerRef = useRef(trigger)
60
- const configRef = useRef({ selector, keyOf, scrollContainer })
67
+ const configRef = useRef({ selector, keyOf, scrollContainer, shouldAnimate })
61
68
  useEffect(() => {
62
- configRef.current = { selector, keyOf, scrollContainer }
69
+ configRef.current = { selector, keyOf, scrollContainer, shouldAnimate }
63
70
  })
64
71
 
65
72
  const measure = (): Positions | null => {
@@ -105,11 +112,16 @@ export function useFlipAnimation(
105
112
  schedule()
106
113
  document.addEventListener('pointerup', onInteraction, true)
107
114
  document.addEventListener('keyup', onInteraction, true)
115
+ // Scrolling a list inside the root moves its items without a trigger
116
+ // change; without a fresh snapshot the next change would replay the scroll
117
+ // distance as movement. (Scroll does not bubble: listen in capture.)
118
+ document.addEventListener('scroll', onInteraction, true)
108
119
  window.addEventListener('resize', schedule)
109
120
  return () => {
110
121
  if (timer) clearTimeout(timer)
111
122
  document.removeEventListener('pointerup', onInteraction, true)
112
123
  document.removeEventListener('keyup', onInteraction, true)
124
+ document.removeEventListener('scroll', onInteraction, true)
113
125
  window.removeEventListener('resize', schedule)
114
126
  }
115
127
  }, [rootRef])
@@ -127,7 +139,7 @@ export function useFlipAnimation(
127
139
  if (durationMs <= 0) return
128
140
  const timing = easing in MOTION_EASING_KEYS ? motionEasing(easing as MotionEasing) : easing
129
141
 
130
- const { selector: sel, keyOf: key } = configRef.current
142
+ const { selector: sel, keyOf: key, shouldAnimate: animates } = configRef.current
131
143
  // A stale snapshot can report huge jumps; those would read as a glitch.
132
144
  const maxJump = root.clientHeight > 0 ? root.clientHeight * 1.5 : Infinity
133
145
  root.querySelectorAll<HTMLElement>(sel).forEach((el) => {
@@ -136,6 +148,7 @@ export function useFlipAnimation(
136
148
  const from = before.get(k)
137
149
  const to = after.get(k)
138
150
  if (!to || typeof el.animate !== 'function') return
151
+ if (animates && !animates(el, k)) return
139
152
  if (!from) {
140
153
  // Entered with this change: fade in instead of popping.
141
154
  el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: durationMs, easing: timing })