diff --git a/modules/ui/src/app/app.component.html b/modules/ui/src/app/app.component.html index 2420bec59..c5b5a7e97 100644 --- a/modules/ui/src/app/app.component.html +++ b/modules/ui/src/app/app.component.html @@ -18,6 +18,7 @@
+ + + +
+ + + +
+ +
+ + + + diff --git a/modules/ui/src/app/components/side-button-menu/side-button-menu.component.scss b/modules/ui/src/app/components/side-button-menu/side-button-menu.component.scss new file mode 100644 index 000000000..2e73d07b1 --- /dev/null +++ b/modules/ui/src/app/components/side-button-menu/side-button-menu.component.scss @@ -0,0 +1,66 @@ +@use 'colors'; +@use 'variables'; +@use '@angular/material' as mat; + +:host { + display: flex; + justify-content: center; + width: 100%; +} + +.side-add-button-container { + position: relative; +} + +.side-add-menu-trigger { + position: absolute; + top: 0; + right: -20px; +} + +.side-add-menu-triangle { + position: absolute; + top: 18px; + left: -12px; +} + +::ng-deep .side-add-menu { + overflow: visible !important; + width: 278px; + border-radius: 4px; + padding: 0 8px; +} + +.side-add-button { + --mdc-fab-container-color: #{colors.$primary-container}; + --mat-icon-color: #{colors.$primary}; +} + +.side-add-menu-button { + gap: 12px; + border-radius: 4px; + width: 100%; + height: 56px; + display: grid; + background: inherit; + grid-template-columns: min-content auto; +} + +.side-add-menu-button-description { + color: colors.$on-surface-variant; + font-family: variables.$font-text; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.1px; +} + +.side-add-menu-button-label { + color: colors.$on-surface; + font-family: variables.$font-text; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 24px; +} diff --git a/modules/ui/src/app/components/side-button-menu/side-button-menu.component.spec.ts b/modules/ui/src/app/components/side-button-menu/side-button-menu.component.spec.ts new file mode 100644 index 000000000..8d261de57 --- /dev/null +++ b/modules/ui/src/app/components/side-button-menu/side-button-menu.component.spec.ts @@ -0,0 +1,105 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { By } from '@angular/platform-browser'; +import { of } from 'rxjs'; +import { SideButtonMenuComponent } from './side-button-menu.component'; +import { + MatMenuHarness, + MatMenuItemHarness, +} from '@angular/material/menu/testing'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; + +describe('SideButtonMenuComponent', () => { + let component: SideButtonMenuComponent; + let fixture: ComponentFixture; + let loader: HarnessLoader; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + SideButtonMenuComponent, + MatMenuModule, + MatButtonModule, + MatIconModule, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SideButtonMenuComponent); + component = fixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(fixture); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render menu button', () => { + const button = fixture.debugElement.query(By.css('.side-add-button')); + expect(button).toBeTruthy(); + }); + + describe('menu', () => { + let menu; + let items: MatMenuItemHarness[]; + const onClickSpy = jasmine.createSpy('onClick'); + + beforeEach(async () => { + fixture.componentRef.setInput('menuItems', [ + { + icon: 'home', + label: 'Home', + onClick: () => {}, + disabled$: of(true), + }, + { + icon: 'settings', + label: 'Settings', + description: 'Settings description', + onClick: onClickSpy, + disabled$: of(false), + }, + ]); + fixture.detectChanges(); + + menu = await loader.getHarness(MatMenuHarness); + await menu.open(); + items = await menu.getItems(); + }); + + it('should render menu items', async () => { + expect(items.length).toBe(2); + + const text0 = await items[0].getText(); + const text1 = await items[1].getText(); + + expect(text0).toContain('Home'); + expect(text1).toContain('Settings'); + expect(text1).toContain('Settings description'); + }); + + it('should emit the correct action when a menu item is clicked', async () => { + await items[1].click(); + + expect(onClickSpy).toHaveBeenCalled(); + }); + + it('should display the correct icons for actions', async () => { + const text0 = await items[0].getText(); + const text1 = await items[1].getText(); + + expect(text0).toContain('home'); + expect(text1).toContain('settings'); + }); + + it('should disable menu item when observable emits true', async () => { + const disabled = await items[0].isDisabled(); + expect(disabled).toBeTrue(); + }); + }); +}); diff --git a/modules/ui/src/app/components/side-button-menu/side-button-menu.component.ts b/modules/ui/src/app/components/side-button-menu/side-button-menu.component.ts new file mode 100644 index 000000000..5a5f4468d --- /dev/null +++ b/modules/ui/src/app/components/side-button-menu/side-button-menu.component.ts @@ -0,0 +1,16 @@ +import { Component, input } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { CommonModule } from '@angular/common'; +import { AddMenuItem } from '../../app.component'; + +@Component({ + selector: 'app-side-button-menu', + imports: [MatButtonModule, MatIconModule, MatMenuModule, CommonModule], + templateUrl: './side-button-menu.component.html', + styleUrl: './side-button-menu.component.scss', +}) +export class SideButtonMenuComponent { + menuItems = input([]); +} diff --git a/modules/ui/src/app/pages/devices/devices.component.spec.ts b/modules/ui/src/app/pages/devices/devices.component.spec.ts index 4b8d45538..f693d8103 100644 --- a/modules/ui/src/app/pages/devices/devices.component.spec.ts +++ b/modules/ui/src/app/pages/devices/devices.component.spec.ts @@ -59,6 +59,7 @@ describe('DevicesComponent', () => { 'getTestModules', 'deleteDevice', ]); + mockDevicesStore.isOpenAddDevice$ = of(false); await TestBed.configureTestingModule({ imports: [ diff --git a/modules/ui/src/app/pages/devices/devices.component.ts b/modules/ui/src/app/pages/devices/devices.component.ts index 9b0a9bb49..793076e38 100644 --- a/modules/ui/src/app/pages/devices/devices.component.ts +++ b/modules/ui/src/app/pages/devices/devices.component.ts @@ -26,14 +26,12 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { Device, DeviceAction, - DeviceStatus, DeviceView, TestModule, } from '../../model/device'; import { LayoutType } from '../../model/layout-type'; import { Subject, takeUntil, timer } from 'rxjs'; import { SimpleDialogComponent } from '../../components/simple-dialog/simple-dialog.component'; -import { combineLatest } from 'rxjs/internal/observable/combineLatest'; import { FocusManagerService } from '../../services/focus-manager.service'; import { Routes } from '../../model/routes'; import { Router } from '@angular/router'; @@ -107,17 +105,10 @@ export class DevicesComponent } ngOnInit(): void { - combineLatest([ - this.devicesStore.devices$, - this.devicesStore.isOpenAddDevice$, - ]) + this.devicesStore.isOpenAddDevice$ .pipe(takeUntil(this.destroy$)) - .subscribe(([devices, isOpenAddDevice]) => { - if ( - !devices?.filter(device => device.status === DeviceStatus.VALID) - .length && - isOpenAddDevice - ) { + .subscribe(isOpenAddDevice => { + if (isOpenAddDevice) { this.openForm(); } }); diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts index 98169ed45..3ae05d261 100644 --- a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts @@ -71,8 +71,12 @@ describe('RiskAssessmentComponent', () => { 'setFocusOnProfileForm', 'updateProfiles', 'removeProfile', + 'isOpenCreateProfile$', + 'profileFormat$', ]); + mockRiskAssessmentStore.profileFormat$ = of([]); + await TestBed.configureTestingModule({ declarations: [FakeProfileItemComponent, FakeProfileFormComponent], imports: [ @@ -107,6 +111,14 @@ describe('RiskAssessmentComponent', () => { expect(component).toBeTruthy(); }); + it('should open form if isOpenAddDevice$ as true', () => { + mockRiskAssessmentStore.profileFormat$ = of([], []); + mockRiskAssessmentStore.isOpenCreateProfile$ = of(true); + component.ngOnInit(); + + expect(component.isOpenProfileForm).toBeTrue(); + }); + describe('with no profiles data', () => { beforeEach(() => { component.viewModel$ = of({ diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts index 5853ed0c9..a100215ab 100644 --- a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts @@ -26,11 +26,10 @@ import { } from '@angular/core'; import { RiskAssessmentStore } from './risk-assessment.store'; import { SimpleDialogComponent } from '../../components/simple-dialog/simple-dialog.component'; -import { Subject, takeUntil, timer } from 'rxjs'; +import { Subject, takeUntil, timer, Observable } from 'rxjs'; import { MatDialog } from '@angular/material/dialog'; import { LiveAnnouncer } from '@angular/cdk/a11y'; import { Profile, ProfileAction, ProfileStatus } from '../../model/profile'; -import { Observable } from 'rxjs/internal/Observable'; import { DeviceValidators } from '../devices/components/device-form/device.validators'; import { SuccessDialogComponent } from './components/success-dialog/success-dialog.component'; import { MatToolbarModule } from '@angular/material/toolbar'; @@ -51,8 +50,8 @@ import { ListLayoutComponent } from '../../components/list-layout/list-layout.co import { LayoutType } from '../../model/layout-type'; import { NoEntitySelectedComponent } from '../../components/no-entity-selected/no-entity-selected.component'; import { EntityAction, EntityActionResult } from '../../model/entity-action'; -import { of } from 'rxjs/internal/observable/of'; import { CanComponentDeactivate } from '../../guards/can-deactivate.guard'; +import { of, combineLatest, skip } from 'rxjs'; const matFormFieldDefaultOptions: MatFormFieldDefaultOptions = { hideRequiredMarker: true, @@ -114,6 +113,17 @@ export class RiskAssessmentComponent ngOnInit() { this.store.getProfilesFormat(); + + combineLatest([ + this.store.isOpenCreateProfile$, + this.store.profileFormat$.pipe(skip(1)), + ]) + .pipe(takeUntil(this.destroy$)) + .subscribe(([isOpenCreateProfile]) => { + if (isOpenCreateProfile) { + this.openForm(); + } + }); } ngOnDestroy() { diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.store.ts b/modules/ui/src/app/pages/risk-assessment/risk-assessment.store.ts index f55ed231e..0e1a35bd1 100644 --- a/modules/ui/src/app/pages/risk-assessment/risk-assessment.store.ts +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.store.ts @@ -23,7 +23,10 @@ import { Profile, ProfileAction, ProfileFormat } from '../../model/profile'; import { FocusManagerService } from '../../services/focus-manager.service'; import { Store } from '@ngrx/store'; import { AppState } from '../../store/state'; -import { selectRiskProfiles } from '../../store/selectors'; +import { + selectIsOpenCreateProfile, + selectRiskProfiles, +} from '../../store/selectors'; import { setRiskProfiles } from '../../store/actions'; import { EntityAction } from '../../model/entity-action'; @@ -43,7 +46,7 @@ export class RiskAssessmentStore extends ComponentStore { profileFormat$ = this.select(state => state.profileFormat); selectedProfile$ = this.select(state => state.selectedProfile); actions$ = this.select(state => state.actions); - + isOpenCreateProfile$ = this.store.select(selectIsOpenCreateProfile); viewModel$ = this.select({ profiles: this.profiles$, profileFormat: this.profileFormat$, diff --git a/modules/ui/src/app/pages/testrun/testrun.component.ts b/modules/ui/src/app/pages/testrun/testrun.component.ts index 3c1ffd1d8..db5e79a9e 100644 --- a/modules/ui/src/app/pages/testrun/testrun.component.ts +++ b/modules/ui/src/app/pages/testrun/testrun.component.ts @@ -34,7 +34,6 @@ import { LOADER_TIMEOUT_CONFIG_TOKEN } from '../../services/loaderConfig'; import { FocusManagerService } from '../../services/focus-manager.service'; import { TestrunStore } from './testrun.store'; import { TestRunService } from '../../services/test-run.service'; -import { NotificationService } from '../../services/notification.service'; import { TestModule } from '../../model/device'; import { combineLatest } from 'rxjs/internal/observable/combineLatest'; import { CommonModule } from '@angular/common'; @@ -79,7 +78,6 @@ import { TestrunStatusCardComponent } from './components/testrun-status-card/tes export class TestrunComponent implements OnInit, OnDestroy { isOpenDownloadOptions: boolean = false; private readonly testRunService = inject(TestRunService); - private readonly notificationService = inject(NotificationService); dialog = inject(MatDialog); private readonly focusManagerService = inject(FocusManagerService); testrunStore = inject(TestrunStore); diff --git a/modules/ui/src/app/store/actions.ts b/modules/ui/src/app/store/actions.ts index 5a290109b..8ababc915 100644 --- a/modules/ui/src/app/store/actions.ts +++ b/modules/ui/src/app/store/actions.ts @@ -146,3 +146,8 @@ export const updateInternetConnection = createAction( export const fetchInterfaces = createAction('[Shared] Fetch interfaces'); export const fetchSystemConfig = createAction('[Shared] Fetch system config'); + +export const setIsOpenProfile = createAction( + '[Shared] Set Is Open Profile', + props<{ isOpenCreateProfile: boolean }>() +); diff --git a/modules/ui/src/app/store/reducers.ts b/modules/ui/src/app/store/reducers.ts index e51fde1b7..82f45dc60 100644 --- a/modules/ui/src/app/store/reducers.ts +++ b/modules/ui/src/app/store/reducers.ts @@ -136,7 +136,13 @@ export const sharedReducer = createReducer( on(Actions.fetchSystemConfigSuccess, (state, { systemConfig }) => ({ ...state, systemConfig, - })) + })), + on(Actions.setIsOpenProfile, (state, { isOpenCreateProfile }) => { + return { + ...state, + isOpenCreateProfile, + }; + }) ); export const rootReducer = sharedReducer; diff --git a/modules/ui/src/app/store/selectors.spec.ts b/modules/ui/src/app/store/selectors.spec.ts index ba6a52315..0e55184c5 100644 --- a/modules/ui/src/app/store/selectors.spec.ts +++ b/modules/ui/src/app/store/selectors.spec.ts @@ -61,6 +61,7 @@ describe('Selectors', () => { internetConnection: null, interfaces: {}, systemConfig: { network: {} }, + isOpenCreateProfile: false, }; it('should select interfaces', () => { diff --git a/modules/ui/src/app/store/selectors.ts b/modules/ui/src/app/store/selectors.ts index 2a3fb0ed9..cee4705b0 100644 --- a/modules/ui/src/app/store/selectors.ts +++ b/modules/ui/src/app/store/selectors.ts @@ -122,3 +122,8 @@ export const selectSystemConfig = createSelector( selectAppState, (state: AppState) => state.systemConfig ); + +export const selectIsOpenCreateProfile = createSelector( + selectAppState, + (state: AppState) => state.isOpenCreateProfile +); diff --git a/modules/ui/src/app/store/state.ts b/modules/ui/src/app/store/state.ts index 2ce978b6f..10de8165f 100644 --- a/modules/ui/src/app/store/state.ts +++ b/modules/ui/src/app/store/state.ts @@ -47,6 +47,7 @@ export interface AppState { testModules: TestModule[]; adapters: Adapters; internetConnection: boolean | null; + isOpenCreateProfile: boolean; } export const initialState: AppState = { @@ -71,4 +72,5 @@ export const initialState: AppState = { internetConnection: null, interfaces: {}, systemConfig: { network: {} }, + isOpenCreateProfile: false, }; diff --git a/modules/ui/src/styles.scss b/modules/ui/src/styles.scss index 6a19a3187..26cfd792e 100644 --- a/modules/ui/src/styles.scss +++ b/modules/ui/src/styles.scss @@ -64,6 +64,20 @@ color: colors.$outline-variant, ) ); + + @include mat.menu-overrides( + ( + item-label-text-color: colors.$on-surface, + item-icon-color: colors.$on-surface-variant, + container-color: colors.$surface, + item-label-text-weight: 400, + item-label-text-size: 16px, + item-label-text-font: variables.$font-text, + item-hover-state-layer-color: colors.$secondary-container, + item-with-icon-leading-spacing: 24px, + item-with-icon-trailing-spacing: 24px, + ) + ); } .consent-dialog { @@ -534,3 +548,11 @@ button:not(.mat-mdc-button-disabled) { .mat-mdc-snackbar-surface { padding-right: 0 !important; } + +.side-add-menu { + @include mat.menu-overrides( + ( + item-hover-state-layer-color: rgba(31, 31, 31, 0.08), + ) + ); +}