infaira-canvas 0.1.9

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,500 @@
1
+ # ICan Widget Development Guide
2
+
3
+ > Reference for building **functional** ICan widgets — data fetching, configuration, localisation, roles, and multi-instance patterns.
4
+ > For **styling** see `ICan-Widget-Styling-Patterns.md`. For the `--ican-*` CSS variable reference see `ICan-Widget-Theming-Guide.md`.
5
+
6
+ ---
7
+
8
+ ## 1. `icanContext` — what it is and the guard you must keep
9
+
10
+ `icanContext` is your widget's connection to the portal. The portal injects it as a prop at runtime:
11
+
12
+ ```tsx
13
+ const MyWidget: React.FC<IWidgetProps> = ({ icanContext }) => { ... }
14
+ ```
15
+
16
+ **It is `undefined` in the dev harness until the harness is configured.** The scaffold always generates a guard for this reason:
17
+
18
+ ```tsx
19
+ React.useEffect(() => {
20
+ if (!icanContext) {
21
+ setLoading(false);
22
+ return; // ← this line is not dead code — remove it and the harness crashes
23
+ }
24
+ // safe to call icanContext.executeAction etc. here
25
+ }, [icanContext]);
26
+ ```
27
+
28
+ Never remove this guard. Calling `icanContext.executeAction(...)` without it will throw `Cannot read properties of undefined` on the first render in the dev harness.
29
+
30
+ In the portal, `icanContext` is always defined — the widget is never mounted until the context is ready.
31
+
32
+ **What's available on `icanContext`:**
33
+
34
+ | Property / Method | Type | What it does |
35
+ |---|---|---|
36
+ | `environment` | `'dev' \| 'prod'` | `'dev'` in local harness, `'prod'` in portal |
37
+ | `userKey` | `string` | Authenticated user's key |
38
+ | `language` | `string` | Active language code, e.g. `'en'` |
39
+ | `themeName` | `string \| undefined` | e.g. `'Dark'`, `'Glass Light'` |
40
+ | `themeType` | `'Dark' \| 'Light' \| 'Glass-Dark' \| 'Glass-Light' \| undefined` | Structured theme type for chart colours |
41
+ | `executeAction` | `(model, action, params?, options?) => Promise<unknown>` | Call an Orch model |
42
+ | `fireEvent` | `(eventId) => Promise<void>` | Trigger a portal event |
43
+ | `hasAppRole` | `(app, role) => boolean` | Check if the user has a role |
44
+ | `$L` | `(code, params?) => string` | Translate a localisation key |
45
+
46
+ ---
47
+
48
+ ## 2. `environment` — returning mock data in dev
49
+
50
+ Use `icanContext.environment` to skip real API calls during local development:
51
+
52
+ ```tsx
53
+ React.useEffect(() => {
54
+ if (!icanContext) { setLoading(false); return; }
55
+
56
+ // Return mock data in the dev harness — no live backend needed
57
+ if (icanContext.environment === 'dev') {
58
+ setData([{ id: '1', name: 'Mock Item', value: 42 }]);
59
+ setLoading(false);
60
+ return;
61
+ }
62
+
63
+ // Only runs in the portal
64
+ icanContext
65
+ .executeAction('InventoryModel', 'GetAll', { limit: 20 }, { json: true })
66
+ .then((res) => { setData(res as Item[]); })
67
+ .catch((err) => { setError(String(err)); })
68
+ .finally(() => setLoading(false));
69
+ }, [icanContext]);
70
+ ```
71
+
72
+ The dev harness always sets `environment: 'dev'`. The portal always sets `environment: 'prod'`. The mock-data branch is never hit in production.
73
+
74
+ ---
75
+
76
+ ## 3. Widget settings panel — `IWidgetPropConfig`
77
+
78
+ Widgets can expose a settings form in the portal. Users open it by clicking the ⚙ button on a widget cell in edit mode. You define the fields in `registerWidget`:
79
+
80
+ ```typescript
81
+ registerWidget({
82
+ id: 'my-widget',
83
+ widget: MyWidget,
84
+ configs: {
85
+ layout: { w: 10, h: 8, minW: 4, minH: 4 },
86
+ props: [
87
+ {
88
+ name: 'title',
89
+ label: 'Widget Title',
90
+ type: 'text',
91
+ value: 'My Widget',
92
+ validate: { required: true, maxLength: 60 },
93
+ },
94
+ {
95
+ name: 'maxRows',
96
+ label: 'Max Rows',
97
+ type: 'number',
98
+ value: 10,
99
+ validate: { minVal: 1, maxVal: 100 },
100
+ },
101
+ {
102
+ name: 'refreshMode',
103
+ label: 'Refresh Mode',
104
+ type: 'select',
105
+ value: 'manual',
106
+ options: [
107
+ { label: 'Manual', value: 'manual' },
108
+ { label: 'Auto (30 s)', value: 'auto' },
109
+ ],
110
+ },
111
+ {
112
+ name: 'showFooter',
113
+ label: 'Show Footer',
114
+ type: 'toggle',
115
+ value: true,
116
+ },
117
+ ],
118
+ },
119
+ });
120
+ ```
121
+
122
+ Each saved value flows to your component as a **prop** — add it to `IWidgetProps` and the component signature:
123
+
124
+ ```tsx
125
+ interface IWidgetProps {
126
+ icanContext?: IContextProvider;
127
+ title?: string; // ← from configs.props
128
+ maxRows?: number;
129
+ refreshMode?: string;
130
+ showFooter?: boolean;
131
+ [key: string]: unknown;
132
+ }
133
+
134
+ const MyWidget: React.FC<IWidgetProps> = ({
135
+ icanContext,
136
+ title = 'My Widget', // ← default matches configs.props[n].value
137
+ maxRows = 10,
138
+ refreshMode = 'manual',
139
+ showFooter = true,
140
+ }) => { ... };
141
+ ```
142
+
143
+ ### `IWidgetPropConfig` field reference
144
+
145
+ | Field | Required | Description |
146
+ |---|---|---|
147
+ | `name` | ✅ | Property name — becomes the prop key on your component |
148
+ | `label` | ✅ | Human-readable label shown in the settings form |
149
+ | `type` | ✅ | `'text'` `'string'` `'password'` `'number'` `'email'` `'checkbox'` `'toggle'` `'select'` `'date'` `'time'` `'json'` |
150
+ | `value` | — | Default value used before the user configures the widget |
151
+ | `placeholder` | — | Placeholder text for text-like inputs |
152
+ | `options` | — | Required for `type: 'select'` — array of `{ label: string; value: string }` |
153
+ | `validate.required` | — | Mark the field as required in the form |
154
+ | `validate.minLength` / `maxLength` | — | String length bounds |
155
+ | `validate.minVal` / `maxVal` | — | Numeric bounds |
156
+
157
+ ---
158
+
159
+ ## 4. `hasAppRole` — role-based UI
160
+
161
+ Show or hide UI elements based on the user's role in the portal:
162
+
163
+ ```tsx
164
+ const MyWidget: React.FC<IWidgetProps> = ({ icanContext }) => {
165
+ const canEdit = icanContext?.hasAppRole('MyApp', 'editor') ?? false;
166
+ const isAdmin = icanContext?.hasAppRole('MyApp', 'admin') ?? false;
167
+
168
+ return (
169
+ <div className="my-widget">
170
+ <DataTable rows={rows} />
171
+ {canEdit && <Button label="Edit" onClick={handleEdit} />}
172
+ {isAdmin && <Button label="Delete All" onClick={handleDelete} variant="danger" />}
173
+ </div>
174
+ );
175
+ };
176
+ ```
177
+
178
+ The first argument is the **app name**, the second is the **role name** — both must match what is configured in the portal's role management screen. `hasAppRole` returns `false` (not an error) if the context is unavailable or the role doesn't exist, so the `?? false` fallback is safe to use everywhere.
179
+
180
+ ---
181
+
182
+ ## 5. `$L()` — localisation
183
+
184
+ ### Define keys in `localization.json`
185
+
186
+ ```json
187
+ {
188
+ "widget.title": {
189
+ "en": "Revenue Dashboard",
190
+ "ar": "لوحة الإيرادات"
191
+ },
192
+ "widget.row-count": {
193
+ "en": "Showing {count} rows",
194
+ "ar": "عرض {count} صفوف"
195
+ }
196
+ }
197
+ ```
198
+
199
+ ### Use `$L()` in your component
200
+
201
+ ```tsx
202
+ const MyWidget: React.FC<IWidgetProps> = ({ icanContext }) => {
203
+ // Fallback to key passthrough when context is unavailable (dev harness)
204
+ const t = icanContext?.$L.bind(icanContext) ?? ((key: string) => key);
205
+
206
+ return (
207
+ <div className="my-widget">
208
+ <h3 className="my-widget__title">{t('widget.title')}</h3>
209
+ <p className="my-widget__count">
210
+ {t('widget.row-count', { count: String(rows.length) })}
211
+ </p>
212
+ </div>
213
+ );
214
+ };
215
+ ```
216
+
217
+ `$L(key, params?)` returns the translation for the portal's active language. `params` is a `Record<string, string>` — each key replaces a `{placeholder}` in the translation string. If a key is missing from `localization.json`, the raw key is returned.
218
+
219
+ In the dev harness, `icanContext` is `undefined` so the `?? ((key) => key)` fallback returns the raw key — fine for development.
220
+
221
+ ---
222
+
223
+ ## 6. `instanceId` — multi-instance widgets
224
+
225
+ The same widget can be placed on a dashboard more than once. Each placement is an independent **instance** with its own `instanceId` prop. The critical thing to understand is that **all instances share the same JavaScript module** — variables declared outside your component are shared by every instance.
226
+
227
+ ### The bug: module-level state
228
+
229
+ ```tsx
230
+ // ❌ wrong — module-level variable, shared by ALL instances on the page
231
+ let cachedData: unknown[] = [];
232
+
233
+ const MyWidget = () => {
234
+ // Instance A and Instance B both read and write the same cachedData
235
+ };
236
+ ```
237
+
238
+ ### The fix: React state is always instance-local
239
+
240
+ ```tsx
241
+ // ✅ correct — useState creates independent state per instance
242
+ const MyWidget = () => {
243
+ const [data, setData] = React.useState<unknown[]>([]);
244
+ // Instance A and Instance B each have their own data array
245
+ };
246
+ ```
247
+
248
+ Any variable declared **outside** the component function — including module-level caches, refs, or counters — is shared across every placement. Always use `React.useState`, `React.useRef`, or `React.useReducer` for anything that should be independent per instance.
249
+
250
+ ```tsx
251
+ const MyWidget: React.FC<IWidgetProps> = ({ icanContext, instanceId }) => {
252
+ // instanceId is unique per placement, e.g. "dw_abc123" vs "dw_def456"
253
+ // Useful for logging, analytics, or keying external subscriptions
254
+ };
255
+ ```
256
+
257
+ ---
258
+
259
+ ## 7. `cancelPrevious` and `key` — preventing overlapping calls
260
+
261
+ When a user types in a search input, multiple requests can be in flight simultaneously. The last one to **respond** wins — not the last one **sent** — so results can jump out of order.
262
+
263
+ Use `cancelPrevious: true` and `key` together to abort the previous call before firing a new one:
264
+
265
+ ```tsx
266
+ const handleSearch = async (query: string): Promise<void> => {
267
+ if (!icanContext) return;
268
+ setLoading(true);
269
+ try {
270
+ const results = await icanContext.executeAction(
271
+ 'SearchModel',
272
+ 'Query',
273
+ { q: query },
274
+ {
275
+ json: true,
276
+ key: 'widget-search', // identifies this "call slot"
277
+ cancelPrevious: true, // cancels any in-flight call with the same key
278
+ }
279
+ );
280
+ setResults(results as SearchResult[]);
281
+ } catch {
282
+ // A cancelled call throws — swallow it
283
+ } finally {
284
+ setLoading(false));
285
+ }
286
+ };
287
+ ```
288
+
289
+ Use a distinct `key` per logical operation (`'search'`, `'load-table'`, `'refresh'`). `cancelPrevious` only cancels in-flight calls with the **same key** — other concurrent calls are unaffected.
290
+
291
+ ---
292
+
293
+ ## 8. Externalized dependencies — React and `ican/components`
294
+
295
+ Both `react` and `ican/components` are **externalized** in `webpack.config.js`. This means:
296
+
297
+ - They are **not bundled** into your `dist/main.js`
298
+ - The portal provides them at runtime from its own copies
299
+ - You cannot bundle a different version of React than the one the portal ships
300
+
301
+ ```bash
302
+ # ✅ safe — externalised, portal provides at runtime
303
+ import { useState } from 'react';
304
+ import { Card, Button } from 'ican/components';
305
+
306
+ # ❌ dangerous — if a library bundles its own React you get two Reacts running
307
+ npm install some-ui-lib-that-bundles-react
308
+ # → Silent crash: "Invalid hook call" or React Error #321
309
+ ```
310
+
311
+ **Before installing any npm package**, check whether it externalises React:
312
+
313
+ | Package type | Safe? |
314
+ |---|---|
315
+ | Utilities (`clsx`, `date-fns`, `lodash`, `zod`) | ✅ No React dependency |
316
+ | Official React ecosystem (`swr`, `zustand`, `react-use`) | ✅ Peer-depend on React |
317
+ | Most chart libs (`recharts`, `victory`, `nivo`) | ⚠️ Bundle their own React — may crash |
318
+ | `@radix-ui/*`, `@floating-ui/*` | ✅ Peer-depend on React |
319
+
320
+ If you need charts, prefer libraries with a pure SVG/data interface (D3, Visx) or use the chart components already provided by `ican/components`.
321
+
322
+ ---
323
+
324
+ ## 9. `bundle.json` full field reference
325
+
326
+ ```jsonc
327
+ {
328
+ "id": "my-bundle", // Unique bundle key — NEVER change after first upload
329
+ "name": "My Bundle", // Display name in the Widget Library
330
+ "version": "1.0.0", // SemVer — bump on every upload
331
+ "author": "your-name",
332
+
333
+ "widgets": [
334
+ {
335
+ "id": "my-widget", // Widget identifier within this bundle
336
+ "name": "My Widget", // Display name in the Add Widget modal
337
+ "description": "...",
338
+ "icon": "", // Emoji or icon key
339
+ "tags": ["finance"], // Used for filtering in the Widget Library
340
+ "category": "Analytics",
341
+ "isTemplate": false // true = shown in the template gallery
342
+ }
343
+ ],
344
+
345
+ "sidebarLinks": [ // Links added to the portal sidebar nav
346
+ {
347
+ "id": "my-link",
348
+ "label": "My Tool",
349
+ "description": "Opens this widget's companion page",
350
+ "target": "/my-page", // Portal-relative URL
351
+ "icon": "chart",
352
+ "roles": ["admin"] // Empty array = visible to all roles
353
+ }
354
+ ],
355
+
356
+ "uis": [ // Full-page UIs registered by this bundle
357
+ {
358
+ "id": "my-ui",
359
+ "label": "My Page",
360
+ "description": "Full-screen page for this bundle",
361
+ "roles": []
362
+ }
363
+ ],
364
+
365
+ "menuItems": [ // Items added to the portal top navigation menu
366
+ {
367
+ "id": "my-menu-item",
368
+ "title": "My Feature"
369
+ }
370
+ ]
371
+ }
372
+ ```
373
+
374
+ **Critical rule:** Never change `bundle.id` after the first upload. The portal uses it as the primary key. Changing it orphans every existing dashboard widget that references the old ID — they become unresolvable and show an error cell.
375
+
376
+ ---
377
+
378
+ ## 10. Versioning and re-upload
379
+
380
+ | Scenario | Action |
381
+ |---|---|
382
+ | Bug fix / style change | Keep same `bundle.id` + `widget.id`. Bump `version`. Re-upload `dist/main.js` + `bundle.json`. All existing widget placements update automatically on next page load. |
383
+ | Adding a new widget to an existing bundle | Keep same `bundle.id`. Add new entry to `widgets[]`. Bump `version`. Re-upload both files. |
384
+ | Changing config props (adding/removing fields) | Keep same IDs. Bump `version`. Existing instances keep their old saved values; new or unconfigured fields use the `value` default from `configs.props`. |
385
+ | Breaking change (completely new widget, different product) | Use a new `bundle.id`. Old placements are orphaned and must be removed manually from dashboards. |
386
+
387
+ Re-uploading replaces the script atomically — users see the new version after their next page load or hard refresh.
388
+
389
+ ---
390
+
391
+ ## 11. AI-agent quick context block
392
+
393
+ > Read this before generating any functional widget code.
394
+
395
+ **Rules:**
396
+ 1. `icanContext` may be `undefined`. Always guard: `if (!icanContext) { setLoading(false); return; }`.
397
+ 2. `icanContext.environment === 'dev'` in the harness, `'prod'` in the portal. Always include a mock-data branch for `'dev'`.
398
+ 3. React and `ican/components` are externalized — never recommend a library that bundles its own React.
399
+ 4. Module-level variables are shared across all instances of the same widget on a dashboard. Always use `useState`/`useRef` for per-instance state.
400
+ 5. Never change `bundle.id` after first upload.
401
+
402
+ **`registerWidget` must always include:**
403
+ - `configs.layout` with sensible `w`, `h`, `minW`, `minH` values.
404
+ - `configs.props` for every user-configurable value, typed to match the component's prop interface.
405
+ - Default values in `configs.props[n].value` must match the component's TypeScript default.
406
+
407
+ **`$L()` pattern:**
408
+ ```tsx
409
+ const t = icanContext?.$L.bind(icanContext) ?? ((key: string) => key);
410
+ ```
411
+
412
+ **`hasAppRole` pattern:**
413
+ ```tsx
414
+ const isAdmin = icanContext?.hasAppRole('AppName', 'admin') ?? false;
415
+ ```
416
+
417
+ **Full functional widget skeleton (copy-paste ready):**
418
+
419
+ ```tsx
420
+ import * as React from 'react';
421
+ import { Card, Button } from 'ican/components';
422
+ import { registerWidget } from './ican';
423
+ import type { IContextProvider } from './ican';
424
+ import './styles.scss';
425
+
426
+ interface IItem { id: string; name: string; }
427
+
428
+ interface IWidgetProps {
429
+ icanContext?: IContextProvider;
430
+ instanceId?: string;
431
+ title?: string;
432
+ maxRows?: number;
433
+ [key: string]: unknown;
434
+ }
435
+
436
+ const MyWidget: React.FC<IWidgetProps> = ({
437
+ icanContext,
438
+ title = 'My Widget',
439
+ maxRows = 10,
440
+ }) => {
441
+ const [data, setData] = React.useState<IItem[]>([]);
442
+ const [loading, setLoading] = React.useState(true);
443
+ const [error, setError] = React.useState<string | null>(null);
444
+
445
+ const t = icanContext?.$L.bind(icanContext) ?? ((key: string) => key);
446
+ const canEdit = icanContext?.hasAppRole('MyApp', 'editor') ?? false;
447
+
448
+ React.useEffect(() => {
449
+ if (!icanContext) { setLoading(false); return; }
450
+
451
+ if (icanContext.environment === 'dev') {
452
+ setData([{ id: '1', name: 'Mock Item' }]);
453
+ setLoading(false);
454
+ return;
455
+ }
456
+
457
+ icanContext
458
+ .executeAction('MyModel', 'GetAll', { limit: maxRows }, { json: true })
459
+ .then((res) => { setData(res as IItem[]); })
460
+ .catch((err: unknown) => { setError(String(err)); })
461
+ .finally(() => setLoading(false));
462
+ }, [icanContext, maxRows]);
463
+
464
+ if (loading) return <div className="my-widget my-widget--loading"><div className="my-widget__spinner" /></div>;
465
+ if (error) return <div className="my-widget my-widget--error"><p className="my-widget__error">{error}</p></div>;
466
+
467
+ return (
468
+ <div className="my-widget">
469
+ <Card>
470
+ <h3 className="my-widget__title">{title}</h3>
471
+ <ul className="my-widget__list">
472
+ {data.map((item) => (
473
+ <li key={item.id} className="my-widget__item">{item.name}</li>
474
+ ))}
475
+ </ul>
476
+ {canEdit && (
477
+ <Button variant="primary" label={t('widget.edit-btn')} onClick={() => {}} />
478
+ )}
479
+ </Card>
480
+ </div>
481
+ );
482
+ };
483
+
484
+ registerWidget({
485
+ id: 'my-widget',
486
+ widget: MyWidget,
487
+ configs: {
488
+ layout: { w: 10, h: 8, minW: 4, minH: 4 },
489
+ props: [
490
+ { name: 'title', label: 'Widget Title', type: 'text', value: 'My Widget' },
491
+ { name: 'maxRows', label: 'Max Rows', type: 'number', value: 10,
492
+ validate: { minVal: 1, maxVal: 100 } },
493
+ ],
494
+ },
495
+ });
496
+ ```
497
+
498
+ ---
499
+
500
+ *Placed in your project root by `infaira-canvas init`. Applies to `infaira-canvas` v0.1.9+.*