Skip to content

Commit 768a9d6

Browse files
guguclaude
andcommitted
fk edit widget: offer an empty-value option for nullable columns
Appends a "— empty" option to the autocomplete suggestions whenever the column is nullable, sourced from either structure.allow_null or widget_params.allow_null. Selecting it emits null, clears the related link, and hides the "open related record" icon. The option sits at the end of the list so mat-autocomplete's autoActiveFirstOption highlights the real current selection, not the empty placeholder. Also threads structure through the widget-branch ndc-dynamic in the row-edit template so the flag reaches the widget when an explicit Foreign_key widget is configured. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fc516b4 commit 768a9d6

5 files changed

Lines changed: 208 additions & 65 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ <h3>
125125
required: tableRowRequiredValues[value],
126126
readonly: (!canEditRow() && pageAction !== 'dub') || pageMode === 'view',
127127
disabled: isReadonlyField(value),
128+
structure: tableRowStructure[value],
128129
widgetStructure: tableWidgets[value],
129130
relations: tableTypes[value] === 'foreign key' ? getRelations(value) : undefined,
130131
rowPrimaryKey: keyAttributesFromURL

frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,8 @@
5252
margin-left: auto;
5353
margin-top: 12px;
5454
}
55+
56+
.foreign-key__null-option {
57+
font-style: italic;
58+
color: var(--mat-sys-on-surface-variant, rgba(0, 0, 0, 0.6));
59+
}
Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
<!-- <pre>{{ value() }}</pre> -->
2-
31
<div class="foreign-key">
42
<mat-form-field class="full-width" appearance="outline">
53
<mat-label>{{normalizedLabel()}}</mat-label>
@@ -14,7 +12,7 @@
1412
<mat-autocomplete autoActiveFirstOption #auto="matAutocomplete" (optionSelected)="updateRelatedLink($event)">
1513
@for (suggestion of suggestions(); track suggestion.fieldValue) {
1614
<mat-option
17-
[ngClass]="{'disabled': suggestion.displayString === 'No matches'}"
15+
[ngClass]="{'disabled': suggestion.displayString === 'No matches', 'foreign-key__null-option': suggestion.isNullOption}"
1816
[value]="suggestion.displayString">
1917
{{suggestion.displayString}}
2018
</mat-option>
@@ -24,19 +22,17 @@
2422
<a routerLink="/dashboard/{{connectionID}}/{{relations().referenced_table_name}}/settings" class="hint-link">here</a>
2523
</mat-hint>
2624
</mat-form-field>
27-
<a routerLink="/dashboard/{{connectionID}}/{{relations().referenced_table_name}}/entry"
28-
[queryParams]="currentFieldQueryParams"
29-
target="_blank"
30-
class="foreign-key__link">
31-
<mat-icon
32-
class="foreign-key__link-icon"
33-
matTooltip="Show related record"
34-
matTooltipPosition="above">
35-
open_in_new
36-
</mat-icon>
37-
</a>
25+
@if (currentFieldValue != null) {
26+
<a routerLink="/dashboard/{{connectionID}}/{{relations().referenced_table_name}}/entry"
27+
[queryParams]="currentFieldQueryParams"
28+
target="_blank"
29+
class="foreign-key__link">
30+
<mat-icon
31+
class="foreign-key__link-icon"
32+
matTooltip="Show related record"
33+
matTooltipPosition="above">
34+
open_in_new
35+
</mat-icon>
36+
</a>
37+
}
3838
</div>
39-
40-
<!-- <pre>{{ relations() | json }}</pre> -->
41-
<!-- <pre>{{ suggestions() | json}}</pre> -->
42-
<!-- <p>foreign-key component</p> -->

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

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,4 +438,116 @@ describe('ForeignKeyEditComponent', () => {
438438
},
439439
]);
440440
});
441+
442+
describe('nullable column', () => {
443+
const nullableStructure = {
444+
column_name: 'userId',
445+
column_default: null,
446+
data_type: 'integer',
447+
isExcluded: false,
448+
isSearched: false,
449+
auto_increment: false,
450+
allow_null: true,
451+
character_maximum_length: null,
452+
};
453+
454+
it('appends a "— empty" null option when structure.allow_null is true', async () => {
455+
vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork));
456+
457+
component.connectionID = '12345678';
458+
fixture.componentRef.setInput('value', '');
459+
fixture.componentRef.setInput('structure', nullableStructure);
460+
461+
await component.ngOnInit();
462+
fixture.detectChanges();
463+
464+
expect(component.allowsNull()).toBe(true);
465+
const suggestions = component.suggestions();
466+
expect(suggestions).toHaveLength(4);
467+
expect(suggestions[suggestions.length - 1]).toEqual({
468+
displayString: '— empty',
469+
fieldValue: null,
470+
isNullOption: true,
471+
});
472+
});
473+
474+
it('appends a null option when widget_params.allow_null is true', async () => {
475+
vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork));
476+
477+
component.connectionID = '12345678';
478+
fixture.componentRef.setInput('value', '');
479+
fixture.componentRef.setInput('widgetStructure', {
480+
field_name: 'userId',
481+
widget_type: 'Foreign_key',
482+
widget_params: { ...fakeRelations, allow_null: true },
483+
name: '',
484+
description: '',
485+
});
486+
487+
await component.ngOnInit();
488+
fixture.detectChanges();
489+
490+
expect(component.allowsNull()).toBe(true);
491+
const suggestions = component.suggestions();
492+
expect(suggestions[suggestions.length - 1].isNullOption).toBe(true);
493+
});
494+
495+
it('does NOT append a null option when the column is not nullable', async () => {
496+
vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork));
497+
498+
component.connectionID = '12345678';
499+
fixture.componentRef.setInput('value', '');
500+
fixture.componentRef.setInput('structure', { ...nullableStructure, allow_null: false });
501+
502+
await component.ngOnInit();
503+
fixture.detectChanges();
504+
505+
expect(component.allowsNull()).toBe(false);
506+
expect(component.suggestions().some((s) => s.isNullOption)).toBe(false);
507+
});
508+
509+
it('keeps the null option present when search returns no rows', async () => {
510+
fixture.componentRef.setInput('structure', nullableStructure);
511+
component.connectionID = '12345678';
512+
513+
vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of({ rows: [] }));
514+
component.currentDisplayedString = 'nomatches';
515+
await component.fetchSuggestions();
516+
517+
expect(component.suggestions()).toEqual([
518+
{ displayString: 'No field starts with "nomatches" in foreign entity.' },
519+
{ displayString: '— empty', fieldValue: null, isNullOption: true },
520+
]);
521+
});
522+
523+
it('emits null and clears the related link when the null option is selected', () => {
524+
const emitSpy = vi.spyOn(component.onFieldChange, 'emit');
525+
component.suggestions.set([
526+
{ displayString: '— empty', fieldValue: null, isNullOption: true },
527+
{ displayString: 'Alex | Taylor', primaryKeys: { id: 33 }, fieldValue: 33 },
528+
]);
529+
component.currentFieldQueryParams = { id: 33 };
530+
component.currentFieldValue = 33;
531+
532+
component.updateRelatedLink({ option: { value: '— empty' } } as any);
533+
534+
expect(component.currentFieldValue).toBeNull();
535+
expect(component.currentFieldQueryParams).toBeUndefined();
536+
expect(emitSpy).toHaveBeenCalledWith(null);
537+
});
538+
539+
it('fetchSuggestions emits null when the current display string matches the null option', async () => {
540+
const emitSpy = vi.spyOn(component.onFieldChange, 'emit');
541+
component.suggestions.set([
542+
{ displayString: '— empty', fieldValue: null, isNullOption: true },
543+
{ displayString: 'Alex | Taylor', primaryKeys: { id: 33 }, fieldValue: 33 },
544+
]);
545+
component.currentDisplayedString = '— empty';
546+
547+
await component.fetchSuggestions();
548+
549+
expect(component.currentFieldValue).toBeNull();
550+
expect(emitSpy).toHaveBeenCalledWith(null);
551+
});
552+
});
441553
});

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

