From 6d28298ec230041a70cd55bfaf505b944fcfe4f4 Mon Sep 17 00:00:00 2001 From: Volha Mardvilka Date: Wed, 11 Dec 2024 10:11:12 +0000 Subject: [PATCH] 382095919: (feat) add settings basic page --- modules/ui/src/app/app-routing.module.ts | 6 + modules/ui/src/app/app.component.html | 13 +- modules/ui/src/app/app.component.spec.ts | 32 +- modules/ui/src/app/app.component.ts | 6 +- modules/ui/src/app/model/routes.ts | 1 + .../settings-dropdown.component.html | 0 .../settings-dropdown.component.scss | 0 .../settings-dropdown.component.spec.ts | 0 .../settings-dropdown.component.ts | 0 .../general-settings.component.html | 116 ++++++ .../general-settings.component.scss | 152 +++++++ .../general-settings.component.spec.ts | 383 ++++++++++++++++++ .../general-settings.component.ts | 267 ++++++++++++ .../general-settings.store.spec.ts} | 14 +- .../general-settings.store.ts} | 6 +- .../only-different-values.validator.ts | 0 .../pages/settings/settings.component.html | 112 +---- .../pages/settings/settings.component.scss | 142 +------ .../pages/settings/settings.component.spec.ts | 357 +--------------- .../app/pages/settings/settings.component.ts | 249 +----------- modules/ui/src/styles.scss | 36 ++ 21 files changed, 1032 insertions(+), 860 deletions(-) rename modules/ui/src/app/pages/{settings => general-settings}/components/settings-dropdown/settings-dropdown.component.html (100%) rename modules/ui/src/app/pages/{settings => general-settings}/components/settings-dropdown/settings-dropdown.component.scss (100%) rename modules/ui/src/app/pages/{settings => general-settings}/components/settings-dropdown/settings-dropdown.component.spec.ts (100%) rename modules/ui/src/app/pages/{settings => general-settings}/components/settings-dropdown/settings-dropdown.component.ts (100%) create mode 100644 modules/ui/src/app/pages/general-settings/general-settings.component.html create mode 100644 modules/ui/src/app/pages/general-settings/general-settings.component.scss create mode 100644 modules/ui/src/app/pages/general-settings/general-settings.component.spec.ts create mode 100644 modules/ui/src/app/pages/general-settings/general-settings.component.ts rename modules/ui/src/app/pages/{settings/settings.store.spec.ts => general-settings/general-settings.store.spec.ts} (97%) rename modules/ui/src/app/pages/{settings/settings.store.ts => general-settings/general-settings.store.ts} (98%) rename modules/ui/src/app/pages/{settings => general-settings}/only-different-values.validator.ts (100%) diff --git a/modules/ui/src/app/app-routing.module.ts b/modules/ui/src/app/app-routing.module.ts index fd77640cf..a94a90222 100644 --- a/modules/ui/src/app/app-routing.module.ts +++ b/modules/ui/src/app/app-routing.module.ts @@ -19,8 +19,14 @@ import { DevicesComponent } from './pages/devices/devices.component'; import { CanDeactivateGuard } from './guards/can-deactivate.guard'; import { TestrunComponent } from './pages/testrun/testrun.component'; import { RiskAssessmentComponent } from './pages/risk-assessment/risk-assessment.component'; +import { SettingsComponent } from './pages/settings/settings.component'; export const routes: Routes = [ + { + path: 'settings', + component: SettingsComponent, + title: 'Testrun - Settings', + }, { path: 'testing', component: TestrunComponent, diff --git a/modules/ui/src/app/app.component.html b/modules/ui/src/app/app.component.html index cff15804e..2aeeb52c2 100644 --- a/modules/ui/src/app/app.component.html +++ b/modules/ui/src/app/app.component.html @@ -98,9 +98,12 @@

Testrun

mat-icon-button aria-label="System settings" matTooltip="Settings" - (click)=" - openGeneralSettings(true, isTestrunInProgress(vm.systemStatus)) - "> + routerLink="{{ Routes.Settings }}" + routerLinkActive="app-sidebar-button-active" + (keydown.enter)="onNavigationClick()"> + + + tune @@ -275,11 +278,11 @@

Testrun

position="end" autoFocus="#setting-panel-close-button" class="settings-drawer"> - - + { FakeTestingCompleteComponent, RouterTestingModule.withRoutes([ { path: 'devices', children: [] }, + { path: 'settings', children: [] }, { path: 'testing', children: [] }, { path: 'reports', children: [] }, ]), @@ -190,7 +191,7 @@ describe('AppComponent', () => { }).overrideComponent(AppComponent, { remove: { imports: [ - SettingsComponent, + GeneralSettingsComponent, SpinnerComponent, ShutdownAppComponent, TestingCompleteComponent, @@ -368,15 +369,16 @@ describe('AppComponent', () => { expect(settings.getSystemConfig).toHaveBeenCalled(); }); - it('should call settingsDrawer open on openSetting', fakeAsync(() => { + it('should navigate to the settings when "settings" button is clicked', fakeAsync(() => { fixture.detectChanges(); - const settingsDrawer = component.settingsDrawer(); - spyOn(settingsDrawer, 'open'); - component.openSetting(false); + const settingsButton = compiled.querySelector( + '.app-toolbar-button-general-settings' + ) as HTMLButtonElement; + settingsButton?.click(); tick(); - expect(settingsDrawer.open).toHaveBeenCalledTimes(1); + expect(router.url).toBe(Routes.Settings); })); it('should announce settingsDrawer disabled on openSetting and settings are disabled', fakeAsync(() => { @@ -394,20 +396,6 @@ describe('AppComponent', () => { ); })); - it('should call settingsDrawer open on click settings button', () => { - fixture.detectChanges(); - - const settingsBtn = compiled.querySelector( - '.app-toolbar-button-general-settings' - ) as HTMLButtonElement; - const settingsDrawer = component.settingsDrawer(); - spyOn(settingsDrawer, 'open'); - - settingsBtn.click(); - - expect(settingsDrawer.open).toHaveBeenCalledTimes(1); - }); - it('should have spinner', () => { const spinner = compiled.querySelector('app-spinner'); @@ -838,7 +826,7 @@ describe('AppComponent', () => { }); @Component({ - selector: 'app-settings', + selector: 'app-general-settings', template: '
', }) class FakeGeneralSettingsComponent { diff --git a/modules/ui/src/app/app.component.ts b/modules/ui/src/app/app.component.ts index 7ef9113ac..fc5f7d30e 100644 --- a/modules/ui/src/app/app.component.ts +++ b/modules/ui/src/app/app.component.ts @@ -32,7 +32,7 @@ import { FocusManagerService } from './services/focus-manager.service'; import { State, Store } from '@ngrx/store'; import { AppState } from './store/state'; import { setIsOpenAddDevice } from './store/actions'; -import { SettingsComponent } from './pages/settings/settings.component'; +import { GeneralSettingsComponent } from './pages/general-settings/general-settings.component'; import { AppStore } from './app.store'; import { TestRunService } from './services/test-run.service'; import { CdkTrapFocus, LiveAnnouncer } from '@angular/cdk/a11y'; @@ -92,7 +92,7 @@ const QUALIFICATION_URL = '/assets/icons/qualification.svg'; CertificatesComponent, WifiComponent, TestingCompleteComponent, - SettingsComponent, + GeneralSettingsComponent, RouterModule, CommonModule, ], @@ -118,7 +118,7 @@ export class AppComponent implements AfterViewInit { readonly certDrawer = viewChild.required('certDrawer'); readonly toggleSettingsBtn = viewChild.required('toggleSettingsBtn'); - readonly settings = viewChild.required('settings'); + readonly settings = viewChild.required('settings'); viewModel$ = this.appStore.viewModel$; readonly riskAssessmentLink = viewChild('riskAssessmentLink'); diff --git a/modules/ui/src/app/model/routes.ts b/modules/ui/src/app/model/routes.ts index 05ff91ed9..b2873b126 100644 --- a/modules/ui/src/app/model/routes.ts +++ b/modules/ui/src/app/model/routes.ts @@ -16,6 +16,7 @@ export enum Routes { Devices = '/devices', + Settings = '/settings', Testing = '/testing', Reports = '/reports', RiskAssessment = '/risk-assessment', diff --git a/modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.html b/modules/ui/src/app/pages/general-settings/components/settings-dropdown/settings-dropdown.component.html similarity index 100% rename from modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.html rename to modules/ui/src/app/pages/general-settings/components/settings-dropdown/settings-dropdown.component.html diff --git a/modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.scss b/modules/ui/src/app/pages/general-settings/components/settings-dropdown/settings-dropdown.component.scss similarity index 100% rename from modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.scss rename to modules/ui/src/app/pages/general-settings/components/settings-dropdown/settings-dropdown.component.scss diff --git a/modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.spec.ts b/modules/ui/src/app/pages/general-settings/components/settings-dropdown/settings-dropdown.component.spec.ts similarity index 100% rename from modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.spec.ts rename to modules/ui/src/app/pages/general-settings/components/settings-dropdown/settings-dropdown.component.spec.ts diff --git a/modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.ts b/modules/ui/src/app/pages/general-settings/components/settings-dropdown/settings-dropdown.component.ts similarity index 100% rename from modules/ui/src/app/pages/settings/components/settings-dropdown/settings-dropdown.component.ts rename to modules/ui/src/app/pages/general-settings/components/settings-dropdown/settings-dropdown.component.ts diff --git a/modules/ui/src/app/pages/general-settings/general-settings.component.html b/modules/ui/src/app/pages/general-settings/general-settings.component.html new file mode 100644 index 000000000..2dd8567b3 --- /dev/null +++ b/modules/ui/src/app/pages/general-settings/general-settings.component.html @@ -0,0 +1,116 @@ + +
+

System settings

+ +
+
+
+
+
+ + + + + + +

+ If a port is missing from this list, you can + + Refresh + + the System settings +

+ + + + +
+ + Both interfaces must have different values + + +
+ + + Warning! No ports detected. + + +
+ diff --git a/modules/ui/src/app/pages/general-settings/general-settings.component.scss b/modules/ui/src/app/pages/general-settings/general-settings.component.scss new file mode 100644 index 000000000..ea51621ed --- /dev/null +++ b/modules/ui/src/app/pages/general-settings/general-settings.component.scss @@ -0,0 +1,152 @@ +/** + * 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 'colors'; +@use 'variables'; + +:host { + display: flex; + flex-direction: column; + height: 100%; + flex: 1 0 auto; +} + +.settings-drawer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 12px 16px 24px; + + &-title { + margin: 0; + font-size: 22px; + font-style: normal; + font-weight: 400; + line-height: 28px; + color: colors.$dark-grey; + } + + &-button { + min-width: 24px; + width: 24px; + height: 24px; + margin: 4px; + padding: 8px; + box-sizing: content-box; + line-height: normal !important; + + .close-button-icon { + width: 24px; + height: 24px; + margin: 0; + } + + ::ng-deep * { + line-height: inherit !important; + } + } +} + +.setting-drawer-content { + padding: 0 16px 8px 16px; + overflow: hidden; + flex: 1; + + form { + display: grid; + height: 100%; + } + + .setting-drawer-content-form-empty { + grid-template-rows: repeat(2, auto) 1fr; + } +} + +.setting-drawer-content-inputs { + overflow: auto; + margin: 0 -16px; + padding: 0 16px; +} + +.error-message-container { + display: block; + margin-top: auto; + padding-bottom: 8px; +} + +.error-message-container + .setting-drawer-footer { + margin-top: 0; +} + +.message { + margin: 0; + padding: 6px 0 12px 0; + color: colors.$grey-800; + font-family: variables.$font-secondary; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; +} + +.setting-drawer-footer { + padding: 0 8px; + margin-top: auto; + display: flex; + flex-shrink: 0; + justify-content: flex-end; + + .close-button, + .save-button { + padding: 0 24px; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + } + + .close-button { + margin-right: 10px; + } +} + +.settings-disabled-overlay { + position: absolute; + width: 100%; + left: 0; + right: 0; + top: 75px; + bottom: 45px; + background-color: rgba(255, 255, 255, 0.7); + z-index: 2; +} + +.disabled { + .message-link { + cursor: default; + pointer-events: none; + + &:focus-visible { + outline: none; + } + } +} + +.settings-drawer-header-button:not(.mat-mdc-button-disabled), +.close-button:not(.mat-mdc-button-disabled), +.save-button:not(.mat-mdc-button-disabled) { + cursor: pointer; + pointer-events: auto; +} diff --git a/modules/ui/src/app/pages/general-settings/general-settings.component.spec.ts b/modules/ui/src/app/pages/general-settings/general-settings.component.spec.ts new file mode 100644 index 000000000..a392de2e2 --- /dev/null +++ b/modules/ui/src/app/pages/general-settings/general-settings.component.spec.ts @@ -0,0 +1,383 @@ +/** + * 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 { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GeneralSettingsComponent } from './general-settings.component'; +import { of } from 'rxjs'; +import { MatRadioModule } from '@angular/material/radio'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIcon, MatIconModule } from '@angular/material/icon'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { Component, Input } from '@angular/core'; +import { LiveAnnouncer } from '@angular/cdk/a11y'; +import SpyObj = jasmine.SpyObj; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { provideMockStore } from '@ngrx/store/testing'; +import { LoaderService } from '../../services/loader.service'; +import { GeneralSettingsStore } from './general-settings.store'; +import { + MOCK_INTERFACES, + MOCK_SYSTEM_CONFIG_WITH_DATA, +} from '../../mocks/settings.mock'; +import { SettingsDropdownComponent } from './components/settings-dropdown/settings-dropdown.component'; +import { CalloutComponent } from '../../components/callout/callout.component'; +import { SpinnerComponent } from '../../components/spinner/spinner.component'; + +describe('GeneralSettingsComponent', () => { + let component: GeneralSettingsComponent; + let fixture: ComponentFixture; + let mockLiveAnnouncer: SpyObj; + let compiled: HTMLElement; + let mockLoaderService: SpyObj; + let mockSettingsStore: SpyObj; + + beforeEach(async () => { + mockLiveAnnouncer = jasmine.createSpyObj(['announce']); + mockLoaderService = jasmine.createSpyObj('LoaderService', ['setLoading']); + mockSettingsStore = jasmine.createSpyObj('SettingsStore', [ + 'getInterfaces', + 'updateSystemConfig', + 'setIsSubmitting', + 'setDefaultFormValues', + 'getSystemConfig', + 'viewModel$', + ]); + + await TestBed.configureTestingModule({ + providers: [ + { provide: LiveAnnouncer, useValue: mockLiveAnnouncer }, + { provide: LoaderService, useValue: mockLoaderService }, + { provide: GeneralSettingsStore, useValue: mockSettingsStore }, + provideMockStore(), + ], + imports: [ + GeneralSettingsComponent, + BrowserAnimationsModule, + MatButtonModule, + MatIconModule, + MatRadioModule, + ReactiveFormsModule, + MatIconTestingModule, + MatIcon, + MatInputModule, + MatSelectModule, + SettingsDropdownComponent, + FakeSpinnerComponent, + FakeCalloutComponent, + ], + }) + .overrideComponent(GeneralSettingsComponent, { + remove: { + imports: [CalloutComponent, SpinnerComponent], + }, + add: { + imports: [FakeSpinnerComponent, FakeCalloutComponent], + }, + }) + .compileComponents(); + + TestBed.overrideProvider(GeneralSettingsStore, { + useValue: mockSettingsStore, + }); + + fixture = TestBed.createComponent(GeneralSettingsComponent); + + component = fixture.componentInstance; + component.viewModel$ = of({ + systemConfig: { network: {} }, + hasConnectionSettings: false, + isSubmitting: false, + isLessThanOneInterface: false, + interfaces: {}, + deviceOptions: {}, + internetOptions: {}, + logLevelOptions: {}, + monitoringPeriodOptions: {}, + }); + fixture.detectChanges(); + compiled = fixture.nativeElement as HTMLElement; + + component.ngOnInit(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('#reloadSetting should call setLoading in loaderService', () => { + component.reloadSetting(); + + expect(mockLoaderService.setLoading).toHaveBeenCalledWith(true); + }); + + describe('#settingsDisable', () => { + it('should disable setting form when get settingDisable as true ', () => { + spyOn(component.settingForm, 'disable'); + + component.settingsDisable = true; + + expect(component.settingForm.disable).toHaveBeenCalled(); + }); + + it('should enable setting form when get settingDisable as false ', () => { + spyOn(component.settingForm, 'enable'); + + component.settingsDisable = false; + + expect(component.settingForm.enable).toHaveBeenCalled(); + }); + + it('should disable "Save" button when get settingDisable as true', () => { + component.settingsDisable = true; + + const saveBtn = compiled.querySelector( + '.save-button' + ) as HTMLButtonElement; + + expect(saveBtn.disabled).toBeTrue(); + }); + + it('should disable "Refresh" link when settingDisable', () => { + component.settingsDisable = true; + + const refreshLink = compiled.querySelector( + '.message-link' + ) as HTMLAnchorElement; + + refreshLink.click(); + + expect(refreshLink.hasAttribute('aria-disabled')).toBeTrue(); + expect(mockLoaderService.setLoading).not.toHaveBeenCalled(); + }); + }); + + describe('#closeSetting', () => { + beforeEach(() => { + component.ngOnInit(); + }); + + it('should emit closeSettingEvent', () => { + spyOn(component.closeSettingEvent, 'emit'); + + component.closeSetting('Message'); + + expect(component.closeSettingEvent.emit).toHaveBeenCalled(); + }); + + it('should call liveAnnouncer with provided message', () => { + const mockMessage = 'mock event'; + + component.closeSetting(mockMessage); + + expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith( + `The ${mockMessage} finished. The system settings panel is closed.` + ); + }); + + it('should call reset settingForm', () => { + spyOn(component.settingForm, 'reset'); + + component.closeSetting('Message'); + + expect(component.settingForm.reset).toHaveBeenCalled(); + }); + + it('should call setDefaultFormValues', () => { + component.closeSetting('Message'); + + expect(mockSettingsStore.setDefaultFormValues).toHaveBeenCalled(); + }); + }); + + describe('#saveSetting', () => { + beforeEach(() => { + component.ngOnInit(); + }); + + it('should have form error if form has the same value', () => { + const mockSameValue = 'sameValue'; + component.deviceControl.setValue(mockSameValue); + component.internetControl.setValue(mockSameValue); + + component.saveSetting(); + + expect(component.settingForm.invalid).toBeTrue(); + expect(component.isFormError).toBeTrue(); + expect(mockSettingsStore.setIsSubmitting).toHaveBeenCalledWith(true); + }); + + it('should call createSystemConfig when setting form valid', () => { + const expectedResult = { + network: { + device_intf: 'mockDeviceKey', + internet_intf: '', + }, + log_level: 'INFO', + monitor_period: 600, + }; + + component.deviceControl.setValue({ + key: 'mockDeviceKey', + value: 'mockDeviceValue', + }); + + component.internetControl.setValue({ + key: '', + value: 'defaultValue', + }); + + component.logLevel.setValue({ + key: 'INFO', + value: '', + }); + + component.monitorPeriod.setValue({ + key: '600', + value: '', + }); + + component.saveSetting(); + + const args = mockSettingsStore.updateSystemConfig.calls.argsFor(0); + // @ts-expect-error config is in object + expect(args[0].config).toEqual(expectedResult); + expect(component.settingForm.invalid).toBeFalse(); + expect(mockSettingsStore.updateSystemConfig).toHaveBeenCalled(); + }); + }); + + describe('with no interfaces data', () => { + beforeEach(() => { + component.viewModel$ = of({ + systemConfig: { network: {} }, + hasConnectionSettings: false, + isSubmitting: false, + isLessThanOneInterface: false, + interfaces: {}, + deviceOptions: {}, + internetOptions: {}, + logLevelOptions: {}, + monitoringPeriodOptions: {}, + }); + fixture.detectChanges(); + }); + + it('should have callout component', () => { + const callout = compiled.querySelector('app-callout'); + + expect(callout).toBeTruthy(); + }); + + it('should have disabled "Save" button', () => { + const saveBtn = compiled.querySelector( + '.save-button' + ) as HTMLButtonElement; + + expect(saveBtn.disabled).toBeTrue(); + }); + }); + + describe('with interfaces length less than one', () => { + beforeEach(() => { + component.viewModel$ = of({ + systemConfig: { network: {} }, + hasConnectionSettings: false, + isSubmitting: false, + isLessThanOneInterface: true, + interfaces: {}, + deviceOptions: {}, + internetOptions: {}, + logLevelOptions: {}, + monitoringPeriodOptions: {}, + }); + fixture.detectChanges(); + }); + + it('should have disabled "Save" button', () => { + component.deviceControl.setValue( + MOCK_SYSTEM_CONFIG_WITH_DATA?.network?.device_intf + ); + component.internetControl.setValue( + MOCK_SYSTEM_CONFIG_WITH_DATA?.network?.internet_intf + ); + fixture.detectChanges(); + + const saveBtn = compiled.querySelector( + '.save-button' + ) as HTMLButtonElement; + + expect(saveBtn.disabled).toBeTrue(); + }); + }); + + describe('with interfaces length more then one', () => { + beforeEach(() => { + component.viewModel$ = of({ + systemConfig: { network: {} }, + hasConnectionSettings: false, + isSubmitting: false, + isLessThanOneInterface: false, + interfaces: MOCK_INTERFACES, + deviceOptions: MOCK_INTERFACES, + internetOptions: MOCK_INTERFACES, + logLevelOptions: {}, + monitoringPeriodOptions: {}, + }); + fixture.detectChanges(); + }); + + it('should not have callout component', () => { + const callout = compiled.querySelector('app-callout'); + + expect(callout).toBeFalsy(); + }); + + it('should not have disabled "Save" button', () => { + component.deviceControl.setValue({ + key: MOCK_SYSTEM_CONFIG_WITH_DATA?.network?.device_intf, + value: 'value', + }); + component.internetControl.setValue({ + key: MOCK_SYSTEM_CONFIG_WITH_DATA?.network?.internet_intf, + value: 'value', + }); + fixture.detectChanges(); + + const saveBtn = compiled.querySelector( + '.save-button' + ) as HTMLButtonElement; + + expect(saveBtn.disabled).toBeFalse(); + }); + }); +}); + +@Component({ + selector: 'app-spinner', + template: '
', +}) +class FakeSpinnerComponent {} + +@Component({ + selector: 'app-callout', + template: '
', +}) +class FakeCalloutComponent { + @Input() type = ''; +} diff --git a/modules/ui/src/app/pages/general-settings/general-settings.component.ts b/modules/ui/src/app/pages/general-settings/general-settings.component.ts new file mode 100644 index 000000000..a4bae3b20 --- /dev/null +++ b/modules/ui/src/app/pages/general-settings/general-settings.component.ts @@ -0,0 +1,267 @@ +/** + * 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, + ElementRef, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + viewChild, + inject, +} from '@angular/core'; +import { + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule, +} from '@angular/forms'; +import { Subject, takeUntil, tap } from 'rxjs'; +import { OnlyDifferentValuesValidator } from './only-different-values.validator'; +import { CalloutType } from '../../model/callout-type'; +import { CdkTrapFocus, LiveAnnouncer } from '@angular/cdk/a11y'; +import { EventType } from '../../model/event-type'; +import { FormKey, SystemConfig } from '../../model/setting'; +import { GeneralSettingsStore } from './general-settings.store'; +import { LoaderService } from '../../services/loader.service'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatInputModule } from '@angular/material/input'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { SpinnerComponent } from '../../components/spinner/spinner.component'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatButtonModule } from '@angular/material/button'; +import { SettingsDropdownComponent } from './components/settings-dropdown/settings-dropdown.component'; +import { MatSelectModule } from '@angular/material/select'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatRadioModule } from '@angular/material/radio'; +import { CalloutComponent } from '../../components/callout/callout.component'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-general-settings', + templateUrl: './general-settings.component.html', + styleUrls: ['./general-settings.component.scss'], + hostDirectives: [CdkTrapFocus], + imports: [ + MatButtonModule, + MatIconModule, + MatToolbarModule, + MatSidenavModule, + MatButtonToggleModule, + MatRadioModule, + MatInputModule, + MatSelectModule, + MatTooltipModule, + ReactiveFormsModule, + MatFormFieldModule, + MatSnackBarModule, + SpinnerComponent, + CalloutComponent, + SettingsDropdownComponent, + CommonModule, + ], + providers: [GeneralSettingsStore], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class GeneralSettingsComponent implements OnInit, OnDestroy { + private readonly fb = inject(FormBuilder); + private liveAnnouncer = inject(LiveAnnouncer); + private readonly onlyDifferentValuesValidator = inject( + OnlyDifferentValuesValidator + ); + private settingsStore = inject(GeneralSettingsStore); + private readonly loaderService = inject(LoaderService); + + readonly reloadSettingLink = viewChild('reloadSettingLink'); + @Output() closeSettingEvent = new EventEmitter(); + + private isSettingsDisable = false; + get settingsDisable(): boolean { + return this.isSettingsDisable; + } + @Input() set settingsDisable(value: boolean) { + this.isSettingsDisable = value; + if (value) { + this.disableSettings(); + } else { + this.enableSettings(); + } + } + public readonly CalloutType = CalloutType; + public readonly EventType = EventType; + public readonly FormKey = FormKey; + public settingForm!: FormGroup; + viewModel$ = this.settingsStore.viewModel$; + + private destroy$: Subject = new Subject(); + + get deviceControl(): FormControl { + return this.settingForm.get(FormKey.DEVICE) as FormControl; + } + + get internetControl(): FormControl { + return this.settingForm.get(FormKey.INTERNET) as FormControl; + } + + get logLevel(): FormControl { + return this.settingForm.get(FormKey.LOG_LEVEL) as FormControl; + } + + get monitorPeriod(): FormControl { + return this.settingForm.get(FormKey.MONITOR_PERIOD) as FormControl; + } + + get isFormValues(): boolean { + return ( + this.deviceControl?.value?.value && + (this.isInternetControlDisabled || this.internetControl?.value?.value) + ); + } + + get isInternetControlDisabled(): boolean { + return this.internetControl?.disabled; + } + + get isFormError(): boolean { + return this.settingForm.hasError('hasSameValues'); + } + + ngOnInit() { + this.createSettingForm(); + this.cleanFormErrorMessage(); + this.settingsStore.getInterfaces(); + this.getSystemConfig(); + this.setDefaultFormValues(); + } + + reloadSetting(): void { + if (this.settingsDisable) { + return; + } + this.showLoading(); + this.getSystemInterfaces(); + this.getSystemConfig(); + this.setDefaultFormValues(); + } + closeSetting(message: string): void { + this.resetForm(); + this.closeSettingEvent.emit(); + this.liveAnnouncer.announce( + `The ${message} finished. The system settings panel is closed.` + ); + this.setDefaultFormValues(); + } + + saveSetting(): void { + if (this.settingForm.invalid) { + this.settingsStore.setIsSubmitting(true); + this.settingForm.markAllAsTouched(); + } else { + this.createSystemConfig(); + } + } + + private disableSettings(): void { + this.settingForm?.disable(); + this.reloadSettingLink()?.nativeElement.setAttribute( + 'aria-disabled', + 'true' + ); + } + + private enableSettings(): void { + this.settingForm?.enable(); + this.reloadSettingLink()?.nativeElement.removeAttribute('aria-disabled'); + } + + private createSettingForm() { + this.settingForm = this.fb.group( + { + device_intf: [''], + internet_intf: [''], + log_level: [''], + monitor_period: [''], + }, + { + validators: [this.onlyDifferentValuesValidator.onlyDifferentSetting()], + updateOn: 'change', + } + ); + } + + private setDefaultFormValues() { + this.settingsStore.setDefaultFormValues(this.settingForm); + } + + private cleanFormErrorMessage(): void { + this.settingForm.valueChanges + .pipe( + takeUntil(this.destroy$), + tap(() => this.settingsStore.setIsSubmitting(false)) + ) + .subscribe(); + } + + private createSystemConfig(): void { + const { device_intf, internet_intf, log_level, monitor_period } = + this.settingForm.value; + const data: SystemConfig = { + network: { + device_intf: device_intf.key, + internet_intf: this.isInternetControlDisabled ? '' : internet_intf.key, + }, + log_level: log_level.key, + monitor_period: Number(monitor_period.key), + }; + this.settingsStore.updateSystemConfig({ + onSystemConfigUpdate: () => { + this.closeSetting(EventType.Save); + }, + config: data, + }); + } + + private resetForm(): void { + this.settingForm.reset(); + } + + ngOnDestroy() { + this.destroy$.next(true); + this.destroy$.unsubscribe(); + } + + getSystemInterfaces(): void { + this.settingsStore.getInterfaces(); + this.hideLoading(); + } + + getSystemConfig(): void { + this.settingsStore.getSystemConfig(); + } + + private showLoading() { + this.loaderService.setLoading(true); + } + + private hideLoading() { + this.loaderService.setLoading(false); + } +} diff --git a/modules/ui/src/app/pages/settings/settings.store.spec.ts b/modules/ui/src/app/pages/general-settings/general-settings.store.spec.ts similarity index 97% rename from modules/ui/src/app/pages/settings/settings.store.spec.ts rename to modules/ui/src/app/pages/general-settings/general-settings.store.spec.ts index 969f5cb9f..451a967df 100644 --- a/modules/ui/src/app/pages/settings/settings.store.spec.ts +++ b/modules/ui/src/app/pages/general-settings/general-settings.store.spec.ts @@ -13,7 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LOG_LEVELS, MONITORING_PERIOD, SettingsStore } from './settings.store'; +import { + LOG_LEVELS, + MONITORING_PERIOD, + GeneralSettingsStore, +} from './general-settings.store'; import { TestRunService } from '../../services/test-run.service'; import SpyObj = jasmine.SpyObj; import { TestBed } from '@angular/core/testing'; @@ -41,8 +45,8 @@ import { MOCK_SYSTEM_CONFIG_WITH_SINGLE_PORT, } from '../../mocks/settings.mock'; -describe('SettingsStore', () => { - let settingsStore: SettingsStore; +describe('GeneralSettingsStore', () => { + let settingsStore: GeneralSettingsStore; let mockService: SpyObj; let store: MockStore; let fb: FormBuilder; @@ -56,7 +60,7 @@ describe('SettingsStore', () => { TestBed.configureTestingModule({ providers: [ - SettingsStore, + GeneralSettingsStore, { provide: TestRunService, useValue: mockService }, provideMockStore({ selectors: [ @@ -68,7 +72,7 @@ describe('SettingsStore', () => { ], }); - settingsStore = TestBed.inject(SettingsStore); + settingsStore = TestBed.inject(GeneralSettingsStore); store = TestBed.inject(MockStore); fb = TestBed.inject(FormBuilder); spyOn(store, 'dispatch').and.callFake(() => {}); diff --git a/modules/ui/src/app/pages/settings/settings.store.ts b/modules/ui/src/app/pages/general-settings/general-settings.store.ts similarity index 98% rename from modules/ui/src/app/pages/settings/settings.store.ts rename to modules/ui/src/app/pages/general-settings/general-settings.store.ts index 1343a1896..56e458187 100644 --- a/modules/ui/src/app/pages/settings/settings.store.ts +++ b/modules/ui/src/app/pages/general-settings/general-settings.store.ts @@ -67,7 +67,7 @@ export const MONITORING_PERIOD = { 600: 'Very slow device', }; @Injectable() -export class SettingsStore extends ComponentStore { +export class GeneralSettingsStore extends ComponentStore { private testRunService = inject(TestRunService); private store = inject>(Store); @@ -282,7 +282,7 @@ export class SettingsStore extends ComponentStore { ): void { this.setDefaultValue( value, - SettingsStore.DEFAULT_LOG_LEVEL, + GeneralSettingsStore.DEFAULT_LOG_LEVEL, options, formGroup.get(FormKey.LOG_LEVEL) as FormControl ); @@ -295,7 +295,7 @@ export class SettingsStore extends ComponentStore { ): void { this.setDefaultValue( value, - SettingsStore.DEFAULT_MONITORING_PERIOD, + GeneralSettingsStore.DEFAULT_MONITORING_PERIOD, options, formGroup.get(FormKey.MONITOR_PERIOD) as FormControl ); diff --git a/modules/ui/src/app/pages/settings/only-different-values.validator.ts b/modules/ui/src/app/pages/general-settings/only-different-values.validator.ts similarity index 100% rename from modules/ui/src/app/pages/settings/only-different-values.validator.ts rename to modules/ui/src/app/pages/general-settings/only-different-values.validator.ts diff --git a/modules/ui/src/app/pages/settings/settings.component.html b/modules/ui/src/app/pages/settings/settings.component.html index 2dd8567b3..b1cd8a1cb 100644 --- a/modules/ui/src/app/pages/settings/settings.component.html +++ b/modules/ui/src/app/pages/settings/settings.component.html @@ -13,104 +13,14 @@ See the License for the specific language governing permissions and limitations under the License. --> -
-

System settings

- -
-
-
-
-
- - - - - - -

- If a port is missing from this list, you can - - Refresh - - the System settings -

- - - - -
- - Both interfaces must have different values - - -
- - - Warning! No ports detected. - - -
- + +

Settings

+
+ + + General content + Certificates content + diff --git a/modules/ui/src/app/pages/settings/settings.component.scss b/modules/ui/src/app/pages/settings/settings.component.scss index ea51621ed..07154d04a 100644 --- a/modules/ui/src/app/pages/settings/settings.component.scss +++ b/modules/ui/src/app/pages/settings/settings.component.scss @@ -13,140 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@use '@angular/material' as mat; -@use 'colors'; -@use 'variables'; -:host { - display: flex; - flex-direction: column; - height: 100%; - flex: 1 0 auto; +.toolbar { + height: auto; + padding: 22px 0px 18px 32px; +} +.title { + padding: 24px 0 16px; } -.settings-drawer-header { +.tab-item { display: flex; - justify-content: space-between; + padding: 0px 32px; align-items: center; - padding: 12px 12px 16px 24px; - - &-title { - margin: 0; - font-size: 22px; - font-style: normal; - font-weight: 400; - line-height: 28px; - color: colors.$dark-grey; - } - - &-button { - min-width: 24px; - width: 24px; - height: 24px; - margin: 4px; - padding: 8px; - box-sizing: content-box; - line-height: normal !important; - - .close-button-icon { - width: 24px; - height: 24px; - margin: 0; - } - - ::ng-deep * { - line-height: inherit !important; - } - } + gap: 32px; } - -.setting-drawer-content { - padding: 0 16px 8px 16px; - overflow: hidden; - flex: 1; - - form { - display: grid; - height: 100%; +.tab-group { + ::ng-deep .mat-mdc-tab-labels { + gap: 16px; + padding: 0 12px; } - .setting-drawer-content-form-empty { - grid-template-rows: repeat(2, auto) 1fr; + ::ng-deep.mat-mdc-tab { + padding: 0px 8px; } } - -.setting-drawer-content-inputs { - overflow: auto; - margin: 0 -16px; - padding: 0 16px; -} - -.error-message-container { - display: block; - margin-top: auto; - padding-bottom: 8px; -} - -.error-message-container + .setting-drawer-footer { - margin-top: 0; -} - -.message { - margin: 0; - padding: 6px 0 12px 0; - color: colors.$grey-800; - font-family: variables.$font-secondary; - font-size: 14px; - line-height: 20px; - letter-spacing: 0.2px; -} - -.setting-drawer-footer { - padding: 0 8px; - margin-top: auto; - display: flex; - flex-shrink: 0; - justify-content: flex-end; - - .close-button, - .save-button { - padding: 0 24px; - font-size: 14px; - font-weight: 500; - line-height: 20px; - letter-spacing: 0.25px; - } - - .close-button { - margin-right: 10px; - } -} - -.settings-disabled-overlay { - position: absolute; - width: 100%; - left: 0; - right: 0; - top: 75px; - bottom: 45px; - background-color: rgba(255, 255, 255, 0.7); - z-index: 2; -} - -.disabled { - .message-link { - cursor: default; - pointer-events: none; - - &:focus-visible { - outline: none; - } - } -} - -.settings-drawer-header-button:not(.mat-mdc-button-disabled), -.close-button:not(.mat-mdc-button-disabled), -.save-button:not(.mat-mdc-button-disabled) { - cursor: pointer; - pointer-events: auto; -} diff --git a/modules/ui/src/app/pages/settings/settings.component.spec.ts b/modules/ui/src/app/pages/settings/settings.component.spec.ts index 84ab9be82..0f5d81c01 100644 --- a/modules/ui/src/app/pages/settings/settings.component.spec.ts +++ b/modules/ui/src/app/pages/settings/settings.component.spec.ts @@ -1,3 +1,6 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SettingsComponent } from './settings.component'; /** * Copyright 2023 Google LLC * @@ -13,369 +16,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { SettingsComponent } from './settings.component'; -import { of } from 'rxjs'; -import { MatRadioModule } from '@angular/material/radio'; -import { ReactiveFormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIcon, MatIconModule } from '@angular/material/icon'; -import { MatIconTestingModule } from '@angular/material/icon/testing'; -import { Component, Input } from '@angular/core'; -import { LiveAnnouncer } from '@angular/cdk/a11y'; -import SpyObj = jasmine.SpyObj; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideMockStore } from '@ngrx/store/testing'; -import { LoaderService } from '../../services/loader.service'; -import { SettingsStore } from './settings.store'; -import { - MOCK_INTERFACES, - MOCK_SYSTEM_CONFIG_WITH_DATA, -} from '../../mocks/settings.mock'; -import { SettingsDropdownComponent } from './components/settings-dropdown/settings-dropdown.component'; -import { CalloutComponent } from '../../components/callout/callout.component'; -import { SpinnerComponent } from '../../components/spinner/spinner.component'; - -describe('GeneralSettingsComponent', () => { +describe('SettingsComponent', () => { let component: SettingsComponent; let fixture: ComponentFixture; - let mockLiveAnnouncer: SpyObj; - let compiled: HTMLElement; - let mockLoaderService: SpyObj; - let mockSettingsStore: SpyObj; beforeEach(async () => { - mockLiveAnnouncer = jasmine.createSpyObj(['announce']); - mockLoaderService = jasmine.createSpyObj('LoaderService', ['setLoading']); - mockSettingsStore = jasmine.createSpyObj('SettingsStore', [ - 'getInterfaces', - 'updateSystemConfig', - 'setIsSubmitting', - 'setDefaultFormValues', - 'getSystemConfig', - 'viewModel$', - ]); - await TestBed.configureTestingModule({ - providers: [ - { provide: LiveAnnouncer, useValue: mockLiveAnnouncer }, - { provide: LoaderService, useValue: mockLoaderService }, - { provide: SettingsStore, useValue: mockSettingsStore }, - provideMockStore(), - ], - imports: [ - SettingsComponent, - BrowserAnimationsModule, - MatButtonModule, - MatIconModule, - MatRadioModule, - ReactiveFormsModule, - MatIconTestingModule, - MatIcon, - MatInputModule, - MatSelectModule, - SettingsDropdownComponent, - FakeSpinnerComponent, - FakeCalloutComponent, - ], - }) - .overrideComponent(SettingsComponent, { - remove: { - imports: [CalloutComponent, SpinnerComponent], - }, - add: { - imports: [FakeSpinnerComponent, FakeCalloutComponent], - }, - }) - .compileComponents(); - - TestBed.overrideProvider(SettingsStore, { useValue: mockSettingsStore }); + imports: [SettingsComponent, NoopAnimationsModule], + }).compileComponents(); fixture = TestBed.createComponent(SettingsComponent); - component = fixture.componentInstance; - component.viewModel$ = of({ - systemConfig: { network: {} }, - hasConnectionSettings: false, - isSubmitting: false, - isLessThanOneInterface: false, - interfaces: {}, - deviceOptions: {}, - internetOptions: {}, - logLevelOptions: {}, - monitoringPeriodOptions: {}, - }); fixture.detectChanges(); - compiled = fixture.nativeElement as HTMLElement; - - component.ngOnInit(); }); it('should create', () => { expect(component).toBeTruthy(); }); - - it('#reloadSetting should call setLoading in loaderService', () => { - component.reloadSetting(); - - expect(mockLoaderService.setLoading).toHaveBeenCalledWith(true); - }); - - describe('#settingsDisable', () => { - it('should disable setting form when get settingDisable as true ', () => { - spyOn(component.settingForm, 'disable'); - - component.settingsDisable = true; - - expect(component.settingForm.disable).toHaveBeenCalled(); - }); - - it('should enable setting form when get settingDisable as false ', () => { - spyOn(component.settingForm, 'enable'); - - component.settingsDisable = false; - - expect(component.settingForm.enable).toHaveBeenCalled(); - }); - - it('should disable "Save" button when get settingDisable as true', () => { - component.settingsDisable = true; - - const saveBtn = compiled.querySelector( - '.save-button' - ) as HTMLButtonElement; - - expect(saveBtn.disabled).toBeTrue(); - }); - - it('should disable "Refresh" link when settingDisable', () => { - component.settingsDisable = true; - - const refreshLink = compiled.querySelector( - '.message-link' - ) as HTMLAnchorElement; - - refreshLink.click(); - - expect(refreshLink.hasAttribute('aria-disabled')).toBeTrue(); - expect(mockLoaderService.setLoading).not.toHaveBeenCalled(); - }); - }); - - describe('#closeSetting', () => { - beforeEach(() => { - component.ngOnInit(); - }); - - it('should emit closeSettingEvent', () => { - spyOn(component.closeSettingEvent, 'emit'); - - component.closeSetting('Message'); - - expect(component.closeSettingEvent.emit).toHaveBeenCalled(); - }); - - it('should call liveAnnouncer with provided message', () => { - const mockMessage = 'mock event'; - - component.closeSetting(mockMessage); - - expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith( - `The ${mockMessage} finished. The system settings panel is closed.` - ); - }); - - it('should call reset settingForm', () => { - spyOn(component.settingForm, 'reset'); - - component.closeSetting('Message'); - - expect(component.settingForm.reset).toHaveBeenCalled(); - }); - - it('should call setDefaultFormValues', () => { - component.closeSetting('Message'); - - expect(mockSettingsStore.setDefaultFormValues).toHaveBeenCalled(); - }); - }); - - describe('#saveSetting', () => { - beforeEach(() => { - component.ngOnInit(); - }); - - it('should have form error if form has the same value', () => { - const mockSameValue = 'sameValue'; - component.deviceControl.setValue(mockSameValue); - component.internetControl.setValue(mockSameValue); - - component.saveSetting(); - - expect(component.settingForm.invalid).toBeTrue(); - expect(component.isFormError).toBeTrue(); - expect(mockSettingsStore.setIsSubmitting).toHaveBeenCalledWith(true); - }); - - it('should call createSystemConfig when setting form valid', () => { - const expectedResult = { - network: { - device_intf: 'mockDeviceKey', - internet_intf: '', - }, - log_level: 'INFO', - monitor_period: 600, - }; - - component.deviceControl.setValue({ - key: 'mockDeviceKey', - value: 'mockDeviceValue', - }); - - component.internetControl.setValue({ - key: '', - value: 'defaultValue', - }); - - component.logLevel.setValue({ - key: 'INFO', - value: '', - }); - - component.monitorPeriod.setValue({ - key: '600', - value: '', - }); - - component.saveSetting(); - - const args = mockSettingsStore.updateSystemConfig.calls.argsFor(0); - // @ts-expect-error config is in object - expect(args[0].config).toEqual(expectedResult); - expect(component.settingForm.invalid).toBeFalse(); - expect(mockSettingsStore.updateSystemConfig).toHaveBeenCalled(); - }); - }); - - describe('with no interfaces data', () => { - beforeEach(() => { - component.viewModel$ = of({ - systemConfig: { network: {} }, - hasConnectionSettings: false, - isSubmitting: false, - isLessThanOneInterface: false, - interfaces: {}, - deviceOptions: {}, - internetOptions: {}, - logLevelOptions: {}, - monitoringPeriodOptions: {}, - }); - fixture.detectChanges(); - }); - - it('should have callout component', () => { - const callout = compiled.querySelector('app-callout'); - - expect(callout).toBeTruthy(); - }); - - it('should have disabled "Save" button', () => { - const saveBtn = compiled.querySelector( - '.save-button' - ) as HTMLButtonElement; - - expect(saveBtn.disabled).toBeTrue(); - }); - }); - - describe('with interfaces length less than one', () => { - beforeEach(() => { - component.viewModel$ = of({ - systemConfig: { network: {} }, - hasConnectionSettings: false, - isSubmitting: false, - isLessThanOneInterface: true, - interfaces: {}, - deviceOptions: {}, - internetOptions: {}, - logLevelOptions: {}, - monitoringPeriodOptions: {}, - }); - fixture.detectChanges(); - }); - - it('should have disabled "Save" button', () => { - component.deviceControl.setValue( - MOCK_SYSTEM_CONFIG_WITH_DATA?.network?.device_intf - ); - component.internetControl.setValue( - MOCK_SYSTEM_CONFIG_WITH_DATA?.network?.internet_intf - ); - fixture.detectChanges(); - - const saveBtn = compiled.querySelector( - '.save-button' - ) as HTMLButtonElement; - - expect(saveBtn.disabled).toBeTrue(); - }); - }); - - describe('with interfaces length more then one', () => { - beforeEach(() => { - component.viewModel$ = of({ - systemConfig: { network: {} }, - hasConnectionSettings: false, - isSubmitting: false, - isLessThanOneInterface: false, - interfaces: MOCK_INTERFACES, - deviceOptions: MOCK_INTERFACES, - internetOptions: MOCK_INTERFACES, - logLevelOptions: {}, - monitoringPeriodOptions: {}, - }); - fixture.detectChanges(); - }); - - it('should not have callout component', () => { - const callout = compiled.querySelector('app-callout'); - - expect(callout).toBeFalsy(); - }); - - it('should not have disabled "Save" button', () => { - component.deviceControl.setValue({ - key: MOCK_SYSTEM_CONFIG_WITH_DATA?.network?.device_intf, - value: 'value', - }); - component.internetControl.setValue({ - key: MOCK_SYSTEM_CONFIG_WITH_DATA?.network?.internet_intf, - value: 'value', - }); - fixture.detectChanges(); - - const saveBtn = compiled.querySelector( - '.save-button' - ) as HTMLButtonElement; - - expect(saveBtn.disabled).toBeFalse(); - }); - }); }); - -@Component({ - selector: 'app-spinner', - template: '
', -}) -class FakeSpinnerComponent {} - -@Component({ - selector: 'app-callout', - template: '
', -}) -class FakeCalloutComponent { - @Input() type = ''; -} diff --git a/modules/ui/src/app/pages/settings/settings.component.ts b/modules/ui/src/app/pages/settings/settings.component.ts index bbd9e3f8a..53385ee1c 100644 --- a/modules/ui/src/app/pages/settings/settings.component.ts +++ b/modules/ui/src/app/pages/settings/settings.component.ts @@ -13,255 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - ChangeDetectionStrategy, - Component, - ElementRef, - EventEmitter, - Input, - OnDestroy, - OnInit, - Output, - viewChild, - inject, -} from '@angular/core'; -import { - FormBuilder, - FormControl, - FormGroup, - ReactiveFormsModule, -} from '@angular/forms'; -import { Subject, takeUntil, tap } from 'rxjs'; -import { OnlyDifferentValuesValidator } from './only-different-values.validator'; -import { CalloutType } from '../../model/callout-type'; -import { CdkTrapFocus, LiveAnnouncer } from '@angular/cdk/a11y'; -import { EventType } from '../../model/event-type'; -import { FormKey, SystemConfig } from '../../model/setting'; -import { SettingsStore } from './settings.store'; -import { LoaderService } from '../../services/loader.service'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatToolbarModule } from '@angular/material/toolbar'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; -import { MatInputModule } from '@angular/material/input'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { SpinnerComponent } from '../../components/spinner/spinner.component'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { MatButtonModule } from '@angular/material/button'; -import { SettingsDropdownComponent } from './components/settings-dropdown/settings-dropdown.component'; -import { MatSelectModule } from '@angular/material/select'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSidenavModule } from '@angular/material/sidenav'; -import { MatRadioModule } from '@angular/material/radio'; -import { CalloutComponent } from '../../components/callout/callout.component'; import { CommonModule } from '@angular/common'; +import { MatTabsModule } from '@angular/material/tabs'; @Component({ selector: 'app-settings', + imports: [CommonModule, MatToolbarModule, MatTabsModule], templateUrl: './settings.component.html', - styleUrls: ['./settings.component.scss'], - hostDirectives: [CdkTrapFocus], - imports: [ - MatButtonModule, - MatIconModule, - MatToolbarModule, - MatSidenavModule, - MatButtonToggleModule, - MatRadioModule, - MatInputModule, - MatSelectModule, - MatTooltipModule, - ReactiveFormsModule, - MatFormFieldModule, - MatSnackBarModule, - SpinnerComponent, - CalloutComponent, - SettingsDropdownComponent, - CommonModule, - ], - providers: [SettingsStore], + styleUrl: './settings.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SettingsComponent implements OnInit, OnDestroy { - private readonly fb = inject(FormBuilder); - private liveAnnouncer = inject(LiveAnnouncer); - private readonly onlyDifferentValuesValidator = inject( - OnlyDifferentValuesValidator - ); - private settingsStore = inject(SettingsStore); - private readonly loaderService = inject(LoaderService); - - readonly reloadSettingLink = viewChild('reloadSettingLink'); - @Output() closeSettingEvent = new EventEmitter(); - - private isSettingsDisable = false; - get settingsDisable(): boolean { - return this.isSettingsDisable; - } - @Input() set settingsDisable(value: boolean) { - this.isSettingsDisable = value; - if (value) { - this.disableSettings(); - } else { - this.enableSettings(); - } - } - public readonly CalloutType = CalloutType; - public readonly EventType = EventType; - public readonly FormKey = FormKey; - public settingForm!: FormGroup; - viewModel$ = this.settingsStore.viewModel$; - - private destroy$: Subject = new Subject(); - - get deviceControl(): FormControl { - return this.settingForm.get(FormKey.DEVICE) as FormControl; - } - - get internetControl(): FormControl { - return this.settingForm.get(FormKey.INTERNET) as FormControl; - } - - get logLevel(): FormControl { - return this.settingForm.get(FormKey.LOG_LEVEL) as FormControl; - } - - get monitorPeriod(): FormControl { - return this.settingForm.get(FormKey.MONITOR_PERIOD) as FormControl; - } - - get isFormValues(): boolean { - return ( - this.deviceControl?.value?.value && - (this.isInternetControlDisabled || this.internetControl?.value?.value) - ); - } - - get isInternetControlDisabled(): boolean { - return this.internetControl?.disabled; - } - - get isFormError(): boolean { - return this.settingForm.hasError('hasSameValues'); - } - - ngOnInit() { - this.createSettingForm(); - this.cleanFormErrorMessage(); - this.settingsStore.getInterfaces(); - this.getSystemConfig(); - this.setDefaultFormValues(); - } - - reloadSetting(): void { - if (this.settingsDisable) { - return; - } - this.showLoading(); - this.getSystemInterfaces(); - this.getSystemConfig(); - this.setDefaultFormValues(); - } - closeSetting(message: string): void { - this.resetForm(); - this.closeSettingEvent.emit(); - this.liveAnnouncer.announce( - `The ${message} finished. The system settings panel is closed.` - ); - this.setDefaultFormValues(); - } - - saveSetting(): void { - if (this.settingForm.invalid) { - this.settingsStore.setIsSubmitting(true); - this.settingForm.markAllAsTouched(); - } else { - this.createSystemConfig(); - } - } - - private disableSettings(): void { - this.settingForm?.disable(); - this.reloadSettingLink()?.nativeElement.setAttribute( - 'aria-disabled', - 'true' - ); - } - - private enableSettings(): void { - this.settingForm?.enable(); - this.reloadSettingLink()?.nativeElement.removeAttribute('aria-disabled'); - } - - private createSettingForm() { - this.settingForm = this.fb.group( - { - device_intf: [''], - internet_intf: [''], - log_level: [''], - monitor_period: [''], - }, - { - validators: [this.onlyDifferentValuesValidator.onlyDifferentSetting()], - updateOn: 'change', - } - ); - } - - private setDefaultFormValues() { - this.settingsStore.setDefaultFormValues(this.settingForm); - } - - private cleanFormErrorMessage(): void { - this.settingForm.valueChanges - .pipe( - takeUntil(this.destroy$), - tap(() => this.settingsStore.setIsSubmitting(false)) - ) - .subscribe(); - } - - private createSystemConfig(): void { - const { device_intf, internet_intf, log_level, monitor_period } = - this.settingForm.value; - const data: SystemConfig = { - network: { - device_intf: device_intf.key, - internet_intf: this.isInternetControlDisabled ? '' : internet_intf.key, - }, - log_level: log_level.key, - monitor_period: Number(monitor_period.key), - }; - this.settingsStore.updateSystemConfig({ - onSystemConfigUpdate: () => { - this.closeSetting(EventType.Save); - }, - config: data, - }); - } - - private resetForm(): void { - this.settingForm.reset(); - } - - ngOnDestroy() { - this.destroy$.next(true); - this.destroy$.unsubscribe(); - } - - getSystemInterfaces(): void { - this.settingsStore.getInterfaces(); - this.hideLoading(); - } - - getSystemConfig(): void { - this.settingsStore.getSystemConfig(); - } - - private showLoading() { - this.loaderService.setLoading(true); - } - - private hideLoading() { - this.loaderService.setLoading(false); - } -} +export class SettingsComponent {} diff --git a/modules/ui/src/styles.scss b/modules/ui/src/styles.scss index 916d6f0d2..faf90c53f 100644 --- a/modules/ui/src/styles.scss +++ b/modules/ui/src/styles.scss @@ -42,6 +42,16 @@ supporting-text-size: 14px, ) ); + + @include mat.tabs-overrides( + ( + inactive-label-text-color: colors.$on-surface-variant, + active-label-text-color: colors.$secondary, + active-focus-label-text-color: colors.$secondary, + label-text-font: variables.$font-text, + container-height: 20px, + ) + ); } .filter-result { @@ -132,6 +142,15 @@ mat-hint { display: none; } +.mat-mdc-tab.mat-focus-indicator { + &:focus::before { + content: none; + } + &:focus-visible { + border: 1px solid colors.$black; + } +} + .mat-sort-header-container.mat-focus-indicator:focus::before { content: none; } @@ -149,6 +168,22 @@ mat-hint { display: none; } } + +.app-toolbar-button.app-sidebar-button-active { + .mat-mdc-button-persistent-ripple::before { + opacity: 1; + background: colors.$light-grey; + } + + &:hover, + &:focus-visible { + .mat-mdc-button-persistent-ripple::before { + opacity: 0.6; + background: colors.$grey-100; + } + } +} + .app-sidebar-button { &.mat-mdc-button:has(.material-icons, mat-icon, [matButtonIcon]) { padding: 6px; @@ -248,6 +283,7 @@ mat-hint { font-weight: 400; line-height: 40px; letter-spacing: 0; + color: colors.$on-surface; } .message-link {