diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.spec.ts index e7274fbf1..70f8737fc 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.spec.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.spec.ts @@ -22,4 +22,76 @@ describe('DbTableRowViewComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('getForeignKeyValue', () => { + const baseRow = { + foreignKeys: { + user_id: { + column_name: 'user_id', + constraint_name: 'fk_user', + referenced_column_name: 'id', + referenced_table_name: 'users', + }, + }, + }; + + it('returns null when record field is null', () => { + component.selectedRow = { ...baseRow, record: { user_id: null } } as any; + expect(component.getForeignKeyValue('user_id')).toBeNull(); + }); + + it('returns null when record field is undefined', () => { + component.selectedRow = { ...baseRow, record: {} } as any; + expect(component.getForeignKeyValue('user_id')).toBeNull(); + }); + + it('returns identity column value when FK object has one', () => { + component.selectedRow = { + ...baseRow, + record: { user_id: { id: 42, name: 'alice' } }, + } as any; + expect(component.getForeignKeyValue('user_id')).toBe('alice'); + }); + + it('returns primitive FK value as-is (including 0)', () => { + component.selectedRow = { ...baseRow, record: { user_id: 0 } } as any; + expect(component.getForeignKeyValue('user_id')).toBe(0); + }); + + it('returns primitive FK value as-is (including empty string)', () => { + component.selectedRow = { ...baseRow, record: { user_id: '' } } as any; + expect(component.getForeignKeyValue('user_id')).toBe(''); + }); + }); + + describe('getForeignKeyQueryParams', () => { + const baseRow = { + foreignKeys: { + user_id: { + column_name: 'user_id', + constraint_name: 'fk_user', + referenced_column_name: 'id', + referenced_table_name: 'users', + }, + }, + }; + + it('returns {} when record field is null', () => { + component.selectedRow = { ...baseRow, record: { user_id: null } } as any; + expect(component.getForeignKeyQueryParams('user_id')).toEqual({}); + }); + + it('returns referenced column param when FK is an object', () => { + component.selectedRow = { + ...baseRow, + record: { user_id: { id: 42, name: 'alice' } }, + } as any; + expect(component.getForeignKeyQueryParams('user_id')).toEqual({ id: 42 }); + }); + + it('returns referenced column param when FK is a primitive', () => { + component.selectedRow = { ...baseRow, record: { user_id: 7 } } as any; + expect(component.getForeignKeyQueryParams('user_id')).toEqual({ id: 7 }); + }); + }); }); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.ts index d89a69f08..50a96305c 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.ts @@ -257,33 +257,23 @@ export class DbTableRowViewComponent implements OnInit, OnDestroy { } getForeignKeyValue(field: string) { - if (this.selectedRow && typeof this.selectedRow.record[field] === 'object') { - const identityColumnName = Object.keys(this.selectedRow.record[field]).find( - (key) => key !== this.selectedRow.foreignKeys[field].referenced_column_name, - ); + const cell = this.selectedRow?.record?.[field]; + if (cell == null) return null; + if (typeof cell === 'object') { const referencedColumnName = this.selectedRow.foreignKeys[field].referenced_column_name; - if (identityColumnName) { - return this.selectedRow.record[field][identityColumnName]; - } - if (referencedColumnName) { - return this.selectedRow.record[field][referencedColumnName]; - } - return this.selectedRow.record[field] || ''; + const identityColumnName = Object.keys(cell).find((key) => key !== referencedColumnName); + if (identityColumnName) return cell[identityColumnName]; + if (referencedColumnName && cell[referencedColumnName] != null) return cell[referencedColumnName]; + return null; } - return this.selectedRow.record[field] || ''; + return cell; } getForeignKeyQueryParams(field: string) { - if (this.selectedRow) { - const referencedColumnName = this.selectedRow.foreignKeys[field]?.referenced_column_name; - - if (typeof this.selectedRow.record[field] === 'object') { - return { [referencedColumnName]: this.selectedRow.record[field][referencedColumnName] }; - } else { - return { [referencedColumnName]: this.selectedRow.record[field] }; - } - } - return {}; + const cell = this.selectedRow?.record?.[field]; + const referencedColumnName = this.selectedRow?.foreignKeys?.[field]?.referenced_column_name; + if (cell == null || !referencedColumnName) return {}; + return { [referencedColumnName]: typeof cell === 'object' ? cell[referencedColumnName] : cell }; } isWidget(columnName: string) { diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.spec.ts index aadb22473..11fba7e69 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.spec.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.spec.ts @@ -241,4 +241,31 @@ describe('DbTableViewComponent', () => { const value = component.getCellValue(foreignKey, cell); expect(value).toEqual('John'); }); + + it('should return null (not throw) when foreign key cell is null', () => { + const foreignKey = { + autocomplete_columns: ['FirstName'], + column_name: 'CustomerId', + column_default: null, + constraint_name: 'Orders_ibfk_2', + referenced_column_name: 'Id', + referenced_table_name: 'Customers', + }; + + expect(component.getCellValue(foreignKey, null)).toBeNull(); + expect(component.getCellValue(foreignKey, undefined)).toBeNull(); + }); + + it('should not throw in isForeignKeySelected when record is null', () => { + const foreignKey = { + autocomplete_columns: ['FirstName'], + column_name: 'CustomerId', + column_default: null, + constraint_name: 'Orders_ibfk_2', + referenced_column_name: 'Id', + referenced_table_name: 'Customers', + }; + + expect(component.isForeignKeySelected(null, foreignKey)).toBe(false); + }); }); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.ts index d16e7399c..bad975c84 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.ts @@ -417,6 +417,7 @@ export class DbTableViewComponent implements OnInit, OnChanges { } getCellValue(foreignKey: TableForeignKey, cell) { + if (cell == null) return null; const identityColumnName = Object.keys(cell).find((key) => key !== foreignKey.referenced_column_name); if (identityColumnName) { return cell[identityColumnName]; @@ -680,6 +681,7 @@ export class DbTableViewComponent implements OnInit, OnChanges { } isForeignKeySelected(record, foreignKey: TableForeignKey) { + if (record == null) return false; const primaryKeyValue = record[foreignKey.referenced_column_name]; if (this.selectedRowType === 'foreignKey' && this.selectedRow && this.selectedRow.record !== null) { diff --git a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html index 74a7e9cd0..52589d951 100644 --- a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html +++ b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html @@ -125,6 +125,7 @@

required: tableRowRequiredValues[value], readonly: (!canEditRow() && pageAction !== 'dub') || pageMode === 'view', disabled: isReadonlyField(value), + structure: tableRowStructure[value], widgetStructure: tableWidgets[value], relations: tableTypes[value] === 'foreign key' ? getRelations(value) : undefined, rowPrimaryKey: keyAttributesFromURL diff --git a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.css b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.css index c015b0116..648d1b63f 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.css +++ b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.css @@ -52,3 +52,8 @@ margin-left: auto; margin-top: 12px; } + +.foreign-key__null-option { + font-style: italic; + color: var(--mat-sys-on-surface-variant, rgba(0, 0, 0, 0.6)); +} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.html b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.html index 65519179d..9678159c9 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.html @@ -1,5 +1,3 @@ - -
{{normalizedLabel()}} @@ -14,7 +12,7 @@ @for (suggestion of suggestions(); track suggestion.fieldValue) { {{suggestion.displayString}} @@ -24,19 +22,17 @@ here - - - open_in_new - - + @if (currentFieldValue != null) { + + + open_in_new + + + }
- - - - \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.spec.ts index 0277a802d..9b42774ba 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.spec.ts @@ -438,4 +438,116 @@ describe('ForeignKeyEditComponent', () => { }, ]); }); + + describe('nullable column', () => { + const nullableStructure = { + column_name: 'userId', + column_default: null, + data_type: 'integer', + isExcluded: false, + isSearched: false, + auto_increment: false, + allow_null: true, + character_maximum_length: null, + }; + + it('appends a "— empty" null option when structure.allow_null is true', async () => { + vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); + + component.connectionID = '12345678'; + fixture.componentRef.setInput('value', ''); + fixture.componentRef.setInput('structure', nullableStructure); + + await component.ngOnInit(); + fixture.detectChanges(); + + expect(component.allowsNull()).toBe(true); + const suggestions = component.suggestions(); + expect(suggestions).toHaveLength(4); + expect(suggestions[suggestions.length - 1]).toEqual({ + displayString: '— empty', + fieldValue: null, + isNullOption: true, + }); + }); + + it('appends a null option when widget_params.allow_null is true', async () => { + vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); + + component.connectionID = '12345678'; + fixture.componentRef.setInput('value', ''); + fixture.componentRef.setInput('widgetStructure', { + field_name: 'userId', + widget_type: 'Foreign_key', + widget_params: { ...fakeRelations, allow_null: true }, + name: '', + description: '', + }); + + await component.ngOnInit(); + fixture.detectChanges(); + + expect(component.allowsNull()).toBe(true); + const suggestions = component.suggestions(); + expect(suggestions[suggestions.length - 1].isNullOption).toBe(true); + }); + + it('does NOT append a null option when the column is not nullable', async () => { + vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); + + component.connectionID = '12345678'; + fixture.componentRef.setInput('value', ''); + fixture.componentRef.setInput('structure', { ...nullableStructure, allow_null: false }); + + await component.ngOnInit(); + fixture.detectChanges(); + + expect(component.allowsNull()).toBe(false); + expect(component.suggestions().some((s) => s.isNullOption)).toBe(false); + }); + + it('keeps the null option present when search returns no rows', async () => { + fixture.componentRef.setInput('structure', nullableStructure); + component.connectionID = '12345678'; + + vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of({ rows: [] })); + component.currentDisplayedString = 'nomatches'; + await component.fetchSuggestions(); + + expect(component.suggestions()).toEqual([ + { displayString: 'No field starts with "nomatches" in foreign entity.' }, + { displayString: '— empty', fieldValue: null, isNullOption: true }, + ]); + }); + + it('emits null and clears the related link when the null option is selected', () => { + const emitSpy = vi.spyOn(component.onFieldChange, 'emit'); + component.suggestions.set([ + { displayString: '— empty', fieldValue: null, isNullOption: true }, + { displayString: 'Alex | Taylor', primaryKeys: { id: 33 }, fieldValue: 33 }, + ]); + component.currentFieldQueryParams = { id: 33 }; + component.currentFieldValue = 33; + + component.updateRelatedLink({ option: { value: '— empty' } } as any); + + expect(component.currentFieldValue).toBeNull(); + expect(component.currentFieldQueryParams).toBeUndefined(); + expect(emitSpy).toHaveBeenCalledWith(null); + }); + + it('fetchSuggestions emits null when the current display string matches the null option', async () => { + const emitSpy = vi.spyOn(component.onFieldChange, 'emit'); + component.suggestions.set([ + { displayString: '— empty', fieldValue: null, isNullOption: true }, + { displayString: 'Alex | Taylor', primaryKeys: { id: 33 }, fieldValue: 33 }, + ]); + component.currentDisplayedString = '— empty'; + + await component.fetchSuggestions(); + + expect(component.currentFieldValue).toBeNull(); + expect(emitSpy).toHaveBeenCalledWith(null); + }); + }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.ts index 0ba4e4d31..84b1ca9b3 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, model, signal } from '@angular/core'; +import { Component, computed, inject, model, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -24,8 +24,17 @@ interface Suggestion { displayString: string; primaryKeys?: Record; fieldValue?: unknown; + isNullOption?: boolean; } +const NULL_OPTION_LABEL = '— empty'; + +const nullSuggestion: Suggestion = { + displayString: NULL_OPTION_LABEL, + fieldValue: null, + isNullOption: true, +}; + @Component({ selector: 'app-edit-foreign-key', templateUrl: './foreign-key.component.html', @@ -59,6 +68,11 @@ export class ForeignKeyEditComponent extends BaseEditFieldComponent { public fkRelations: TableForeignKey = null; + public allowsNull = computed(() => { + const ws = this.widgetStructure(); + return !!ws?.widget_params?.allow_null || !!this.structure()?.allow_null; + }); + private _debounceTimer: ReturnType; async ngOnInit(): Promise { @@ -121,25 +135,27 @@ export class ForeignKeyEditComponent extends BaseEditFieldComponent { this.identityColumn = suggestionsRes.identity_column; this.suggestions.set( - suggestionsRes.rows.map((row) => { - const modifiedRow = this.getModifiedRow(row); - return { - displayString: this.identityColumn - ? `${row[this.identityColumn]} (${Object.values(modifiedRow) - .filter((value) => value) - .join(' | ')})` - : Object.values(modifiedRow) - .filter((value) => value) - .join(' | '), - primaryKeys: Object.assign( - {}, - ...suggestionsRes.primaryColumns.map((primaeyKey) => ({ - [primaeyKey.column_name]: row[primaeyKey.column_name], - })), - ), - fieldValue: row[this.fkRelations.referenced_column_name], - }; - }), + this.withNullOption( + suggestionsRes.rows.map((row) => { + const modifiedRow = this.getModifiedRow(row); + return { + displayString: this.identityColumn + ? `${row[this.identityColumn]} (${Object.values(modifiedRow) + .filter((value) => value) + .join(' | ')})` + : Object.values(modifiedRow) + .filter((value) => value) + .join(' | '), + primaryKeys: Object.assign( + {}, + ...suggestionsRes.primaryColumns.map((primaeyKey) => ({ + [primaeyKey.column_name]: row[primaeyKey.column_name], + })), + ), + fieldValue: row[this.fkRelations.referenced_column_name], + }; + }), + ), ); this.fetching.set(false); } catch (error) { @@ -181,32 +197,36 @@ export class ForeignKeyEditComponent extends BaseEditFieldComponent { this.identityColumn = res.identity_column; if (res.rows.length === 0) { - this.suggestions.set([ - { - displayString: `No field starts with "${this.currentDisplayedString}" in foreign entity.`, - }, - ]); + this.suggestions.set( + this.withNullOption([ + { + displayString: `No field starts with "${this.currentDisplayedString}" in foreign entity.`, + }, + ]), + ); } else { this.suggestions.set( - res.rows.map((row) => { - const modifiedRow = this.getModifiedRow(row); - return { - displayString: this.identityColumn - ? `${row[this.identityColumn]} (${Object.values(modifiedRow) - .filter((value) => value) - .join(' | ')})` - : Object.values(modifiedRow) - .filter((value) => value) - .join(' | '), - primaryKeys: Object.assign( - {}, - ...res.primaryColumns.map((primaeyKey) => ({ - [primaeyKey.column_name]: row[primaeyKey.column_name], - })), - ), - fieldValue: row[this.fkRelations.referenced_column_name], - }; - }), + this.withNullOption( + res.rows.map((row) => { + const modifiedRow = this.getModifiedRow(row); + return { + displayString: this.identityColumn + ? `${row[this.identityColumn]} (${Object.values(modifiedRow) + .filter((value) => value) + .join(' | ')})` + : Object.values(modifiedRow) + .filter((value) => value) + .join(' | '), + primaryKeys: Object.assign( + {}, + ...res.primaryColumns.map((primaeyKey) => ({ + [primaeyKey.column_name]: row[primaeyKey.column_name], + })), + ), + fieldValue: row[this.fkRelations.referenced_column_name], + }; + }), + ), ); } this.fetching.set(false); @@ -239,8 +259,17 @@ export class ForeignKeyEditComponent extends BaseEditFieldComponent { } updateRelatedLink(e: MatAutocompleteSelectedEvent) { - this.currentFieldQueryParams = this.suggestions().find( - (suggestion) => suggestion.displayString === e.option.value, - ).primaryKeys; + const selected = this.suggestions().find((suggestion) => suggestion.displayString === e.option.value); + if (selected?.isNullOption) { + this.currentFieldValue = null; + this.currentFieldQueryParams = undefined; + this.onFieldChange.emit(null); + return; + } + this.currentFieldQueryParams = selected?.primaryKeys; + } + + private withNullOption(suggestions: Suggestion[]): Suggestion[] { + return this.allowsNull() ? [...suggestions, nullSuggestion] : suggestions; } } diff --git a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.html b/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.html index a5b804a17..d9083c910 100644 --- a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.html +++ b/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.html @@ -1,8 +1,12 @@ - - {{displayValue()}} - visibility - +@if (displayValue() == null) { + +} @else { + + {{displayValue()}} + visibility + +} diff --git a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.spec.ts index ede913808..518b9ade1 100644 --- a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.spec.ts @@ -27,4 +27,49 @@ describe('ForeignKeyRecordViewComponent', () => { component.ngOnInit(); expect(component.foreignKeyURLParams).toEqual({ id: 1, mode: 'view' }); }); + + it('should render a dash when displayValue is null', () => { + fixture.componentRef.setInput('link', '/foo'); + fixture.componentRef.setInput('primaryKeysParams', { id: 1 }); + fixture.componentRef.setInput('displayValue', null); + fixture.detectChanges(); + + const anchor = fixture.nativeElement.querySelector('a.foreign-key-link'); + const span = fixture.nativeElement.querySelector('span.field-view-value'); + expect(anchor).toBeFalsy(); + expect(span?.textContent?.trim()).toBe('—'); + }); + + it('should render a dash when displayValue is undefined', () => { + fixture.componentRef.setInput('link', '/foo'); + fixture.componentRef.setInput('primaryKeysParams', { id: 1 }); + fixture.componentRef.setInput('displayValue', undefined); + fixture.detectChanges(); + + const anchor = fixture.nativeElement.querySelector('a.foreign-key-link'); + const span = fixture.nativeElement.querySelector('span.field-view-value'); + expect(anchor).toBeFalsy(); + expect(span?.textContent?.trim()).toBe('—'); + }); + + it('should render the link (not a dash) when displayValue is 0', () => { + fixture.componentRef.setInput('link', '/foo'); + fixture.componentRef.setInput('primaryKeysParams', { id: 1 }); + fixture.componentRef.setInput('displayValue', 0 as unknown as string); + fixture.detectChanges(); + + const anchor = fixture.nativeElement.querySelector('a.foreign-key-link'); + expect(anchor).toBeTruthy(); + expect(anchor.querySelector('span').textContent.trim()).toBe('0'); + }); + + it('should render the link (not a dash) when displayValue is empty string', () => { + fixture.componentRef.setInput('link', '/foo'); + fixture.componentRef.setInput('primaryKeysParams', { id: 1 }); + fixture.componentRef.setInput('displayValue', ''); + fixture.detectChanges(); + + const anchor = fixture.nativeElement.querySelector('a.foreign-key-link'); + expect(anchor).toBeTruthy(); + }); }); diff --git a/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.html b/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.html index f44d3699a..66853cc22 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.html +++ b/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.html @@ -1,6 +1,8 @@
- @if (relations() && value()) { + @if (value() == null) { + + } @else if (relations()) {