Skip to content

Commit eeb1622

Browse files
guguclaude
andauthored
feat: add encoding parameter to binary widget (#1737)
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 eeb1622

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)