@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.
- package/bin/promptable-cli.js +35 -0
- package/dist/generator/generator.js +12 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/plugin-kit.d.ts +34 -1
- package/dist/plugin-kit.d.ts.map +1 -1
- package/dist/plugin-kit.js +87 -1
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/API.md +394 -0
- package/docs/CONFIG.md +428 -0
- package/docs/CONTEXT.md +500 -0
- package/docs/FULL.md +848 -0
- package/docs/FUNCTIONS.md +623 -0
- package/docs/HOOKS.md +532 -0
- package/docs/INDEX.md +486 -0
- package/docs/LIFECYCLE.md +490 -0
- package/docs/SCREENS.md +547 -0
- package/docs/SERVICES.md +350 -0
- package/docs/TESTING.md +593 -0
- package/docs/mcp-execution-api.md +490 -0
- package/package.json +18 -3
package/docs/SCREENS.md
ADDED
|
@@ -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
|