Use faster, editable dropdown for Collations in database dialogs (#23974)

* Also fixed an issue where a manually edited text field doesn't get updated when selecting the same dropdown value from before the manual edit.
---------

Co-authored-by: Charles Gagnon <chgagnon@microsoft.com>
This commit is contained in:
Cory Rivera
2023-07-26 10:04:29 -07:00
committed by GitHub
parent 5f1801d6d4
commit cc778ad69f
7 changed files with 67 additions and 32 deletions

View File

@@ -161,7 +161,7 @@ export const MemberSectionHeader = localize('objectManagement.membersLabel', "Me
export const SchemaText = localize('objectManagement.schemaLabel', "Schema"); export const SchemaText = localize('objectManagement.schemaLabel', "Schema");
// Database // Database
export const DatabaseExistsError = (dbName: string) => localize('objectManagement.databaseExistsError', "Database '{0}' already exists. Choose a different database name.", dbName); export const CollationNotValidError = (collationName: string) => localize('objectManagement.collationNotValidError', "The selected collation '{0}' is not valid. Please choose a different collation.", collationName);
export const CollationText = localize('objectManagement.collationLabel', "Collation"); export const CollationText = localize('objectManagement.collationLabel', "Collation");
export const RecoveryModelText = localize('objectManagement.recoveryModelLabel', "Recovery Model"); export const RecoveryModelText = localize('objectManagement.recoveryModelLabel', "Recovery Model");
export const CompatibilityLevelText = localize('objectManagement.compatibilityLevelLabel', "Compatibility Level"); export const CompatibilityLevelText = localize('objectManagement.compatibilityLevelLabel', "Compatibility Level");

View File

@@ -134,6 +134,14 @@ export class DatabaseDialog extends ObjectManagementDialogBase<Database, Databas
} }
} }
protected override async validateInput(): Promise<string[]> {
let errors = await super.validateInput();
if (this.viewInfo.collationNames?.length > 0 && !this.viewInfo.collationNames.some(name => name.toLowerCase() === this.objectInfo.collationName?.toLowerCase())) {
errors.push(localizedConstants.CollationNotValidError(this.objectInfo.collationName ?? ''));
}
return errors;
}
//#region Create Database //#region Create Database
private initializeGeneralSection(): azdata.GroupContainer { private initializeGeneralSection(): azdata.GroupContainer {
let containers: azdata.Component[] = []; let containers: azdata.Component[] = [];
@@ -167,7 +175,7 @@ export class DatabaseDialog extends ObjectManagementDialogBase<Database, Databas
this.objectInfo.collationName = this.viewInfo.collationNames[0]; this.objectInfo.collationName = this.viewInfo.collationNames[0];
let collationDropbox = this.createDropdown(localizedConstants.CollationText, async () => { let collationDropbox = this.createDropdown(localizedConstants.CollationText, async () => {
this.objectInfo.collationName = collationDropbox.value as string; this.objectInfo.collationName = collationDropbox.value as string;
}, this.viewInfo.collationNames, this.viewInfo.collationNames[0]); }, this.viewInfo.collationNames, this.viewInfo.collationNames[0], true, DefaultInputWidth, true, true);
containers.push(this.createLabelInputContainer(localizedConstants.CollationText, collationDropbox)); containers.push(this.createLabelInputContainer(localizedConstants.CollationText, collationDropbox));
} }
@@ -277,7 +285,7 @@ export class DatabaseDialog extends ObjectManagementDialogBase<Database, Databas
// Collation // Collation
let collationDropbox = this.createDropdown(localizedConstants.CollationText, async (newValue) => { let collationDropbox = this.createDropdown(localizedConstants.CollationText, async (newValue) => {
this.objectInfo.collationName = newValue as string; this.objectInfo.collationName = newValue as string;
}, this.viewInfo.collationNames, this.objectInfo.collationName); }, this.viewInfo.collationNames, this.objectInfo.collationName, true, DefaultInputWidth, true, true);
containers.push(this.createLabelInputContainer(localizedConstants.CollationText, collationDropbox)); containers.push(this.createLabelInputContainer(localizedConstants.CollationText, collationDropbox));
// Recovery Model // Recovery Model

View File