Lines changed: 76 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CommonModule } from '@angular/common';
2-
import { Component, inject, model, signal } from '@angular/core';
2+
import { Component, computed, inject, model, signal } from '@angular/core';
33
import { FormsModule } from '@angular/forms';
44
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
55
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -24,8 +24,17 @@ interface Suggestion {
2424
displayString: string;
2525
primaryKeys?: Record<string, unknown>;
2626
fieldValue?: unknown;
27+
isNullOption?: boolean;
2728
}
2829

30+
const NULL_OPTION_LABEL = '— empty';
31+
32+
const nullSuggestion: Suggestion = {
33+
displayString: NULL_OPTION_LABEL,
34+
fieldValue: null,
35+
isNullOption: true,
36+
};
37+
2938
@Component({
3039
selector: 'app-edit-foreign-key',
3140
templateUrl: './foreign-key.component.html',
@@ -59,6 +68,11 @@ export class ForeignKeyEditComponent extends BaseEditFieldComponent {
5968

6069
public fkRelations: TableForeignKey = null;
6170

71+
public allowsNull = computed(() => {
72+
const ws = this.widgetStructure();
73+
return !!ws?.widget_params?.allow_null || !!this.structure()?.allow_null;
74+
});
75+
6276
private _debounceTimer: ReturnType<typeof setTimeout>;
6377

6478
async ngOnInit(): Promise<void> {
@@ -121,25 +135,27 @@ export class ForeignKeyEditComponent extends BaseEditFieldComponent {
121135

122136
this.identityColumn = suggestionsRes.identity_column;
123137
this.suggestions.set(
124-
suggestionsRes.rows.map((row) => {
125-
const modifiedRow = this.getModifiedRow(row);
126-
return {
127-
displayString: this.identityColumn
128-
? `${row[this.identityColumn]} (${Object.values(modifiedRow)
129-
.filter((value) => value)
130-
.join(' | ')})`
131-
: Object.values(modifiedRow)
132-
.filter((value) => value)
133-
.join(' | '),
134-
primaryKeys: Object.assign(
135-
{},
136-
...suggestionsRes.primaryColumns.map((primaeyKey) => ({
137-
[primaeyKey.column_name]: row[primaeyKey.column_name],
138-
})),
139-
),
140-
fieldValue: row[this.fkRelations.referenced_column_name],
141-
};
142-
}),
138+
this.withNullOption(
139+
suggestionsRes.rows.map((row) => {
140+
const modifiedRow = this.getModifiedRow(row);
141+
return {
142+
displayString: this.identityColumn
143+
? `${row[this.identityColumn]} (${Object.values(modifiedRow)
144+
.filter((value) => value)
145+
.join(' | ')})`
146+
: Object.values(modifiedRow)
147+
.filter((value) => value)
148+
.join(' | '),
149+
primaryKeys: Object.assign(
150+
{},
151+
...suggestionsRes.primaryColumns.map((primaeyKey) => ({
152+
[primaeyKey.column_name]: row[primaeyKey.column_name],
153+
})),
154+
),
155+
fieldValue: row[this.fkRelations.referenced_column_name],
156+
};
157+
}),
158+
),
143159
);
144160
this.fetching.set(false);
145161
} catch (error) {
@@ -181,32 +197,36 @@ export class ForeignKeyEditComponent extends BaseEditFieldComponent {
181197

182198
this.identityColumn = res.identity_column;
183199
if (res.rows.length === 0) {
184-
this.suggestions.set([
185-
{
186-
displayString: `No field starts with "${this.currentDisplayedString}" in foreign entity.`,
187-
},
188-
]);
200+
this.suggestions.set(
201+
this.withNullOption([
202+
{
203+
displayString: `No field starts with "${this.currentDisplayedString}" in foreign entity.`,
204+
},
205+
]),
206+
);
189207
} else {
190208
this.suggestions.set(
191-
res.rows.map((row) => {
192-
const modifiedRow = this.getModifiedRow(row);
193-
return {
194-
displayString: this.identityColumn
195-
? `${row[this.identityColumn]} (${Object.values(modifiedRow)
196-
.filter((value) => value)
197-
.join(' | ')})`
198-
: Object.values(modifiedRow)
199-
.filter((value) => value)
200-
.join(' | '),
201-
primaryKeys: Object.assign(
202-
{},
203-
...res.primaryColumns.map((primaeyKey) => ({
204-
[primaeyKey.column_name]: row[primaeyKey.column_name],
205-
})),
206-
),
207-
fieldValue: row[this.fkRelations.referenced_column_name],
208-
};
209-
}),
209+
this.withNullOption(
210+
res.rows.map((row) => {
211+
const modifiedRow = this.getModifiedRow(row);
212+
return {
213+
displayString: this.identityColumn
214+
? `${row[this.identityColumn]} (${Object.values(modifiedRow)
215+
.filter((value) => value)
216+
.join(' | ')})`
217+
: Object.values(modifiedRow)
218+
.filter((value) => value)
219+
.join(' | '),
220+
primaryKeys: Object.assign(
221+
{},
222+
...res.primaryColumns.map((primaeyKey) => ({
223+
[primaeyKey.column_name]: row[primaeyKey.column_name],
224+
})),
225+
),
226+
fieldValue: row[this.fkRelations.referenced_column_name],
227+
};
228+
}),
229+
),
210230
);
211231
}
212232
this.fetching.set(false);
@@ -239,8 +259,17 @@ export class ForeignKeyEditComponent extends BaseEditFieldComponent {
239259
}
240260

241261
updateRelatedLink(e: MatAutocompleteSelectedEvent) {
242-
this.currentFieldQueryParams = this.suggestions().find(
243-
(suggestion) => suggestion.displayString === e.option.value,
244-
).primaryKeys;
262+
const selected = this.suggestions().find((suggestion) => suggestion.displayString === e.option.value);
263+
if (selected?.isNullOption) {
264+
this.currentFieldValue = null;
265+
this.currentFieldQueryParams = undefined;
266+
this.onFieldChange.emit(null);
267+
return;
268+
}
269+
this.currentFieldQueryParams = selected?.primaryKeys;
270+
}
271+
272+
private withNullOption(suggestions: Suggestion[]): Suggestion[] {
273+
return this.allowsNull() ? [...suggestions, nullSuggestion] : suggestions;
245274
}
246275
}

0 commit comments

Comments
 (0)