Skip to content

Commit 364d4a5

Browse files
committed
feat(calm-widgets): table enhancements to support flat vertical tables
1 parent 5d04ec8 commit 364d4a5

File tree

15 files changed

+401
-176
lines changed

15 files changed

+401
-176
lines changed

calm-widgets/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ Renders data as Markdown tables with support for nested objects and column filte
1717
1818
{{!-- Filter specific columns --}}
1919
{{table services columns="name,port,version" key="id"}}
20-
```
2120
21+
{{table services oritentation='vertical' columns="name,status"}}
22+
```
2223
**Options:**
24+
- `oritentation` (vertical | horizontal): table layout (default: horizontal)
2325
- `headers` (boolean): Show/hide table headers (default: true)
2426
- `columns` (string): Comma-separated list of columns to include
2527
- `key` (string): Property to use as unique identifier (default: "unique-id")

calm-widgets/src/widgets.e2e.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ describe('Widgets E2E - Handlebars Integration', () => {
5454

5555
expect(result.trim()).toBe(expected);
5656
});
57+
58+
it('renders an flat vertical table', () => {
59+
const { context, template, expected } = fixtures.loadFixture('table-widget', 'flat-vertical-table');
60+
61+
const compiledTemplate = handlebars.compile(template);
62+
const result = compiledTemplate(context);
63+
64+
expect(result.trim()).toBe(expected);
65+
});
5766
});
5867

5968
describe('List Widget', () => {

calm-widgets/src/widgets/table/index.spec.ts

Lines changed: 112 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -26,122 +26,146 @@ describe('TableWidget', () => {
2626
});
2727
});
2828

