From 48f06423310bb61e803fee6e2711d502a37e2e5c Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 24 Apr 2026 20:42:08 +0000 Subject: [PATCH] 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) --- .../utils/validate-create-widgets-ds.ts | 11 ++ backend/src/enums/widget-type.enum.ts | 1 + .../saas-tests/table-widgets-e2e.test.ts | 109 ++++++++++++++++ .../db-table-widgets.component.ts | 8 +- .../binary/binary.component.html | 44 +++++-- .../binary/binary.component.spec.ts | 62 ++++++++-- .../filter-fields/binary/binary.component.ts | 50 ++++++-- .../binary/binary.component.html | 40 ++++-- .../binary/binary.component.spec.ts | 70 +++++++++-- .../binary/binary.component.ts | 43 ++++--- .../binary/binary.component.html | 8 +- .../binary/binary.component.spec.ts | 38 ++++-- .../binary/binary.component.ts | 17 ++- .../binary/binary.component.html | 6 +- .../binary/binary.component.spec.ts | 49 +++++++- .../binary/binary.component.ts | 15 ++- frontend/src/app/lib/binary.spec.ts | 116 +++++++++++++++++- frontend/src/app/lib/binary.ts | 79 ++++++++++++ .../src/app/validators/base64.validator.ts | 13 +- 19 files changed, 673 insertions(+), 106 deletions(-) diff --git a/backend/src/entities/widget/utils/validate-create-widgets-ds.ts b/backend/src/entities/widget/utils/validate-create-widgets-ds.ts index 8f17fd014..02777ecc7 100644 --- a/backend/src/entities/widget/utils/validate-create-widgets-ds.ts +++ b/backend/src/entities/widget/utils/validate-create-widgets-ds.ts @@ -108,6 +108,17 @@ export async function validateCreateWidgetsDs( errors.push(Messages.WIDGET_REQUIRED_PARAMETER_MISSING('aws_secret_access_key_secret_name')); } } + + if (widget_type && widget_type === WidgetTypeEnum.Binary) { + const rawParams = widgetDS.widget_params; + if (rawParams) { + const widget_params: Record = + typeof rawParams === 'string' ? JSON5.parse(rawParams) : (rawParams as Record); + if (widget_params.encoding !== undefined && !['hex', 'base64', 'ascii'].includes(widget_params.encoding)) { + errors.push(Messages.WIDGET_PARAMETER_UNSUPPORTED('encoding', WidgetTypeEnum.Binary)); + } + } + } } return errors; } diff --git a/backend/src/enums/widget-type.enum.ts b/backend/src/enums/widget-type.enum.ts index db1e037d9..a8b6af74d 100644 --- a/backend/src/enums/widget-type.enum.ts +++ b/backend/src/enums/widget-type.enum.ts @@ -23,4 +23,5 @@ export enum WidgetTypeEnum { Timezone = 'Timezone', S3 = 'S3', Email = 'Email', + Binary = 'Binary', } diff --git a/backend/test/ava-tests/saas-tests/table-widgets-e2e.test.ts b/backend/test/ava-tests/saas-tests/table-widgets-e2e.test.ts index 25b2d540d..d32f08b24 100644 --- a/backend/test/ava-tests/saas-tests/table-widgets-e2e.test.ts +++ b/backend/test/ava-tests/saas-tests/table-widgets-e2e.test.ts @@ -1699,3 +1699,112 @@ test.serial( } }, ); + +currentTest = 'POST /widget/:slug with Binary widget'; + +test.serial(`${currentTest} should accept a valid encoding value`, async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const newConnection = getTestData(mockFactory).newEncryptedConnection; + const createdConnection = await request(app.getHttpServer()) + .post('/connection') + .send(newConnection) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const connectionId = JSON.parse(createdConnection.text).id; + + const binaryWidgetsDTO: CreateOrUpdateTableWidgetsDto = { + widgets: [ + { + widget_type: WidgetTypeEnum.Binary, + widget_params: JSON.stringify({ encoding: 'base64' }), + field_name: 'id', + description: 'binary widget test', + name: 'binary widget', + widget_options: JSON.stringify({}), + }, + ], + }; + const createResponse = await request(app.getHttpServer()) + .post(`/widget/${connectionId}?tableName=${tableNameForWidgets}`) + .send(binaryWidgetsDTO) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createResponse.status, 201); + const ro = JSON.parse(createResponse.text); + t.is(ro[0].widget_type, WidgetTypeEnum.Binary); + const params = JSON5.parse(ro[0].widget_params); + t.is(params.encoding, 'base64'); +}); + +test.serial(`${currentTest} should reject an invalid encoding value`, async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const newConnection = getTestData(mockFactory).newEncryptedConnection; + const createdConnection = await request(app.getHttpServer()) + .post('/connection') + .send(newConnection) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const connectionId = JSON.parse(createdConnection.text).id; + + const binaryWidgetsDTO: CreateOrUpdateTableWidgetsDto = { + widgets: [ + { + widget_type: WidgetTypeEnum.Binary, + widget_params: JSON.stringify({ encoding: 'utf8' }), + field_name: 'id', + description: 'binary widget test', + name: 'binary widget', + widget_options: JSON.stringify({}), + }, + ], + }; + const createResponse = await request(app.getHttpServer()) + .post(`/widget/${connectionId}?tableName=${tableNameForWidgets}`) + .send(binaryWidgetsDTO) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createResponse.status, 400); + t.true(createResponse.text.includes(Messages.WIDGET_PARAMETER_UNSUPPORTED('encoding', WidgetTypeEnum.Binary))); +}); + +test.serial(`${currentTest} should accept an absent encoding (defaults on the client)`, async (t) => { + const { token } = await registerUserAndReturnUserInfo(app); + const newConnection = getTestData(mockFactory).newEncryptedConnection; + const createdConnection = await request(app.getHttpServer()) + .post('/connection') + .send(newConnection) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const connectionId = JSON.parse(createdConnection.text).id; + + const binaryWidgetsDTO: CreateOrUpdateTableWidgetsDto = { + widgets: [ + { + widget_type: WidgetTypeEnum.Binary, + widget_params: JSON.stringify({}), + field_name: 'id', + description: 'binary widget test', + name: 'binary widget', + widget_options: JSON.stringify({}), + }, + ], + }; + const createResponse = await request(app.getHttpServer()) + .post(`/widget/${connectionId}?tableName=${tableNameForWidgets}`) + .send(binaryWidgetsDTO) + .set('Cookie', token) + .set('masterpwd', 'ahalaimahalai') + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createResponse.status, 201); +}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts index 291881a89..c662c8209 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts @@ -69,7 +69,13 @@ export class DbTableWidgetsComponent implements OnInit { }; // JSON5-formatted default params public defaultParams = { - Binary: `// No settings required`, + Binary: `// Configure binary display/edit encoding. +// Supported: "hex" (default), "base64", "ascii" +// example: +{ + "encoding": "hex" +} +`, Boolean: `// Display "Yes/No" buttons with configurable options: // - allow_null: Use "false" to require selection, "true" if field can be left unspecified // - invert_colors: Swap the color scheme (typically green=Yes, red=No becomes red=Yes, green=No) diff --git a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.html b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.html index c0837cb60..fa15842bd 100644 --- a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.html +++ b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.html @@ -10,16 +10,40 @@ @if (filterMode !== 'empty') { - {{normalizedLabel()}} (hex) - - @if (hexInput.errors?.isInvalidHex) { - Invalid hex. + {{normalizedLabel()}} ({{ encoding() }}) + @switch (encoding()) { + @case ('hex') { + + @if (binaryInput.errors?.isInvalidHex) { + Invalid hex. + } + } + @case ('base64') { + + @if (binaryInput.errors?.isInvalidBase64) { + Invalid base64. + } + } + @case ('ascii') { + + } } } diff --git a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.spec.ts index eee282570..f32a7453a 100644 --- a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.spec.ts +++ b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.spec.ts @@ -20,44 +20,44 @@ describe('BinaryFilterComponent', () => { expect(component).toBeTruthy(); }); - it('defaults to eq filter mode with empty hex', () => { + it('defaults to eq filter mode with empty input', () => { fixture.detectChanges(); expect(component.filterMode).toBe('eq'); - expect(component.hexValue).toBe(''); + expect(component.rawInput).toBe(''); }); it('normalizes the incoming hex value through bytes on init', () => { component.value = '48656c6c6f'; component.ngOnInit(); - expect(component.hexValue).toBe('48656c6c6f'); + expect(component.rawInput).toBe('48656c6c6f'); }); it('drops a malformed incoming hex value to empty on init', () => { component.value = 'zz'; component.ngOnInit(); - expect(component.hexValue).toBe(''); + expect(component.rawInput).toBe(''); }); - it('emits the hex string and current comparator on hex change', () => { + it('emits the hex string and current comparator on input change', () => { vi.spyOn(component.onFieldChange, 'emit'); vi.spyOn(component.onComparatorChange, 'emit'); fixture.detectChanges(); - component.onHexValueChange('abcdef'); + component.onInputChange('abcdef'); expect(component.onFieldChange.emit).toHaveBeenCalledWith('abcdef'); expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); }); - it('switches to empty mode and clears hex', () => { + it('switches to empty mode and clears input', () => { vi.spyOn(component.onFieldChange, 'emit'); vi.spyOn(component.onComparatorChange, 'emit'); fixture.detectChanges(); - component.hexValue = 'abcdef'; + component.rawInput = 'abcdef'; component.onFilterModeChange('empty'); - expect(component.hexValue).toBe(''); + expect(component.rawInput).toBe(''); expect(component.onComparatorChange.emit).toHaveBeenCalledWith('empty'); expect(component.onFieldChange.emit).toHaveBeenCalledWith(''); }); @@ -67,7 +67,7 @@ describe('BinaryFilterComponent', () => { vi.spyOn(component.onComparatorChange, 'emit'); fixture.detectChanges(); - component.hexValue = 'abcdef'; + component.rawInput = 'abcdef'; component.onFilterModeChange('contains'); expect(component.onComparatorChange.emit).toHaveBeenCalledWith('contains'); @@ -81,4 +81,46 @@ describe('BinaryFilterComponent', () => { component.onFilterModeChange('startswith'); expect(component.onComparatorChange.emit).toHaveBeenCalledWith('startswith'); }); + + describe('encoding param', () => { + it('seeds rawInput in the selected encoding from the incoming hex value', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); + component.value = '48656c6c6f'; + component.ngOnInit(); + expect(component.encoding()).toBe('base64'); + expect(component.rawInput).toBe('SGVsbG8='); + }); + + it('emits hex to backend when the user types base64', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); + vi.spyOn(component.onFieldChange, 'emit'); + fixture.detectChanges(); + + component.onInputChange('SGVsbG8='); + + expect(component.onFieldChange.emit).toHaveBeenCalledWith('48656c6c6f'); + expect(component.isInvalidInput).toBe(false); + }); + + it('emits empty hex and marks invalid when base64 input is malformed', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); + vi.spyOn(component.onFieldChange, 'emit'); + fixture.detectChanges(); + + component.onInputChange('!!!'); + + expect(component.onFieldChange.emit).toHaveBeenCalledWith(''); + expect(component.isInvalidInput).toBe(true); + }); + + it('emits hex to backend when the user types ascii', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'ascii' } }); + vi.spyOn(component.onFieldChange, 'emit'); + fixture.detectChanges(); + + component.onInputChange('Hi'); + + expect(component.onFieldChange.emit).toHaveBeenCalledWith('4869'); + }); + }); }); diff --git a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.ts b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.ts index be6ce664d..203d8b722 100644 --- a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.ts @@ -1,10 +1,11 @@ -import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, computed, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; +import { Base64ValidationDirective } from 'src/app/directives/base64Validator.directive'; import { HexValidationDirective } from 'src/app/directives/hexValidator.directive'; -import { bytesToHex, hexStringToBytes } from 'src/app/lib/binary'; +import { bytesToEncoded, bytesToHex, encodedToBytes, hexStringToBytes, isBinaryEncoding } from 'src/app/lib/binary'; import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; export type BinaryFilterMode = 'eq' | 'contains' | 'startswith' | 'empty'; @@ -13,17 +14,31 @@ export type BinaryFilterMode = 'eq' | 'contains' | 'startswith' | 'empty'; selector: 'app-filter-binary', templateUrl: './binary.component.html', styleUrls: ['./binary.component.css'], - imports: [FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule, HexValidationDirective], + imports: [ + FormsModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + HexValidationDirective, + Base64ValidationDirective, + ], }) export class BinaryFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { @Input() value: string; @ViewChild('inputElement') inputElement: ElementRef; public filterMode: BinaryFilterMode = 'eq'; - public hexValue = ''; + public rawInput = ''; + public isInvalidInput = false; + + public readonly encoding = computed(() => { + const raw = this.widgetStructure()?.widget_params?.encoding; + return isBinaryEncoding(raw) ? raw : 'hex'; + }); override ngOnInit(): void { - this.hexValue = bytesToHex(hexStringToBytes(this.value ?? '')); + const incomingBytes = hexStringToBytes(this.value ?? ''); + this.rawInput = bytesToEncoded(incomingBytes, this.encoding()); } ngAfterViewInit(): void { @@ -35,18 +50,33 @@ export class BinaryFilterComponent extends BaseFilterFieldComponent implements O onFilterModeChange(mode: BinaryFilterMode): void { this.filterMode = mode; if (mode === 'empty') { - this.hexValue = ''; + this.rawInput = ''; + this.isInvalidInput = false; this.onComparatorChange.emit('empty'); this.onFieldChange.emit(''); return; } this.onComparatorChange.emit(mode); - this.onFieldChange.emit(this.hexValue); + this.onFieldChange.emit(this.currentHex()); } - onHexValueChange(hex: string): void { - this.hexValue = hex; - this.onFieldChange.emit(hex); + onInputChange(value: string): void { + this.rawInput = value; + this.onFieldChange.emit(this.currentHex()); this.onComparatorChange.emit(this.filterMode); } + + private currentHex(): string { + if (!this.rawInput) { + this.isInvalidInput = false; + return ''; + } + const bytes = encodedToBytes(this.rawInput, this.encoding()); + if (bytes === null) { + this.isInvalidInput = true; + return ''; + } + this.isInvalidInput = false; + return bytesToHex(bytes); + } } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.html b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.html index 6f42a8d86..8c77d7136 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.html @@ -1,12 +1,34 @@ - {{ normalizedLabel() }} (hex) - - @if (hexContent.errors?.isInvalidHex) { - Invalid hex. + {{ normalizedLabel() }} ({{ encoding() }}) + @switch (encoding()) { + @case ('hex') { + + @if (binaryContent.errors?.isInvalidHex) { + Invalid hex. + } + } + @case ('base64') { + + @if (binaryContent.errors?.isInvalidBase64) { + Invalid base64. + } + } + @case ('ascii') { + + } } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.spec.ts index f83b1eb33..3cefea83c 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.spec.ts @@ -25,8 +25,7 @@ describe('BinaryEditComponent', () => { vi.spyOn(component.onFieldChange, 'emit'); fixture.componentRef.setInput('value', 'Hello'); component.ngOnInit(); - // 'Hello' -> bytes 48 65 6c 6c 6f -> hex '48656c6c6f' - expect(component.hexData).toBe('48656c6c6f'); + expect(component.rawInput).toBe('48656c6c6f'); expect(component.onFieldChange.emit).not.toHaveBeenCalled(); }); @@ -34,7 +33,7 @@ describe('BinaryEditComponent', () => { vi.spyOn(component.onFieldChange, 'emit'); fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c] }); component.ngOnInit(); - expect(component.hexData).toBe('48656c'); + expect(component.rawInput).toBe('48656c'); expect(component.onFieldChange.emit).not.toHaveBeenCalled(); }); @@ -42,14 +41,14 @@ describe('BinaryEditComponent', () => { vi.spyOn(component.onFieldChange, 'emit'); fixture.componentRef.setInput('value', null); component.ngOnInit(); - expect(component.hexData).toBe(''); + expect(component.rawInput).toBe(''); expect(component.onFieldChange.emit).not.toHaveBeenCalled(); }); it('emits Buffer-JSON when the user types valid hex', () => { vi.spyOn(component.onFieldChange, 'emit'); - component.hexData = '48656c6c6f'; - component.onHexChange(); + component.rawInput = '48656c6c6f'; + component.onInputChange(); expect(component.onFieldChange.emit).toHaveBeenCalledWith({ type: 'Buffer', data: [0x48, 0x65, 0x6c, 0x6c, 0x6f], @@ -59,16 +58,67 @@ describe('BinaryEditComponent', () => { it('emits null when the field is cleared', () => { vi.spyOn(component.onFieldChange, 'emit'); - component.hexData = ''; - component.onHexChange(); + component.rawInput = ''; + component.onInputChange(); expect(component.onFieldChange.emit).toHaveBeenCalledWith(null); }); it('marks invalid and emits raw string when the user types malformed hex', () => { vi.spyOn(component.onFieldChange, 'emit'); - component.hexData = 'zz'; - component.onHexChange(); + component.rawInput = 'zz'; + component.onInputChange(); expect(component.isInvalidInput).toBe(true); expect(component.onFieldChange.emit).toHaveBeenCalledWith('zz'); }); + + describe('encoding param', () => { + it('seeds the input as base64 when encoding=base64', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); + fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c, 0x6c, 0x6f] }); + component.ngOnInit(); + expect(component.encoding()).toBe('base64'); + expect(component.rawInput).toBe('SGVsbG8='); + }); + + it('emits Buffer-JSON for valid base64', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); + vi.spyOn(component.onFieldChange, 'emit'); + component.rawInput = 'SGVsbG8='; + component.onInputChange(); + expect(component.isInvalidInput).toBe(false); + expect(component.onFieldChange.emit).toHaveBeenCalledWith({ + type: 'Buffer', + data: [0x48, 0x65, 0x6c, 0x6c, 0x6f], + }); + }); + + it('marks invalid and emits raw when base64 is malformed', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); + vi.spyOn(component.onFieldChange, 'emit'); + component.rawInput = '!!!bad!!!'; + component.onInputChange(); + expect(component.isInvalidInput).toBe(true); + expect(component.onFieldChange.emit).toHaveBeenCalledWith('!!!bad!!!'); + }); + + it('seeds the input as ascii with non-printable replacement when encoding=ascii', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'ascii' } }); + fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x00, 0x69] }); + component.ngOnInit(); + expect(component.encoding()).toBe('ascii'); + expect(component.rawInput).toBe('H.i'); + }); + + it('always emits Buffer-JSON for ascii input, never invalid', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'ascii' } }); + vi.spyOn(component.onFieldChange, 'emit'); + component.rawInput = 'Hi'; + component.onInputChange(); + expect(component.isInvalidInput).toBe(false); + expect(component.onFieldChange.emit).toHaveBeenCalledWith({ + type: 'Buffer', + data: [0x48, 0x69], + }); + }); + }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.ts index 6755ff7eb..510fd25ce 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.ts @@ -1,45 +1,56 @@ -import { Component, model, OnInit } from '@angular/core'; +import { Component, computed, model, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { Base64ValidationDirective } from 'src/app/directives/base64Validator.directive'; import { HexValidationDirective } from 'src/app/directives/hexValidator.directive'; -import { BinaryBufferJson, bytesToHex, hexStringToBytes, parseBinaryValue, toBufferJson } from 'src/app/lib/binary'; -import { hexValidation } from 'src/app/validators/hex.validator'; +import { + BinaryBufferJson, + bytesToEncoded, + encodedToBytes, + isBinaryEncoding, + parseBinaryValue, + toBufferJson, +} from 'src/app/lib/binary'; import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; @Component({ selector: 'app-edit-binary', templateUrl: './binary.component.html', styleUrls: ['./binary.component.css'], - imports: [FormsModule, MatFormFieldModule, MatInputModule, HexValidationDirective], + imports: [FormsModule, MatFormFieldModule, MatInputModule, HexValidationDirective, Base64ValidationDirective], }) export class BinaryEditComponent extends BaseEditFieldComponent implements OnInit { readonly value = model(); static type = 'file'; - public hexData = ''; + public rawInput = ''; public isInvalidInput = false; + public readonly encoding = computed(() => { + const raw = this.widgetStructure()?.widget_params?.encoding; + return isBinaryEncoding(raw) ? raw : 'hex'; + }); + ngOnInit(): void { super.ngOnInit(); - this.hexData = bytesToHex(parseBinaryValue(this.value())); - } - - onHexChange(): void { - this.isInvalidInput = !!hexValidation()({ value: this.hexData } as never); - this.emitCurrentValue(); + this.rawInput = bytesToEncoded(parseBinaryValue(this.value()), this.encoding()); } - private emitCurrentValue(): void { - if (!this.hexData) { + onInputChange(): void { + if (!this.rawInput) { + this.isInvalidInput = false; this.onFieldChange.emit(null); return; } - if (this.isInvalidInput) { - this.onFieldChange.emit(this.hexData); + const bytes = encodedToBytes(this.rawInput, this.encoding()); + if (bytes === null) { + this.isInvalidInput = true; + this.onFieldChange.emit(this.rawInput); return; } - this.onFieldChange.emit(toBufferJson(hexStringToBytes(this.hexData))); + this.isInvalidInput = false; + this.onFieldChange.emit(toBufferJson(bytes)); } } diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.html b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.html index 0695c8c71..19b931141 100644 --- a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.html +++ b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.html @@ -1,12 +1,12 @@
{{ displayText() }} - @if (hexValue()) { + @if (encodedValue()) { diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.spec.ts index beaf6e3c6..7c2bc7fe3 100644 --- a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.spec.ts @@ -22,29 +22,53 @@ describe('BinaryRecordViewComponent', () => { it('shows em-dash for empty value', () => { fixture.detectChanges(); - expect(component.displayText()).toBe('\u2014'); + expect(component.displayText()).toBe('—'); }); it('parses a server string as char-code-per-byte', () => { fixture.componentRef.setInput('value', 'Hel'); fixture.detectChanges(); expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]); - expect(component.hexValue()).toBe('48656c'); + expect(component.encodedValue()).toBe('48656c'); }); it('parses a Buffer-JSON value to a byte array', () => { fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c] }); fixture.detectChanges(); expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]); - expect(component.hexValue()).toBe('48656c'); + expect(component.encodedValue()).toBe('48656c'); }); it('truncates long hex with ellipsis', () => { - // 40 bytes of 0xaa → 80 hex chars, just at the limit; bump to 50 bytes to trigger truncation. - fixture.componentRef.setInput('value', '\u00aa'.repeat(50)); + fixture.componentRef.setInput('value', 'ª'.repeat(50)); fixture.detectChanges(); - expect(component.hexValue()).toBe('aa'.repeat(50)); + expect(component.encodedValue()).toBe('aa'.repeat(50)); expect(component.isTruncated()).toBe(true); - expect(component.displayText()).toBe('aa'.repeat(40) + '\u2026'); + expect(component.displayText()).toBe('aa'.repeat(40) + '…'); + }); + + describe('encoding param', () => { + it('defaults to hex when widgetStructure is absent', () => { + fixture.componentRef.setInput('value', 'Hi'); + fixture.detectChanges(); + expect(component.encoding()).toBe('hex'); + expect(component.encodedValue()).toBe('4869'); + }); + + it('renders base64 when encoding=base64', () => { + fixture.componentRef.setInput('value', 'Hello'); + fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); + fixture.detectChanges(); + expect(component.encoding()).toBe('base64'); + expect(component.encodedValue()).toBe('SGVsbG8='); + }); + + it('renders ascii with non-printable replacement', () => { + fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x00, 0x69] }); + fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'ascii' } }); + fixture.detectChanges(); + expect(component.encoding()).toBe('ascii'); + expect(component.encodedValue()).toBe('H.i'); + }); }); }); diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.ts b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.ts index 9fcaa52eb..22ed34467 100644 --- a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.ts +++ b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.ts @@ -3,7 +3,7 @@ import { Component, computed } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { bytesToHex, parseBinaryValue } from 'src/app/lib/binary'; +import { bytesToEncoded, isBinaryEncoding, parseBinaryValue } from 'src/app/lib/binary'; import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; const MAX_DISPLAY_LENGTH = 80; @@ -16,13 +16,18 @@ const MAX_DISPLAY_LENGTH = 80; }) export class BinaryRecordViewComponent extends BaseRecordViewFieldComponent { public readonly bytes = computed(() => parseBinaryValue(this.value())); - public readonly hexValue = computed(() => bytesToHex(this.bytes())); + public readonly encoding = computed(() => { + const raw = this.widgetStructure()?.widget_params?.encoding; + return isBinaryEncoding(raw) ? raw : 'hex'; + }); + public readonly encodedValue = computed(() => bytesToEncoded(this.bytes(), this.encoding())); + public readonly copyTooltip = computed(() => `Copy ${this.encoding()}`); public readonly displayText = computed(() => { - const hex = this.hexValue(); - if (!hex) return '\u2014'; - return hex.length > MAX_DISPLAY_LENGTH ? hex.substring(0, MAX_DISPLAY_LENGTH) + '\u2026' : hex; + const encoded = this.encodedValue(); + if (!encoded) return '—'; + return encoded.length > MAX_DISPLAY_LENGTH ? encoded.substring(0, MAX_DISPLAY_LENGTH) + '…' : encoded; }); - public readonly isTruncated = computed(() => this.hexValue().length > MAX_DISPLAY_LENGTH); + public readonly isTruncated = computed(() => this.encodedValue().length > MAX_DISPLAY_LENGTH); } diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.html b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.html index e91d24679..4ab62fd2b 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.html +++ b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.html @@ -1,10 +1,10 @@
{{ displayText() }} - @if (hexValue()) { + @if (encodedValue()) {