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.
- package/README.md +264 -0
- package/dist/commands/init.d.ts +17 -0
- package/dist/commands/init.js +647 -0
- package/dist/commands/upload.d.ts +8 -0
- package/dist/commands/upload.js +164 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +123 -0
- package/package.json +44 -0
- package/templates/ICan-Customizing-Components.md +195 -0
- package/templates/ICan-Widget-Development-Guide.md +500 -0
- package/templates/ICan-Widget-Styling-Patterns.md +890 -0
- package/templates/ICan-Widget-Theming-Guide.md +633 -0
- package/templates/README.md +127 -0
- package/templates/designer.d.ts +468 -0
- package/templates/ican.d.ts +763 -0
- package/templates/index.html +2225 -0
- package/templates/resources/favicon.ico +2 -0
- package/templates/resources/ican-components.js +1734 -0
- package/templates/resources/infaira-icon.png +0 -0
- package/templates/resources/infaira-logo.png +0 -0
- package/templates/site.webmanifest +17 -0
- package/templates/ui.html +1670 -0
|
@@ -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+.*
|