@asteby/metacore-runtime-react 36.1.0 → 37.0.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.
@@ -0,0 +1,182 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // realtime-context — React surface over the host's RealtimeAPI
3
+ // (`@asteby/metacore-sdk`): a provider the host mounts once, `useRealtime`
4
+ // for handlers, `useRealtimeInvalidate` to refresh react-query caches, and
5
+ // `useRealtimeTick` — the opt-in refetch signal `DynamicTable` /
6
+ // `DynamicKanban` consume through their `realtime` prop.
7
+ //
8
+ // Module-federation note: a federated addon may run its OWN copy of this
9
+ // module, in which case React context from the host is invisible to it. Every
10
+ // hook therefore accepts an explicit `client` (pass `host.realtime` or
11
+ // `api.realtime`) that wins over the context — the plain object crosses the
12
+ // federation boundary, the context does not.
13
+ //
14
+ // react-query: `useRealtimeInvalidate` uses `useQueryClient()` so it always
15
+ // targets the QueryClient the host mounted (single instance per page).
16
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
17
+ import { useQueryClient } from '@tanstack/react-query';
18
+ const RealtimeContext = createContext({ client: null, defaultRealtime: false });
19
+ /**
20
+ * Mount once, inside the host's WebSocket provider and QueryClientProvider.
21
+ * `client` may be null while the host is still connecting — hooks become
22
+ * no-ops and re-subscribe when a client appears.
23
+ */
24
+ export function RealtimeProvider({ client, defaultRealtime = false, children }) {
25
+ const value = useMemo(() => ({ client: client ?? null, defaultRealtime }), [client, defaultRealtime]);
26
+ return _jsx(RealtimeContext.Provider, { value: value, children: children });
27
+ }
28
+ /** The host realtime client from context (null when none is mounted). */
29
+ export function useRealtimeClient(explicit) {
30
+ const ctx = useContext(RealtimeContext);
31
+ return explicit ?? ctx.client;
32
+ }
33
+ /** Whether dynamic components should refetch on data events by default. */
34
+ export function useRealtimeDefault() {
35
+ return useContext(RealtimeContext).defaultRealtime;
36
+ }
37
+ /**
38
+ * Subscribe to data events for the given models. The handler is kept in a
39
+ * ref so callers don't need to memoise it; the subscription is torn down on
40
+ * unmount and re-created when models/events/client change.
41
+ *
42
+ * useRealtime({ models: ['SalesOrder'], client: host.realtime }, (e) => {
43
+ * if (e.action === 'resync' || e.id === currentId) refetch()
44
+ * })
45
+ */
46
+ export function useRealtime(options, handler) {
47
+ const client = useRealtimeClient(options.client);
48
+ const handlerRef = useRef(handler);
49
+ handlerRef.current = handler;
50
+ const enabled = options.enabled ?? true;
51
+ const modelsKey = (options.models ?? []).map((m) => m.trim().toLowerCase()).sort().join('|');
52
+ const eventsKey = (options.events ?? []).slice().sort().join('|');
53
+ useEffect(() => {
54
+ if (!client || !enabled || !modelsKey)
55
+ return;
56
+ const models = modelsKey.split('|');
57
+ const events = eventsKey ? eventsKey.split('|') : undefined;
58
+ let off = () => { };
59
+ try {
60
+ off = client.subscribe({ models, events }, (event) => handlerRef.current(event));
61
+ }
62
+ catch {
63
+ /* a host client must not throw, but never take the page down */
64
+ }
65
+ return off;
66
+ }, [client, enabled, modelsKey, eventsKey]);
67
+ }
68
+ /** Live status of the realtime client ('closed' when none). */
69
+ export function useRealtimeStatus(explicit) {
70
+ const client = useRealtimeClient(explicit);
71
+ const [status, setStatus] = useState(() => client?.status() ?? 'closed');
72
+ useEffect(() => {
73
+ if (!client) {
74
+ setStatus('closed');
75
+ return;
76
+ }
77
+ setStatus(client.status());
78
+ if (!client.onStatus)
79
+ return;
80
+ return client.onStatus(setStatus);
81
+ }, [client]);
82
+ return status;
83
+ }
84
+ /** Default matcher: any string segment of the query key (case-insensitive)
85
+ * equals the event's model, table or `addon.model`, or contains the model
86
+ * as a path segment (`/data/sales_orders`). */
87
+ export function queryKeyMatchesEvent(queryKey, event) {
88
+ const needles = [event.model, event.table ?? '', `${event.addon}.${event.model}`]
89
+ .map((s) => s.toLowerCase())
90
+ .filter(Boolean);
91
+ const parts = flattenKey(queryKey);
92
+ for (const part of parts) {
93
+ const p = part.toLowerCase();
94
+ for (const n of needles) {
95
+ if (p === n)
96
+ return true;
97
+ // '/data/sales_orders?x=1' or 'pos/SalesOrder' style segments.
98
+ const segs = p.split(/[/?&=]/);
99
+ if (segs.includes(n))
100
+ return true;
101
+ }
102
+ }
103
+ return false;
104
+ }
105
+ function flattenKey(key, out = [], depth = 0) {
106
+ if (depth > 4)
107
+ return out;
108
+ for (const item of key) {
109
+ if (typeof item === 'string')
110
+ out.push(item);
111
+ else if (Array.isArray(item))
112
+ flattenKey(item, out, depth + 1);
113
+ else if (item && typeof item === 'object') {
114
+ for (const v of Object.values(item)) {
115
+ if (typeof v === 'string')
116
+ out.push(v);
117
+ }
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ /**
123
+ * Invalidate every react-query query whose key mentions the event's model /
124
+ * table (see `queryKeyMatchesEvent`). Uses the host's QueryClient. Bursts
125
+ * are debounced so a coalesced frame storm becomes a single refetch round.
126
+ *
127
+ * useRealtimeInvalidate({ models: ['SalesOrder', 'sales_order_items'], client: host.realtime })
128
+ */
129
+ export function useRealtimeInvalidate(options) {
130
+ const queryClient = useQueryClient();
131
+ const { match = queryKeyMatchesEvent, debounceMs = 250, onEvent } = options;
132
+ const pendingRef = useRef([]);
133
+ const timerRef = useRef(null);
134
+ const matchRef = useRef(match);
135
+ matchRef.current = match;
136
+ const onEventRef = useRef(onEvent);
137
+ onEventRef.current = onEvent;
138
+ const flush = useCallback(() => {
139
+ timerRef.current = null;
140
+ const batch = pendingRef.current;
141
+ pendingRef.current = [];
142
+ if (batch.length === 0)
143
+ return;
144
+ void queryClient.invalidateQueries({
145
+ predicate: (query) => batch.some((event) => matchRef.current(query.queryKey, event)),
146
+ });
147
+ }, [queryClient]);
148
+ useRealtime(options, (event) => {
149
+ pendingRef.current.push(event);
150
+ onEventRef.current?.(event);
151
+ if (timerRef.current)
152
+ return;
153
+ timerRef.current = setTimeout(flush, debounceMs);
154
+ });
155
+ useEffect(() => () => {
156
+ if (timerRef.current)
157
+ clearTimeout(timerRef.current);
158
+ }, []);
159
+ }
160
+ /**
161
+ * A counter that increments (debounced) whenever a data event lands for one
162
+ * of `models`. Fold it into a refetch effect's dependencies — that is exactly
163
+ * what `DynamicTable` / `DynamicKanban` do behind their `realtime` prop.
164
+ */
165
+ export function useRealtimeTick(options) {
166
+ const [tick, setTick] = useState(0);
167
+ const timerRef = useRef(null);
168
+ const debounceMs = options.debounceMs ?? 300;
169
+ useRealtime({ models: options.models, client: options.client, enabled: options.enabled }, () => {
170
+ if (timerRef.current)
171
+ return;
172
+ timerRef.current = setTimeout(() => {
173
+ timerRef.current = null;
174
+ setTick((t) => t + 1);
175
+ }, debounceMs);
176
+ });
177
+ useEffect(() => () => {
178
+ if (timerRef.current)
179
+ clearTimeout(timerRef.current);
180
+ }, []);
181
+ return tick;
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "36.1.0",
3
+ "version": "37.0.1",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,7 +37,7 @@
37
37
  "react-i18next": ">=13",
38
38
  "sonner": ">=1.7",
39
39
  "zustand": ">=5",
40
- "@asteby/metacore-sdk": "^3.6.0",
40
+ "@asteby/metacore-sdk": "^3.7.0",
41
41
  "@asteby/metacore-ui": "^2.17.3"
42
42
  },
43
43
  "peerDependenciesMeta": {
@@ -67,7 +67,7 @@
67
67
  "typescript": "^6.0.0",
68
68
  "vitest": "^4.0.0",
69
69
  "zustand": "^5.0.0",
70
- "@asteby/metacore-sdk": "3.6.0",
70
+ "@asteby/metacore-sdk": "3.7.0",
71
71
  "@asteby/metacore-ui": "2.17.3"
72
72
  },
73
73
  "scripts": {
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { translateMetadataLabel } from '../dynamic-columns-helpers'
3
+
4
+ describe('translateMetadataLabel', () => {
5
+ it('resolves a host translation when available', () => {
6
+ const t = vi.fn(() => 'Estado del pago')
7
+ expect(translateMetadataLabel('models.orders.payment_status', t)).toBe('Estado del pago')
8
+ expect(t).toHaveBeenCalledWith('models.orders.payment_status', { defaultValue: 'Payment Status' })
9
+ })
10
+
11
+ it('humanizes the final segment when an addon locale is not loaded', () => {
12
+ const t = vi.fn((key: string) => key)
13
+ expect(translateMetadataLabel('models.addon.fields.custom_code', t)).toBe('Custom Code')
14
+ })
15
+
16
+ it('preserves already human labels', () => {
17
+ expect(translateMetadataLabel('Nombre comercial')).toBe('Nombre comercial')
18
+ })
19
+ })
@@ -0,0 +1,195 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Realtime hooks over a fake RealtimeAPI: useRealtime subscribes/unsubscribes
4
+ // with normalised models, an explicit client beats the context (federation
5
+ // boundary), useRealtimeInvalidate targets the host QueryClient by key match,
6
+ // and useRealtimeTick debounces bursts into one bump.
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { act, cleanup, renderHook } from '@testing-library/react'
9
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
10
+ import type { ReactNode } from 'react'
11
+ import type { DataEvent, DataEventHandler, RealtimeAPI, RealtimeSubscribeOptions } from '@asteby/metacore-sdk'
12
+ import {
13
+ RealtimeProvider,
14
+ queryKeyMatchesEvent,
15
+ useRealtime,
16
+ useRealtimeInvalidate,
17
+ useRealtimeStatus,
18
+ useRealtimeTick,
19
+ } from '../realtime-context'
20
+
21
+ afterEach(cleanup)
22
+
23
+ function fakeClient() {
24
+ const subs: Array<{ opts: RealtimeSubscribeOptions; handler: DataEventHandler }> = []
25
+ const statusListeners = new Set<(s: 'connecting' | 'open' | 'closed') => void>()
26
+ let status: 'connecting' | 'open' | 'closed' = 'open'
27
+ const client: RealtimeAPI & { subs: typeof subs; emit: (e: DataEvent) => void; setStatus: (s: typeof status) => void } = {
28
+ subs,
29
+ subscribe(opts, handler) {
30
+ const entry = { opts, handler }
31
+ subs.push(entry)
32
+ return () => {
33
+ const i = subs.indexOf(entry)
34
+ if (i >= 0) subs.splice(i, 1)
35
+ }
36
+ },
37
+ status: () => status,
38
+ onStatus(l) {
39
+ statusListeners.add(l)
40
+ return () => statusListeners.delete(l)
41
+ },
42
+ emit(e) {
43
+ subs.forEach((s) => s.handler(e))
44
+ },
45
+ setStatus(s) {
46
+ status = s
47
+ statusListeners.forEach((l) => l(s))
48
+ },
49
+ }
50
+ return client
51
+ }
52
+
53
+ const event = (over: Partial<DataEvent> = {}): DataEvent => ({
54
+ org_id: 'org',
55
+ addon: 'pos',
56
+ model: 'SalesOrder',
57
+ table: 'sales_orders',
58
+ action: 'updated',
59
+ id: '1',
60
+ at: '2026-08-31T00:00:00Z',
61
+ ...over,
62
+ })
63
+
64
+ describe('useRealtime', () => {
65
+ it('subscribes through the context client with normalised models and tears down on unmount', () => {
66
+ const client = fakeClient()
67
+ const handler = vi.fn()
68
+ const wrapper = ({ children }: { children: ReactNode }) => (
69
+ <RealtimeProvider client={client}>{children}</RealtimeProvider>
70
+ )
71
+ const { unmount } = renderHook(() => useRealtime({ models: [' SalesOrder ', 'WorkOrder'] }, handler), { wrapper })
72
+ expect(client.subs).toHaveLength(1)
73
+ expect(client.subs[0]!.opts.models).toEqual(['salesorder', 'workorder'])
74
+ client.emit(event())
75
+ expect(handler).toHaveBeenCalledTimes(1)
76
+ unmount()
77
+ expect(client.subs).toHaveLength(0)
78
+ })
79
+
80
+ it('prefers an explicit client over the context (federated addons)', () => {
81
+ const ctxClient = fakeClient()
82
+ const explicit = fakeClient()
83
+ const wrapper = ({ children }: { children: ReactNode }) => (
84
+ <RealtimeProvider client={ctxClient}>{children}</RealtimeProvider>
85
+ )
86
+ renderHook(() => useRealtime({ models: ['SalesOrder'], client: explicit }, () => {}), { wrapper })
87
+ expect(explicit.subs).toHaveLength(1)
88
+ expect(ctxClient.subs).toHaveLength(0)
89
+ })
90
+
91
+ it('is a no-op without a client, without models, or when disabled', () => {
92
+ const client = fakeClient()
93
+ renderHook(() => useRealtime({ models: ['SalesOrder'] }, () => {}))
94
+ renderHook(() => useRealtime({ models: [], client }, () => {}))
95
+ renderHook(() => useRealtime({ models: ['SalesOrder'], client, enabled: false }, () => {}))
96
+ expect(client.subs).toHaveLength(0)
97
+ })
98
+ })
99
+
100
+ describe('useRealtimeStatus', () => {
101
+ it('tracks the client status', () => {
102
+ const client = fakeClient()
103
+ const { result } = renderHook(() => useRealtimeStatus(client))
104
+ expect(result.current).toBe('open')
105
+ act(() => client.setStatus('connecting'))
106
+ expect(result.current).toBe('connecting')
107
+ const { result: none } = renderHook(() => useRealtimeStatus(null))
108
+ expect(none.current).toBe('closed')
109
+ })
110
+ })
111
+
112
+ describe('queryKeyMatchesEvent', () => {
113
+ it('matches model, table, qualified name and path segments (case-insensitive)', () => {
114
+ const e = event()
115
+ expect(queryKeyMatchesEvent(['salesorder', 'list'], e)).toBe(true)
116
+ expect(queryKeyMatchesEvent(['data', 'sales_orders', { page: 1 }], e)).toBe(true)
117
+ expect(queryKeyMatchesEvent(['pos.SalesOrder'], e)).toBe(true)
118
+ expect(queryKeyMatchesEvent(['/data/sales_orders?page=1'], e)).toBe(true)
119
+ expect(queryKeyMatchesEvent([{ model: 'SalesOrder' }], e)).toBe(true)
120
+ expect(queryKeyMatchesEvent(['work_orders'], e)).toBe(false)
121
+ expect(queryKeyMatchesEvent(['sales_orders_archive'], e)).toBe(false)
122
+ })
123
+ })
124
+
125
+ describe('useRealtimeInvalidate', () => {
126
+ it('invalidates only the queries whose key mentions the event model, debounced', async () => {
127
+ vi.useFakeTimers()
128
+ try {
129
+ const client = fakeClient()
130
+ const queryClient = new QueryClient()
131
+ queryClient.setQueryData(['sales_orders', 'list'], [])
132
+ queryClient.setQueryData(['work_orders', 'list'], [])
133
+ const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
134
+ const wrapper = ({ children }: { children: ReactNode }) => (
135
+ <QueryClientProvider client={queryClient}>
136
+ <RealtimeProvider client={client}>{children}</RealtimeProvider>
137
+ </QueryClientProvider>
138
+ )
139
+ const onEvent = vi.fn()
140
+ renderHook(() => useRealtimeInvalidate({ models: ['SalesOrder'], debounceMs: 100, onEvent }), { wrapper })
141
+
142
+ act(() => {
143
+ client.emit(event({ id: '1' }))
144
+ client.emit(event({ id: '2' }))
145
+ })
146
+ expect(onEvent).toHaveBeenCalledTimes(2)
147
+ expect(invalidate).not.toHaveBeenCalled()
148
+ act(() => {
149
+ vi.advanceTimersByTime(120)
150
+ })
151
+ expect(invalidate).toHaveBeenCalledTimes(1)
152
+ const predicate = (invalidate.mock.calls[0]![0] as { predicate: (q: { queryKey: unknown[] }) => boolean }).predicate
153
+ expect(predicate({ queryKey: ['sales_orders', 'list'] })).toBe(true)
154
+ expect(predicate({ queryKey: ['work_orders', 'list'] })).toBe(false)
155
+ expect(queryClient.getQueryState(['sales_orders', 'list'])?.isInvalidated).toBe(true)
156
+ expect(queryClient.getQueryState(['work_orders', 'list'])?.isInvalidated).toBe(false)
157
+ } finally {
158
+ vi.useRealTimers()
159
+ }
160
+ })
161
+ })
162
+
163
+ describe('useRealtimeTick', () => {
164
+ it('bumps once per burst and stays quiet when disabled', () => {
165
+ vi.useFakeTimers()
166
+ try {
167
+ const client = fakeClient()
168
+ const { result, rerender } = renderHook(
169
+ ({ enabled }: { enabled: boolean }) =>
170
+ useRealtimeTick({ models: ['SalesOrder'], client, enabled, debounceMs: 50 }),
171
+ { initialProps: { enabled: true } },
172
+ )
173
+ expect(result.current).toBe(0)
174
+ act(() => {
175
+ client.emit(event())
176
+ client.emit(event({ id: '2' }))
177
+ client.emit(event({ id: '3' }))
178
+ })
179
+ expect(result.current).toBe(0)
180
+ act(() => {
181
+ vi.advanceTimersByTime(60)
182
+ })
183
+ expect(result.current).toBe(1)
184
+
185
+ rerender({ enabled: false })
186
+ expect(client.subs).toHaveLength(0)
187
+ act(() => {
188
+ vi.advanceTimersByTime(100)
189
+ })
190
+ expect(result.current).toBe(1)
191
+ } finally {
192
+ vi.useRealTimers()
193
+ }
194
+ })
195
+ })
@@ -78,6 +78,20 @@ describe('isRowActionVisible', () => {
78
78
  expect(isActionConditionMet({ condition: { field: 's', operator: 'neq', value: 'a' } }, { s: 'a' })).toBe(false)
79
79
  })
80
80
 
81
+ it('isActionConditionMet supports truthy/falsy (and the present/set/blank/empty aliases)', () => {
82
+ const hasBalance = { condition: { field: 'amount_due', operator: 'truthy' } }
83
+ const noBalance = { condition: { field: 'amount_due', operator: 'falsy' } }
84
+ expect(isActionConditionMet(hasBalance, { amount_due: 6180 })).toBe(true)
85
+ expect(isActionConditionMet(hasBalance, { amount_due: 0 })).toBe(false)
86
+ expect(isActionConditionMet(hasBalance, { amount_due: undefined })).toBe(false)
87
+ expect(isActionConditionMet(noBalance, { amount_due: 0 })).toBe(true)
88
+ expect(isActionConditionMet(noBalance, { amount_due: 6180 })).toBe(false)
89
+ expect(isActionConditionMet({ condition: { field: 'flag', operator: 'present' } }, { flag: 'x' })).toBe(true)
90
+ expect(isActionConditionMet({ condition: { field: 'flag', operator: 'set' } }, { flag: false })).toBe(false)
91
+ expect(isActionConditionMet({ condition: { field: 'flag', operator: 'blank' } }, { flag: '' })).toBe(true)
92
+ expect(isActionConditionMet({ condition: { field: 'flag', operator: 'empty' } }, { flag: 'x' })).toBe(false)
93
+ })
94
+
81
95
  it('isActionConditionMet resolves nested paths and equals/notEquals aliases', () => {
82
96
  const approve = {
83
97
  condition: { field: 'user.verified', operator: 'equals', value: false },
@@ -56,3 +56,25 @@ export function humanizeToken(value: unknown): string {
56
56
  })
57
57
  .join(' ')
58
58
  }
59
+
60
+ export type MetadataTranslator = (
61
+ key: string,
62
+ options?: { defaultValue?: string },
63
+ ) => string
64
+
65
+ /**
66
+ * Resolve addon-contributed metadata at render time. Known i18n keys use the
67
+ * host locale; missing keys degrade to a readable label instead of leaking a
68
+ * machine path such as `models.orders.table.columns.payment_status`.
69
+ */
70
+ export function translateMetadataLabel(
71
+ value: string | undefined,
72
+ translate?: MetadataTranslator,
73
+ ): string {
74
+ if (!value) return ''
75
+ const token = value.includes('.') ? value.split('.').at(-1) ?? value : value
76
+ const fallback = humanizeToken(token)
77
+ if (!translate) return fallback
78
+ const translated = translate(value, { defaultValue: fallback })
79
+ return translated && translated !== value ? translated : fallback
80
+ }
@@ -42,7 +42,7 @@ import {
42
42
  relationChipStyles,
43
43
  } from '@asteby/metacore-ui/lib'
44
44
  import { Progress } from './dialogs/_primitives'
45
- import { humanizeToken } from './dynamic-columns-helpers'
45
+ import { humanizeToken, translateMetadataLabel } from './dynamic-columns-helpers'
46
46
  import { objectLabel } from './dynamic-relation-helpers'
47
47
  import {
48
48
  OptionBadge,
@@ -244,9 +244,21 @@ export const isActionAllowedForRowState = (action: any, row: any): boolean => {
244
244
  * Declarative `condition` gate for a per-row action: shows the action only when
245
245
  * the row's `field` satisfies the operator. Supports both the SDK dialect
246
246
  * (`eq` | `neq` | `in` | `not_in`) and the common host dialect
247
- * (`equals` | `notEquals` | `not_in`). Nested paths (`user.verified`) are
248
- * resolved via `getNestedValue`. No condition always shown. Unknown
249
- * operator permissive.
247
+ * (`equals` | `notEquals` | `not_in`), plus the truthy/falsy family (same
248
+ * operator set as the host's document print gate — services/document_gate.go
249
+ * kept in sync so a manifest author doesn't have to know which gate a given
250
+ * contribution goes through). Nested paths (`user.verified`) are resolved via
251
+ * `getNestedValue`. No condition → always shown.
252
+ *
253
+ * `default: return true` for a genuinely unknown operator is deliberate — an
254
+ * addon shipped against a newer SDK than the host runs should degrade to
255
+ * "always show" (worst case: an extra menu item), never to "always hide"
256
+ * (worst case: a feature silently vanishes). That same permissiveness is why
257
+ * `truthy`/`falsy` going unrecognized here was a real, silent bug rather
258
+ * than a build error: confirmed live — a `condition: {field: "amount_due",
259
+ * operator: "truthy"}` row action rendered on every row regardless of
260
+ * amount_due, because the switch fell through to the default and nothing
261
+ * ever signaled it wasn't actually gating anything.
250
262
  */
251
263
  export const isActionConditionMet = (action: any, row: any): boolean => {
252
264
  if (!action?.condition) return true
@@ -279,6 +291,14 @@ export const isActionConditionMet = (action: any, row: any): boolean => {
279
291
  case 'not_in':
280
292
  case 'notin':
281
293
  return !values.includes(rowValue)
294
+ case 'truthy':
295
+ case 'present':
296
+ case 'set':
297
+ return rowValue !== '' && rowValue !== 'false' && rowValue !== '0'
298
+ case 'falsy':
299
+ case 'blank':
300
+ case 'empty':
301
+ return rowValue === '' || rowValue === 'false' || rowValue === '0'
282
302
  default:
283
303
  return true
284
304
  }
@@ -854,7 +874,7 @@ export function makeDefaultGetDynamicColumns(
854
874
  // `visibility` scope (skips `'modal'` and `'list'`).
855
875
  if (!isColumnVisibleInTable(col)) return
856
876
 
857
- const translatedLabel = col.label
877
+ const translatedLabel = translateMetadataLabel(col.label, t)
858
878
  const filterConfig = filterConfigs?.get(col.key)
859
879
 
860
880
  const columnMeta: Record<string, unknown> = {
@@ -1478,7 +1498,7 @@ export function makeDefaultGetDynamicColumns(
1478
1498
  onClick={() => onAction && onAction(action.key, row.original)}
1479
1499
  >
1480
1500
  <DynamicIcon name={action.icon} className="mr-2 h-4 w-4" />
1481
- {action.label}
1501
+ {translateMetadataLabel(action.label, t)}
1482
1502
  </DropdownMenuItem>
1483
1503
  ))}
1484
1504
  </DropdownMenuContent>
@@ -90,6 +90,7 @@ import {
90
90
  import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
91
91
  import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
92
92
  import { useApi } from './api-context'
93
+ import { useRealtimeDefault, useRealtimeTick } from './realtime-context'
93
94
  import {
94
95
  useStageAutomations,
95
96
  StageAutomationsButton,
@@ -462,6 +463,12 @@ export interface DynamicKanbanProps {
462
463
  endpoint?: string
463
464
  /** Bump to force a metadata + records refetch (same contract as DynamicTable). */
464
465
  refreshTrigger?: any
466
+ /**
467
+ * Refetch the board when the host's realtime client reports a data event
468
+ * for this model (same contract as DynamicTable's `realtime`). Off by
469
+ * default; `<RealtimeProvider defaultRealtime>` flips the default.
470
+ */
471
+ realtime?: boolean
465
472
  /** Called when a card is clicked (outside its action menu). */
466
473
  onCardClick?: (row: any) => void
467
474
  /**
@@ -497,6 +504,7 @@ export function DynamicKanban({
497
504
  model,
498
505
  endpoint,
499
506
  refreshTrigger,
507
+ realtime: realtimeProp,
500
508
  onCardClick,
501
509
  onAction,
502
510
  pageSize = 50,
@@ -508,6 +516,13 @@ export function DynamicKanban({
508
516
  const { t, i18n } = useTranslation()
509
517
  const api = useApi()
510
518
  const isDark = useIsDarkTheme()
519
+ // Realtime refetch (opt-in) — debounced counter bumped by DATA_EVENTs for
520
+ // this model; folded into the board refetch effect next to refreshTrigger.
521
+ const realtimeDefault = useRealtimeDefault()
522
+ const realtimeTick = useRealtimeTick({
523
+ models: [model],
524
+ enabled: realtimeProp ?? realtimeDefault,
525
+ })
511
526
 
512
527
  // Stage automations (Bitrix-style per-lane rules). Degrades to no-op when
513
528
  // the host has no `/stage-automations` endpoint — the ⚡ affordance hides.
@@ -782,7 +797,9 @@ export function DynamicKanban({
782
797
  void fetchData()
783
798
  }, 200)
784
799
  return () => clearTimeout(handle)
785
- }, [fetchData, metadata, refreshTrigger])
800
+ // realtimeTick: data events for this model (see the `realtime` prop).
801
+ // eslint-disable-next-line react-hooks/exhaustive-deps
802
+ }, [fetchData, metadata, refreshTrigger, realtimeTick])
786
803
 
787
804
  // Filterable fields for the toolbar, in metadata order (explicit filters
788
805
  // first, then filterable columns), each labeled from its metadata source.