diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index c2a4754c8c..1f4cc82ca7 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -1413,9 +1413,13 @@ declare module 'azdata' { */ export interface InfoBoxComponent extends Component, InfoBoxComponentProperties { /** - * An event called when the InfoBox is clicked + * An event fired when the InfoBox is clicked */ onDidClick: vscode.Event; + /** + * An event fired when the Infobox link is clicked + */ + onLinkClick: vscode.Event; } export interface InfoBoxComponentProperties { @@ -1429,5 +1433,27 @@ declare module 'azdata' { * Sets the ariaLabel for the right arrow button that shows up in clickable infoboxes */ clickableButtonAriaLabel?: string; + + /** + * List of links to embed within the text. If links are specified there must be placeholder + * values in the value indicating where the links should be placed, in the format {i} + * + * e.g. "Click {0} for more information!"" + */ + links?: LinkArea[]; + } + + /** + * Event argument for infobox link click event. + */ + export interface InfoBoxLinkClickEventArgs { + /** + * Index of the link selected + */ + index: number; + /** + * Link that is clicked + */ + link: LinkArea; } } diff --git a/src/sql/platform/dashboard/browser/interfaces.ts b/src/sql/platform/dashboard/browser/interfaces.ts index 795bf9c844..d4aa019a4a 100644 --- a/src/sql/platform/dashboard/browser/interfaces.ts +++ b/src/sql/platform/dashboard/browser/interfaces.ts @@ -22,7 +22,8 @@ export enum ComponentEventType { onCellAction, onEnterKeyPressed, onInput, - onComponentLoaded + onComponentLoaded, + onChildClick } /** diff --git a/src/sql/workbench/api/common/extHostModelView.ts b/src/sql/workbench/api/common/extHostModelView.ts index 9376a83aa2..2344fafc6a 100644 --- a/src/sql/workbench/api/common/extHostModelView.ts +++ b/src/sql/workbench/api/common/extHostModelView.ts @@ -2085,7 +2085,8 @@ class InfoBoxComponentWrapper extends ComponentWrapper implements azdata.InfoBox constructor(proxy: MainThreadModelViewShape, handle: number, id: string, logService: ILogService) { super(proxy, handle, ModelComponentTypes.InfoBox, id, logService); this.properties = {}; - this._emitterMap.set(ComponentEventType.onDidClick, new Emitter()); + this._emitterMap.set(ComponentEventType.onDidClick, new Emitter()); + this._emitterMap.set(ComponentEventType.onChildClick, new Emitter()); } public get style(): azdata.InfoBoxStyle { @@ -2104,6 +2105,14 @@ class InfoBoxComponentWrapper extends ComponentWrapper implements azdata.InfoBox this.setProperty('text', v); } + public get links(): azdata.LinkArea[] { + return this.properties['links']; + } + + public set links(v: azdata.LinkArea[]) { + this.setProperty('links', v); + } + public get announceText(): boolean { return this.properties['announceText']; } @@ -2128,10 +2137,15 @@ class InfoBoxComponentWrapper extends ComponentWrapper implements azdata.InfoBox this.setProperty('clickableButtonAriaLabel', v); } - public get onDidClick(): vscode.Event { + public get onDidClick(): vscode.Event { let emitter = this._emitterMap.get(ComponentEventType.onDidClick); return emitter && emitter.event; } + + public get onLinkClick(): vscode.Event { + let emitter = this._emitterMap.get(ComponentEventType.onChildClick); + return emitter && emitter.event; + } } class SliderComponentWrapper extends ComponentWrapper implements azdata.SliderComponent { diff --git a/src/sql/workbench/api/common/sqlExtHostTypes.ts b/src/sql/workbench/api/common/sqlExtHostTypes.ts index 4b3fe83601..7e476029d8 100644 --- a/src/sql/workbench/api/common/sqlExtHostTypes.ts +++ b/src/sql/workbench/api/common/sqlExtHostTypes.ts @@ -246,7 +246,8 @@ export enum ComponentEventType { onCellAction, onEnterKeyPressed, onInput, - onComponentLoaded + onComponentLoaded, + onChildClick } export interface IComponentEventArgs { diff --git a/src/sql/workbench/browser/modelComponents/infoBox.component.ts b/src/sql/workbench/browser/modelComponents/infoBox.component.ts index 04ebd0b468..1764e8f62c 100644 --- a/src/sql/workbench/browser/modelComponents/infoBox.component.ts +++ b/src/sql/workbench/browser/modelComponents/infoBox.component.ts @@ -11,9 +11,10 @@ import * as azdata from 'azdata'; import { ComponentEventType, IComponent, IComponentDescriptor, IModelStore } from 'sql/platform/dashboard/browser/interfaces'; import { ILogService } from 'vs/platform/log/common/log'; import { ComponentBase } from 'sql/workbench/browser/modelComponents/componentBase'; -import { InfoBox, InfoBoxStyle } from 'sql/base/browser/ui/infoBox/infoBox'; import { IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; import { attachInfoBoxStyler } from 'sql/platform/theme/common/styler'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { InfoBox, InfoBoxStyle } from 'sql/workbench/browser/ui/infoBox/infoBox'; @Component({ selector: 'modelview-infobox', @@ -31,7 +32,8 @@ export default class InfoBoxComponent extends ComponentBase ChangeDetectorRef)) changeRef: ChangeDetectorRef, @Inject(forwardRef(() => ElementRef)) el: ElementRef, - @Inject(IWorkbenchThemeService) private themeService: IWorkbenchThemeService, + @Inject(IWorkbenchThemeService) private _themeService: IWorkbenchThemeService, + @Inject(IInstantiationService) private _instantiationService: IInstantiationService, @Inject(ILogService) logService: ILogService) { super(changeRef, el, logService); } @@ -39,14 +41,20 @@ export default class InfoBoxComponent extends ComponentBase { this.fireEvent({ eventType: ComponentEventType.onDidClick, args: e }); }); + this._infoBox.onLinkClick(e => { + this.fireEvent({ + eventType: ComponentEventType.onChildClick, + args: e + }); + }); this.updateInfoBox(); } } @@ -70,6 +78,7 @@ export default class InfoBoxComponent extends ComponentBase((props) => props.clickableButtonAriaLabel, ''); } + + public get links(): azdata.LinkArea[] { + return this.getPropertyOrDefault((props) => props.links, []); + } } diff --git a/src/sql/base/browser/ui/infoBox/infoBox.ts b/src/sql/workbench/browser/ui/infoBox/infoBox.ts similarity index 64% rename from src/sql/base/browser/ui/infoBox/infoBox.ts rename to src/sql/workbench/browser/ui/infoBox/infoBox.ts index 55c19dd8f6..f5cb3dcab9 100644 --- a/src/sql/base/browser/ui/infoBox/infoBox.ts +++ b/src/sql/workbench/browser/ui/infoBox/infoBox.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import 'vs/css!./media/infoBox'; +import * as azdata from 'azdata'; import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { alert, status } from 'vs/base/browser/ui/aria/aria'; import { IThemable } from 'vs/base/common/styler'; @@ -13,6 +14,8 @@ import { Event, Emitter } from 'vs/base/common/event'; import { Codicon } from 'vs/base/common/codicons'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { KeyCode } from 'vs/base/common/keyCodes'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { ILogService } from 'vs/platform/log/common/log'; export interface IInfoBoxStyles { informationBackground?: Color; @@ -25,6 +28,7 @@ export type InfoBoxStyle = 'information' | 'warning' | 'error' | 'success'; export interface InfoBoxOptions { text: string; + links?: azdata.LinkArea[]; style: InfoBoxStyle; announceText?: boolean; isClickable?: boolean; @@ -37,6 +41,7 @@ export class InfoBox extends Disposable implements IThemable { private _infoBoxElement: HTMLDivElement; private _clickableIndicator: HTMLDivElement; private _text = ''; + private _links: azdata.LinkArea[] = []; private _infoBoxStyle: InfoBoxStyle = 'information'; private _styles: IInfoBoxStyles; private _announceText: boolean = false; @@ -47,7 +52,16 @@ export class InfoBox extends Disposable implements IThemable { private _onDidClick: Emitter = this._register(new Emitter()); get onDidClick(): Event { return this._onDidClick.event; } - constructor(container: HTMLElement, options?: InfoBoxOptions) { + private _linkListenersDisposableStore = new DisposableStore(); + private _onLinkClick: Emitter = this._register(new Emitter()); + get onLinkClick(): Event { return this._onLinkClick.event; } + + constructor( + container: HTMLElement, + options: InfoBoxOptions | undefined, + @IOpenerService private _openerService: IOpenerService, + @ILogService private _logService: ILogService + ) { super(); this._infoBoxElement = document.createElement('div'); this._imageElement = document.createElement('div'); @@ -63,6 +77,7 @@ export class InfoBox extends Disposable implements IThemable { if (options) { this.infoBoxStyle = options.style; + this.links = options.links; this.text = options.text; this._announceText = (options.announceText === true); this.isClickable = (options.isClickable === true); @@ -98,6 +113,15 @@ export class InfoBox extends Disposable implements IThemable { this.updateStyle(); } + public get links(): azdata.LinkArea[] { + return this._links; + } + + public set links(v: azdata.LinkArea[]) { + this._links = v ?? []; + this.createTextWithHyperlinks(); + } + public get text(): string { return this._text; } @@ -105,16 +129,96 @@ export class InfoBox extends Disposable implements IThemable { public set text(text: string) { if (this._text !== text) { this._text = text; - this._textElement.innerText = text; - if (this.announceText) { - if (this.infoBoxStyle === 'warning' || this.infoBoxStyle === 'error') { - alert(text); - } - else { - status(text); + this.createTextWithHyperlinks(); + } + } + + public createTextWithHyperlinks() { + let text = this._text; + DOM.clearNode(this._textElement); + this._linkListenersDisposableStore.clear(); + + for (let i = 0; i < this._links.length; i++) { + const placeholderIndex = text.indexOf(`{${i}}`); + if (placeholderIndex < 0) { + this._logService.warn(`Could not find placeholder text {${i}} in text ${text}`); + // Just continue on so we at least show the rest of the text if just one was missed or something + continue; + } + + // First insert any text from the start of the current string fragment up to the placeholder + let curText = text.slice(0, placeholderIndex); + if (curText) { + const span = DOM.$('span'); + span.innerText = text.slice(0, placeholderIndex); + this._textElement.appendChild(span); + } + + // Now insert the link element + const link = this._links[i]; + + /** + * If the url is empty, electron displays the link as visited. + * TODO: Investigate why it happens and fix the issue iin electron/vsbase. + */ + const linkElement = DOM.$('a', { + href: link.url === '' ? ' ' : link.url + }); + + linkElement.innerText = link.text; + + if (link.accessibilityInformation) { + linkElement.setAttribute('aria-label', link.accessibilityInformation.label); + if (link.accessibilityInformation.role) { + linkElement.setAttribute('role', link.accessibilityInformation.role); } } + this._linkListenersDisposableStore.add(DOM.addDisposableListener(linkElement, DOM.EventType.CLICK, e => { + this._onLinkClick.fire({ + index: i, + link: link + }); + if (link.url) { + this.openLink(link.url); + } + e.stopPropagation(); + })); + + this._linkListenersDisposableStore.add(DOM.addDisposableListener(linkElement, DOM.EventType.KEY_PRESS, e => { + const event = new StandardKeyboardEvent(e); + if (this._isClickable && (event.equals(KeyCode.Enter) || !event.equals(KeyCode.Space))) { + this._onLinkClick.fire({ + index: i, + link: link + }); + if (link.url) { + this.openLink(link.url); + } + e.stopPropagation(); + } + })); + this._textElement.appendChild(linkElement); + text = text.slice(placeholderIndex + 3); } + + if (text) { + const span = DOM.$('span'); + span.innerText = text; + this._textElement.appendChild(span); + } + + if (this.announceText) { + if (this.infoBoxStyle === 'warning' || this.infoBoxStyle === 'error') { + alert(text); + } + else { + status(text); + } + } + } + + private openLink(href: string): void { + this._openerService.open(href); } public get isClickable(): boolean { diff --git a/src/sql/base/browser/ui/infoBox/media/infoBox.css b/src/sql/workbench/browser/ui/infoBox/media/infoBox.css similarity index 100% rename from src/sql/base/browser/ui/infoBox/media/infoBox.css rename to src/sql/workbench/browser/ui/infoBox/media/infoBox.css diff --git a/src/sql/base/browser/ui/infoBox/media/status_error.svg b/src/sql/workbench/browser/ui/infoBox/media/status_error.svg similarity index 100% rename from src/sql/base/browser/ui/infoBox/media/status_error.svg rename to src/sql/workbench/browser/ui/infoBox/media/status_error.svg diff --git a/src/sql/base/browser/ui/infoBox/media/status_info.svg b/src/sql/workbench/browser/ui/infoBox/media/status_info.svg similarity index 100% rename from src/sql/base/browser/ui/infoBox/media/status_info.svg rename to src/sql/workbench/browser/ui/infoBox/media/status_info.svg diff --git a/src/sql/base/browser/ui/infoBox/media/status_success.svg b/src/sql/workbench/browser/ui/infoBox/media/status_success.svg similarity index 100% rename from src/sql/base/browser/ui/infoBox/media/status_success.svg rename to src/sql/workbench/browser/ui/infoBox/media/status_success.svg diff --git a/src/sql/base/browser/ui/infoBox/media/status_warning.svg b/src/sql/workbench/browser/ui/infoBox/media/status_warning.svg similarity index 100% rename from src/sql/base/browser/ui/infoBox/media/status_warning.svg rename to src/sql/workbench/browser/ui/infoBox/media/status_warning.svg diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlan.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlan.ts index 3c77278aa2..5348b57eb5 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/executionPlan.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlan.ts @@ -42,7 +42,7 @@ import { URI } from 'vs/base/common/uri'; import { ITextResourcePropertiesService } from 'vs/editor/common/services/textResourceConfigurationService'; import { IExecutionPlanService } from 'sql/workbench/services/executionPlan/common/interfaces'; import { LoadingSpinner } from 'sql/base/browser/ui/loadingSpinner/loadingSpinner'; -import { InfoBox } from 'sql/base/browser/ui/infoBox/infoBox'; +import { InfoBox } from 'sql/workbench/browser/ui/infoBox/infoBox'; let azdataGraph = azdataGraphModule(); @@ -158,7 +158,7 @@ export class ExecutionPlanView implements IPanelView { } this._loadingSpinner.loadingCompletedMessage = localize('executionPlanFileLoadingComplete', "Execution plans are generated"); } catch (e) { - this._loadingErrorInfoBox = new InfoBox(this._container, { + this._loadingErrorInfoBox = this.instantiationService.createInstance(InfoBox, this._container, { text: e.toString(), style: 'error', isClickable: false