Skip to content

Commit fc516b4

Browse files
guguclaude
andcommitted
db table view: render null foreign-key values as a dash
Guards the helpers that extract FK cell values against null/undefined and updates the two FK display components (table cell, row preview) to render a dash only when the value is genuinely absent. Using `== null` keeps `0`, empty string, and `false` as real FK values so rows whose FK id is 0 still render the link, not a dash. Previously `Object.keys(null)` threw on any row with a null FK column and `{{ value() || '—' }}` substituted a dash for legitimately-zero ids. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6d1dcdc commit fc516b4

9 files changed

Lines changed: 236 additions & 59 deletions

File tree

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,76 @@ describe('DbTableRowViewComponent', () => {
2222
it('should create', () => {
2323
expect(component).toBeTruthy();
2424
});
25+
26+
describe('getForeignKeyValue', () => {
27+
const baseRow = {
28+
foreignKeys: {
29+
user_id: {
30+
column_name: 'user_id',
31+
constraint_name: 'fk_user',
32+
referenced_column_name: 'id',
33+
referenced_table_name: 'users',
34+
},
35+
},
36+
};
37+
38+
it('returns null when record field is null', () => {
39+
component.selectedRow = { ...baseRow, record: { user_id: null } } as any;
40+
expect(component.getForeignKeyValue('user_id')).toBeNull();
41+
});
42+
43+
it('returns null when record field is undefined', () => {
44+
component.selectedRow = { ...baseRow, record: {} } as any;
45+
expect(component.getForeignKeyValue('user_id')).toBeNull();
46+
});
47+
48+
it('returns identity column value when FK object has one', () => {
49+
component.selectedRow = {
50+
...baseRow,
51+
record: { user_id: { id: 42, name: 'alice' } },
52+
} as any;
53+
expect(component.getForeignKeyValue('user_id')).toBe('alice');
54+
});
55+
56+
it('returns primitive FK value as-is (including 0)', () => {
57+
component.selectedRow = { ...baseRow, record: { user_id: 0 } } as any;
58+
expect(component.getForeignKeyValue('user_id')).toBe(0);
59+
});
60+
61+
it('returns primitive FK value as-is (including empty string)', () => {
62+
component.selectedRow = { ...baseRow, record: { user_id: '' } } as any;
63+
expect(component.getForeignKeyValue('user_id')).toBe('');
64+
});
65+
});
66+
67+
describe('getForeignKeyQueryParams', () => {
68+
const baseRow = {
69+
foreignKeys: {
70+
user_id: {
71+
column_name: 'user_id',
72+
constraint_name: 'fk_user',
73+
referenced_column_name: 'id',
74+
referenced_table_name: 'users',
75+
},
76+
},
77+
};
78+
79+
it('returns {} when record field is null', () => {
80+
component.selectedRow = { ...baseRow, record: { user_id: null } } as any;
81+
expect(component.getForeignKeyQueryParams('user_id')).toEqual({});
82+
});
83+
84+
it('returns referenced column param when FK is an object', () => {
85+
component.selectedRow = {
86+
...baseRow,
87+
record: { user_id: { id: 42, name: 'alice' } },
88+
} as any;
89+
expect(component.getForeignKeyQueryParams('user_id')).toEqual({ id: 42 });
90+
});
91+
92+
it('returns referenced column param when FK is a primitive', () => {
93+
component.selectedRow = { ...baseRow, record: { user_id: 7 } } as any;
94+
expect(component.getForeignKeyQueryParams('user_id')).toEqual({ id: 7 });
95+
});
96+
});
2597
});

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

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -257,33 +257,23 @@ export class DbTableRowViewComponent implements OnInit, OnDestroy {
257257
}
258258

