Skip to content

Commit 7e39bb6

Browse files
committed
row edit: only send touched fields on update
Track which columns the user actually modified and filter getFormattedUpdatedRow to those keys on update (add/dup still send all fields). Deep-compare in updateField so widget-mount re-emits from ForeignKeyEditComponent and BinaryEditComponent don't taint the dirty set.
1 parent 253bc82 commit 7e39bb6

2 files changed

Lines changed: 162 additions & 3 deletions

File tree

frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.spec.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import { MatDialogModule } from '@angular/material/dialog';
44
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
55
import { provideRouter } from '@angular/router';
66
import { Angulartics2Module } from 'angulartics2';
7+
import { of } from 'rxjs';
78
import { Connection, ConnectionType, DBtype } from 'src/app/models/connection';
89
import { ConnectionsService } from 'src/app/services/connections.service';
10+
import { TableRowService } from 'src/app/services/table-row.service';
911
import { TablesService } from 'src/app/services/tables.service';
1012
import { 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
});

frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Title } from '@angular/platform-browser';
1515
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
1616
import JsonURL from '@jsonurl/jsonurl';
1717
import JSON5 from 'json5';
18+
import { isEqual } from 'lodash-es';
1819
import { DynamicModule } from 'ng-dynamic-component';
1920
import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io';
2021
import { defaultTimestampValues, recordEditTypes, timestampTypes, UIwidgets } from 'src/app/consts/record-edit-types';
@@ -74,6 +75,7 @@ export class DbTableRowEditComponent implements OnInit {
7475
public tableName: string | null = null;
7576
public dispalyTableName: string | null = null;
7677
public tableRowValues: Record<string, any>;
78+
private touchedFields = new Set<string>();
7779
public tableRowStructure: object;
7880
public tableRowRequiredValues: object;
7981
public identityColumn: string;
@@ -612,6 +614,9 @@ export class DbTableRowEditComponent implements OnInit {
612614

613615
updateField = (updatedValue: any, field: string) => {
614616
const existing = this.tableRowValues[field];
617+
if (!isEqual(updatedValue, existing)) {
618+
this.touchedFields.add(field);
619+
}
615620
if (
616621
typeof updatedValue === 'object' &&
617622
updatedValue !== null &&
@@ -631,8 +636,10 @@ export class DbTableRowEditComponent implements OnInit {
631636
}
632637
};
633638

634-
getFormattedUpdatedRow = () => {
635-
let updatedRow = { ...this.tableRowValues };
639+
getFormattedUpdatedRow = (onlyTouched: boolean = false) => {
640+
let updatedRow = onlyTouched
641+
? Object.fromEntries(Object.entries(this.tableRowValues).filter(([key]) => this.touchedFields.has(key)))
642+
: { ...this.tableRowValues };
636643

637644
//crutch, format datetime fields
638645
//if no one edit manually datetime field, we have to remove '.000Z', cuz mysql return this format but it doesn't record it
@@ -756,12 +763,13 @@ export class DbTableRowEditComponent implements OnInit {
756763
updateRow(continueEditing: boolean) {
757764
this.submitting = true;
758765

759-
const formattedUpdatedRow = this.getFormattedUpdatedRow();
766+
const formattedUpdatedRow = this.getFormattedUpdatedRow(true);
760767

761768
this._tableRow
762769
.updateTableRow(this.connectionID, this.tableName, this.keyAttributesFromURL, formattedUpdatedRow)
763770
.subscribe(
764771
(res) => {
772+
this.touchedFields.clear();
765773
this.ngZone.run(() => {
766774
if (continueEditing) {
767775
if (this.isPrimaryKeyUpdated) {

0 commit comments

Comments
 (0)