@majkapp/plugin-kit 3.2.0 → 3.3.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.
@@ -0,0 +1,547 @@
1
+ # Screens
2
+
3
+ Screens display UI in MAJK. Use React for rich interfaces with auto-generated hooks.
4
+
5
+ ## React Screen
6
+
7
+ ```typescript
8
+ // src/index.ts
9
+ import { definePlugin } from '@majkapp/plugin-kit';
10
+
11
+ const plugin = definePlugin('my-plugin', 'My Plugin', '1.0.0')
12
+ .pluginRoot(__dirname)
13
+
14
+ // Define functions first - they generate hooks
15
+ .function('health', { /* ... */ })
16
+ .function('getAnalytics', { /* ... */ })
17
+
18
+ // React screen - loads your built React app
19
+ .screenReact({
20
+ id: 'my-plugin-main', // Unique ID
21
+ name: 'My Plugin', // Display name in UI
22
+ description: 'Main plugin dashboard showing health and analytics',
23
+ route: '/plugin-screens/my-plugin/main', // MUST start with /plugin-screens/{plugin-id}/
24
+ pluginPath: '/index.html' // Path to built React app (relative to dist/)
25
+ })
26
+
27
+ .build();
28
+ ```
29
+
30
+ ## React App Setup
31
+
32
+ ```typescript
33
+ // ui/src/App.tsx
34
+ import { BrowserRouter, Routes, Route } from 'react-router-dom';
35
+ import { DashboardPage } from './components/DashboardPage';
36
+ import { SettingsPage } from './components/SettingsPage';
37
+
38
+ export function App() {
39
+ return (
40
+ <BrowserRouter basename={window.__MAJK_IFRAME_BASE__}>
41
+ <Routes>
42
+ <Route path="/" element={<DashboardPage />} />
43
+ <Route path="/settings" element={<SettingsPage />} />
44
+ </Routes>
45
+ </BrowserRouter>
46
+ );
47
+ }
48
+ ```
49
+
50
+ **Critical:** Use `window.__MAJK_IFRAME_BASE__` as basename for routing.
51
+
52
+ Available globals:
53
+ - `window.__MAJK_BASE_URL__` - Host base URL
54
+ - `window.__MAJK_IFRAME_BASE__` - Plugin iframe base path
55
+ - `window.__MAJK_PLUGIN_ID__` - Your plugin ID
56
+
57
+ ## Multiple Screens with Hash Routing
58
+
59
+ ```typescript
60
+ // src/index.ts
61
+ const plugin = definePlugin('my-plugin', 'My Plugin', '1.0.0')
62
+ .pluginRoot(__dirname)
63
+
64
+ // Dashboard screen - default route
65
+ .screenReact({
66
+ id: 'my-plugin-dashboard',
67
+ name: 'Dashboard',
68
+ description: 'Main dashboard with metrics and controls',
69
+ route: '/plugin-screens/my-plugin/dashboard',
70
+ pluginPath: '/index.html',
71
+ pluginPathHash: '#/' // Routes to #/ in your React app
72
+ })
73
+
74
+ // Settings screen - separate route
75
+ .screenReact({
76
+ id: 'my-plugin-settings',
77
+ name: 'Settings',
78
+ description: 'Plugin configuration and preferences',
79
+ route: '/plugin-screens/my-plugin/settings',
80
+ pluginPath: '/index.html',
81
+ pluginPathHash: '#/settings' // Routes to #/settings in your React app
82
+ })
83
+
84
+ .build();
85
+ ```
86
+
87
+ MAJK will load your React app at `/index.html` and navigate to the specified hash.
88
+
89
+ ## Best Practice: React Component with data-majk-* Attributes
90
+
91
+ **Always use `data-majk-*` attributes** for testability and debugging.
92
+
93
+ ```typescript
94
+ // ui/src/components/DashboardPage.tsx
95
+ import { useHealth, useGetAnalytics, useClearEvents, useUiLog } from '../generated/hooks';
96
+ import { PluginStatGrid, PluginActionPanel, PluginList } from '@majkapp/plugin-ui';
97
+ import { useState, useEffect } from 'react';
98
+
99
+ export function DashboardPage() {
100
+ // Query hooks - auto-fetch on mount
101
+ const { data: health, loading: healthLoading, error: healthError, refetch: refetchHealth } = useHealth();
102
+ const { data: analytics, loading: analyticsLoading } = useGetAnalytics(
103
+ { period: '7d' },
104
+ { refetchInterval: 30000 } // Auto-refresh every 30s
105
+ );
106
+
107
+ // Mutation hooks - call .mutate() manually
108
+ const { mutate: clearEvents, loading: clearingEvents } = useClearEvents();
109
+ const { mutate: uiLog } = useUiLog();
110
+
111
+ const [autoRefresh, setAutoRefresh] = useState(true);
112
+
113
+ // Log component lifecycle for debugging
114
+ useEffect(() => {
115
+ uiLog({
116
+ level: 'info',
117
+ component: 'DashboardPage',
118
+ message: 'Dashboard mounted',
119
+ data: { timestamp: new Date().toISOString() }
120
+ });
121
+
122
+ return () => {
123
+ uiLog({
124
+ level: 'info',
125
+ component: 'DashboardPage',
126
+ message: 'Dashboard unmounting'
127
+ });
128
+ };
129
+ }, [uiLog]);
130
+
131
+ // Log errors
132
+ useEffect(() => {
133
+ if (healthError) {
134
+ uiLog({
135
+ level: 'error',
136
+ component: 'DashboardPage',
137
+ message: 'Health check failed',
138
+ data: { error: healthError.message }
139
+ });
140
+ }
141
+ }, [healthError, uiLog]);
142
+
143
+ const handleRefresh = () => {
144
+ uiLog({
145
+ level: 'info',
146
+ component: 'DashboardPage',
147
+ message: 'Manual refresh triggered by user'
148
+ });
149
+ refetchHealth();
150
+ };
151
+
152
+ const handleClearEvents = async () => {
153
+ try {
154
+ uiLog({
155
+ level: 'info',
156
+ component: 'DashboardPage',
157
+ message: 'Clear events triggered by user'
158
+ });
159
+
160
+ await clearEvents({});
161
+
162
+ uiLog({
163
+ level: 'info',
164
+ component: 'DashboardPage',
165
+ message: 'Events cleared successfully'
166
+ });
167
+ } catch (error) {
168
+ uiLog({
169
+ level: 'error',
170
+ component: 'DashboardPage',
171
+ message: 'Failed to clear events',
172
+ data: { error: error.message }
173
+ });
174
+ }
175
+ };
176
+
177
+ return (
178
+ <div
179
+ data-majk-id="dashboardContainer"
180
+ data-majk-state={healthLoading || analyticsLoading ? 'loading' : 'populated'}
181
+ >
182
+ {/* Action buttons - data-majk-id for bot clicking, data-majk-state for verification */}
183
+ <PluginActionPanel
184
+ actions={[
185
+ {
186
+ label: autoRefresh ? '⏸ Pause Refresh' : '▶️ Resume Refresh',
187
+ onClick: () => {
188
+ setAutoRefresh(!autoRefresh);
189
+ uiLog({
190
+ level: 'info',
191
+ component: 'DashboardPage',
192
+ message: `Auto-refresh ${autoRefresh ? 'paused' : 'resumed'}`
193
+ });
194
+ },
195
+ 'data-majk-id': 'toggleRefreshButton',
196
+ 'data-majk-state': autoRefresh ? 'refreshing' : 'paused'
197
+ },
198
+ {
199
+ label: '🔄 Refresh Now',
200
+ onClick: handleRefresh,
201
+ disabled: healthLoading,
202
+ 'data-majk-id': 'refreshHealthButton',
203
+ 'data-majk-state': healthLoading ? 'loading' : 'idle'
204
+ },
205
+ {
206
+ label: '🗑 Clear Events',
207
+ onClick: handleClearEvents,
208
+ disabled: clearingEvents,
209
+ color: 'danger',
210
+ 'data-majk-id': 'clearEventsButton',
211
+ 'data-majk-state': clearingEvents ? 'clearing' : 'idle'
212
+ }
213
+ ]}
214
+ />
215
+
216
+ {/* Stats grid - data-majk-id for each stat, data-majk-state for loading/empty/populated */}
217
+ <PluginStatGrid
218
+ stats={[
219
+ {
220
+ label: 'Health Status',
221
+ value: healthError ? 'Error' : health?.status || 'Unknown',
222
+ trend: health?.status === 'ok' ? 'up' : 'down',
223
+ 'data-majk-id': 'healthStatus',
224
+ 'data-majk-state': healthLoading ? 'loading' : healthError ? 'error' : health ? 'populated' : 'empty'
225
+ },
226
+ {
227
+ label: 'Last Check',
228
+ value: health?.timestamp ? new Date(health.timestamp).toLocaleTimeString() : '--',
229
+ 'data-majk-id': 'lastCheckTime',
230
+ 'data-majk-state': health?.timestamp ? 'populated' : 'empty'
231
+ },
232
+ {
233
+ label: 'Active Users (7d)',
234
+ value: analytics?.metrics.activeUsers || 0,
235
+ 'data-majk-id': 'activeUsersCount',
236
+ 'data-majk-state': analyticsLoading ? 'loading' : analytics ? 'populated' : 'empty'
237
+ },
238
+ {
239
+ label: 'Total Sessions',
240
+ value: analytics?.metrics.totalSessions || 0,
241
+ 'data-majk-id': 'totalSessionsCount'
242
+ },
243
+ {
244
+ label: 'Avg Duration',
245
+ value: analytics?.metrics.avgDuration ? `${analytics.metrics.avgDuration}s` : '--',
246
+ 'data-majk-id': 'avgDurationValue'
247
+ }
248
+ ]}
249
+ />
250
+
251
+ {/* Error display - data-majk-state for testing error states */}
252
+ {healthError && (
253
+ <div
254
+ data-majk-id="errorMessage"
255
+ data-majk-state="error"
256
+ style={{ padding: '12px', background: '#fee', color: '#c00', borderRadius: '4px' }}
257
+ >
258
+ Error: {healthError.message}
259
+ </div>
260
+ )}
261
+ </div>
262
+ );
263
+ }
264
+ ```
265
+
266
+ ## data-majk-* Attributes Reference
267
+
268
+ ### data-majk-id
269
+
270
+ Identifies elements for bot testing and debugging.
271
+
272
+ **Use on:**
273
+ - All clickable elements (buttons, links)
274
+ - Key statistics and values
275
+ - Input fields
276
+ - List items
277
+ - Containers with important content
278
+
279
+ ```typescript
280
+ <button data-majk-id="saveButton">Save</button>
281
+ <div data-majk-id="totalCount">{count}</div>
282
+ <input data-majk-id="nameInput" />
283
+ ```
284
+
285
+ **Naming convention:** Use camelCase, be descriptive but concise.
286
+
287
+ ### data-majk-state
288
+
289
+ Tracks UI state for testing assertions.
290
+
291
+ **Common states:**
292
+ - `loading` - Data is being fetched
293
+ - `empty` - No data available
294
+ - `populated` - Data is displayed
295
+ - `error` - Error occurred
296
+ - `idle` - Button/action ready
297
+ - `processing` - Action in progress
298
+ - Custom states: `paused`, `active`, `disabled`, etc.
299
+
300
+ ```typescript
301
+ <div
302
+ data-majk-id="userList"
303
+ data-majk-state={loading ? 'loading' : users.length > 0 ? 'populated' : 'empty'}
304
+ >
305
+ {/* content */}
306
+ </div>
307
+
308
+ <button
309
+ data-majk-id="submitButton"
310
+ data-majk-state={submitting ? 'processing' : 'idle'}
311
+ disabled={submitting}
312
+ >
313
+ {submitting ? 'Submitting...' : 'Submit'}
314
+ </button>
315
+ ```
316
+
317
+ ## List Components with data-majk-*
318
+
319
+ ```typescript
320
+ // ui/src/components/EventsList.tsx
321
+ import { useGetEvents, useUiLog } from '../generated/hooks';
322
+ import { PluginList } from '@majkapp/plugin-ui';
323
+ import { useEffect } from 'react';
324
+
325
+ export function EventsList() {
326
+ const { data: events, loading, error } = useGetEvents();
327
+ const { mutate: uiLog } = useUiLog();
328
+
329
+ useEffect(() => {
330
+ if (error) {
331
+ uiLog({
332
+ level: 'error',
333
+ component: 'EventsList',
334
+ message: 'Failed to load events',
335
+ data: { error: error.message }
336
+ });
337
+ }
338
+ }, [error, uiLog]);
339
+
340
+ const items = events?.events || [];
341
+
342
+ return (
343
+ <div
344
+ data-majk-id="eventsContainer"
345
+ data-majk-state={loading ? 'loading' : error ? 'error' : items.length > 0 ? 'populated' : 'empty'}
346
+ >
347
+ {loading && <div data-majk-id="loadingSpinner">Loading...</div>}
348
+
349
+ {error && (
350
+ <div data-majk-id="errorMessage" data-majk-state="error">
351
+ Error loading events: {error.message}
352
+ </div>
353
+ )}
354
+
355
+ {!loading && !error && items.length === 0 && (
356
+ <div data-majk-id="emptyMessage" data-majk-state="empty">
357
+ No events to display
358
+ </div>
359
+ )}
360
+
361
+ {!loading && !error && items.length > 0 && (
362
+ <PluginList
363
+ items={items.map(event => ({
364
+ id: event.id,
365
+ title: `${event.entityType}.${event.eventType}`,
366
+ description: event.entitySummary?.name || event.entityId,
367
+ metadata: {
368
+ timestamp: new Date(event.timestamp).toLocaleString()
369
+ }
370
+ }))}
371
+ // CRITICAL: Pass data-majk-id via itemProps so each list item is testable
372
+ itemProps={{ 'data-majk-id': 'eventItem' }}
373
+ onItemClick={(item) => {
374
+ uiLog({
375
+ level: 'info',
376
+ component: 'EventsList',
377
+ message: 'Event clicked',
378
+ data: { eventId: item.id }
379
+ });
380
+ }}
381
+ />
382
+ )}
383
+
384
+ {/* Display counts for testing assertions */}
385
+ <div style={{ marginTop: '16px', fontSize: '12px', color: '#666' }}>
386
+ <span data-majk-id="displayedEventsCount">{items.length} events</span>
387
+ </div>
388
+ </div>
389
+ );
390
+ }
391
+ ```
392
+
393
+ ## Testing Your Screen
394
+
395
+ ```typescript
396
+ // tests/plugin/ui/unit/dashboard.test.js
397
+ import { test, bot, mock } from '@majkapp/plugin-test';
398
+ import assert from 'assert';
399
+
400
+ test('dashboard displays health status', async () => {
401
+ // Mock function responses
402
+ const context = mock()
403
+ .withMockHandler('health', async () => ({
404
+ status: 'ok',
405
+ timestamp: new Date().toISOString()
406
+ }))
407
+ .withMockHandler('getAnalytics', async () => ({
408
+ period: '7d',
409
+ metrics: {
410
+ activeUsers: 42,
411
+ totalSessions: 150,
412
+ avgDuration: 320
413
+ }
414
+ }))
415
+ .build();
416
+
417
+ const b = await bot(context);
418
+
419
+ // Navigate to screen
420
+ await b.goto('/plugin-screens/my-plugin/dashboard');
421
+
422
+ // Wait for data to load - verify data-majk-state changed to 'populated'
423
+ await b.waitForSelector('[data-majk-id="healthStatus"][data-majk-state="populated"]');
424
+
425
+ // Verify displayed values using data-majk-id
426
+ const healthText = await b.getText('[data-majk-id="healthStatus"]');
427
+ assert.ok(healthText.includes('ok'), `Expected 'ok' in "${healthText}"`);
428
+
429
+ const usersText = await b.getText('[data-majk-id="activeUsersCount"]');
430
+ assert.ok(usersText.includes('42'), `Expected '42' in "${usersText}"`);
431
+
432
+ // Test button interaction
433
+ await b.click('[data-majk-id="refreshHealthButton"]');
434
+
435
+ // Verify loading state
436
+ await b.waitForSelector('[data-majk-id="refreshHealthButton"][data-majk-state="loading"]');
437
+
438
+ // Take screenshot for visual verification
439
+ await b.screenshot('dashboard-after-refresh.png');
440
+
441
+ await b.close();
442
+ });
443
+
444
+ test('dashboard handles empty state', async () => {
445
+ const context = mock()
446
+ .withMockHandler('health', async () => ({
447
+ status: 'ok',
448
+ timestamp: new Date().toISOString()
449
+ }))
450
+ .withMockHandler('getAnalytics', async () => ({
451
+ period: '7d',
452
+ metrics: {
453
+ activeUsers: 0,
454
+ totalSessions: 0,
455
+ avgDuration: 0
456
+ }
457
+ }))
458
+ .build();
459
+
460
+ const b = await bot(context);
461
+ await b.goto('/plugin-screens/my-plugin/dashboard');
462
+
463
+ // Wait for render
464
+ await b.waitForSelector('[data-majk-id="dashboardContainer"]');
465
+
466
+ // Verify empty state
467
+ const usersCount = await b.getText('[data-majk-id="activeUsersCount"]');
468
+ assert.strictEqual(usersCount, '0');
469
+
470
+ await b.close();
471
+ });
472
+
473
+ test('dashboard toggle refresh button changes state', async () => {
474
+ const context = mock()
475
+ .withMockHandler('health', async () => ({
476
+ status: 'ok',
477
+ timestamp: new Date().toISOString()
478
+ }))
479
+ .build();
480
+
481
+ const b = await bot(context);
482
+ await b.goto('/plugin-screens/my-plugin/dashboard');
483
+
484
+ // Wait for page load
485
+ await b.waitForSelector('[data-majk-id="toggleRefreshButton"]');
486
+
487
+ // Initial state should be 'refreshing'
488
+ let state = await b.getAttribute('[data-majk-id="toggleRefreshButton"]', 'data-majk-state');
489
+ assert.strictEqual(state, 'refreshing');
490
+
491
+ // Click to pause
492
+ await b.click('[data-majk-id="toggleRefreshButton"]');
493
+
494
+ // Verify state changed to 'paused'
495
+ state = await b.getAttribute('[data-majk-id="toggleRefreshButton"]', 'data-majk-state');
496
+ assert.strictEqual(state, 'paused');
497
+
498
+ await b.close();
499
+ });
500
+ ```
501
+
502
+ ## Best Practice: Component Organization
503
+
504
+ ```
505
+ ui/src/
506
+ ├── App.tsx # Router setup
507
+ ├── components/
508
+ │ ├── DashboardPage.tsx # Page component
509
+ │ ├── SettingsPage.tsx
510
+ │ ├── EventsList.tsx # Feature component
511
+ │ └── shared/
512
+ │ ├── ErrorBoundary.tsx
513
+ │ └── LoadingSpinner.tsx
514
+ ├── hooks/
515
+ │ ├── useAutoRefresh.ts # Custom hook
516
+ │ └── useLocalStorage.ts
517
+ ├── generated/ # Auto-generated (DO NOT EDIT)
518
+ │ ├── hooks.ts # useHealth(), useGetAnalytics(), etc.
519
+ │ ├── client.ts
520
+ │ ├── types.ts
521
+ │ └── index.ts
522
+ └── utils/
523
+ ├── formatters.ts
524
+ └── validators.ts
525
+ ```
526
+
527
+ ## Using @majkapp/plugin-ui Components
528
+
529
+ ```bash
530
+ # View available components
531
+ npx @majkapp/plugin-ui
532
+ ```
533
+
534
+ Commonly used components:
535
+
536
+ - `PluginActionPanel` - Action buttons with consistent styling
537
+ - `PluginStatGrid` - Display metrics/statistics
538
+ - `PluginList` - List items with metadata
539
+ - `PluginCard` - Styled containers
540
+ - `PluginTable` - Data tables
541
+ - All accept `data-majk-*` attributes via props
542
+
543
+ ## Next Steps
544
+
545
+ Run `npx @majkapp/plugin-kit --hooks` - Generated React hooks reference
546
+ Run `npx @majkapp/plugin-kit --testing` - Complete UI testing guide
547
+ Run `npx @majkapp/plugin-kit --config` - vite.config.js setup