259259
getForeignKeyValue(field: string) {
260-
if (this.selectedRow && typeof this.selectedRow.record[field] === 'object') {
261-
const identityColumnName = Object.keys(this.selectedRow.record[field]).find(
262-
(key) => key !== this.selectedRow.foreignKeys[field].referenced_column_name,
263-
);
260+
const cell = this.selectedRow?.record?.[field];
261+
if (cell == null) return null;
262+
if (typeof cell === 'object') {
264263
const referencedColumnName = this.selectedRow.foreignKeys[field].referenced_column_name;
265-
if (identityColumnName) {
266-
return this.selectedRow.record[field][identityColumnName];
267-
}
268-
if (referencedColumnName) {
269-
return this.selectedRow.record[field][referencedColumnName];
270-
}
271-
return this.selectedRow.record[field] || '';
264+
const identityColumnName = Object.keys(cell).find((key) => key !== referencedColumnName);
265+
if (identityColumnName) return cell[identityColumnName];
266+
if (referencedColumnName && cell[referencedColumnName] != null) return cell[referencedColumnName];
267+
return null;
272268
}
273-
return this.selectedRow.record[field] || '';
269+
return cell;
274270
}
275271

276272
getForeignKeyQueryParams(field: string) {
277-
if (this.selectedRow) {
278-
const referencedColumnName = this.selectedRow.foreignKeys[field]?.referenced_column_name;
279-
280-
if (typeof this.selectedRow.record[field] === 'object') {
281-
return { [referencedColumnName]: this.selectedRow.record[field][referencedColumnName] };
282-
} else {
283-
return { [referencedColumnName]: this.selectedRow.record[field] };
284-
}
285-
}
286-
return {};
273+
const cell = this.selectedRow?.record?.[field];
274+
const referencedColumnName = this.selectedRow?.foreignKeys?.[field]?.referenced_column_name;
275+
if (cell == null || !referencedColumnName) return {};
276+
return { [referencedColumnName]: typeof cell === 'object' ? cell[referencedColumnName] : cell };
287277
}
288278

289279
isWidget(columnName: string) {

frontend/src/app/components/dashboard/db-table-view/db-table-view.component.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,4 +241,31 @@ describe('DbTableViewComponent', () => {
241241
const value = component.getCellValue(foreignKey, cell);
242242
expect(value).toEqual('John');
243243
});
244+
245+
it('should return null (not throw) when foreign key cell is null', () => {
246+
const foreignKey = {
247+
autocomplete_columns: ['FirstName'],
248+
column_name: 'CustomerId',
249+
column_default: null,
250+
constraint_name: 'Orders_ibfk_2',
251+
referenced_column_name: 'Id',
252+
referenced_table_name: 'Customers',
253+
};
254+
255+
expect(component.getCellValue(foreignKey, null)).toBeNull();
256+
expect(component.getCellValue(foreignKey, undefined)).toBeNull();
257+
});
258+
259+
it('should not throw in isForeignKeySelected when record is null', () => {
260+
const foreignKey = {
261+
autocomplete_columns: ['FirstName'],
262+
column_name: 'CustomerId',
263+
column_default: null,
264+
constraint_name: 'Orders_ibfk_2',
265+
referenced_column_name: 'Id',
266+
referenced_table_name: 'Customers',
267+
};
268+
269+
expect(component.isForeignKeySelected(null, foreignKey)).toBe(false);
270+
});
244271
});

frontend/src/app/components/dashboard/db-table-view/db-table-view.component.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ export class DbTableViewComponent implements OnInit, OnChanges {
417417
}
418418

