@asteby/metacore-runtime-react 41.0.0 → 43.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 (47) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/api-context.d.ts.map +1 -1
  3. package/dist/api-context.js +6 -4
  4. package/dist/dynamic-icon.d.ts.map +1 -1
  5. package/dist/dynamic-icon.js +9 -13
  6. package/dist/dynamic-kanban.d.ts +83 -6
  7. package/dist/dynamic-kanban.d.ts.map +1 -1
  8. package/dist/dynamic-kanban.js +647 -146
  9. package/dist/dynamic-table.d.ts +1 -1
  10. package/dist/dynamic-table.d.ts.map +1 -1
  11. package/dist/dynamic-table.js +14 -1
  12. package/dist/index.d.ts +3 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +3 -1
  15. package/dist/license/license-gate.d.ts +0 -21
  16. package/dist/license/license-gate.d.ts.map +1 -1
  17. package/dist/license/license-gate.js +5 -2
  18. package/dist/portal-drag-overlay.d.ts +5 -0
  19. package/dist/portal-drag-overlay.d.ts.map +1 -0
  20. package/dist/portal-drag-overlay.js +8 -0
  21. package/dist/query-batch.d.ts.map +1 -1
  22. package/dist/query-batch.js +6 -3
  23. package/dist/sidebar-layout-manager.js +3 -2
  24. package/dist/use-flip-animation.d.ts +6 -0
  25. package/dist/use-flip-animation.d.ts.map +1 -1
  26. package/dist/use-flip-animation.js +11 -4
  27. package/dist/use-persisted-query.d.ts +53 -0
  28. package/dist/use-persisted-query.d.ts.map +1 -0
  29. package/dist/use-persisted-query.js +92 -0
  30. package/package.json +6 -5
  31. package/src/__tests__/dynamic-icon-slot.test.tsx +50 -0
  32. package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +2 -1
  33. package/src/__tests__/dynamic-kanban-pipelines-pro.test.tsx +388 -0
  34. package/src/__tests__/dynamic-kanban.test.tsx +15 -2
  35. package/src/__tests__/image-cell-lucide.test.tsx +7 -5
  36. package/src/__tests__/use-persisted-query.test.tsx +132 -0
  37. package/src/api-context.tsx +7 -5
  38. package/src/dynamic-icon.tsx +12 -16
  39. package/src/dynamic-kanban.tsx +950 -262
  40. package/src/dynamic-table.tsx +16 -2
  41. package/src/index.ts +14 -0
  42. package/src/license/license-gate.tsx +6 -2
  43. package/src/portal-drag-overlay.tsx +20 -0
  44. package/src/query-batch.ts +6 -3
  45. package/src/sidebar-layout-manager.tsx +3 -3
  46. package/src/use-flip-animation.ts +16 -3
  47. package/src/use-persisted-query.ts +152 -0
@@ -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
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,
@@ -135,6 +141,14 @@ export {
135
141
  type UseOptimisticMutationResult,
136
142
  } from './use-optimistic-mutation'
137
143
  export { useFlipAnimation, type UseFlipAnimationOptions } from './use-flip-animation'
144
+ export {
145
+ usePersistedQuery,
146
+ createPersistedSnapshot,
147
+ type UsePersistedQueryOptions,
148
+ type PersistedSnapshot,
149
+ type PersistedSnapshotOptions,
150
+ type PersistedEntry,
151
+ } from './use-persisted-query'
138
152
  export {
139
153
  motionDuration,
140
154
  motionEasing,
@@ -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
  }
