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()) {