diff --git a/modules/ui/src/app/app.component.html b/modules/ui/src/app/app.component.html
index c5b5a7e97..4375cc2ef 100644
--- a/modules/ui/src/app/app.component.html
+++ b/modules/ui/src/app/app.component.html
@@ -84,6 +84,15 @@
Testrun
+
+
+
+
diff --git a/modules/ui/src/app/app.component.scss b/modules/ui/src/app/app.component.scss
index 0115cf2ae..8d5e82cca 100644
--- a/modules/ui/src/app/app.component.scss
+++ b/modules/ui/src/app/app.component.scss
@@ -105,7 +105,8 @@ $nav-width: 96px;
min-width: 24px;
}
-.app-sidebar-button-active {
+.app-sidebar-button-active,
+:host:has(app-help-tip) .app-toolbar-button-help-tips {
.material-symbols-outlined {
font-variation-settings:
'FILL' 1,
@@ -230,3 +231,7 @@ app-version {
padding-top: 82px;
}
}
+
+.closed-tip {
+ display: none;
+}
diff --git a/modules/ui/src/app/app.component.spec.ts b/modules/ui/src/app/app.component.spec.ts
index e1bd105c5..41f1780b4 100644
--- a/modules/ui/src/app/app.component.spec.ts
+++ b/modules/ui/src/app/app.component.spec.ts
@@ -76,6 +76,7 @@ import { SpinnerComponent } from './components/spinner/spinner.component';
import { ShutdownAppComponent } from './components/shutdown-app/shutdown-app.component';
import { TestingCompleteComponent } from './components/testing-complete/testing-complete.component';
import { VersionComponent } from './components/version/version.component';
+import { MOCK_MODULES } from './mocks/device.mock';
const windowMock = {
location: {
@@ -201,6 +202,8 @@ describe('AppComponent', () => {
},
});
+ mockService.fetchDevices.and.returnValue(of([]));
+ mockService.getTestModules.and.returnValue(of([...MOCK_MODULES]));
mockMqttService.getNetworkAdapters.and.returnValue(of(MOCK_ADAPTERS));
store = TestBed.inject(MockStore);
fixture = TestBed.createComponent(AppComponent);
@@ -360,32 +363,92 @@ describe('AppComponent', () => {
});
});
- describe('Callout component visibility', () => {
+ describe('Help tip component visibility', () => {
describe('with no connection settings', () => {
beforeEach(() => {
store.overrideSelector(selectHasConnectionSettings, false);
fixture.detectChanges();
});
- it('should have callout component with "Step 1" text', () => {
- const callout = compiled.querySelector('app-callout');
- const calloutContent = callout?.innerHTML.trim();
+ it('should have help tip component with "Step 1" text', () => {
+ const helpTip = compiled.querySelector('app-help-tip');
+ const helpTipTitle = compiled.querySelector('app-help-tip .title');
+ const helpTipContent = helpTipTitle?.innerHTML.trim();
- expect(callout).toBeTruthy();
- expect(calloutContent).toContain('Step 1');
+ expect(helpTip).toBeTruthy();
+ expect(helpTipContent).toContain('Step 1');
});
- it('should have callout content with "System settings" link ', () => {
- const calloutLinkEl = compiled.querySelector(
- '.callout-action-link'
+ it('should have help tip content with "Go to Settings" link ', () => {
+ const helpTipLinkEl = compiled.querySelector(
+ '.tip-action-link'
) as HTMLAnchorElement;
- const calloutLinkContent = calloutLinkEl.innerHTML.trim();
+ const helpTipLinkContent = helpTipLinkEl.innerHTML.trim();
+
+ expect(helpTipLinkEl).toBeTruthy();
+ expect(helpTipLinkContent).toContain('Go to Settings');
+ });
+ });
+
+ describe('with no devices set', () => {
+ beforeEach(() => {
+ store.overrideSelector(selectHasDevices, false);
+ fixture.detectChanges();
+ });
+
+ it('should have helpTip component', () => {
+ const helpTip = compiled.querySelector('app-help-tip');
+
+ expect(helpTip).toBeTruthy();
+ });
+
+ it('should have help tip component with "Step 2" text', () => {
+ const helpTipTitle = compiled.querySelector('app-help-tip .title');
+ const helpTipTitleContent = helpTipTitle?.innerHTML.trim();
+
+ expect(helpTipTitleContent).toContain('Step 2');
+ });
+
+ it('should have help tip content with "Create Device" link ', () => {
+ const helpTipLinkEl = compiled.querySelector(
+ '.tip-action-link'
+ ) as HTMLAnchorElement;
+ const helpTipLinkContent = helpTipLinkEl.innerHTML.trim();
+
+ expect(helpTipLinkEl).toBeTruthy();
+ expect(helpTipLinkContent).toContain('Device');
+ });
+
+ keyboardCases.forEach(testCase => {
+ it(`should navigate to the device-repository on keydown ${testCase.name} "Create Device" link`, fakeAsync(() => {
+ const helpTipLinkEl = compiled.querySelector(
+ '.tip-action-link'
+ ) as HTMLAnchorElement;
+
+ helpTipLinkEl.dispatchEvent(testCase.event);
+ flush();
- expect(calloutLinkEl).toBeTruthy();
- expect(calloutLinkContent).toContain('System settings');
+ expect(router.url).toBe(Routes.Devices);
+ }));
});
+
+ it('should navigate to the device-repository on click "Create a Device" link', fakeAsync(() => {
+ const helpTipLinkEl = compiled.querySelector(
+ '.tip-action-link'
+ ) as HTMLAnchorElement;
+
+ helpTipLinkEl.click();
+ flush();
+
+ expect(router.url).toBe(Routes.Devices);
+ expect(store.dispatch).toHaveBeenCalledWith(
+ setIsOpenAddDevice({ isOpenAddDevice: true })
+ );
+ }));
});
+ });
+ describe('Callout component visibility', () => {
describe('with system status as "Idle"', () => {
beforeEach(() => {
component.appStore.updateIsStatusLoaded(true);
@@ -479,64 +542,6 @@ describe('AppComponent', () => {
});
});
- describe('with no devices setted', () => {
- beforeEach(() => {
- store.overrideSelector(selectHasDevices, false);
- fixture.detectChanges();
- });
-
- it('should have callout component', () => {
- const callout = compiled.querySelector('app-callout');
-
- expect(callout).toBeTruthy();
- });
-
- it('should have callout component with "Step 2" text', () => {
- const callout = compiled.querySelector('app-callout');
- const calloutContent = callout?.innerHTML.trim();
-
- expect(callout).toBeTruthy();
- expect(calloutContent).toContain('Step 2');
- });
-
- it('should have callout content with "Create a Device" link ', () => {
- const calloutLinkEl = compiled.querySelector(
- '.callout-action-link'
- ) as HTMLAnchorElement;
- const calloutLinkContent = calloutLinkEl.innerHTML.trim();
-
- expect(calloutLinkEl).toBeTruthy();
- expect(calloutLinkContent).toContain('Devices');
- });
-
- keyboardCases.forEach(testCase => {
- it(`should navigate to the device-repository on keydown ${testCase.name} "Create a Device" link`, fakeAsync(() => {
- const calloutLinkEl = compiled.querySelector(
- '.callout-action-link'
- ) as HTMLAnchorElement;
-
- calloutLinkEl.dispatchEvent(testCase.event);
- flush();
-
- expect(router.url).toBe(Routes.Devices);
- }));
- });
-
- it('should navigate to the device-repository on click "Create a Device" link', fakeAsync(() => {
- const calloutLinkEl = compiled.querySelector(
- '.callout-action-link'
- ) as HTMLAnchorElement;
-
- calloutLinkEl.click();
- flush();
-
- expect(router.url).toBe(Routes.Devices);
- expect(store.dispatch).toHaveBeenCalledWith(
- setIsOpenAddDevice({ isOpenAddDevice: true })
- );
- }));
- });
-
describe('with devices setted but without systemStatus data', () => {
beforeEach(() => {
store.overrideSelector(selectHasDevices, true);
diff --git a/modules/ui/src/app/app.component.ts b/modules/ui/src/app/app.component.ts
index e2f1c82f3..857959aba 100644
--- a/modules/ui/src/app/app.component.ts
+++ b/modules/ui/src/app/app.component.ts
@@ -20,6 +20,8 @@ import {
ElementRef,
viewChild,
inject,
+ ViewChild,
+ ChangeDetectorRef,
} from '@angular/core';
import { MatIconModule, MatIconRegistry } from '@angular/material/icon';
import { DomSanitizer } from '@angular/platform-browser';
@@ -46,7 +48,7 @@ import { BypassComponent } from './components/bypass/bypass.component';
import { ShutdownAppComponent } from './components/shutdown-app/shutdown-app.component';
import { SpinnerComponent } from './components/spinner/spinner.component';
import { MatTooltipModule } from '@angular/material/tooltip';
-import { MatButtonModule } from '@angular/material/button';
+import { MatButton, MatButtonModule } from '@angular/material/button';
import { VersionComponent } from './components/version/version.component';
import { MatSelectModule } from '@angular/material/select';
import { WifiComponent } from './components/wifi/wifi.component';
@@ -57,6 +59,8 @@ import { CommonModule } from '@angular/common';
import { SideButtonMenuComponent } from './components/side-button-menu/side-button-menu.component';
import { Observable } from 'rxjs/internal/Observable';
import { of } from 'rxjs/internal/observable/of';
+import { HelpTipComponent } from './components/help-tip/help-tip.component';
+import { HelpTips } from './model/tip-config';
export interface AddMenuItem {
icon?: string;
@@ -96,6 +100,7 @@ const QUALIFICATION_URL = '/assets/icons/qualification.svg';
BypassComponent,
VersionComponent,
CalloutComponent,
+ HelpTipComponent,
ShutdownAppComponent,
WifiComponent,
TestingCompleteComponent,
@@ -112,15 +117,21 @@ export class AppComponent implements AfterViewInit {
private store = inject>(Store);
private readonly focusManagerService = inject(FocusManagerService);
private testRunService = inject(TestRunService);
+ private cdr = inject(ChangeDetectorRef);
appStore = inject(AppStore);
public readonly CalloutType = CalloutType;
public readonly StatusOfTestrun = StatusOfTestrun;
+ public readonly HelpTips = HelpTips;
public readonly Routes = Routes;
viewModel$ = this.appStore.viewModel$;
readonly riskAssessmentLink = viewChild('riskAssessmentLink');
private skipCount = 0;
+ @ViewChild('settingButton', { static: false }) settingButton!: MatButton;
+ settingTipTarget!: HTMLElement;
+ deviceTipTarget!: HTMLElement;
+ isClosedTip = false;
navigateToRuntime = () => {
this.route.navigate([Routes.Testing]);
@@ -206,6 +217,11 @@ export class AppComponent implements AfterViewInit {
}
ngAfterViewInit() {
+ this.settingTipTarget = this.settingButton._elementRef.nativeElement;
+ this.deviceTipTarget = document.querySelector(
+ '.app-sidebar-button.app-sidebar-button-devices'
+ ) as HTMLElement;
+
this.viewModel$
.pipe(
filter(({ isStatusLoaded }) => isStatusLoaded === true),
@@ -217,6 +233,8 @@ export class AppComponent implements AfterViewInit {
this.skipCount = 1;
}
});
+
+ this.cdr.detectChanges();
}
get isRiskAssessmentRoute(): boolean {
@@ -242,6 +260,10 @@ export class AppComponent implements AfterViewInit {
this.appStore.setFocusOnPage();
});
}
+
+ onCLoseTip(isClosed: boolean): void {
+ this.isClosedTip = isClosed;
+ }
consentShown() {
this.appStore.setContent();
}
diff --git a/modules/ui/src/app/components/help-tip/help-tip.component.html b/modules/ui/src/app/components/help-tip/help-tip.component.html
new file mode 100644
index 000000000..8e2122902
--- /dev/null
+++ b/modules/ui/src/app/components/help-tip/help-tip.component.html
@@ -0,0 +1,48 @@
+
+
+
+
+
{{ data()?.title }}
+
+
+
+ {{ data()?.content }}
+
+
+
+
diff --git a/modules/ui/src/app/components/help-tip/help-tip.component.scss b/modules/ui/src/app/components/help-tip/help-tip.component.scss
new file mode 100644
index 000000000..2432ef431
--- /dev/null
+++ b/modules/ui/src/app/components/help-tip/help-tip.component.scss
@@ -0,0 +1,107 @@
+/**
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@use '@angular/material' as mat;
+@use 'm3-theme' as *;
+@use 'colors';
+@use 'variables';
+
+.tip {
+ position: absolute;
+ z-index: 100;
+ width: 256px;
+ box-sizing: border-box;
+}
+
+.tip-container {
+ position: relative;
+ border-radius: 28px;
+ background: colors.$primary;
+ color: colors.$white;
+ box-shadow:
+ 0px 4px 8px 3px rgba(0, 0, 0, 0.15),
+ 0px 1px 3px 0px rgba(0, 0, 0, 0.3);
+
+ p {
+ margin: 0;
+ }
+}
+
+.tip-container::before {
+ content: '';
+ position: absolute;
+ border-radius: 4px;
+ height: 20px;
+ width: 20px;
+ background: colors.$primary;
+ box-sizing: border-box;
+ transform: rotate(45deg) translate(-50%);
+}
+
+.tip-container.top::before {
+ top: 0;
+ left: 50%;
+}
+
+.tip-container.left::before {
+ top: 50%;
+ left: 0;
+}
+
+.heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ padding: 4px 4px 0 24px;
+}
+
+.title {
+ font-family: variables.$font-text;
+ font-size: 16px;
+ font-weight: 500;
+ line-height: 24px;
+}
+
+.close-button {
+ margin-bottom: 4px;
+
+ mat-icon {
+ color: colors.$white;
+ }
+}
+
+.tip-content {
+ padding: 0 24px;
+ font-family: variables.$font-text;
+ font-size: 14px;
+ line-height: 20px;
+}
+
+.tip-action-container {
+ display: flex;
+ justify-content: flex-end;
+ padding: 16px 12px 14px 24px;
+
+ .tip-action-link {
+ display: inline-block;
+ padding: 10px 24px;
+ text-decoration: none;
+ cursor: pointer;
+ font-family: variables.$font-text;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 20px;
+ }
+}
diff --git a/modules/ui/src/app/components/help-tip/help-tip.component.spec.ts b/modules/ui/src/app/components/help-tip/help-tip.component.spec.ts
new file mode 100644
index 000000000..b3d846ea1
--- /dev/null
+++ b/modules/ui/src/app/components/help-tip/help-tip.component.spec.ts
@@ -0,0 +1,108 @@
+import {
+ ComponentFixture,
+ fakeAsync,
+ TestBed,
+ tick,
+} from '@angular/core/testing';
+
+import { HelpTipComponent } from './help-tip.component';
+import { HelpTips } from '../../model/tip-config';
+
+describe('HelpTipComponent', () => {
+ let component: HelpTipComponent;
+ let fixture: ComponentFixture;
+ let compiled: HTMLElement;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [HelpTipComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(HelpTipComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('data', HelpTips.step1);
+ compiled = fixture.nativeElement as HTMLElement;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should have provided data', () => {
+ const tipTitle = compiled.querySelector('.tip-container .title');
+ const tipContent = compiled.querySelector('.tip-container .tip-content');
+
+ expect(tipTitle?.innerHTML.trim()).toContain(HelpTips.step1.title);
+ expect(tipContent?.innerHTML.trim()).toContain(HelpTips.step1.content);
+ });
+
+ it('should have class provided from arrowPosition', () => {
+ const tipContainerEl = compiled.querySelector('.tip-container');
+
+ expect(tipContainerEl?.classList).toContain('top');
+ });
+
+ describe('#updateTipPosition', () => {
+ beforeEach(() => {
+ const mockTarget = document.createElement('div');
+ spyOn(mockTarget, 'getBoundingClientRect').and.returnValue({
+ top: 100,
+ left: 100,
+ height: 100,
+ width: 100,
+ bottom: 100,
+ right: 100,
+ } as DOMRect);
+ fixture.componentRef.setInput('target', mockTarget);
+ fixture.detectChanges();
+ });
+
+ it('should update tip position when data.position as "bottom"', () => {
+ component.ngOnInit();
+
+ expect(component.tipPosition.left).toBe(22);
+ expect(component.tipPosition.top).toBe(114);
+ });
+
+ it('should update tip position when data.position as "right"', fakeAsync(() => {
+ fixture.componentRef.setInput('data', HelpTips.step2);
+ tick();
+
+ component.ngOnInit();
+
+ expect(component.tipPosition.left).toBe(100);
+ expect(component.tipPosition.top).toBe(68);
+ }));
+
+ it('should update tip position when data.position as "left"', fakeAsync(() => {
+ const mockData = { ...HelpTips.step2, position: 'left' };
+ fixture.componentRef.setInput('data', mockData);
+ tick();
+
+ component.ngOnInit();
+
+ expect(component.tipPosition.left).toBe(-170);
+ expect(component.tipPosition.top).toBe(150);
+ }));
+
+ it('should update tip position when data.position as "top"', fakeAsync(() => {
+ const mockData = { ...HelpTips.step2, position: 'top' };
+ fixture.componentRef.setInput('data', mockData);
+ tick();
+
+ component.ngOnInit();
+
+ expect(component.tipPosition.left).toBe(22);
+ expect(component.tipPosition.top).toBe(86);
+ }));
+
+ it('should call updateTipPosition on window resize', () => {
+ spyOn(component, 'updateTipPosition');
+
+ window.dispatchEvent(new Event('resize'));
+
+ expect(component.updateTipPosition).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/modules/ui/src/app/components/help-tip/help-tip.component.ts b/modules/ui/src/app/components/help-tip/help-tip.component.ts
new file mode 100644
index 000000000..6bfc5a627
--- /dev/null
+++ b/modules/ui/src/app/components/help-tip/help-tip.component.ts
@@ -0,0 +1,99 @@
+/**
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+ ChangeDetectionStrategy,
+ Component,
+ HostListener,
+ input,
+ OnInit,
+ output,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MatIconModule } from '@angular/material/icon';
+import { MatButtonModule } from '@angular/material/button';
+import { TipConfig } from '../../model/tip-config';
+
+@Component({
+ selector: 'app-help-tip',
+ imports: [CommonModule, MatIconModule, MatButtonModule],
+ templateUrl: './help-tip.component.html',
+ styleUrl: './help-tip.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class HelpTipComponent implements OnInit {
+ data = input();
+ target = input();
+ action = input();
+ onAction = output();
+ onCLoseTip = output();
+ tipPosition = { top: 0, left: 0 };
+
+ @HostListener('window:resize')
+ onResize() {
+ this.updateTipPosition(this.target());
+ }
+
+ ngOnInit() {
+ this.updateTipPosition(this.target());
+ }
+
+ updateTipPosition(target: HTMLElement | undefined | null) {
+ if (!target) {
+ return;
+ }
+
+ const targetRect = target.getBoundingClientRect();
+
+ const tipWidth = 256;
+ const arrowOffset = 14;
+ const topOffset = 82;
+
+ let top = 0;
+ let left = 0;
+
+ switch (this.data()?.position) {
+ case 'left':
+ top = targetRect.top + window.scrollY + targetRect.height / 2; // Center tip vertically
+ left = targetRect.left + window.scrollX - tipWidth - arrowOffset;
+ break;
+ case 'right':
+ top =
+ targetRect.top + window.scrollY - topOffset + targetRect.height / 2; // Center tip vertically
+ left = targetRect.right + window.scrollX;
+ break;
+ case 'top':
+ top = targetRect.top + window.scrollY - arrowOffset; // Position above the button
+ left =
+ targetRect.left +
+ window.scrollX +
+ targetRect.width / 2 -
+ tipWidth / 2; // Center horizontally above button
+ break;
+ case 'bottom':
+ top = targetRect.bottom + window.scrollY + arrowOffset; // Position below the button
+ left =
+ targetRect.left +
+ window.scrollX +
+ targetRect.width / 2 -
+ tipWidth / 2; // Center horizontally below button
+ break;
+ default:
+ throw new Error('Unsupported tip position!');
+ }
+
+ this.tipPosition = { top, left };
+ }
+}
diff --git a/modules/ui/src/app/model/tip-config.ts b/modules/ui/src/app/model/tip-config.ts
new file mode 100644
index 000000000..eab024c0d
--- /dev/null
+++ b/modules/ui/src/app/model/tip-config.ts
@@ -0,0 +1,41 @@
+/**
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export interface TipConfig {
+ title: string;
+ content: string;
+ action: string;
+ arrowPosition: 'left' | 'right' | 'top' | 'bottom';
+ position: 'left' | 'right' | 'top' | 'bottom'; // Position related to the target
+}
+
+export const HelpTips = {
+ step1: {
+ title: 'Step 1:',
+ content:
+ 'To get started testing, please select your testing interfaces in system\n' +
+ 'settings.',
+ action: 'Go to Settings',
+ position: 'bottom',
+ arrowPosition: 'top',
+ } as TipConfig,
+ step2: {
+ title: 'Step 2:',
+ content: 'Create a device to start your first test attempt.',
+ action: 'Create Device',
+ position: 'right',
+ arrowPosition: 'left',
+ } as TipConfig,
+};
diff --git a/modules/ui/src/styles.scss b/modules/ui/src/styles.scss
index 26cfd792e..b81653bdd 100644
--- a/modules/ui/src/styles.scss
+++ b/modules/ui/src/styles.scss
@@ -206,7 +206,8 @@ body {
}
}
-.app-toolbar-button.app-sidebar-button-active {
+.app-toolbar-button.app-sidebar-button-active,
+body:has(app-help-tip) .app-toolbar-button-help-tips {
.mat-mdc-button-persistent-ripple::before {
opacity: 1;
background: colors.$light-grey;