419419
getCellValue(foreignKey: TableForeignKey, cell) {
420+
if (cell == null) return null;
420421
const identityColumnName = Object.keys(cell).find((key) => key !== foreignKey.referenced_column_name);
421422
if (identityColumnName) {
422423
return cell[identityColumnName];
@@ -680,6 +681,7 @@ export class DbTableViewComponent implements OnInit, OnChanges {
680681
}
681682

682683
isForeignKeySelected(record, foreignKey: TableForeignKey) {
684+
if (record == null) return false;
683685
const primaryKeyValue = record[foreignKey.referenced_column_name];
684686

685687
if (this.selectedRowType === 'foreignKey' && this.selectedRow && this.selectedRow.record !== null) {
Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
<a
2-
[routerLink]="link()"
3-
[queryParams]="foreignKeyURLParams"
4-
class="field-view-value foreign-key-link"
5-
(click)="onForeignKeyClick.emit({ foreignKey: primaryKeysParams(), value: displayValue() })">
6-
<span>{{displayValue()}}</span>
7-
<mat-icon fontSet="material-symbols-outlined" class="field-view-icon">visibility</mat-icon>
8-
</a>
1+
@if (displayValue() == null) {
2+
<span class="field-view-value"></span>
3+
} @else {
4+
<a
5+
[routerLink]="link()"
6+
[queryParams]="foreignKeyURLParams"
7+
class="field-view-value foreign-key-link"
8+
(click)="onForeignKeyClick.emit({ foreignKey: primaryKeysParams(), value: displayValue() })">
9+
<span>{{displayValue()}}</span>
10+
<mat-icon fontSet="material-symbols-outlined" class="field-view-icon">visibility</mat-icon>
11+
</a>
12+
}

frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,49 @@ describe('ForeignKeyRecordViewComponent', () => {
2727
component.ngOnInit();
2828
expect(component.foreignKeyURLParams).toEqual({ id: 1, mode: 'view' });
2929
});
30+
31+
it('should render a dash when displayValue is null', () => {
32+
fixture.componentRef.setInput('link', '/foo');
33+
fixture.componentRef.setInput('primaryKeysParams', { id: 1 });
34+
fixture.componentRef.setInput('displayValue', null);
35+
fixture.detectChanges();
36+
37+
const anchor = fixture.nativeElement.querySelector('a.foreign-key-link');
38+
const span = fixture.nativeElement.querySelector('span.field-view-value');
39+
expect(anchor).toBeFalsy();
40+
expect(span?.textContent?.trim()).toBe('—');
41+
});
42+
43+
it('should render a dash when displayValue is undefined', () => {
44+
fixture.componentRef.setInput('link', '/foo');
45+
fixture.componentRef.setInput('primaryKeysParams', { id: 1 });
46+
fixture.componentRef.setInput('displayValue', undefined);
47+
fixture.detectChanges();
48+
49+
const anchor = fixture.nativeElement.querySelector('a.foreign-key-link');
50+
const span = fixture.nativeElement.querySelector('span.field-view-value');
51+
expect(anchor).toBeFalsy();
52+
expect(span?.textContent?.trim()).toBe('—');
53+
});
54+
55+
it('should render the link (not a dash) when displayValue is 0', () => {
56+
fixture.componentRef.setInput('link', '/foo');
57+
fixture.componentRef.setInput('primaryKeysParams', { id: 1 });
58+
fixture.componentRef.setInput('displayValue', 0 as unknown as string);
59+
fixture.detectChanges();
60+
61+
const anchor = fixture.nativeElement.querySelector('a.foreign-key-link');
62+
expect(anchor).toBeTruthy();
63+
expect(anchor.querySelector('span').textContent.trim()).toBe('0');
64+
});
65+
66+
it('should render the link (not a dash) when displayValue is empty string', () => {
67+
fixture.componentRef.setInput('link', '/foo');
68+
fixture.componentRef.setInput('primaryKeysParams', { id: 1 });
69+
fixture.componentRef.setInput('displayValue', '');
70+
fixture.detectChanges();
71+
72+
const anchor = fixture.nativeElement.querySelector('a.foreign-key-link');
73+
expect(anchor).toBeTruthy();
74+
});
3075
});

frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<div class="field-display">
22
<div class="field-value">
3-
@if (relations() && value()) {
3+
@if (value() == null) {
4+
<span></span>
5+
} @else if (relations()) {
46
<button
57
class="foreign-key-button"
68
[ngClass]="{'foreign-key-button_selected': isSelected()}"
@@ -9,7 +11,7 @@
911
{{ value() }}
1012
</button>
1113
} @else {
12-
<span>{{ value() || '—' }}</span>
14+
<span>{{ value() }}</span>
1315
}
1416
</div>
1517
<button type="button"

frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.spec.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ describe('ForeignKeyDisplayComponent', () => {
55
let component: ForeignKeyDisplayComponent;
66
let fixture: ComponentFixture<ForeignKeyDisplayComponent>;
77

8+
const relations = {
9+
column_name: 'user_id',
10+
constraint_name: 'fk_user',
11+
referenced_column_name: 'id',
12+
referenced_table_name: 'users',
13+
};
14+
815
beforeEach(async () => {
916
await TestBed.configureTestingModule({
1017
imports: [ForeignKeyDisplayComponent],
@@ -28,15 +35,44 @@ describe('ForeignKeyDisplayComponent', () => {
2835

2936
it('should show foreign key button when relations exist', () => {
3037
fixture.componentRef.setInput('value', '42');
31-
fixture.componentRef.setInput('relations', {
32-
column_name: 'user_id',
33-
constraint_name: 'fk_user',
34-
referenced_column_name: 'id',
35-
referenced_table_name: 'users',
36-
});
38+
fixture.componentRef.setInput('relations', relations);
39+
fixture.detectChanges();
40+
const button = fixture.nativeElement.querySelector('button.foreign-key-button');
41+
expect(button).toBeTruthy();
42+
});
43+
44+
it('should render a dash when value is null', () => {
45+
fixture.componentRef.setInput('value', null);
46+
fixture.componentRef.setInput('relations', relations);
47+
fixture.detectChanges();
48+
const button = fixture.nativeElement.querySelector('button.foreign-key-button');
49+
const span = fixture.nativeElement.querySelector('.field-value span');
50+
expect(button).toBeFalsy();
51+
expect(span?.textContent?.trim()).toBe('—');
52+
});
53+
54+
it('should render a dash when value is undefined', () => {
55+
fixture.componentRef.setInput('value', undefined);
56+
fixture.componentRef.setInput('relations', relations);
57+
fixture.detectChanges();
58+
const span = fixture.nativeElement.querySelector('.field-value span');
59+
expect(span?.textContent?.trim()).toBe('—');
60+
});
61+
62+
it('should render the FK button (not a dash) when value is 0', () => {
63+
fixture.componentRef.setInput('value', 0);
64+
fixture.componentRef.setInput('relations', relations);
65+
fixture.detectChanges();
66+
const button = fixture.nativeElement.querySelector('button.foreign-key-button');
67+
expect(button).toBeTruthy();
68+
expect(button.textContent.trim()).toBe('0');
69+
});
70+
71+
it('should render the FK button (not a dash) when value is empty string', () => {
72+
fixture.componentRef.setInput('value', '');
73+
fixture.componentRef.setInput('relations', relations);
3774
fixture.detectChanges();
38-
const compiled = fixture.nativeElement;
39-
const button = compiled.querySelector('button');
75+
const button = fixture.nativeElement.querySelector('button.foreign-key-button');
4076
expect(button).toBeTruthy();
4177
});
4278
});
Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,31 @@
1-
import { Component, input, output } from '@angular/core';
2-
3-
import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component';
41
import { ClipboardModule } from '@angular/cdk/clipboard';
52
import { CommonModule } from '@angular/common';
3+
import { Component, input, output } from '@angular/core';
64
import { MatButtonModule } from '@angular/material/button';
75
import { MatIconModule } from '@angular/material/icon';
86
import { MatTooltipModule } from '@angular/material/tooltip';
97
import { TableForeignKey } from 'src/app/models/table';
8+
import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component';
109

1110
@Component({
12-
selector: 'app-display-foreign-key',
13-
templateUrl: './foreign-key.component.html',
14-
styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './foreign-key.component.css'],
15-
imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule, CommonModule]
11+
selector: 'app-display-foreign-key',
12+
templateUrl: './foreign-key.component.html',
13+
styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './foreign-key.component.css'],
14+
imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule, CommonModule],
1615
})
1716
export class ForeignKeyDisplayComponent extends BaseTableDisplayFieldComponent {
18-
readonly isSelected = input<boolean>(false);
19-
readonly relations = input<TableForeignKey>();
17+
readonly isSelected = input<boolean>(false);
18+
readonly relations = input<TableForeignKey>();
2019

21-
readonly onForeignKeyClick = output<{foreignKey: any, value: string}>();
20+
readonly onForeignKeyClick = output<{ foreignKey: any; value: string }>();
2221

23-
handleForeignKeyClick($event): void {
24-
$event.stopPropagation();
25-
if (this.relations() && this.value()) {
26-
this.onForeignKeyClick.emit({
27-
foreignKey: this.relations(),
28-
value: this.value()
29-
});
30-
}
31-
}
22+
handleForeignKeyClick($event): void {
23+
$event.stopPropagation();
24+
if (this.relations() && this.value() != null) {
25+
this.onForeignKeyClick.emit({
26+
foreignKey: this.relations(),
27+
value: this.value(),
28+
});
29+
}
30+
}
3231
}

0 commit comments

Comments
 (0)