@asteby/metacore-runtime-react 36.0.1 → 37.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.
- package/CHANGELOG.md +22 -0
- package/README.md +33 -0
- package/dist/dynamic-kanban.d.ts +7 -1
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +12 -2
- package/dist/dynamic-select-field.d.ts +13 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +6 -1
- package/dist/dynamic-table.d.ts +19 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +29 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/realtime-context.d.ts +82 -0
- package/dist/realtime-context.d.ts.map +1 -0
- package/dist/realtime-context.js +182 -0
- package/package.json +5 -5
- package/src/__tests__/realtime-context.test.tsx +195 -0
- package/src/dynamic-kanban.tsx +18 -1
- package/src/dynamic-select-field.tsx +19 -0
- package/src/dynamic-table.tsx +48 -6
- package/src/index.ts +15 -0
- package/src/realtime-context.tsx +248 -0
|
@@ -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": "
|
|
3
|
+
"version": "37.0.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"react-i18next": ">=13",
|
|
38
38
|
"sonner": ">=1.7",
|
|
39
39
|
"zustand": ">=5",
|
|
40
|
-
"@asteby/metacore-sdk": "^3.
|
|
41
|
-
"@asteby/metacore-ui": "^2.17.
|
|
40
|
+
"@asteby/metacore-sdk": "^3.7.0",
|
|
41
|
+
"@asteby/metacore-ui": "^2.17.3"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@tanstack/react-router": {
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"typescript": "^6.0.0",
|
|
68
68
|
"vitest": "^4.0.0",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
|
-
"@asteby/metacore-
|
|
71
|
-
"@asteby/metacore-
|
|
70
|
+
"@asteby/metacore-sdk": "3.7.0",
|
|
71
|
+
"@asteby/metacore-ui": "2.17.3"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|
|
@@ -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
|
+
})
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
@@ -192,6 +192,18 @@ export interface DynamicSelectFieldProps {
|
|
|
192
192
|
* meaningful in context.
|
|
193
193
|
*/
|
|
194
194
|
hideCreate?: boolean
|
|
195
|
+
/**
|
|
196
|
+
* Pre-filled values for the record the inline "+" creates, forwarded to
|
|
197
|
+
* the host's create modal via the `metacore:create-record` event.
|
|
198
|
+
* Generic: any model, any fields (e.g. a mechanic picker seeding
|
|
199
|
+
* `{ role: 'mecanico' }` on a new team user).
|
|
200
|
+
*/
|
|
201
|
+
createDefaults?: Record<string, unknown>
|
|
202
|
+
/**
|
|
203
|
+
* Fields of the inline-created record the user must NOT change — the
|
|
204
|
+
* host's create modal renders them locked. Pairs with `createDefaults`.
|
|
205
|
+
*/
|
|
206
|
+
createLockedFields?: string[]
|
|
195
207
|
}
|
|
196
208
|
|
|
197
209
|
export function DynamicSelectField({
|
|
@@ -205,6 +217,8 @@ export function DynamicSelectField({
|
|
|
205
217
|
staticOptions = null,
|
|
206
218
|
descriptionAsBadge = false,
|
|
207
219
|
hideCreate = false,
|
|
220
|
+
createDefaults,
|
|
221
|
+
createLockedFields,
|
|
208
222
|
}: DynamicSelectFieldProps) {
|
|
209
223
|
const { t } = useTranslation()
|
|
210
224
|
const ph = (fallback: string) =>
|
|
@@ -325,6 +339,11 @@ export function DynamicSelectField({
|
|
|
325
339
|
new CustomEvent('metacore:create-record', {
|
|
326
340
|
detail: {
|
|
327
341
|
model: fieldRef,
|
|
342
|
+
// Generic passthrough: the host's create modal seeds these
|
|
343
|
+
// values and locks these fields (DynamicRecordDialog's own
|
|
344
|
+
// defaults/lockedFields props). Undefined when unused.
|
|
345
|
+
defaults: createDefaults,
|
|
346
|
+
lockedFields: createLockedFields,
|
|
328
347
|
onCreated: (rec: any) => {
|
|
329
348
|
if (rec && rec.id != null) {
|
|
330
349
|
const id = String(rec.id)
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -64,6 +64,7 @@ import { toast } from 'sonner'
|
|
|
64
64
|
import { Progress } from './dialogs/_primitives'
|
|
65
65
|
import { useMetadataCache } from './metadata-cache'
|
|
66
66
|
import { useApi, useCurrentBranch } from './api-context'
|
|
67
|
+
import { useRealtimeDefault, useRealtimeTick } from './realtime-context'
|
|
67
68
|
import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
|
|
68
69
|
import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
|
|
69
70
|
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
|
|
@@ -176,6 +177,17 @@ export interface DynamicTableProps {
|
|
|
176
177
|
/** Hide the export action on this view. See `hideImport`. */
|
|
177
178
|
hideExport?: boolean
|
|
178
179
|
hiddenColumns?: string[]
|
|
180
|
+
/**
|
|
181
|
+
* Row-action ALLOWLIST for this table instance, by action key (manifest
|
|
182
|
+
* v3 NavItem.actions, carried through the host's nav item for this route).
|
|
183
|
+
* When set, only these row actions render — even though the model may
|
|
184
|
+
* declare more. Lets two views of the SAME model differ: a generic list
|
|
185
|
+
* keeps every action, while a purpose-built screen (e.g. a credit-approval
|
|
186
|
+
* queue) shows only authorize_credit/reject_credit instead of every action
|
|
187
|
+
* SalesOrder declares (Generar factura, Cancelar, etc. included).
|
|
188
|
+
* Undefined → every row action the model declares (unchanged default).
|
|
189
|
+
*/
|
|
190
|
+
allowedActionKeys?: string[]
|
|
179
191
|
onAction?: (action: string, row: any) => void
|
|
180
192
|
/**
|
|
181
193
|
* Called when the user clicks anywhere on a data row (not on a checkbox,
|
|
@@ -185,6 +197,13 @@ export interface DynamicTableProps {
|
|
|
185
197
|
*/
|
|
186
198
|
onRowClick?: (row: any) => void
|
|
187
199
|
refreshTrigger?: any
|
|
200
|
+
/**
|
|
201
|
+
* Refetch when the host's realtime client reports a data event for this
|
|
202
|
+
* model (created/updated/deleted by anyone in the org, or a `resync`).
|
|
203
|
+
* Off by default; `<RealtimeProvider defaultRealtime>` turns it on for
|
|
204
|
+
* every table, and an explicit prop always wins. No-op without a client.
|
|
205
|
+
*/
|
|
206
|
+
realtime?: boolean
|
|
188
207
|
defaultFilters?: Record<string, any>
|
|
189
208
|
extraColumns?: ColumnDef<any>[]
|
|
190
209
|
/**
|
|
@@ -238,9 +257,11 @@ export function DynamicTable({
|
|
|
238
257
|
hideImport,
|
|
239
258
|
hideExport,
|
|
240
259
|
hiddenColumns = [],
|
|
260
|
+
allowedActionKeys,
|
|
241
261
|
onAction,
|
|
242
262
|
onRowClick,
|
|
243
263
|
refreshTrigger,
|
|
264
|
+
realtime: realtimeProp,
|
|
244
265
|
defaultFilters,
|
|
245
266
|
extraColumns = [],
|
|
246
267
|
getDynamicColumns = defaultGetDynamicColumns,
|
|
@@ -255,6 +276,13 @@ export function DynamicTable({
|
|
|
255
276
|
const { t, i18n } = useTranslation()
|
|
256
277
|
const api = useApi()
|
|
257
278
|
const currentBranch = useCurrentBranch()
|
|
279
|
+
// Realtime refetch (opt-in): a debounced counter that bumps on every
|
|
280
|
+
// DATA_EVENT for this model and rides the same deps as `refreshTrigger`.
|
|
281
|
+
const realtimeDefault = useRealtimeDefault()
|
|
282
|
+
const realtimeTick = useRealtimeTick({
|
|
283
|
+
models: [model],
|
|
284
|
+
enabled: realtimeProp ?? realtimeDefault,
|
|
285
|
+
})
|
|
258
286
|
|
|
259
287
|
const prevBranchId = useRef(currentBranch?.id)
|
|
260
288
|
|
|
@@ -742,7 +770,8 @@ export function DynamicTable({
|
|
|
742
770
|
} finally {
|
|
743
771
|
setLoadingData(false)
|
|
744
772
|
}
|
|
745
|
-
|
|
773
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
774
|
+
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, realtimeTick, endpoint, currentBranch?.id, api, enableUrlSync])
|
|
746
775
|
|
|
747
776
|
// Columns whose metadata opts into a footer total (display_config.aggregate
|
|
748
777
|
// → styleConfig.aggregate). When empty, no footer row is rendered and no
|
|
@@ -911,8 +940,9 @@ export function DynamicTable({
|
|
|
911
940
|
// matching the classic path (fetchData carries refreshTrigger in its
|
|
912
941
|
// deps). Without it the comment above lied: infinite lists silently
|
|
913
942
|
// failed to reload after a create ("a veces no recarga la tabla").
|
|
943
|
+
// realtimeTick plays the same role for data events (see the `realtime` prop).
|
|
914
944
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
915
|
-
}, [infiniteScroll, metadata, filterSignature, refreshTrigger])
|
|
945
|
+
}, [infiniteScroll, metadata, filterSignature, refreshTrigger, realtimeTick])
|
|
916
946
|
|
|
917
947
|
const handleRefresh = useCallback(() => {
|
|
918
948
|
// Infinite mode owns its own list: refresh reloads page 1 and drops the
|
|
@@ -1124,15 +1154,27 @@ export function DynamicTable({
|
|
|
1124
1154
|
// Row-action column only renders per-row actions. Table-level placements
|
|
1125
1155
|
// ("table"/"create") are surfaced by <ModelActionToolbar> at the page
|
|
1126
1156
|
// level, so strip them here to avoid a meaningless per-row button.
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1157
|
+
// `allowedActionKeys`, when given, further narrows the row set to a
|
|
1158
|
+
// per-VIEW allowlist — two nav entries on the same model (a generic
|
|
1159
|
+
// list vs. a purpose-built approval queue) can then show different
|
|
1160
|
+
// actions instead of every action the model declares.
|
|
1161
|
+
const rowMetadata = (() => {
|
|
1162
|
+
let actions = viewMetadata.actions
|
|
1163
|
+
if (actions?.some((a) => a.placement === 'table' || a.placement === 'create')) {
|
|
1164
|
+
actions = actions.filter((a) => !a.placement || a.placement === 'row')
|
|
1165
|
+
}
|
|
1166
|
+
if (allowedActionKeys && actions) {
|
|
1167
|
+
const allowed = new Set(allowedActionKeys)
|
|
1168
|
+
actions = actions.filter((a) => allowed.has(a.key))
|
|
1169
|
+
}
|
|
1170
|
+
return actions === viewMetadata.actions ? viewMetadata : { ...viewMetadata, actions }
|
|
1171
|
+
})()
|
|
1130
1172
|
const baseColumns = getDynamicColumns(rowMetadata, handleInternalAction, t, i18n.language, columnFilterConfigs, timeZone, currency)
|
|
1131
1173
|
const filteredBase = baseColumns.filter((col: ColumnDef<any>) => !hiddenColumns.includes(col.id as string))
|
|
1132
1174
|
const actionsCol = filteredBase.find((c: ColumnDef<any>) => c.id === 'actions')
|
|
1133
1175
|
const otherCols = filteredBase.filter((c: ColumnDef<any>) => c.id !== 'actions')
|
|
1134
1176
|
return [...otherCols, ...extraColumns, ...(actionsCol ? [actionsCol] : [])]
|
|
1135
|
-
}, [viewMetadata, handleInternalAction, hiddenColumns, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone, currency])
|
|
1177
|
+
}, [viewMetadata, handleInternalAction, hiddenColumns, allowedActionKeys, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone, currency])
|
|
1136
1178
|
|
|
1137
1179
|
const filters = useMemo(() => [], [])
|
|
1138
1180
|
|
package/src/index.ts
CHANGED
|
@@ -213,6 +213,21 @@ export * from './print-document-button'
|
|
|
213
213
|
export * from './use-org-document-templates'
|
|
214
214
|
export * from './document-template-editor'
|
|
215
215
|
export * from './metadata-cache'
|
|
216
|
+
export {
|
|
217
|
+
RealtimeProvider,
|
|
218
|
+
useRealtimeClient,
|
|
219
|
+
useRealtimeDefault,
|
|
220
|
+
useRealtime,
|
|
221
|
+
useRealtimeStatus,
|
|
222
|
+
useRealtimeInvalidate,
|
|
223
|
+
useRealtimeTick,
|
|
224
|
+
queryKeyMatchesEvent,
|
|
225
|
+
type RealtimeContextValue,
|
|
226
|
+
type RealtimeProviderProps,
|
|
227
|
+
type UseRealtimeOptions,
|
|
228
|
+
type UseRealtimeInvalidateOptions,
|
|
229
|
+
type UseRealtimeTickOptions,
|
|
230
|
+
} from './realtime-context'
|
|
216
231
|
export {
|
|
217
232
|
ADDON_MANIFEST_CHANGED_TYPE,
|
|
218
233
|
wireHotSwapInvalidation,
|