@@ -4,8 +4,10 @@ import { MatDialogModule } from '@angular/material/dialog';
44import { MatSnackBar , MatSnackBarModule } from '@angular/material/snack-bar' ;
55import { provideRouter } from '@angular/router' ;
66import { Angulartics2Module } from 'angulartics2' ;
7+ import { of } from 'rxjs' ;
78import { Connection , ConnectionType , DBtype } from 'src/app/models/connection' ;
89import { ConnectionsService } from 'src/app/services/connections.service' ;
10+ import { TableRowService } from 'src/app/services/table-row.service' ;
911import { TablesService } from 'src/app/services/tables.service' ;
1012import { DbTableRowEditComponent } from './db-table-row-edit.component' ;
1113
@@ -296,6 +298,40 @@ describe('DbTableRowEditComponent', () => {
296298 component . pageAction = null ;
297299 } ) ;
298300
301+ describe ( 'onlyTouched filtering' , ( ) => {
302+ beforeEach ( ( ) => {
303+ component . tableRowValues = {
304+ id : 1 ,
305+ username : 'original' ,
306+ email : 'a@x.test' ,
307+ bio : 'hello' ,
308+ } ;
309+ } ) ;
310+
311+ it ( 'returns all fields by default' , ( ) => {
312+ const result = component . getFormattedUpdatedRow ( ) ;
313+ expect ( Object . keys ( result ) . sort ( ) ) . toEqual ( [ 'bio' , 'email' , 'id' , 'username' ] ) ;
314+ } ) ;
315+
316+ it ( 'returns only touched fields when onlyTouched=true' , ( ) => {
317+ component . updateField ( 'renamed' , 'username' ) ;
318+ const result = component . getFormattedUpdatedRow ( true ) ;
319+ expect ( result ) . toEqual ( { username : 'renamed' } ) ;
320+ } ) ;
321+
322+ it ( 'returns empty object when nothing was touched' , ( ) => {
323+ const result = component . getFormattedUpdatedRow ( true ) ;
324+ expect ( result ) . toEqual ( { } ) ;
325+ } ) ;
326+
327+ it ( 'tracks multiple touched fields' , ( ) => {
328+ component . updateField ( 'renamed' , 'username' ) ;
329+ component . updateField ( 'b@x.test' , 'email' ) ;
330+ const result = component . getFormattedUpdatedRow ( true ) ;
331+ expect ( result ) . toEqual ( { username : 'renamed' , email : 'b@x.test' } ) ;
332+ } ) ;
333+ } ) ;
334+
299335 it ( 'should include password field when it has a value' , ( ) => {
300336 component . tableRowValues = {
301337 id : 1 ,
@@ -342,4 +378,119 @@ describe('DbTableRowEditComponent', () => {
342378 expect ( ( result as any ) . password ) . toBe ( '' ) ;
343379 } ) ;
344380 } ) ;
381+
382+ // Integration-style: render the edit form with FK and binary widgets, then save.
383+ // FK widget (foreign-key.component.ts:120) and Binary widget (binary.component.ts:27)
384+ // both re-emit their current value on ngOnInit. Touched-tracking MUST ignore those
385+ // init-time emits — otherwise every edit PUT silently re-writes FK/binary columns
386+ // the user never touched.
387+ describe ( 'render + save: untouched FK and binary fields are not sent' , ( ) => {
388+ let tableRowService : TableRowService ;
389+ let updateTableRowSpy : ReturnType < typeof vi . spyOn > ;
390+
391+ beforeEach ( async ( ) => {
392+ tableRowService = TestBed . inject ( TableRowService ) ;
393+ const tablesService = TestBed . inject ( TablesService ) ;
394+
395+ updateTableRowSpy = vi
396+ . spyOn ( tableRowService , 'updateTableRow' )
397+ . mockReturnValue ( of ( { row : { id : '42' } , primaryColumns : [ { column_name : 'id' } ] } as any ) ) ;
398+
399+ // FK widget loads its current row + autocomplete suggestions via fetchTable.
400+ // Both calls must resolve so its ngOnInit can reach the onFieldChange.emit.
401+ vi . spyOn ( tablesService , 'fetchTable' ) . mockReturnValue (
402+ of ( {
403+ rows : [ { id : 7 , name : 'Alice' } ] ,
404+ primaryColumns : [ { column_name : 'id' , data_type : 'int' } ] ,
405+ identity_column : 'name' ,
406+ } as any ) ,
407+ ) ;
408+
409+ vi . spyOn ( connectionsService , 'currentConnection' , 'get' ) . mockReturnValue ( {
410+ id : 'conn-1' ,
411+ database : 'shop' ,
412+ title : 'Shop' ,
413+ host : 'localhost' ,
414+ port : '5432' ,
415+ sid : null ,
416+ type : DBtype . Postgres ,
417+ username : 'u' ,
418+ ssh : false ,
419+ ssl : false ,
420+ cert : '' ,
421+ masterEncryption : false ,
422+ azure_encryption : false ,
423+ connectionType : ConnectionType . Direct ,
424+ } as Connection ) ;
425+ vi . spyOn ( connectionsService , 'currentConnectionID' , 'get' ) . mockReturnValue ( 'conn-1' ) ;
426+
427+ // Overwrite whatever the initial (failing) ngOnInit left, then render.
428+ component . connectionID = 'conn-1' ;
429+ component . tableName = 'orders' ;
430+ component . hasKeyAttributesFromURL = true ;
431+ component . keyAttributesFromURL = { id : '42' } ;
432+ component . keyAttributesListFromStructure = [ 'id' ] ;
433+ component . readonlyFields = [ ] ;
434+ component . nonModifyingFields = [ ] ;
435+ component . pageMode = 'edit' ;
436+ component . pageAction = null ;
437+ component . tableForeignKeys = [
438+ {
439+ column_name : 'CustomerId' ,
440+ referenced_column_name : 'id' ,
441+ referenced_table_name : 'customers' ,
442+ constraint_name : 'fk_customer' ,
443+ autocomplete_columns : [ ] ,
444+ } as any ,
445+ ] ;
446+ component . tableRowValues = {
447+ name : 'order-42' ,
448+ CustomerId : 7 ,
449+ payload : { type : 'Buffer' , data : [ 1 , 2 , 3 , 4 ] } ,
450+ } ;
451+ component . tableTypes = {
452+ name : 'varchar' ,
453+ CustomerId : 'foreign key' ,
454+ payload : 'bytea' ,
455+ } ;
456+ component . tableRowRequiredValues = { name : false , CustomerId : false , payload : false } ;
457+ component . tableRowStructure = {
458+ name : { column_name : 'name' , data_type : 'varchar' , allow_null : true } ,
459+ CustomerId : { column_name : 'CustomerId' , data_type : 'integer' , allow_null : true } ,
460+ payload : { column_name : 'payload' , data_type : 'bytea' , allow_null : true } ,
461+ } as any ;
462+ component . fieldsOrdered = [ 'name' , 'CustomerId' , 'payload' ] ;
463+ component . tableWidgetsList = [ ] ;
464+ component . tableWidgets = { } ;
465+ component . loading = false ;
466+
467+ fixture . detectChanges ( ) ;
468+ await fixture . whenStable ( ) ;
469+ fixture . detectChanges ( ) ;
470+ } ) ;
471+
472+ it ( 'sends an empty body when the user did not touch any field' , async ( ) => {
473+ component . handleRowSubmitting ( false ) ;
474+ await fixture . whenStable ( ) ;
475+
476+ expect ( updateTableRowSpy ) . toHaveBeenCalledTimes ( 1 ) ;
477+ const body = updateTableRowSpy . mock . calls [ 0 ] [ 3 ] ;
478+ expect ( body ) . toEqual ( { } ) ;
479+ } ) ;
480+
481+ it ( 'sends only the touched text field when the user edits name' , async ( ) => {
482+ // simulate the user typing into the text widget — same path the widget's
483+ // (onFieldChange) output would take through the template
484+ component . updateField ( 'order-43' , 'name' ) ;
485+
486+ component . handleRowSubmitting ( false ) ;
487+ await fixture . whenStable ( ) ;
488+
489+ expect ( updateTableRowSpy ) . toHaveBeenCalledTimes ( 1 ) ;
490+ const body = updateTableRowSpy . mock . calls [ 0 ] [ 3 ] ;
491+ expect ( body ) . toEqual ( { name : 'order-43' } ) ;
492+ expect ( body ) . not . toHaveProperty ( 'CustomerId' ) ;
493+ expect ( body ) . not . toHaveProperty ( 'payload' ) ;
494+ } ) ;
495+ } ) ;
345496} ) ;
0 commit comments