robobyte-front-builder 1.0.21 → 1.0.24

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,131 @@
1
+ /**
2
+ * Build a Together AI fine-tuning JSONL file from session-log entries.
3
+ *
4
+ * Together accepts the OpenAI conversational fine-tune format:
5
+ * {"messages": [
6
+ * {"role": "system", "content": "<system prompt>"},
7
+ * {"role": "user", "content": "<user instruction>"},
8
+ * {"role": "assistant", "content": "<assistant reply>"}
9
+ * ]}
10
+ * One example per line.
11
+ *
12
+ * For each session-log entry:
13
+ * - system = full system prompt from buildSystemPrompt()
14
+ * - user = entry.prompt (skipped if empty)
15
+ * - assistant = a brief explanation + ```json {...} ``` block carrying the
16
+ * simplified schema. Identical to OUTPUT_FORMAT in the system
17
+ * prompt so the model learns to produce the same shape.
18
+ *
19
+ * End-to-end fine-tune workflow:
20
+ *
21
+ * 1. Save examples in the UI Builder / Print Builder. They are auto-logged
22
+ * to localStorage (rbb:sessionLogs:v1).
23
+ * 2. Open Session Logs dialog → fill in the `prompt` field for each entry
24
+ * that should be used for training. Entries without a prompt are
25
+ * excluded from the export.
26
+ * 3. Click "Export for Together fine-tuning" → downloads .jsonl.
27
+ * 4. CLI: `pip install together && export TOGETHER_API_KEY=…`
28
+ * `together files check rbb-finetune-…jsonl`
29
+ * `together files upload rbb-finetune-…jsonl \
30
+ * --type jsonl --purpose fine-tune`
31
+ * (the --type flag is required — Together rejects the upload
32
+ * with "invalid type, only JsonL, parquet, csv are allowed"
33
+ * if the request omits file_type, even when the extension
34
+ * is .jsonl.)
35
+ * (note the returned file id, then:)
36
+ * `together fine-tuning create \
37
+ * --training-file <file-id> \
38
+ * --model meta-llama/Llama-3.3-70B-Instruct-Reference \
39
+ * --lora`
40
+ * 5. Wait for the job to finish (Together emails you, or `together
41
+ * fine-tuning list`).
42
+ * 6. Deploy as a Dedicated Endpoint, or use serverless if supported for
43
+ * that base model.
44
+ * 7. In .env.local set:
45
+ * NEXT_PUBLIC_AI_PROVIDER=together
46
+ * TOGETHER_API_KEY=...
47
+ * NEXT_PUBLIC_TOGETHER_MODEL=<your-fine-tune-id>
48
+ * The /api/ai proxy already supports the `together` provider.
49
+ */
50
+
51
+ import { buildSystemPrompt } from './systemPrompt'
52
+ import { simplifyFullSchema } from './schemaTransformer'
53
+
54
+ /**
55
+ * Build the assistant content for one example. Mirrors OUTPUT_FORMAT in the
56
+ * system prompt: a brief explanation followed by one fenced JSON block.
57
+ */
58
+ function buildAssistantContent(entry, simplifiedSchema) {
59
+ const innerMessage = (entry.notes && entry.notes.trim())
60
+ ? entry.notes.trim()
61
+ : `Generated ${entry.builder === 'print' ? 'print layout' : 'view'} schema.`
62
+
63
+ const payload = {
64
+ type: 'schema',
65
+ message: innerMessage,
66
+ schema: simplifiedSchema,
67
+ }
68
+ return `${innerMessage}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``
69
+ }
70
+
71
+ /**
72
+ * Convert eligible entries into one Together-fine-tune training example each.
73
+ * Returns an array of { messages: [...] } objects. Entries are skipped if:
74
+ * - prompt is empty (no instruction text → can't be a (instruction, output) pair)
75
+ * - the simplified schema has no root (transformer failed)
76
+ *
77
+ * Returns { examples, skipped } so callers can warn the user about losses.
78
+ */
79
+ export function buildTrainingExamples(entries) {
80
+ const systemPrompt = buildSystemPrompt()
81
+ const examples = []
82
+ const skipped = []
83
+
84
+ for (const entry of entries) {
85
+ const userMessage = (entry.prompt ?? '').trim()
86
+ if (!userMessage) {
87
+ skipped.push({ id: entry.id, reason: 'empty prompt' })
88
+ continue
89
+ }
90
+ const simplified = simplifyFullSchema(entry.schema)
91
+ if (!simplified.root) {
92
+ skipped.push({ id: entry.id, reason: 'unusable schema' })
93
+ continue
94
+ }
95
+ const assistantContent = buildAssistantContent(entry, simplified)
96
+ examples.push({
97
+ messages: [
98
+ { role: 'system', content: systemPrompt },
99
+ { role: 'user', content: userMessage },
100
+ { role: 'assistant', content: assistantContent },
101
+ ],
102
+ })
103
+ }
104
+ return { examples, skipped }
105
+ }
106
+
107
+ /** Serialize examples as JSONL — one JSON object per line. */
108
+ export function buildTrainingJSONL(entries) {
109
+ const { examples, skipped } = buildTrainingExamples(entries)
110
+ const jsonl = examples.map(ex => JSON.stringify(ex)).join('\n')
111
+ return { jsonl, count: examples.length, skipped }
112
+ }
113
+
114
+ /** Trigger a JSONL file download. Returns { count, skipped }. */
115
+ export function exportTrainingJSONL(entries) {
116
+ if (typeof window === 'undefined') return { count: 0, skipped: [] }
117
+ const { jsonl, count, skipped } = buildTrainingJSONL(entries)
118
+ if (count === 0) return { count: 0, skipped }
119
+
120
+ const blob = new Blob([jsonl + '\n'], { type: 'application/jsonl' })
121
+ const url = URL.createObjectURL(blob)
122
+ const a = document.createElement('a')
123
+ const stamp = new Date().toISOString().slice(0, 10)
124
+ a.href = url
125
+ a.download = `rbb-finetune-${stamp}.jsonl`
126
+ document.body.appendChild(a)
127
+ a.click()
128
+ document.body.removeChild(a)
129
+ setTimeout(() => URL.revokeObjectURL(url), 0)
130
+ return { count, skipped }
131
+ }
@@ -1,6 +1,14 @@
1
1
  import {
2
- Box, Button,
3
- CircularProgress,TextField } from '@mui/material'
2
+ Box,
3
+ Button,
4
+ CircularProgress,
5
+ Dialog,
6
+ DialogActions,
7
+ DialogContent,
8
+ DialogTitle,
9
+ TextField
10
+ } from '@mui/material'
11
+ import ContentCopyOutlinedIcon from '@mui/icons-material/ContentCopyOutlined'
4
12
  import { useBuilder } from 'context/BuilderContext'
5
13
  import { useRouter } from 'next/router'
6
14
  import { useEffect, useState } from 'react'
@@ -9,14 +17,36 @@ import { Endpoints, Services } from 'services/Endpoints'
9
17
  import Typography from '@mui/material/Typography'
10
18
  import GroupLevelAutocomplete from 'views/builder/sidebar/tabs/Components/GroupLevelAutocomplete'
11
19
 
20
+ // ── Defaults for the "Save As New" dialog ──────────────────────────────────
21
+ // "Copy of X" is the universal duplicate convention (Excel, Photoshop, …).
22
+ // viewIds must be unique server-side, so we append "_copy" — the user can
23
+ // edit before submitting if the suffix would collide with an existing view.
24
+ function suggestCopyTitle(title) {
25
+ const t = (title ?? '').trim()
26
+ if (!t) return 'Copy'
27
+ if (/^copy of\b/i.test(t)) return t // don't recursively prefix "Copy of Copy of …"
28
+ return `Copy of ${t}`
29
+ }
12
30
 
31
+ function suggestCopyViewId(viewId) {
32
+ const v = (viewId ?? '').trim()
33
+ if (!v) return ''
34
+ if (/_copy$/i.test(v)) return `${v}_${Date.now().toString(36)}`
35
+ return `${v}_copy`
36
+ }
13
37
 
14
38
  export default function ViewTab() {
15
39
  const router = useRouter()
16
- const { id} = router.query
17
- const { viewMetaData,setViewMetaData,schema, setSchema } = useBuilder()
40
+ const { viewMetaData, setViewMetaData, schema, markClean, logSchema } = useBuilder()
18
41
  const [isLoading, setIsLoading] = useState(false)
19
- const { control, handleSubmit, reset,errors } = useForm({
42
+
43
+ // Save As New / Duplicate
44
+ const [duplicateOpen, setDuplicateOpen] = useState(false)
45
+ const [isDuplicating, setIsDuplicating] = useState(false)
46
+ const [duplicateTitle, setDuplicateTitle] = useState('')
47
+ const [duplicateViewId, setDuplicateViewId] = useState('')
48
+
49
+ const { control, handleSubmit, reset, errors } = useForm({
20
50
  defaultValues: {
21
51
  title: viewMetaData?.title || '',
22
52
  viewId: null,
@@ -28,19 +58,18 @@ export default function ViewTab() {
28
58
  try {
29
59
  setIsLoading(true)
30
60
  let data = {
31
- id : viewMetaData?.id ?? 0,
61
+ id: viewMetaData?.id ?? 0,
32
62
  title: values.title,
33
63
  viewId: values.viewId,
34
64
  groupId: values.groupId ?? null,
35
65
  value: JSON.stringify(schema)
36
66
  }
37
67
 
38
- let response = null;
68
+ let response = null
39
69
 
40
- if(viewMetaData.isNew) {
70
+ if (viewMetaData.isNew) {
41
71
  response = await Services.PostService(Endpoints.UiBuilder.Post.AddUpdate, true, data)
42
- }
43
- else {
72
+ } else {
44
73
  response = await Services.UpdateService(
45
74
  Endpoints.UiBuilder.Post.AddUpdate,
46
75
  true,
@@ -58,6 +87,16 @@ export default function ViewTab() {
58
87
  groupId: values.groupId,
59
88
  id: viewMetaData.id ?? response.data
60
89
  }))
90
+ markClean?.()
91
+ try {
92
+ logSchema?.({
93
+ builder: 'ui',
94
+ trigger: 'save',
95
+ title: values.title,
96
+ viewMetaData: { id: viewMetaData.id ?? response.data, title: values.title, viewId: values.viewId },
97
+ schema
98
+ })
99
+ } catch (e) { console.warn('[sessionLog] log on save failed:', e) }
61
100
  }
62
101
  } catch (error) {
63
102
  console.error(error)
@@ -65,6 +104,71 @@ export default function ViewTab() {
65
104
  setIsLoading(false)
66
105
  }
67
106
  }
107
+
108
+ // ── Save As New ────────────────────────────────────────────────────────────
109
+ // Always POSTs with id: 0 so the backend creates a new row. Title + viewId
110
+ // come from the dialog form (pre-filled with sensible "Copy of …" defaults
111
+ // but editable). On success, navigate to the new view via URL so the
112
+ // standard load path in viewBuilder/index.jsx picks it up — no manual
113
+ // state sync needed.
114
+ const openDuplicateDialog = () => {
115
+ setDuplicateTitle(suggestCopyTitle(viewMetaData?.title))
116
+ setDuplicateViewId(suggestCopyViewId(viewMetaData?.viewId))
117
+ setDuplicateOpen(true)
118
+ }
119
+
120
+ const closeDuplicateDialog = () => {
121
+ if (isDuplicating) return
122
+ setDuplicateOpen(false)
123
+ }
124
+
125
+ const handleSaveAsNew = async () => {
126
+ if (!schema?.root) return
127
+ const title = (duplicateTitle ?? '').trim()
128
+ const viewId = (duplicateViewId ?? '').trim()
129
+ if (!title) return
130
+ if (!viewId) return
131
+
132
+ try {
133
+ setIsDuplicating(true)
134
+ const data = {
135
+ id: 0,
136
+ title,
137
+ viewId,
138
+ groupId: viewMetaData?.groupId ?? null,
139
+ value: JSON.stringify(schema)
140
+ }
141
+ const response = await Services.PostService(Endpoints.UiBuilder.Post.AddUpdate, true, data)
142
+ if (!response) return
143
+
144
+ const newId = response.data
145
+ markClean?.()
146
+ // Log the new view as a fresh save (becomes a labelable training example).
147
+ try {
148
+ logSchema?.({
149
+ builder: 'ui',
150
+ trigger: 'duplicate',
151
+ title,
152
+ viewMetaData: { id: newId, title, viewId, groupId: viewMetaData?.groupId ?? null },
153
+ schema
154
+ })
155
+ } catch (e) { console.warn('[sessionLog] log on duplicate failed:', e) }
156
+
157
+ // Hand off to the page's load path — switching `id` in the query triggers
158
+ // viewBuilder/index.jsx's useEffect → handleGetView → loadSchema.
159
+ setDuplicateOpen(false)
160
+ await router.push(
161
+ { pathname: router.pathname, query: { ...router.query, id: newId } },
162
+ undefined,
163
+ { shallow: false }
164
+ )
165
+ } catch (error) {
166
+ console.error('Save As New failed:', error)
167
+ } finally {
168
+ setIsDuplicating(false)
169
+ }
170
+ }
171
+
68
172
  useEffect(() => {
69
173
  reset({
70
174
  title: viewMetaData?.title,
@@ -73,6 +177,7 @@ export default function ViewTab() {
73
177
  })
74
178
  }, [viewMetaData])
75
179
 
180
+ const canDuplicate = Boolean(schema?.root)
76
181
 
77
182
  return (
78
183
  <Box p={2}>
@@ -104,9 +209,7 @@ export default function ViewTab() {
104
209
  />
105
210
  <GroupLevelAutocomplete selectedViewGroup={viewMetaData?.group} control={control} errors={errors} />
106
211
  <Button
107
- sx={{
108
- mt: 2
109
- }}
212
+ sx={{ mt: 2 }}
110
213
  type='submit'
111
214
  size={'small'}
112
215
  fullWidth={true}
@@ -115,8 +218,65 @@ export default function ViewTab() {
115
218
  >
116
219
  {isLoading ? <CircularProgress size={24} /> : 'Save'}
117
220
  </Button>
221
+ <Button
222
+ size='small'
223
+ fullWidth
224
+ variant='outlined'
225
+ startIcon={<ContentCopyOutlinedIcon fontSize='small' />}
226
+ onClick={openDuplicateDialog}
227
+ disabled={!canDuplicate || isLoading || isDuplicating}
228
+ sx={{ mt: -1.5 }}
229
+ >
230
+ Save As New
231
+ </Button>
118
232
  </Box>
119
233
  </form>
234
+
235
+ {/* ── Save As New / Duplicate dialog ───────────────────────────────────── */}
236
+ <Dialog open={duplicateOpen} onClose={closeDuplicateDialog} maxWidth='xs' fullWidth>
237
+ <DialogTitle>Save As New View</DialogTitle>
238
+ <DialogContent>
239
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
240
+ <Box>
241
+ <Typography variant='body2'>Title</Typography>
242
+ <TextField
243
+ autoFocus
244
+ size='small'
245
+ fullWidth
246
+ value={duplicateTitle}
247
+ onChange={e => setDuplicateTitle(e.target.value)}
248
+ onKeyDown={e => { if (e.key === 'Enter' && duplicateTitle.trim() && duplicateViewId.trim()) handleSaveAsNew() }}
249
+ disabled={isDuplicating}
250
+ />
251
+ </Box>
252
+ <Box>
253
+ <Typography variant='body2'>View ID</Typography>
254
+ <TextField
255
+ size='small'
256
+ fullWidth
257
+ value={duplicateViewId}
258
+ onChange={e => setDuplicateViewId(e.target.value)}
259
+ helperText='Must be unique. Edit if "<id>_copy" already exists.'
260
+ disabled={isDuplicating}
261
+ />
262
+ </Box>
263
+ <Typography variant='caption' color='text.secondary'>
264
+ The current schema will be saved as a brand-new view. The original is untouched.
265
+ You will be navigated to the new view after save.
266
+ </Typography>
267
+ </Box>
268
+ </DialogContent>
269
+ <DialogActions>
270
+ <Button onClick={closeDuplicateDialog} disabled={isDuplicating}>Cancel</Button>
271
+ <Button
272
+ variant='contained'
273
+ onClick={handleSaveAsNew}
274
+ disabled={isDuplicating || !duplicateTitle.trim() || !duplicateViewId.trim()}
275
+ >
276
+ {isDuplicating ? <CircularProgress size={20} /> : 'Save As New'}
277
+ </Button>
278
+ </DialogActions>
279
+ </Dialog>
120
280
  </Box>
121
281
  )
122
282
  }
@@ -69,6 +69,8 @@ export default function ViewerComponentWrapper({ node, children, viewerContext }
69
69
  // Get selection state from builder context (only available in edit mode)
70
70
  const isSelected = builderContext?.selectedId === node.id
71
71
  const canEdit = isEditMode && (builderContext != null)
72
+ const isRoot = node.id === 'root'
73
+ const canDrag = canEdit && !isRoot
72
74
  const isDraggingThis = builderContext?.draggingNodeId === node.id
73
75
  // Handle component selection
74
76
  const handleClick = e => {
@@ -213,7 +215,7 @@ export default function ViewerComponentWrapper({ node, children, viewerContext }
213
215
  <Box
214
216
  className={node.className}
215
217
  style={elementCss}
216
- draggable={canEdit}
218
+ draggable={canDrag}
217
219
  onDragStart={handleDragStart}
218
220
  onDragEnd={handleDragEnd}
219
221
  onClick={handleClick}
@@ -226,15 +228,17 @@ export default function ViewerComponentWrapper({ node, children, viewerContext }
226
228
  ...elementStyle,
227
229
  // Edit mode styling
228
230
  ...(canEdit && {
229
- cursor: 'grab',
231
+ cursor: canDrag ? 'grab' : 'default',
230
232
  outline: isSelected ? '2px solid #1976d2' : '1px solid transparent',
231
233
  outlineOffset: '2px',
232
234
  '&:hover': {
233
235
  outline: isSelected ? '2px solid #1976d2' : '1px solid #90caf9'
234
236
  },
235
- '&:active': {
236
- cursor: 'grabbing'
237
- }
237
+ ...(canDrag && {
238
+ '&:active': {
239
+ cursor: 'grabbing'
240
+ }
241
+ })
238
242
  }),
239
243
  // Show hidden components with opacity in edit mode
240
244
  ...(isEditMode &&
@@ -1,6 +1,6 @@
1
1
  import { Box, ToggleButton, ToggleButtonGroup, IconButton, Tooltip, Dialog, Tabs, Tab, Chip } from '@mui/material'
2
2
  import { useState, useCallback, useEffect } from 'react'
3
- import { DataObjectOutlined, FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, BugReportOutlined, RefreshOutlined, DarkModeOutlined, LightModeOutlined, UndoOutlined, RedoOutlined } from '@mui/icons-material'
3
+ import { DataObjectOutlined, FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, BugReportOutlined, RefreshOutlined, DarkModeOutlined, LightModeOutlined, UndoOutlined, RedoOutlined, HistoryOutlined } from '@mui/icons-material'
4
4
  import Editor from '@monaco-editor/react'
5
5
  import SaveViewDialog from './SaveViewDialog'
6
6
  import { useBuilder } from 'src/context/BuilderContext'
@@ -185,7 +185,7 @@ function DebugPanel({ open, onClose, getState }) {
185
185
  // ── Main toolbar ──────────────────────────────────────────────────────────────
186
186
 
187
187
  export default function ViewerToolbar({ mode, setMode, previewStateRef, isDark, onToggleTheme }) {
188
- const { schema, data, form, dataRef, reportRefs, undo, redo, canUndo, canRedo } = useBuilder()
188
+ const { schema, data, form, dataRef, reportRefs, undo, redo, canUndo, canRedo, markClean, logSchema, openSessionLog, viewMetaData } = useBuilder()
189
189
  const [saveDialogOpen, setSaveDialogOpen] = useState(false)
190
190
  const [isSaving, setIsSaving] = useState(false)
191
191
  const [schemaOpen, setSchemaOpen] = useState(false)
@@ -197,7 +197,15 @@ export default function ViewerToolbar({ mode, setMode, previewStateRef, isDark,
197
197
  setIsSaving(true)
198
198
  try {
199
199
  const response = await PostService(Endpoints.View.Post.Add, true, { title, value: JSON.stringify(schema) })
200
- if (response) setSaveDialogOpen(false)
200
+ if (response) {
201
+ setSaveDialogOpen(false)
202
+ markClean()
203
+ // Capture this save as a future training example. Prompt is left
204
+ // empty — developers fill it in via the Session Logs dialog.
205
+ try {
206
+ logSchema({ builder: 'ui', trigger: 'save', title, viewMetaData, schema })
207
+ } catch (e) { console.warn('[sessionLog] log on save failed:', e) }
208
+ }
201
209
  } catch (error) {
202
210
  console.error('Error saving view:', error)
203
211
  } finally {
@@ -244,6 +252,13 @@ export default function ViewerToolbar({ mode, setMode, previewStateRef, isDark,
244
252
  </Tooltip>
245
253
  )}
246
254
 
255
+ {/* Session Logs */}
256
+ <Tooltip title='Session Logs (training data)'>
257
+ <IconButton size='small' onClick={openSessionLog} sx={{ color: 'text.secondary' }}>
258
+ <HistoryOutlined fontSize='small' />
259
+ </IconButton>
260
+ </Tooltip>
261
+
247
262
  {/* State Inspector */}
248
263
  <Tooltip title='State Inspector'>
249
264
  <IconButton
@@ -1,4 +1,4 @@
1
- import React, { useRef, useEffect, useCallback, useState } from 'react'
1
+ import React, { useRef, useEffect, useCallback, useState, useMemo } from 'react'
2
2
  import { Box, CircularProgress } from '@mui/material'
3
3
  import ReportViewer from 'pages/builders/report/viewer'
4
4
  import ViewerComponentWrapper from '../ViewerComponentWrapper'
@@ -6,6 +6,7 @@ import { resolveProps } from 'services/builderHelper/resolveProps'
6
6
  import RowActionsCell from './RowActionsCell'
7
7
  import { PrintTable } from '../PrintDialog'
8
8
  import fetchReportDataByPageId from 'services/reportData/fetchReportData'
9
+ import { executeJSCode } from 'services/builderHelper/jsExecutor'
9
10
 
10
11
  // ── Print-mode report table ────────────────────────────────────────────────────
11
12
  // Uses fetchReportDataByPageId with isPagination:false to load ALL rows in one
@@ -104,7 +105,9 @@ export default function ReportViewerRenderer({ node, viewerContext }) {
104
105
 
105
106
  // Strip 'key' before spreading — React treats it as a reserved prop and
106
107
  // would silently drop it; we pass it explicitly below.
107
- const { key: _key, actionsConfig: _actionsConfig, ...mainProps } = main
108
+ // Also strip actionsConfig (row actions bound separately into actionsRenderer)
109
+ // and viewerActions (page-level actions — bound separately below).
110
+ const { key: _key, actionsConfig: _actionsConfig, viewerActions: _viewerActions, ...mainProps } = main
108
111
 
109
112
  // Each ReportViewer instance owns its updateRef for tracking row mutations.
110
113
  // Register under nodeName so column functions can access it via reportRefs.
@@ -144,6 +147,46 @@ export default function ReportViewerRenderer({ node, viewerContext }) {
144
147
  [JSON.stringify(actionsConfig)]
145
148
  )
146
149
 
150
+ // ── Build viewer actions with bound onClick handlers ───────────────────────
151
+ // Page-level buttons rendered in the report toolbar after Filter / Refresh.
152
+ // Each item is a copy of the schema config plus a stable `onClick` that
153
+ // runs the Calculation in the same scope as row actions, minus rowData.
154
+ const viewerActionsConfig = Array.isArray(main.viewerActions) && main.viewerActions.length > 0
155
+ ? main.viewerActions
156
+ : null
157
+
158
+ const viewerActions = useMemo(() => {
159
+ if (!viewerActionsConfig) return null
160
+ return viewerActionsConfig.map((action, idx) => ({
161
+ key: `${action.label ?? 'action'}-${idx}`,
162
+ label: action.label,
163
+ icon: action.icon,
164
+ color: action.color,
165
+ variant: action.variant,
166
+ disabled: action.disabled,
167
+ confirmation: action.confirmation,
168
+ onClick: async () => {
169
+ if (!action.code) return
170
+ const ctx = ctxRef.current ?? {}
171
+ await executeJSCode(action.code, {
172
+ form: ctx.form ?? {},
173
+ data: ctx.data ?? {},
174
+ setData: ctx.setData ?? (() => {}),
175
+ dataRef: ctx.dataRef?.current ?? {},
176
+ reportRefs: ctx.reportRefs?.current ?? {},
177
+ openDialog: ctx.openDialog ?? (() => {}),
178
+ closeDialog: ctx.closeDialog ?? (() => {}),
179
+ pageData: ctx.pageData,
180
+ pageSetData: ctx.pageSetData,
181
+ pageDataRef: ctx.pageDataRef,
182
+ pageReportRefs: ctx.pageReportRefs,
183
+ })
184
+ },
185
+ }))
186
+ // Stringify the config so we recompute only when its shape changes.
187
+ // eslint-disable-next-line react-hooks/exhaustive-deps
188
+ }, [JSON.stringify(viewerActionsConfig)])
189
+
147
190
  // ── Edit mode: rich placeholder (both UI builder and print builder) ────────
148
191
  if (isEditMode) {
149
192
  const cols = Array.isArray(mainProps.columnDefs) ? mainProps.columnDefs : []
@@ -216,6 +259,7 @@ export default function ReportViewerRenderer({ node, viewerContext }) {
216
259
  noHeader={true}
217
260
  {...mainProps}
218
261
  actions={actionsRenderer ?? mainProps.actions}
262
+ viewerActions={viewerActions}
219
263
  updateRef={updateRef}
220
264
  nodeId={nodeName}
221
265
  reportRefs={reportRefs}