@@ -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">
@@ -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 })
@@ -0,0 +1,152 @@
1
+ import { useEffect, useMemo } from 'react'
2
+ import {
3
+ useQuery,
4
+ useQueryClient,
5
+ type QueryKey,
6
+ type UseQueryOptions,
7
+ type UseQueryResult,
8
+ } from '@tanstack/react-query'
9
+
10
+ /**
11
+ * A versioned, scoped copy of a value in localStorage. Shell data that decides
12
+ * the first paint (navigation, the org's menu layout, installed presets,
13
+ * permissions) is read from here synchronously, so a reload paints the last
14
+ * known state instead of a default that reorders when the network answers.
15
+ *
16
+ * The entry lives under `<key>:v<version>:<scope>`. Bump `version` when the
17
+ * stored shape changes; old entries are simply never read again. `scope` keeps
18
+ * one org or user from seeing another's copy on a shared browser.
19
+ */
20
+ export interface PersistedSnapshotOptions {
21
+ key: string
22
+ version: number
23
+ /** Entries older than this are ignored (default: 30 days). */
24
+ maxAgeMs?: number
25
+ }
26
+
27
+ export interface PersistedEntry<T> {
28
+ data: T
29
+ /** When the data was fetched (ms epoch). */
30
+ ts: number
31
+ }
32
+
33
+ export interface PersistedSnapshot<T> {
34
+ read(scope: string): PersistedEntry<T> | undefined
35
+ write(scope: string, data: T, meta?: { ts?: number }): void
36
+ clear(scope: string): void
37
+ }
38
+
39
+ const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
40
+
41
+ function storage(): Storage | null {
42
+ try {
43
+ return typeof localStorage === 'undefined' ? null : localStorage
44
+ } catch {
45
+ return null
46
+ }
47
+ }
48
+
49
+ export function createPersistedSnapshot<T>({
50
+ key,
51
+ version,
52
+ maxAgeMs = DEFAULT_MAX_AGE_MS,
53
+ }: PersistedSnapshotOptions): PersistedSnapshot<T> {
54
+ const slot = (scope: string) => `${key}:v${version}:${scope}`
55
+ return {
56
+ read(scope) {
57
+ const s = storage()
58
+ if (!s) return undefined
59
+ try {
60
+ const raw = s.getItem(slot(scope))
61
+ if (!raw) return undefined
62
+ const parsed = JSON.parse(raw) as Partial<PersistedEntry<T>>
63
+ if (typeof parsed?.ts !== 'number' || !('data' in parsed)) return undefined
64
+ if (Date.now() - parsed.ts > maxAgeMs) return undefined
65
+ return { data: parsed.data as T, ts: parsed.ts }
66
+ } catch {
67
+ return undefined
68
+ }
69
+ },
70
+ write(scope, data, { ts = Date.now() } = {}) {
71
+ try {
72
+ storage()?.setItem(slot(scope), JSON.stringify({ data, ts }))
73
+ } catch {
74
+ // quota / private mode: the snapshot is an optimization, never fatal
75
+ }
76
+ },
77
+ clear(scope) {
78
+ try {
79
+ storage()?.removeItem(slot(scope))
80
+ } catch {
81
+ /* ignore */
82
+ }
83
+ },
84
+ }
85
+ }
86
+
87
+ export type UsePersistedQueryOptions<TQueryFnData, TData, TQueryKey extends QueryKey> = UseQueryOptions<
88
+ TQueryFnData,
89
+ Error,
90
+ TData,
91
+ TQueryKey
92
+ > & {
93
+ persist: {
94
+ snapshot: PersistedSnapshot<TQueryFnData>
95
+ /**
96
+ * Org/user the data belongs to. Without a scope nothing is read or
97
+ * written (e.g. before sign-in).
98
+ */
99
+ scope: string | null | undefined
100
+ }
101
+ }
102
+
103
+ /**
104
+ * `useQuery` seeded from a {@link PersistedSnapshot}: the first render already
105
+ * has the last fetched value (stale-while-revalidate), and every later value —
106
+ * a refetch, or a `setQueryData` from a mutation — is written back. The seed is
107
+ * stale from the start: every page load revalidates it once in the background,
108
+ * so a change made elsewhere (a plantilla applied, an addon installed,
109
+ * permissions edited) shows on the next load even inside `staleTime`.
110
+ *
111
+ * Persist the raw server payload and derive UI shapes with `select`; the
112
+ * snapshot must be JSON.
113
+ */
114
+ export function usePersistedQuery<
115
+ TQueryFnData,
116
+ TData = TQueryFnData,
117
+ TQueryKey extends QueryKey = QueryKey,
118
+ >(
119
+ options: UsePersistedQueryOptions<TQueryFnData, TData, TQueryKey>,
120
+ ): UseQueryResult<TData, Error> {
121
+ const { persist, ...queryOptions } = options
122
+ const { snapshot, scope } = persist
123
+ const seed = useMemo(
124
+ () => (scope ? snapshot.read(scope) : undefined),
125
+ [snapshot, scope],
126
+ )
127
+ const query = useQuery<TQueryFnData, Error, TData, TQueryKey>({
128
+ ...queryOptions,
129
+ ...(seed && queryOptions.initialData === undefined
130
+ ? { initialData: seed.data, initialDataUpdatedAt: 0 }
131
+ : {}),
132
+ } as UseQueryOptions<TQueryFnData, Error, TData, TQueryKey>)
133
+
134
+ const queryClient = useQueryClient()
135
+ const { dataUpdatedAt, status, isPlaceholderData } = query
136
+ const queryKey = queryOptions.queryKey
137
+ useEffect(() => {
138
+ if (!scope || status !== 'success' || isPlaceholderData) return
139
+ // Still the seed: nothing new to store.
140
+ if (dataUpdatedAt === 0) return
141
+ // `query.data` is the selected shape; persist the raw cache value.
142
+ const raw = queryClient.getQueryData<TQueryFnData>(queryKey)
143
+ if (raw === undefined) return
144
+ snapshot.write(scope, raw, { ts: dataUpdatedAt })
145
+ // queryKey is compared by react-query's hash, not identity: a new array
146
+ // each render must not rewrite the snapshot.
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ }, [queryClient, scope, snapshot, seed, status, isPlaceholderData, dataUpdatedAt])
149
+
150
+ return query
151
+ }
152
+