29-
describe('transformToViewModel', () => {
29+
describe('transformToViewModel (array context)', () => {
3030
const data = [
3131
{ 'unique-id': '1', name: 'Alice' },
3232
{ 'unique-id': '2', name: 'Bob', extra: undefined },
3333
{ 'unique-id': '', name: 'Empty ID' },
3434
{ name: 'No ID' },
35-
{ 'unique-id': null }
35+
{ 'unique-id': null },
3636
];
3737

3838
it('transforms array with default options', () => {
3939
const vm = TableWidget.transformToViewModel!(data, {});
40-
expect(vm.rows.length).toBe(5); // Now processes all valid objects
4140
expect(vm.headers).toBe(true);
42-
expect(vm.rows[0]).toEqual({
43-
id: '1', // Uses the unique-id value
44-
data: { 'unique-id': '1', name: 'Alice' }
45-
});
46-
expect(vm.rows[1]).toEqual({
47-
id: '2',
48-
data: { 'unique-id': '2', name: 'Bob' }
49-
});
50-
expect(vm.rows[2]).toEqual({
51-
id: '2', // Uses array index as fallback for empty unique-id
52-
data: { 'unique-id': '', name: 'Empty ID' }
53-
});
54-
expect(vm.rows[3]).toEqual({
55-
id: '3', // Uses array index as fallback for missing unique-id
56-
data: { name: 'No ID' }
57-
});
58-
expect(vm.rows[4]).toEqual({
59-
id: '4', // Uses array index as fallback for null unique-id
60-
data: { 'unique-id': null }
61-
});
62-
});
63-
64-
it('transforms object into entries', () => {
65-
const input = {
66-
foo: { name: 'Foo' },
67-
bar: { name: 'Bar' }
68-
};
69-
const vm = TableWidget.transformToViewModel!(input, {});
70-
expect(vm.rows.length).toBe(2);
71-
expect(vm.rows[0].id).toBe('foo');
72-
expect(vm.rows[0].data).toEqual({ name: 'Foo', 'unique-id': 'foo' });
41+
expect(vm.flatTable).toBe(false);
42+
expect(vm.rows.length).toBe(5);
43+
expect(vm.rows[0]).toEqual({ id: '1', data: { 'unique-id': '1', name: 'Alice' } });
44+
expect(vm.rows[1]).toEqual({ id: '2', data: { 'unique-id': '2', name: 'Bob' } });
45+
expect(vm.rows[2]).toEqual({ id: '2', data: { 'unique-id': '', name: 'Empty ID' } });
46+
expect(vm.rows[3]).toEqual({ id: '3', data: { name: 'No ID' } });
47+
expect(vm.rows[4]).toEqual({ id: '4', data: { 'unique-id': null } });
48+
});
49+
50+
it('trims whitespace id and falls back to index', () => {
51+
const vm = TableWidget.transformToViewModel!([{ 'unique-id': ' ', name: 'X' }], {});
52+
expect(vm.rows[0]).toEqual({ id: '0', data: { 'unique-id': ' ', name: 'X' } });
53+
});
54+
55+
it('respects headers option = false', () => {
56+
const vm = TableWidget.transformToViewModel!(data, { headers: false });
57+
expect(vm.headers).toBe(false);
58+
});
59+
60+
it('filters columns correctly and sets flatTable', () => {
61+
const vm = TableWidget.transformToViewModel!(data, { columns: 'name' });
62+
expect(vm.flatTable).toBe(true);
63+
expect(vm.columnNames).toEqual(['name']);
64+
expect(vm.rows[0]).toEqual({ id: '1', data: { name: 'Alice' } });
65+
});
66+
67+
it('filters columns and keeps key out of data', () => {
68+
const vm = TableWidget.transformToViewModel!(data, { columns: 'name', key: 'unique-id' });
69+
expect(vm.rows[0]).toEqual({ id: '1', data: { name: 'Alice' } });
70+
});
71+
72+
it('injects unique-id into data when requested in columns', () => {
73+
const vm = TableWidget.transformToViewModel!([{ name: 'Z' }], { columns: 'unique-id, name' });
74+
expect(vm.rows[0]).toEqual({ id: '0', data: { 'unique-id': '0', name: 'Z' } });
75+
});
76+
77+
it('injects id into data when requested in columns', () => {
78+
const vm = TableWidget.transformToViewModel!([{ name: 'Y' }], { columns: 'id, name' });
79+
expect(vm.rows[0]).toEqual({ id: '0', data: { id: '0', name: 'Y' } });
7380
});
7481

7582
it('uses custom key', () => {
76-
const custom = [{ key: 'abc', name: 'Test' }];
77-
const vm = TableWidget.transformToViewModel!(custom, {
78-
key: 'key'
79-
});
83+
const vm = TableWidget.transformToViewModel!([{ key: 'abc', name: 'Test' }], { key: 'key' });
8084
expect(vm.rows[0].id).toBe('abc');
85+
expect(vm.rows[0].data).toEqual({ key: 'abc', name: 'Test' });
86+
});
87+
88+
it('handles missing/non-string key by falling back to index', () => {
89+
const vm = TableWidget.transformToViewModel!([{ id: 123 }, { id: null }, {}], { key: 'id' });
90+
expect(vm.rows.length).toBe(3);
91+
expect(vm.rows[0].id).toBe('123'); // numeric id is stringified, not dropped
92+
expect(vm.rows[1].id).toBe('1'); // null -> fallback
93+
expect(vm.rows[2].id).toBe('2'); // missing -> fallback
8194
});
95+
});
8296

83-
it('skips records with missing or non-string key', () => {
84-
const invalid = [{ id: 123 }, { id: null }, {}];
85-
const vm = TableWidget.transformToViewModel!(invalid, {
86-
key: 'id'
87-
});
88-
expect(vm.rows.length).toBe(3); // All objects processed with fallback indices
89-
expect(vm.rows[0].id).toBe('0'); // Uses array index
90-
expect(vm.rows[1].id).toBe('1');
91-
expect(vm.rows[2].id).toBe('2');
97+
describe('transformToViewModel (object context)', () => {
98+
it('expands object entries where values are objects and attaches key', () => {
99+
const input = { foo: { name: 'Foo' }, bar: { name: 'Bar' } };
100+
const vm = TableWidget.transformToViewModel!(input, {});
101+
expect(vm.flatTable).toBe(false);
102+
expect(vm.rows.length).toBe(2);
103+
expect(vm.rows[0]).toEqual({ id: 'foo', data: { name: 'Foo', 'unique-id': 'foo' } });
104+
expect(vm.rows[1]).toEqual({ id: 'bar', data: { name: 'Bar', 'unique-id': 'bar' } });
92105
});
93106

94-
it('respects headers option = false', () => {
95-
const vm = TableWidget.transformToViewModel!(data, {
96-
headers: false
97-
});
98-
expect(vm.headers).toBe(false);
107+
it('expands primitive values into { value, key } records', () => {
108+
const input = { a: 1, b: 'x', c: { y: 2 } };
109+
const vm = TableWidget.transformToViewModel!(input, {});
110+
const asMap = Object.fromEntries(vm.rows.map(r => [r.id, r.data]));
111+
expect(asMap['a']).toEqual({ value: 1, 'unique-id': 'a' });
112+
expect(asMap['b']).toEqual({ value: 'x', 'unique-id': 'b' });
113+
expect(asMap['c']).toEqual({ y: 2, 'unique-id': 'c' });
99114
});
100115

101-
it('filters columns correctly', () => {
102-
const vm = TableWidget.transformToViewModel!(data, {
103-
columns: 'name'
104-
});
105-
expect(vm.rows[0].data).toEqual({ name: 'Alice' });
116+
it('single-row with columns when object itself has those columns', () => {
117+
const input = { a: 1, b: 2, 'unique-id': 'row1', extra: undefined };
118+
const vm = TableWidget.transformToViewModel!(input, { columns: 'a, b' });
119+
expect(vm.flatTable).toBe(true);
120+
expect(vm.rows.length).toBe(1);
121+
expect(vm.rows[0]).toEqual({ id: '0', data: { a: 1, b: 2 } });
106122
});
107123

108-
it('filters columns and keeps key out of data', () => {
109-
const vm = TableWidget.transformToViewModel!(data, {
110-
columns: 'name', key: 'unique-id'
111-
});
112-
expect(vm.rows[0]).toEqual({
113-
id: '1',
114-
data: { name: 'Alice' }
115-
});
116-
});
117-
118-
it('works with object and columns', () => {
119-
const input = {
120-
foo: { a: 1, b: 2 },
121-
bar: { a: 3, b: 4 }
122-
};
123-
const vm = TableWidget.transformToViewModel!(input, {
124-
columns: 'a'
125-
});
126-
expect(vm.rows[0].data).toEqual({ a: 1 });
124+
125+
it('object with nested values and columns does not flatten (flatTable=false)', () => {
126+
const input = { foo: { a: 1, b: 2 }, bar: { a: 3, b: 4 } };
127+
const vm = TableWidget.transformToViewModel!(input, { columns: 'a' });
128+
expect(vm.flatTable).toBe(false);
129+
expect(vm.rows[0]).toEqual({ id: 'foo', data: { a: 1, b: 2, 'unique-id': 'foo' } });
130+
expect(vm.rows[1]).toEqual({ id: 'bar', data: { a: 3, b: 4, 'unique-id': 'bar' } });
131+
});
132+
133+
it('orientation="vertical" keeps single row; with columns uses only those fields', () => {
134+
const input = { a: 1, b: 2, c: 3 };
135+
const vm = TableWidget.transformToViewModel!(input, { orientation: 'vertical', columns: 'a, c' });
136+
expect(vm.isVertical).toBe(true);
137+
expect(vm.flatTable).toBe(true);
138+
expect(vm.rows.length).toBe(1);
139+
expect(vm.rows[0]).toEqual({ id: '0', data: { a: 1, c: 3 } });
127140
});
128141
});
129142

130-
describe('registerHelpers', () => {
131-
const helpers = TableWidget.registerHelpers?.();
132-
const fn = helpers?.objectEntries;
143+
describe('error handling', () => {
144+
it('throws on unsupported context', () => {
145+
// @ts-expect-error – intentionally passing wrong type to validate error branch
146+
expect(() => TableWidget.transformToViewModel!('nope', {})).toThrow();
147+
});
148+
});
133149

134-
it('objectEntries returns id/data pairs', () => {
135-
const result = fn?.({ a: 1, b: 2 });
136-
expect(result).toEqual([
150+
describe('registerHelpers', () => {
151+
it('exposes objectEntries and and, and they behave', () => {
152+
expect(TableWidget.registerHelpers).toBeDefined();
153+
const helpers = TableWidget.registerHelpers!();
154+
const { objectEntries, and } = helpers;
155+
expect(typeof objectEntries).toBe('function');
156+
expect(typeof and).toBe('function');
157+
expect(objectEntries({ a: 1, b: 2 })).toEqual([
137158
{ id: 'a', data: 1 },
138-
{ id: 'b', data: 2 }
159+
{ id: 'b', data: 2 },
139160
]);
140-
});
141-
142-
it('objectEntries returns empty for non-object or array', () => {
143-
expect(fn?.(null)).toEqual([]);
144-
expect(fn?.([1, 2, 3])).toEqual([]);
161+
expect(objectEntries(null)).toEqual([]);
162+
expect(objectEntries([1, 2, 3])).toEqual([]);
163+
const opts: Record<string, never> = {};
164+
expect(and(true, true, opts)).toBe(true);
165+
expect(and(true, false, opts)).toBe(false);
166+
expect(and(1, 'x', opts)).toBe(true);
167+
expect(and(0, 'x', opts)).toBe(false);
145168
});
146169
});
170+
147171
});

0 commit comments

Comments
 (0)