|
| 1 | +# CALM Widgets Framework |
| 2 | + |
| 3 | +A TypeScript widget system built on Handlebars that provides reusable components for generating Markdown documentation. The framework allows you to create custom widgets that can transform data into formatted output using Handlebars templates. |
| 4 | + |
| 5 | +## 🔧 Built-in Widgets |
| 6 | + |
| 7 | +### Table Widget |
| 8 | + |
| 9 | +Renders data as Markdown tables with support for nested objects and column filtering. |
| 10 | + |
| 11 | +```handlebars |
| 12 | +{{!-- Basic table with headers --}} |
| 13 | +{{table services}} |
| 14 | +
|
| 15 | +{{!-- Table without headers --}} |
| 16 | +{{table services headers=false}} |
| 17 | +
|
| 18 | +{{!-- Filter specific columns --}} |
| 19 | +{{table services columns="name,port,version" key="id"}} |
| 20 | +``` |
| 21 | + |
| 22 | +**Options:** |
| 23 | +- `headers` (boolean): Show/hide table headers (default: true) |
| 24 | +- `columns` (string): Comma-separated list of columns to include |
| 25 | +- `key` (string): Property to use as unique identifier (default: "unique-id") |
| 26 | + |
| 27 | +### List Widget |
| 28 | + |
| 29 | +Renders arrays as Markdown lists (ordered or unordered). |
| 30 | + |
| 31 | +```handlebars |
| 32 | +{{!-- Unordered list --}} |
| 33 | +{{list features}} |
| 34 | +
|
| 35 | +{{!-- Ordered list --}} |
| 36 | +{{list steps ordered=true}} |
| 37 | +
|
| 38 | +{{!-- Extract specific property from objects --}} |
| 39 | +{{list services property="name"}} |
| 40 | +``` |
| 41 | + |
| 42 | +**Options:** |
| 43 | +- `ordered` (boolean): Create numbered list (default: false) |
| 44 | +- `property` (string): Extract specific property from objects |
| 45 | + |
| 46 | +### JSON Viewer Widget |
| 47 | + |
| 48 | +Renders data as formatted JSON blocks. |
| 49 | + |
| 50 | +```handlebars |
| 51 | +{{!-- Simple JSON output --}} |
| 52 | +{{json-viewer config}} |
| 53 | +``` |
| 54 | + |
| 55 | +## 🛠️ Creating Custom Widgets |
| 56 | + |
| 57 | +### 1. Widget Definition |
| 58 | + |
| 59 | +Create a widget by implementing the `CalmWidget` interface: |
| 60 | + |
| 61 | +```typescript |
| 62 | +// src/widgets/my-widget/index.ts |
| 63 | +import { CalmWidget } from '@finos/calm-widgets'; |
| 64 | + |
| 65 | +export interface MyWidgetContext { |
| 66 | + title: string; |
| 67 | + items: string[]; |
| 68 | +} |
| 69 | + |
| 70 | +export interface MyWidgetOptions { |
| 71 | + showCount?: boolean; |
| 72 | + prefix?: string; |
| 73 | +} |
| 74 | + |
| 75 | +export interface MyWidgetViewModel { |
| 76 | + title: string; |
| 77 | + items: string[]; |
| 78 | + count?: number; |
| 79 | + prefix: string; |
| 80 | +} |
| 81 | + |
| 82 | +export const MyWidget: CalmWidget< |
| 83 | + MyWidgetContext, |
| 84 | + MyWidgetOptions, |
| 85 | + MyWidgetViewModel |
| 86 | +> = { |
| 87 | + id: 'my-widget', |
| 88 | + templatePartial: 'my-widget-template.html', |
| 89 | + |
| 90 | + // Optional: additional template partials |
| 91 | + partials: ['item-template.html'], |
| 92 | + |
| 93 | + // Transform input data to view model |
| 94 | + transformToViewModel: (context, options) => { |
| 95 | + const showCount = options?.hash?.showCount ?? false; |
| 96 | + const prefix = options?.hash?.prefix ?? '•'; |
| 97 | + |
| 98 | + return { |
| 99 | + title: context.title, |
| 100 | + items: context.items, |
| 101 | + count: showCount ? context.items.length : undefined, |
| 102 | + prefix |
| 103 | + }; |
| 104 | + }, |
| 105 | + |
| 106 | + // Validate input context |
| 107 | + validateContext: (context): context is MyWidgetContext => { |
| 108 | + return ( |
| 109 | + typeof context === 'object' && |
| 110 | + context !== null && |
| 111 | + typeof (context as any).title === 'string' && |
| 112 | + Array.isArray((context as any).items) && |
| 113 | + (context as any).items.every((item: any) => typeof item === 'string') |
| 114 | + ); |
| 115 | + }, |
| 116 | + |
| 117 | + // Optional: register custom helpers |
| 118 | + registerHelpers: () => ({ |
| 119 | + upperCase: (str: string) => str.toUpperCase(), |
| 120 | + repeat: (str: string, count: number) => str.repeat(count) |
| 121 | + }) |
| 122 | +}; |
| 123 | +``` |
| 124 | + |
| 125 | +### 2. Template Files |
| 126 | + |
| 127 | +Create Handlebars templates for your widget: |
| 128 | + |
| 129 | +```handlebars |
| 130 | +<!-- src/widgets/my-widget/my-widget-template.html --> |
| 131 | +## {{title}} |
| 132 | +{{#if count}} |
| 133 | +*Total items: {{count}}* |
| 134 | +{{/if}} |
| 135 | +
|
| 136 | +{{#each items}} |
| 137 | +{{../prefix}} {{upperCase this}} |
| 138 | +{{/each}} |
| 139 | +``` |
| 140 | + |
| 141 | +```handlebars |
| 142 | +<!-- src/widgets/my-widget/item-template.html --> |
| 143 | +{{prefix}} **{{upperCase this}}** |
| 144 | +``` |
| 145 | + |
| 146 | +### 3. Widget Tests |
| 147 | + |
| 148 | +Create comprehensive tests for your widget: |
| 149 | + |
| 150 | +```typescript |
| 151 | +// src/widgets/my-widget/index.spec.ts |
| 152 | +import { describe, it, expect } from 'vitest'; |
| 153 | +import { MyWidget } from './index'; |
| 154 | + |
| 155 | +describe('MyWidget', () => { |
| 156 | + describe('validateContext', () => { |
| 157 | + it('accepts valid context', () => { |
| 158 | + const context = { |
| 159 | + title: 'Test Title', |
| 160 | + items: ['item1', 'item2'] |
| 161 | + }; |
| 162 | + expect(MyWidget.validateContext(context)).toBe(true); |
| 163 | + }); |
| 164 | + |
| 165 | + it('rejects invalid context', () => { |
| 166 | + expect(MyWidget.validateContext(null)).toBe(false); |
| 167 | + expect(MyWidget.validateContext({ title: 123 })).toBe(false); |
| 168 | + }); |
| 169 | + }); |
| 170 | + |
| 171 | + describe('transformToViewModel', () => { |
| 172 | + it('transforms context correctly', () => { |
| 173 | + const context = { title: 'Test', items: ['a', 'b'] }; |
| 174 | + const options = { hash: { showCount: true, prefix: '-' } }; |
| 175 | + |
| 176 | + const result = MyWidget.transformToViewModel!(context, options); |
| 177 | + |
| 178 | + expect(result).toEqual({ |
| 179 | + title: 'Test', |
| 180 | + items: ['a', 'b'], |
| 181 | + count: 2, |
| 182 | + prefix: '-' |
| 183 | + }); |
| 184 | + }); |
| 185 | + }); |
| 186 | +}); |
| 187 | +``` |
| 188 | + |
| 189 | +### 4. Test Fixtures |
| 190 | + |
| 191 | +Create test fixtures to verify widget output: |
| 192 | + |
| 193 | +```json |
| 194 | +// test-fixtures/my-widget/basic-example/context.json |
| 195 | +{ |
| 196 | + "title": "My Items", |
| 197 | + "items": ["First Item", "Second Item", "Third Item"] |
| 198 | +} |
| 199 | +``` |
| 200 | + |
| 201 | +```handlebars |
| 202 | +{{!-- test-fixtures/my-widget/basic-example/template.hbs --}} |
| 203 | +{{my-widget . showCount=true prefix="→"}} |
| 204 | +``` |
| 205 | + |
| 206 | +```markdown |
| 207 | +<!-- test-fixtures/my-widget/basic-example/expected.md --> |
| 208 | +## My Items |
| 209 | +*Total items: 3* |
| 210 | + |
| 211 | +→ FIRST ITEM |
| 212 | +→ SECOND ITEM |
| 213 | +→ THIRD ITEM |
| 214 | +``` |
| 215 | + |
| 216 | +### 5. Register Your Widget |
| 217 | + |
| 218 | +Add your widget to the engine: |
| 219 | + |
| 220 | +```typescript |
| 221 | +import { MyWidget } from './widgets/my-widget'; |
| 222 | + |
| 223 | +// Register individual widget |
| 224 | +engine.setupWidgets([{ |
| 225 | + widget: MyWidget, |
| 226 | + folder: __dirname + '/widgets/my-widget' |
| 227 | +}]); |
| 228 | + |
| 229 | +// Or extend registerDefaultWidgets |
| 230 | +class MyWidgetEngine extends WidgetEngine { |
| 231 | + registerDefaultWidgets() { |
| 232 | + super.registerDefaultWidgets(); |
| 233 | + |
| 234 | + this.setupWidgets([{ |
| 235 | + widget: MyWidget, |
| 236 | + folder: __dirname + '/widgets/my-widget' |
| 237 | + }]); |
| 238 | + } |
| 239 | +} |
| 240 | +``` |
| 241 | + |
| 242 | +## 🧪 Testing |
| 243 | + |
| 244 | +The framework includes comprehensive testing utilities: |
| 245 | + |
| 246 | +### Running Tests |
| 247 | + |
| 248 | +```bash |
| 249 | +# Run all tests |
| 250 | +npm test |
| 251 | + |
| 252 | +# Run specific widget tests |
| 253 | +npm test -- my-widget |
| 254 | + |
| 255 | +# Run with coverage |
| 256 | +npm run test:coverage |
| 257 | +``` |
| 258 | + |
| 259 | +### Test Fixtures |
| 260 | + |
| 261 | +Use the fixture system for consistent testing: |
| 262 | + |
| 263 | +```typescript |
| 264 | +import { FixtureLoader } from './test-utils/fixture-loader'; |
| 265 | + |
| 266 | +const fixtures = new FixtureLoader(); |
| 267 | +const { context, template, expected } = fixtures.loadFixture('my-widget', 'basic-example'); |
| 268 | + |
| 269 | +const compiledTemplate = handlebars.compile(template); |
| 270 | +const result = compiledTemplate(context); |
| 271 | + |
| 272 | +expect(result.trim()).toBe(expected); |
| 273 | +``` |
| 274 | + |
| 275 | +### Updating Fixtures |
| 276 | + |
| 277 | +Use the fixture update script to regenerate expected outputs: |
| 278 | + |
| 279 | +```bash |
| 280 | +npx tsx src/scripts/update-fixtures.ts |
| 281 | +``` |
| 282 | + |
| 283 | +## 🔍 Architecture |
| 284 | + |
| 285 | +### Core Components |
| 286 | + |
| 287 | +- **WidgetEngine**: Orchestrates widget registration and setup |
| 288 | +- **WidgetRegistry**: Manages widget storage and Handlebars partial registration |
| 289 | +- **WidgetRenderer**: Handles widget rendering with context validation |
| 290 | +- **Widget Helpers**: Global Handlebars helpers available to all widgets |
| 291 | + |
| 292 | +### Helper Functions |
| 293 | + |
| 294 | +The framework provides built-in helpers: |
| 295 | + |
| 296 | +- `eq`, `ne`: Equality comparisons |
| 297 | +- `lookup`: Property access |
| 298 | +- `json`: JSON stringification |
| 299 | +- `kebabToTitleCase`: Convert "api-service" → "Api Service" |
| 300 | +- `kebabCase`: Convert "API Service" → "api-service" |
| 301 | +- `isObject`, `isArray`: Type checking |
| 302 | +- `notEmpty`: Check for non-empty values |
| 303 | +- `or`: Logical OR operations |
| 304 | +- `currentTimestamp`, `currentDate`: Date utilities |
| 305 | +- `instanceOf`: Constructor name checking |
| 306 | +- `eachInMap`: Object iteration |
| 307 | + |
| 308 | +### Type Safety |
| 309 | + |
| 310 | +The framework uses TypeScript generics for type-safe widgets: |
| 311 | + |
| 312 | +```typescript |
| 313 | +CalmWidget<TContext, TOptions, TViewModel> |
| 314 | +``` |
| 315 | + |
| 316 | +- `TContext`: Input data type |
| 317 | +- `TOptions`: Handlebars options/parameters |
| 318 | +- `TViewModel`: Transformed data for template |
| 319 | + |
| 320 | +## 📝 Best Practices |
| 321 | + |
| 322 | +### Widget Design |
| 323 | + |
| 324 | +1. **Keep widgets focused**: Each widget should have a single responsibility |
| 325 | +2. **Validate inputs**: Always implement robust `validateContext` |
| 326 | +3. **Transform data**: Use `transformToViewModel` to prepare data for templates |
| 327 | +4. **Handle errors gracefully**: Provide meaningful error messages |
| 328 | +5. **Test thoroughly**: Include unit tests and integration fixtures |
| 329 | + |
| 330 | +### Template Guidelines |
| 331 | + |
| 332 | +1. **Use semantic markup**: Generate clean, readable Markdown |
| 333 | +2. **Handle empty data**: Gracefully handle missing or empty inputs |
| 334 | +3. **Be consistent**: Follow established patterns from built-in widgets |
| 335 | +4. **Optimize performance**: Avoid complex logic in templates |
| 336 | + |
| 337 | +### Testing Strategy |
| 338 | + |
| 339 | +1. **Unit test widget logic**: Test `validateContext` and `transformToViewModel` |
| 340 | +2. **Integration test output**: Use fixtures to verify rendered output |
| 341 | +3. **Test edge cases**: Handle null, undefined, and malformed data |
| 342 | +4. **Maintain fixtures**: Keep expected outputs up to date |
| 343 | + |
| 344 | +## 🤝 Contributing |
| 345 | + |
| 346 | +1. **Create your widget** following the structure above |
| 347 | +2. **Add comprehensive tests** including fixtures |
| 348 | +3. **Update documentation** if adding new concepts |
| 349 | +4. **Follow code style** using the project's ESLint configuration |
| 350 | +5. **Test thoroughly** with `npm test` |
0 commit comments