@@ -27,7 +27,6 @@ export const DefaultTableListItemEnabledStateGetter: TableListItemEnabledStateGe
export const DefaultTableListItemValueGetter: TableListItemValueGetter<any> = (item: any) => [item?.toString() ?? '']; export const DefaultTableListItemValueGetter: TableListItemValueGetter<any> = (item: any) => [item?.toString() ?? ''];
export const DefaultTableListItemComparer: TableListItemComparer<any> = (item1: any, item2: any) => item1 === item2; export const DefaultTableListItemComparer: TableListItemComparer<any> = (item1: any, item2: any) => item1 === item2;
export abstract class DialogBase<DialogResult> { export abstract class DialogBase<DialogResult> {
protected readonly disposables: vscode.Disposable[] = []; protected readonly disposables: vscode.Disposable[] = [];
protected readonly dialogObject: azdata.window.Dialog; protected readonly dialogObject: azdata.window.Dialog;
@@ -287,7 +286,7 @@ export abstract class DialogBase<DialogResult> {
return this.createButtonContainer([addButton, removeButton]); return this.createButtonContainer([addButton, removeButton]);
} }
protected createDropdown(ariaLabel: string, handler: (newValue: string) => Promise<void>, values: string[], value: string | undefined, enabled: boolean = true, width: number = DefaultInputWidth): azdata.DropDownComponent { protected createDropdown(ariaLabel: string, handler: (newValue: string) => Promise<void>, values: string[], value: string | undefined, enabled: boolean = true, width: number = DefaultInputWidth, editable?: boolean, strictSelection?: boolean): azdata.DropDownComponent {
// Automatically add an empty item to the beginning of the list if the current value is not specified. // Automatically add an empty item to the beginning of the list if the current value is not specified.
// This is needed when no meaningful default value can be provided. // This is needed when no meaningful default value can be provided.
// Create a new array so that the original array isn't modified. // Create a new array so that the original array isn't modified.
@@ -301,7 +300,9 @@ export abstract class DialogBase<DialogResult> {
values: dropdownValues, values: dropdownValues,
value: value, value: value,
width: width, width: width,
enabled: enabled enabled: enabled,
editable: editable,
strictSelection: strictSelection
}).component(); }).component();
this.disposables.push(dropdown.onValueChanged(async () => { this.disposables.push(dropdown.onValueChanged(async () => {
await handler(<string>dropdown.value!); await handler(<string>dropdown.value!);

View File

@@ -1826,14 +1826,21 @@ declare module 'azdata' {
/** /**
* Corresponds to the aria-live accessibility attribute for this component * Corresponds to the aria-live accessibility attribute for this component
*/ */
ariaLive?: AriaLiveValue ariaLive?: AriaLiveValue;
} }
export interface ContainerProperties extends ComponentProperties { export interface ContainerProperties extends ComponentProperties {
/** /**
* Corresponds to the aria-live accessibility attribute for this component * Corresponds to the aria-live accessibility attribute for this component
*/ */
ariaLive?: AriaLiveValue ariaLive?: AriaLiveValue;
}
export interface DropDownProperties {
/**
* Whether or not an option in the list must be selected or a "new" option can be set. Only applicable when 'editable' is true. Default false.
*/
strictSelection?: boolean;
} }
export interface NodeInfo { export interface NodeInfo {

View File

@@ -112,9 +112,11 @@ export class Dropdown extends Disposable implements IListVirtualDelegate<string>
this._inputContainer = DOM.append(this._el, DOM.$('.dropdown-input.select-container')); this._inputContainer = DOM.append(this._el, DOM.$('.dropdown-input.select-container'));
this._inputContainer.style.width = '100%'; this._inputContainer.style.width = '100%';
this._inputContainer.style.height = '100%'; this._inputContainer.style.height = '100%';
this._selectListContainer = DOM.$('div'); this._selectListContainer = DOM.$('div');
this._selectListContainer.style.backgroundColor = opt.contextBackground; this._selectListContainer.style.backgroundColor = opt.contextBackground;
this._selectListContainer.style.outline = `1px solid ${opt.contextBorder}`; this._selectListContainer.style.outline = `1px solid ${opt.contextBorder}`;
this._input = new InputBox(this._inputContainer, contextViewService, { this._input = new InputBox(this._inputContainer, contextViewService, {
validationOptions: { validationOptions: {
// @SQLTODO // @SQLTODO
@@ -141,11 +143,11 @@ export class Dropdown extends Disposable implements IListVirtualDelegate<string>
})); }));
const inputTracker = this._register(DOM.trackFocus(this._input.inputElement)); const inputTracker = this._register(DOM.trackFocus(this._input.inputElement));
inputTracker.onDidBlur(() => { this._register(inputTracker.onDidBlur(() => {
if (!this._selectList.isDOMFocused()) { if (!this._selectList.isDOMFocused()) {
this._onBlur.fire(); this._onBlur.fire();
} }
}); }));
/* /*
This event listener is intended to close the expanded drop down when the ADS shell window is resized This event listener is intended to close the expanded drop down when the ADS shell window is resized
@@ -167,14 +169,12 @@ export class Dropdown extends Disposable implements IListVirtualDelegate<string>
break; break;
case KeyCode.Escape: case KeyCode.Escape:
if (this._isDropDownVisible) { if (this._isDropDownVisible) {
this._input.validate();
this._onBlur.fire(); this._onBlur.fire();
this._hideList(); this._hideList();
e.stopPropagation(); e.stopPropagation();
} }
break; break;
case KeyCode.Tab: case KeyCode.Tab:
this._input.validate();
this._onBlur.fire(); this._onBlur.fire();
this._hideList(); this._hideList();
break; break;
@@ -244,7 +244,7 @@ export class Dropdown extends Disposable implements IListVirtualDelegate<string>
} }
})); }));
this._input.onDidChange(e => { this._register(this._input.onDidChange(e => {
if (this._dataSource.values.length > 0) { if (this._dataSource.values.length > 0) {
this._dataSource.filter = e; this._dataSource.filter = e;
if (this._isDropDownVisible) { if (this._isDropDownVisible) {
@@ -254,12 +254,11 @@ export class Dropdown extends Disposable implements IListVirtualDelegate<string>
if (this.fireOnTextChange) { if (this.fireOnTextChange) {
this.value = e; this.value = e;
} }
}); }));
this.onBlur(() => { this._register(this.onBlur(() => {
this._hideList(); this._hideList();
this._input.validate(); }));
});
this._register(this._selectList); this._register(this._selectList);
this._register(this._input); this._register(this._input);
@@ -316,6 +315,8 @@ export class Dropdown extends Disposable implements IListVirtualDelegate<string>
private _hideList(): void { private _hideList(): void {
this.contextViewService.hideContextView(); this.contextViewService.hideContextView();
this._inputContainer.setAttribute('aria-expanded', 'false'); this._inputContainer.setAttribute('aria-expanded', 'false');
// Show error for input box in case the user closed the dropdown without selecting anything, like by hitting Escape
this.input.validate();
} }
private _updateDropDownList(): void { private _updateDropDownList(): void {
@@ -323,17 +324,9 @@ export class Dropdown extends Disposable implements IListVirtualDelegate<string>
const selectedIndex = this._dataSource.filteredValues.indexOf(this.value); const selectedIndex = this._dataSource.filteredValues.indexOf(this.value);
this._selectList.setSelection(selectedIndex !== -1 ? [selectedIndex] : []); this._selectList.setSelection(selectedIndex !== -1 ? [selectedIndex] : []);
let width = this._inputContainer.clientWidth;
// Find the longest option in the list and set our width to that (max 500px)
const longestOption = this._dataSource.filteredValues.reduce((previous, current) => {
return previous.length > current.length ? previous : current;
}, '');
this._widthControlElement.innerText = longestOption;
const inputContainerWidth = DOM.getContentWidth(this._inputContainer); const inputContainerWidth = DOM.getContentWidth(this._inputContainer);
const longestOptionWidth = DOM.getTotalWidth(this._widthControlElement); const longestOptionWidth = DOM.getTotalWidth(this._widthControlElement);
width = clamp(longestOptionWidth, inputContainerWidth, 500); let width = clamp(longestOptionWidth, inputContainerWidth, 500);
const height = Math.min(this._dataSource.filteredValues.length * this.getHeight(), this._options.maxHeight ?? 500); const height = Math.min(this._dataSource.filteredValues.length * this.getHeight(), this._options.maxHeight ?? 500);
this._selectListContainer.style.width = `${width}px`; this._selectListContainer.style.width = `${width}px`;
@@ -345,6 +338,13 @@ export class Dropdown extends Disposable implements IListVirtualDelegate<string>
if (vals) { if (vals) {
this._dataSource.filter = undefined; this._dataSource.filter = undefined;
this._dataSource.values = vals; this._dataSource.values = vals;
// Find the longest option in the list to set the width of the dropdown
let longestOption = this._dataSource.values.reduce((previous, current) => {
return previous.length > current.length ? previous : current;
}, '');
this._widthControlElement.innerText = longestOption;
if (this._isDropDownVisible) { if (this._isDropDownVisible) {
this._updateDropDownList(); this._updateDropDownList();
} }
@@ -357,9 +357,12 @@ export class Dropdown extends Disposable implements IListVirtualDelegate<string>
} }
public set value(val: string) { public set value(val: string) {
this._input.value = val; // A value can be changed either by selecting an option from the dropdown list or editing the text field directly.
if (this._previousValue !== val) { // If you try to select the same dropdown value again after changing the text field directly, that change should
// still be applied, which is why we check both _previousValue and _input.value.
if (this._previousValue !== val || this._input.value !== val) {
this._previousValue = val; this._previousValue = val;
this._input.value = val;
this._onValueChange.fire(val); this._onValueChange.fire(val);
} }
} }
@@ -378,7 +381,7 @@ export class Dropdown extends Disposable implements IListVirtualDelegate<string>
} }
private _inputValidator(value: string): IMessage | null { private _inputValidator(value: string): IMessage | null {
if (!this._input.hasFocus() && this._input.isEnabled() && !this._selectList.isDOMFocused() && !this._dataSource.values.some(i => i === value)) { if (this._input.isEnabled() && !this._selectList.isDOMFocused() && !this._isDropDownVisible && !this._dataSource.values.some(i => i === value)) {
if (this._options.strictSelection && this._options.errorMessage) { if (this._options.strictSelection && this._options.errorMessage) {
return { return {
content: this._options.errorMessage, content: this._options.errorMessage,
@@ -418,4 +421,8 @@ export class Dropdown extends Disposable implements IListVirtualDelegate<string>
public get options(): IDropdownOptions { public get options(): IDropdownOptions {
return this._options; return this._options;
} }
public set strictSelection(val: boolean | undefined) {
this._options.strictSelection = val;
}
} }

View File

@@ -68,4 +68,12 @@ suite('Editable dropdown tests', () => {
dropdown.input.value = options.values[0]; dropdown.input.value = options.values[0];
assert.strictEqual(count, 3, 'onValueChange event was fired with input box value change even after setting the fireOnTextChange to false'); assert.strictEqual(count, 3, 'onValueChange event was fired with input box value change even after setting the fireOnTextChange to false');
}); });
test('selecting same dropdown value again after changing text field should update text field', () => {
const dropdown = new Dropdown(container, undefined, options);
dropdown.value = options.values[0];
dropdown.input.value = 'NotARealValue';
dropdown.value = options.values[0];
assert.strictEqual(dropdown.input.value, options.values[0]);
});
}); });

View File

@@ -81,7 +81,7 @@ export default class DropDownComponent extends ComponentBase<azdata.DropDownProp
if (this._editableDropDownContainer) { if (this._editableDropDownContainer) {
let dropdownOptions: IDropdownOptions = { let dropdownOptions: IDropdownOptions = {
values: [], values: [],
strictSelection: false, strictSelection: this.strictSelection ?? false,
placeholder: this.placeholder, placeholder: this.placeholder,
maxHeight: 125, maxHeight: 125,
ariaLabel: '', ariaLabel: '',
@@ -178,7 +178,7 @@ export default class DropDownComponent extends ComponentBase<azdata.DropDownProp
} }
this._editableDropdown.enabled = this.enabled; this._editableDropdown.enabled = this.enabled;
this._editableDropdown.fireOnTextChange = this.fireOnTextChange; this._editableDropdown.fireOnTextChange = this.fireOnTextChange;
this._editableDropdown.strictSelection = this.strictSelection;
if (this.placeholder) { if (this.placeholder) {
this._editableDropdown.input.setPlaceHolder(this.placeholder); this._editableDropdown.input.setPlaceHolder(this.placeholder);
} }
@@ -338,7 +338,11 @@ export default class DropDownComponent extends ComponentBase<azdata.DropDownProp
} }
public get placeholder(): string | undefined { public get placeholder(): string | undefined {
return this.getPropertyOrDefault<string>((props) => props.placeholder, undefined); return this.getPropertyOrDefault<string | undefined>((props) => props.placeholder, undefined);
}
public get strictSelection(): boolean | undefined {
return this.getPropertyOrDefault<boolean | undefined>((props) => props.strictSelection, undefined);
} }
public get validationErrorMessages(): string[] | undefined { public get validationErrorMessages(): string[] | undefined {