Skip to content

Commit 48f0642

Browse files
guguclaude
andcommitted
feat: add encoding parameter to binary widget
Support hex (default), base64, and ascii encodings for binary widget display, record view, edit input, and filter. Adds backend validation for the encoding param on create/update and covers each encoding with frontend and AVA tests. Filter continues to emit hex on the wire to preserve the backend comparator contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8ec9628 commit 48f0642

19 files changed

Lines changed: 673 additions & 106 deletions

File tree

backend/src/entities/widget/utils/validate-create-widgets-ds.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,17 @@ export async function validateCreateWidgetsDs(
108108
errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING('aws_secret_access_key_secret_name'));
109109
}
110110
}
111+
112+
if (widget_type && widget_type === WidgetTypeEnum.Binary) {
113+
const rawParams = widgetDS.widget_params;
114+
if (rawParams) {
115+
const widget_params: Record<string, any> =
116+
typeof rawParams === 'string' ? JSON5.parse(rawParams) : (rawParams as Record<string, any>);
117+
if (widget_params.encoding !== undefined && !['hex', 'base64', 'ascii'].includes(widget_params.encoding)) {
118+
errors.push(Messages.WIDGET_PARAMETER_UNSUPPORTED('encoding', WidgetTypeEnum.Binary));
119+
}
120+
}
121+
}
111122
}
112123
return errors;
113124
}

backend/src/enums/widget-type.enum.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ export enum WidgetTypeEnum {
2323
Timezone = 'Timezone',
2424
S3 = 'S3',
2525
Email = 'Email',
26+
Binary = 'Binary',
2627
}

backend/test/ava-tests/saas-tests/table-widgets-e2e.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1699,3 +1699,112 @@ test.serial(
16991699
}
17001700
},
17011701
);
1702+
1703+
currentTest = 'POST /widget/:slug with Binary widget';
1704+
1705+
test.serial(`${currentTest} should accept a valid encoding value`, async (t) => {
1706+
const { token } = await registerUserAndReturnUserInfo(app);
1707+
const newConnection = getTestData(mockFactory).newEncryptedConnection;
1708+
const createdConnection = await request(app.getHttpServer())
1709+
.post('/connection')
1710+
.send(newConnection)
1711+
.set('Cookie', token)
1712+
.set('masterpwd', 'ahalaimahalai')
1713+
.set('Content-Type', 'application/json')
1714+
.set('Accept', 'application/json');
1715+
const connectionId = JSON.parse(createdConnection.text).id;
1716+
1717+
const binaryWidgetsDTO: CreateOrUpdateTableWidgetsDto = {
1718+
widgets: [
1719+
{
1720+
widget_type: WidgetTypeEnum.Binary,
1721+
widget_params: JSON.stringify({ encoding: 'base64' }),
1722+
field_name: 'id',
1723+
description: 'binary widget test',
1724+
name: 'binary widget',
1725+
widget_options: JSON.stringify({}),
1726+
},
1727+
],
1728+
};
1729+
const createResponse = await request(app.getHttpServer())
1730+
.post(`/widget/${connectionId}?tableName=${tableNameForWidgets}`)
1731+
.send(binaryWidgetsDTO)
1732+
.set('Cookie', token)
1733+
.set('masterpwd', 'ahalaimahalai')
1734+
.set('Content-Type', 'application/json')
1735+
.set('Accept', 'application/json');
1736+
t.is(createResponse.status, 201);
1737+
const ro = JSON.parse(createResponse.text);
1738+
t.is(ro[0].widget_type, WidgetTypeEnum.Binary);
1739+
const params = JSON5.parse(ro[0].widget_params);
1740+
t.is(params.encoding, 'base64');
1741+
});
1742+
1743+
test.serial(`${currentTest} should reject an invalid encoding value`, async (t) => {
1744+
const { token } = await registerUserAndReturnUserInfo(app);
1745+
const newConnection = getTestData(mockFactory).newEncryptedConnection;
1746+
const createdConnection = await request(app.getHttpServer())
1747+
.post('/connection')
1748+
.send(newConnection)
1749+
.set('Cookie', token)
1750+
.set('masterpwd', 'ahalaimahalai')
1751+
.set('Content-Type', 'application/json')
1752+
.set('Accept', 'application/json');
1753+
const connectionId = JSON.parse(createdConnection.text).id;
1754+
1755+
const binaryWidgetsDTO: CreateOrUpdateTableWidgetsDto = {
1756+
widgets: [
1757+
{
1758+
widget_type: WidgetTypeEnum.Binary,
1759+
widget_params: JSON.stringify({ encoding: 'utf8' }),
1760+
field_name: 'id',
1761+
description: 'binary widget test',
1762+
name: 'binary widget',
1763+
widget_options: JSON.stringify({}),
1764+
},
1765+
],
1766+
};
1767+
const createResponse = await request(app.getHttpServer())
1768+
.post(`/widget/${connectionId}?tableName=${tableNameForWidgets}`)
1769+
.send(binaryWidgetsDTO)
1770+
.set('Cookie', token)
1771+
.set('masterpwd', 'ahalaimahalai')
1772+
.set('Content-Type', 'application/json')
1773+
.set('Accept', 'application/json');
1774+
t.is(createResponse.status, 400);
1775+
t.true(createResponse.text.includes(Messages.WIDGET_PARAMETER_UNSUPPORTED('encoding', WidgetTypeEnum.Binary)));
1776+
});
1777+
1778+
test.serial(`${currentTest} should accept an absent encoding (defaults on the client)`, async (t) => {
1779+
const { token } = await registerUserAndReturnUserInfo(app);
1780+
const newConnection = getTestData(mockFactory).newEncryptedConnection;
1781+
const createdConnection = await request(app.getHttpServer())
1782+
.post('/connection')
1783+
.send(newConnection)
1784+
.set('Cookie', token)
1785+
.set('masterpwd', 'ahalaimahalai')
1786+
.set('Content-Type', 'application/json')
1787+
.set('Accept', 'application/json');
1788+
const connectionId = JSON.parse(createdConnection.text).id;
1789+
1790+
const binaryWidgetsDTO: CreateOrUpdateTableWidgetsDto = {
1791+
widgets: [
1792+
{
1793+
widget_type: WidgetTypeEnum.Binary,
1794+
widget_params: JSON.stringify({}),
1795+
field_name: 'id',
1796+
description: 'binary widget test',
1797+
name: 'binary widget',
1798+
widget_options: JSON.stringify({}),
1799+
},
1800+
],
1801+
};
1802+
const createResponse = await request(app.getHttpServer())
1803+
.post(`/widget/${connectionId}?tableName=${tableNameForWidgets}`)
1804+
.send(binaryWidgetsDTO)
1805+
.set('Cookie', token)
1806+
.set('masterpwd', 'ahalaimahalai')
1807+
.set('Content-Type', 'application/json')
1808+
.set('Accept', 'application/json');
1809+
t.is(createResponse.status, 201);
1810+
});

frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,13 @@ export class DbTableWidgetsComponent implements OnInit {
6969
};
7070
// JSON5-formatted default params
7171
public defaultParams = {
72-
Binary: `// No settings required`,
72+
Binary: `// Configure binary display/edit encoding.
73+
// Supported: "hex" (default), "base64", "ascii"
74+
// example:
75+
{
76+
"encoding": "hex"
77+
}
78+
`,
7379
Boolean: `// Display "Yes/No" buttons with configurable options:
7480
// - allow_null: Use "false" to require selection, "true" if field can be left unspecified
7581
// - invert_colors: Swap the color scheme (typically green=Yes, red=No becomes red=Yes, green=No)

frontend/src/app/components/ui-components/filter-fields/binary/binary.component.html

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,40 @@
1010

1111
@if (filterMode !== 'empty') {
1212
<mat-form-field class="value-field" appearance="outline">
13-
<mat-label>{{normalizedLabel()}} (hex)</mat-label>
14-
<input matInput type="text" hexValidator
15-
name="{{label()}}-{{key()}}"
16-
#inputElement
17-
#hexInput="ngModel"
18-
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
19-
placeholder="48656c6c6f"
20-
[(ngModel)]="hexValue" (ngModelChange)="onHexValueChange($event)">
21-
@if (hexInput.errors?.isInvalidHex) {
22-
<mat-error>Invalid hex.</mat-error>
13+
<mat-label>{{normalizedLabel()}} ({{ encoding() }})</mat-label>
14+
@switch (encoding()) {
15+
@case ('hex') {
16+
<input matInput type="text" hexValidator
17+
name="{{label()}}-{{key()}}"
18+
#inputElement
19+
#binaryInput="ngModel"
20+
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
21+
placeholder="48656c6c6f"
22+
[ngModel]="rawInput" (ngModelChange)="onInputChange($event)">
23+
@if (binaryInput.errors?.isInvalidHex) {
24+
<mat-error>Invalid hex.</mat-error>
25+
}
26+
}
27+
@case ('base64') {
28+
<input matInput type="text" base64Validator
29+
name="{{label()}}-{{key()}}"
30+
#inputElement
31+
#binaryInput="ngModel"
32+
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
33+
placeholder="SGVsbG8="
34+
[ngModel]="rawInput" (ngModelChange)="onInputChange($event)">
35+
@if (binaryInput.errors?.isInvalidBase64) {
36+
<mat-error>Invalid base64.</mat-error>
37+
}
38+
}
39+
@case ('ascii') {
40+
<input matInput type="text"
41+
name="{{label()}}-{{key()}}"
42+
#inputElement
43+
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
44+
placeholder="Hello"
45+
[ngModel]="rawInput" (ngModelChange)="onInputChange($event)">
46+
}
2347
}
2448
</mat-form-field>
2549
}

frontend/src/app/components/ui-components/filter-fields/binary/binary.component.spec.ts

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,44 +20,44 @@ describe('BinaryFilterComponent', () => {
2020
expect(component).toBeTruthy();
2121
});
2222

23-
it('defaults to eq filter mode with empty hex', () => {
23+
it('defaults to eq filter mode with empty input', () => {
2424
fixture.detectChanges();
2525
expect(component.filterMode).toBe('eq');
26-
expect(component.hexValue).toBe('');
26+
expect(component.rawInput).toBe('');
2727
});
2828

2929
it('normalizes the incoming hex value through bytes on init', () => {
3030
component.value = '48656c6c6f';
3131
component.ngOnInit();
32-
expect(component.hexValue).toBe('48656c6c6f');
32+
expect(component.rawInput).toBe('48656c6c6f');
3333
});
3434

3535
it('drops a malformed incoming hex value to empty on init', () => {
3636
component.value = 'zz';
3737
component.ngOnInit();
38-
expect(component.hexValue).toBe('');
38+
expect(component.rawInput).toBe('');
3939
});
4040

41-
it('emits the hex string and current comparator on hex change', () => {
41+
it('emits the hex string and current comparator on input change', () => {
4242
vi.spyOn(component.onFieldChange, 'emit');
4343
vi.spyOn(component.onComparatorChange, 'emit');
4444
fixture.detectChanges();
4545

46-
component.onHexValueChange('abcdef');
46+
component.onInputChange('abcdef');
4747

4848
expect(component.onFieldChange.emit).toHaveBeenCalledWith('abcdef');
4949
expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq');
5050
});
5151

52-
it('switches to empty mode and clears hex', () => {
52+
it('switches to empty mode and clears input', () => {
5353
vi.spyOn(component.onFieldChange, 'emit');
5454
vi.spyOn(component.onComparatorChange, 'emit');
5555
fixture.detectChanges();
5656

57-
component.hexValue = 'abcdef';
57+
component.rawInput = 'abcdef';
5858
component.onFilterModeChange('empty');
5959

60-
expect(component.hexValue).toBe('');
60+
expect(component.rawInput).toBe('');
6161
expect(component.onComparatorChange.emit).toHaveBeenCalledWith('empty');
6262
expect(component.onFieldChange.emit).toHaveBeenCalledWith('');
6363
});
@@ -67,7 +67,7 @@ describe('BinaryFilterComponent', () => {
6767
vi.spyOn(component.onComparatorChange, 'emit');
6868
fixture.detectChanges();
6969

70-
component.hexValue = 'abcdef';
70+
component.rawInput = 'abcdef';
7171
component.onFilterModeChange('contains');
7272

7373
expect(component.onComparatorChange.emit).toHaveBeenCalledWith('contains');
@@ -81,4 +81,46 @@ describe('BinaryFilterComponent', () => {
8181
component.onFilterModeChange('startswith');
8282
expect(component.onComparatorChange.emit).toHaveBeenCalledWith('startswith');
8383
});
84+
85+
describe('encoding param', () => {
86+
it('seeds rawInput in the selected encoding from the incoming hex value', () => {
87+
fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } });
88+
component.value = '48656c6c6f';
89+
component.ngOnInit();
90+
expect(component.encoding()).toBe('base64');
91+
expect(component.rawInput).toBe('SGVsbG8=');
92+
});
93+
94+
it('emits hex to backend when the user types base64', () => {
95+
fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } });
96+
vi.spyOn(component.onFieldChange, 'emit');
97+
fixture.detectChanges();
98+
99+
component.onInputChange('SGVsbG8=');
100+
101+
expect(component.onFieldChange.emit).toHaveBeenCalledWith('48656c6c6f');
102+
expect(component.isInvalidInput).toBe(false);
103+
});
104+
105+
it('emits empty hex and marks invalid when base64 input is malformed', () => {
106+
fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } });
107+
vi.spyOn(component.onFieldChange, 'emit');
108+
fixture.detectChanges();
109+
110+
component.onInputChange('!!!');
111+
112+
expect(component.onFieldChange.emit).toHaveBeenCalledWith('');
113+
expect(component.isInvalidInput).toBe(true);
114+
});
115+
116+
it('emits hex to backend when the user types ascii', () => {
117+
fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'ascii' } });
118+
vi.spyOn(component.onFieldChange, 'emit');
119+
fixture.detectChanges();
120+
121+
component.onInputChange('Hi');
122+
123+
expect(component.onFieldChange.emit).toHaveBeenCalledWith('4869');
124+
});
125+
});
84126
});

0 commit comments

Comments
 (0)