From 1f9b8b07800de6477f5f5da4df2b24a688a73c3a Mon Sep 17 00:00:00 2001 From: kurilova Date: Mon, 30 Dec 2024 13:43:21 +0000 Subject: [PATCH] Adds layout with devices list; adds state with no device selected --- .../device-item/device-item.component.html | 104 ++++------- .../device-item/device-item.component.scss | 173 +++++------------- .../device-item/device-item.component.spec.ts | 20 -- .../device-item/device-item.component.ts | 4 - .../empty-message.component.html | 2 +- .../empty-message.component.scss | 12 +- .../list-item/list-item.component.html | 40 ++++ .../list-item/list-item.component.scss | 59 ++++++ .../list-item/list-item.component.spec.ts | 101 ++++++++++ .../list-item/list-item.component.ts | 39 ++++ .../list-layout/list-layout.component.html | 48 ++++- .../list-layout/list-layout.component.scss | 120 ++++++++++++ .../list-layout/list-layout.component.spec.ts | 125 ++++++++++++- .../list-layout/list-layout.component.ts | 59 +++++- .../no-entity-selected.component.html | 21 +++ .../no-entity-selected.component.scss | 43 +++++ .../no-entity-selected.component.ts | 28 +++ .../program-type-icon.component.ts | 1 - modules/ui/src/app/model/device.ts | 5 + modules/ui/src/app/model/entity-action.ts | 26 +++ .../app/pages/devices/devices.component.html | 52 +++--- .../app/pages/devices/devices.component.scss | 28 --- .../pages/devices/devices.component.spec.ts | 69 ++++--- .../app/pages/devices/devices.component.ts | 98 +++++----- .../app/pages/devices/devices.store.spec.ts | 8 + .../ui/src/app/pages/devices/devices.store.ts | 10 +- modules/ui/src/assets/icons/cornerstone.svg | 9 + modules/ui/src/assets/icons/dog.svg | 15 ++ modules/ui/src/assets/icons/switch.svg | 30 +++ modules/ui/src/theming/variables.scss | 1 + 30 files changed, 980 insertions(+), 370 deletions(-) create mode 100644 modules/ui/src/app/components/list-item/list-item.component.html create mode 100644 modules/ui/src/app/components/list-item/list-item.component.scss create mode 100644 modules/ui/src/app/components/list-item/list-item.component.spec.ts create mode 100644 modules/ui/src/app/components/list-item/list-item.component.ts create mode 100644 modules/ui/src/app/components/no-entity-selected/no-entity-selected.component.html create mode 100644 modules/ui/src/app/components/no-entity-selected/no-entity-selected.component.scss create mode 100644 modules/ui/src/app/components/no-entity-selected/no-entity-selected.component.ts create mode 100644 modules/ui/src/app/model/entity-action.ts create mode 100644 modules/ui/src/assets/icons/cornerstone.svg create mode 100644 modules/ui/src/assets/icons/dog.svg create mode 100644 modules/ui/src/assets/icons/switch.svg diff --git a/modules/ui/src/app/components/device-item/device-item.component.html b/modules/ui/src/app/components/device-item/device-item.component.html index f1586f9a0..f98a6e1d4 100644 --- a/modules/ui/src/app/components/device-item/device-item.component.html +++ b/modules/ui/src/app/components/device-item/device-item.component.html @@ -18,85 +18,61 @@ [tabIndex]="tabIndex" (click)="itemClick()" [attr.aria-label]="label" - class="device-item" + class="device-item device-item-basic" type="button"> - +
- + class="device-item device-item-basic non-interactive"> +
- -

- {{ device.test_pack }} - - - {{ device.manufacturer }} -

-

- {{ device.model }} -

-

- {{ device.mac_addr }} -

-
-
+ [class.device-item-outdated]="device.status === DeviceStatus.INVALID"> -
+ + + {{ device.test_pack }} + + +

+ {{ device.manufacturer }} +

+
+

{{ device.manufacturer }}

+ + edit_square +
+
+ {{ INVALID_DEVICE }} +
+

+ {{ device.model }} +

+
diff --git a/modules/ui/src/app/components/device-item/device-item.component.scss b/modules/ui/src/app/components/device-item/device-item.component.scss index 92be58bfe..5c078860a 100644 --- a/modules/ui/src/app/components/device-item/device-item.component.scss +++ b/modules/ui/src/app/components/device-item/device-item.component.scss @@ -23,74 +23,73 @@ $border-radius: 12px; .device-item { display: grid; - width: variables.$device-item-width; - height: 80px; - border-radius: $border-radius; - border: 1px solid colors.$blue-300; - background: colors.$white; + grid-column-gap: 24px; box-sizing: border-box; - grid-template-columns: 1fr 1fr; - padding: 0; - grid-column-gap: 8px; - grid-row-gap: 4px; font-family: variables.$font-primary; + border-radius: variables.$corner-large; + align-items: center; + border: none; +} + +.device-item-basic { + padding: 0 24px 0 32px; + width: variables.$device-item-width; + height: 92px; + box-shadow: + 0px 1px 2px 0px rgba(0, 0, 0, 0.3), + 0px 1px 3px 1px rgba(0, 0, 0, 0.15); + background: colors.$surface-container; + grid-template-columns: auto 1fr; grid-template-areas: - 'manufacturer manufacturer' - 'name address'; + 'icon manufacturer' + 'icon name'; &:hover { cursor: pointer; + background: colors.$primary-container; } &.non-interactive { &:hover { + background: colors.$surface-container; cursor: default; } } -} -.device-item-with-actions { - display: grid; - width: variables.$device-item-width; - min-height: calc($icon-width - 2px); - border-radius: $border-radius; - border: 1px solid colors.$blue-300; - background: colors.$white; - box-sizing: border-box; - grid-template-columns: 1fr $icon-width; - grid-column-gap: 1px; - padding: 0; - font-family: variables.$font-primary; - grid-template-areas: 'edit start'; + .item-manufacturer { + display: block; + max-width: 100%; + box-sizing: border-box; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + margin: 0; + } - &.device-item-outdated { - border-color: colors.$red-300; + .item-name { + width: 230px; + box-sizing: border-box; + text-align: start; + margin: 0; + } + + .item-mac-address { + margin: 0; } } .button-edit { - display: grid; - grid-area: edit; - background: colors.$white; - box-sizing: border-box; - grid-template-columns: 1fr 1fr; - padding: 0 6px 0 0; - grid-column-gap: 8px; - grid-row-gap: 4px; - font-family: variables.$font-primary; + width: 100%; + height: 100%; + background: inherit; + grid-template-columns: auto 1fr 1fr; grid-template-areas: - 'manufacturer status' - 'name address'; - border-radius: $border-radius 0 0 $border-radius; - border: none; + 'icon manufacturer status' + 'icon name name'; &:hover { cursor: pointer; - .item-manufacturer-text { - max-width: 82px; - } - .item-manufacturer-icon { visibility: visible; width: 24px; @@ -103,22 +102,6 @@ $border-radius: 12px; } } -.device-item:not(.non-interective):hover { - border-color: mat.get-theme-color($light-theme, primary, 35); -} - -.device-item-with-actions:not(.device-item-outdated):has( - .button-edit:not(:disabled):hover - ) { - border-color: mat.get-theme-color($light-theme, primary, 35); -} - -.device-item-with-actions:not(.device-item-outdated):has( - .button-start:not(:disabled):hover - ) { - border-color: mat.get-theme-color($light-theme, primary, 35); -} - .item-status { margin-right: 16px; grid-area: status; @@ -137,11 +120,10 @@ $border-radius: 12px; .item-manufacturer { display: flex; - padding: 0 0 0 16px; grid-area: manufacturer; justify-self: start; align-self: end; - color: #1f1f1f; + color: colors.$on-surface; justify-content: flex-start; font-size: 16px; font-weight: 500; @@ -153,7 +135,6 @@ $border-radius: 12px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; - max-width: 106px; } .item-manufacturer-icon { @@ -167,10 +148,10 @@ $border-radius: 12px; } .item-name { - padding: 0 2px 0 16px; + align-self: start; grid-area: name; justify-self: start; - color: colors.$grey-800; + color: colors.$on-surface-variant; font-size: 14px; font-style: normal; font-weight: 400; @@ -184,66 +165,10 @@ $border-radius: 12px; text-align: left; } -.item-mac-address { - padding-right: 16px; - grid-area: address; - justify-self: end; - color: colors.$grey-700; - font-family: Roboto, sans-serif; - font-size: 12px; - padding-top: 2px; - line-height: 20px; - max-width: 100%; +app-program-type-icon { + grid-area: icon; } -.button-start { - grid-area: start; - width: $icon-width; +.device-item-outdated { height: 100%; - background-color: mat.get-theme-color($light-theme, primary, 95); - justify-self: end; - border-radius: 0 $border-radius $border-radius 0; - - &:hover, - &:focus-visible { - background-color: mat.get-theme-color($light-theme, primary, 35); - - .button-start-icon { - color: colors.$white; - } - } - &:disabled { - pointer-events: none; - background: rgba(60, 64, 67, 0.12); - } -} - -.button-start-icon { - margin: 0; - width: 30px; - height: 24px; -} - -.device-item { - .item-manufacturer { - display: block; - max-width: 100%; - box-sizing: border-box; - padding: 0 16px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - margin: 0; - } - - .item-name { - width: 230px; - box-sizing: border-box; - text-align: start; - margin: 0; - } - - .item-mac-address { - margin: 0; - } } diff --git a/modules/ui/src/app/components/device-item/device-item.component.spec.ts b/modules/ui/src/app/components/device-item/device-item.component.spec.ts index ddd95c696..431a6d4c6 100644 --- a/modules/ui/src/app/components/device-item/device-item.component.spec.ts +++ b/modules/ui/src/app/components/device-item/device-item.component.spec.ts @@ -68,11 +68,9 @@ describe('DeviceItemComponent', () => { it('should display information about device', () => { const name = compiled.querySelector('.item-name'); const manufacturer = compiled.querySelector('.item-manufacturer'); - const mac = compiled.querySelector('.item-mac-address'); expect(name?.textContent?.trim()).toEqual('O3-DIN-CPU'); expect(manufacturer?.textContent?.trim()).toEqual('Delta'); - expect(mac?.textContent?.trim()).toEqual('00:1e:42:35:73:c4'); }); it('should have qualification icon if testing type is qualification', () => { @@ -122,12 +120,6 @@ describe('DeviceItemComponent', () => { expect(status).toBeTruthy(); expect(status?.textContent?.trim()).toEqual('Outdated'); }); - - it('should disable start buttons', () => { - const startBtn = compiled.querySelector('.button-start') as HTMLElement; - - expect(startBtn.getAttribute('disabled')).toBeTruthy(); - }); }); it('should emit device on click edit button', () => { @@ -138,34 +130,22 @@ describe('DeviceItemComponent', () => { expect(clickSpy).toHaveBeenCalledWith(component.device); }); - it('should emit device on click start button', () => { - const clickSpy = spyOn(component.startTestrunClicked, 'emit'); - const editBtn = compiled.querySelector('.button-start') as HTMLElement; - editBtn.click(); - - expect(clickSpy).toHaveBeenCalledWith(component.device); - }); - it('should disable buttons if disable set to true', () => { component.disabled = true; fixture.detectChanges(); - const startBtn = compiled.querySelector('.button-start') as HTMLElement; const editBtn = compiled.querySelector('.button-edit') as HTMLElement; expect(editBtn.getAttribute('disabled')).not.toBeNull(); - expect(startBtn.getAttribute('disabled')).toBeTruthy(); }); it('should not disable buttons if disable set to false', () => { component.disabled = false; fixture.detectChanges(); - const startBtn = compiled.querySelector('.button-start') as HTMLElement; const editBtn = compiled.querySelector('.button-edit') as HTMLElement; expect(editBtn.getAttribute('disabled')).toBeNull(); - expect(startBtn.getAttribute('disabled')).toBeFalsy(); }); }); }); diff --git a/modules/ui/src/app/components/device-item/device-item.component.ts b/modules/ui/src/app/components/device-item/device-item.component.ts index eb3ed428d..d78dbfd6d 100644 --- a/modules/ui/src/app/components/device-item/device-item.component.ts +++ b/modules/ui/src/app/components/device-item/device-item.component.ts @@ -50,15 +50,11 @@ export class DeviceItemComponent { @Input() deviceView!: string; @Input() disabled = false; @Output() itemClicked = new EventEmitter(); - @Output() startTestrunClicked = new EventEmitter(); readonly DeviceView = DeviceView; itemClick(): void { this.itemClicked.emit(this.device); } - startTestrunClick(): void { - this.startTestrunClicked.emit(this.device); - } get label() { const deviceStatus = diff --git a/modules/ui/src/app/components/empty-message/empty-message.component.html b/modules/ui/src/app/components/empty-message/empty-message.component.html index 7a6c479d1..46f2281ea 100644 --- a/modules/ui/src/app/components/empty-message/empty-message.component.html +++ b/modules/ui/src/app/components/empty-message/empty-message.component.html @@ -19,7 +19,7 @@
empty message image
- + {{ header() }} {{ message() }} diff --git a/modules/ui/src/app/components/empty-message/empty-message.component.scss b/modules/ui/src/app/components/empty-message/empty-message.component.scss index fe2959929..33e4a4f95 100644 --- a/modules/ui/src/app/components/empty-message/empty-message.component.scss +++ b/modules/ui/src/app/components/empty-message/empty-message.component.scss @@ -35,13 +35,16 @@ max-width: 764px; .empty-message-img { grid-row: 1 / 3; - width: 288px; - height: 199px; justify-self: end; } .empty-message-text { align-self: start; padding-top: 12px; + &.one-line { + grid-row: 1 / 3; + align-self: center; + padding-top: 0; + } } .empty-message-main { padding-top: 8px; @@ -64,8 +67,3 @@ color: colors.$on-surface-variant; display: block; } - -.empty-message-img { - width: 293px; - height: 154px; -} diff --git a/modules/ui/src/app/components/list-item/list-item.component.html b/modules/ui/src/app/components/list-item/list-item.component.html new file mode 100644 index 000000000..7ef2d3e23 --- /dev/null +++ b/modules/ui/src/app/components/list-item/list-item.component.html @@ -0,0 +1,40 @@ + +
+ + +
+ + + + diff --git a/modules/ui/src/app/components/list-item/list-item.component.scss b/modules/ui/src/app/components/list-item/list-item.component.scss new file mode 100644 index 000000000..457d60e41 --- /dev/null +++ b/modules/ui/src/app/components/list-item/list-item.component.scss @@ -0,0 +1,59 @@ +/** + * 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 'variables'; +@use 'colors'; +@use '@angular/material' as mat; + +// Customize the entire app. Change :root to your selector if you want to scope the styles. +::ng-deep :root { + @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, + ) + ); +} + +::ng-deep { + .list-item-menu { + width: 220px; + } +} + +.list-item { + border-radius: variables.$corner-large; + background-color: colors.$surface-container; + height: 92px; + padding: 0 24px 0 32px; + display: grid; + grid-template-columns: auto 40px; + align-items: center; +} + +.example-menu { + left: 12px; +} + +.list-item-menu-item { + padding: 16px 24px; +} diff --git a/modules/ui/src/app/components/list-item/list-item.component.spec.ts b/modules/ui/src/app/components/list-item/list-item.component.spec.ts new file mode 100644 index 000000000..5bc51fc6a --- /dev/null +++ b/modules/ui/src/app/components/list-item/list-item.component.spec.ts @@ -0,0 +1,101 @@ +/** + * 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 { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ListItemComponent } from './list-item.component'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { + MatMenuHarness, + MatMenuItemHarness, +} from '@angular/material/menu/testing'; + +describe('ListItemComponent', () => { + let component: ListItemComponent; + let fixture: ComponentFixture; + let compiled: HTMLElement; + let loader: HarnessLoader; + const testActions = [ + { action: 'Edit', icon: 'edit_icon' }, + { action: 'Delete', icon: 'delete_icon' }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + ListItemComponent, + MatButtonModule, + MatIconModule, + MatMenuModule, + NoopAnimationsModule, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ListItemComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('actions', testActions); + compiled = fixture.nativeElement as HTMLElement; + loader = TestbedHarnessEnvironment.loader(fixture); + fixture.detectChanges(); + }); + + describe('menu', () => { + let menu; + let items: MatMenuItemHarness[]; + + beforeEach(async () => { + menu = await loader.getHarness(MatMenuHarness); + await menu.open(); + items = await menu.getItems(); + }); + + it('should render actions in the menu', async () => { + expect(items.length).toBe(2); + + const text0 = await items[0].getText(); + const text1 = await items[1].getText(); + + expect(text0).toContain('Edit'); + expect(text1).toContain('Delete'); + }); + + it('should emit the correct action when a menu item is clicked', async () => { + const menuItemClickedSpy = spyOn(component.menuItemClicked, 'emit'); + + await items[0].click(); + + expect(menuItemClickedSpy).toHaveBeenCalledWith('Edit'); + }); + + it('should display the correct icons for actions', async () => { + const text0 = await items[0].getText(); + const text1 = await items[1].getText(); + + expect(text0).toContain('edit'); + expect(text1).toContain('delete'); + }); + }); + + it('should render menu button', () => { + const button = compiled.querySelector('button[mat-icon-button]'); + + expect(button).toBeTruthy(); + expect(button?.textContent).toContain('more_vert'); + }); +}); diff --git a/modules/ui/src/app/components/list-item/list-item.component.ts b/modules/ui/src/app/components/list-item/list-item.component.ts new file mode 100644 index 000000000..ea9d8afbf --- /dev/null +++ b/modules/ui/src/app/components/list-item/list-item.component.ts @@ -0,0 +1,39 @@ +/** + * 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 { Component, input, output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { CommonModule } from '@angular/common'; +import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; +import { EntityAction } from '../../model/entity-action'; + +@Component({ + selector: 'app-list-item', + imports: [ + MatButtonModule, + MatIconModule, + MatMenuTrigger, + MatMenu, + MatMenuItem, + CommonModule, + ], + templateUrl: './list-item.component.html', + styleUrl: './list-item.component.scss', +}) +export class ListItemComponent { + actions = input([]); + menuItemClicked = output(); +} diff --git a/modules/ui/src/app/components/list-layout/list-layout.component.html b/modules/ui/src/app/components/list-layout/list-layout.component.html index dedf67cd1..652988e27 100644 --- a/modules/ui/src/app/components/list-layout/list-layout.component.html +++ b/modules/ui/src/app/components/list-layout/list-layout.component.html @@ -13,7 +13,53 @@ See the License for the specific language governing permissions and limitations under the License. --> - + + + + +

{{ title() }}

+ + + + +
+ + search +
+
+
+ + + + + +
+
+ +
+
+
+

{{ title() }}

diff --git a/modules/ui/src/app/components/list-layout/list-layout.component.scss b/modules/ui/src/app/components/list-layout/list-layout.component.scss index 956a7b1c4..a8b7519db 100644 --- a/modules/ui/src/app/components/list-layout/list-layout.component.scss +++ b/modules/ui/src/app/components/list-layout/list-layout.component.scss @@ -17,6 +17,10 @@ @use 'colors'; @use 'variables'; +.content { + height: 100%; +} + .content-empty { @include mixins.content-empty; } @@ -33,3 +37,119 @@ margin: 0; } } + +.add-entity-button { + font-family: variables.$font-text; + font-size: 16px; + border-radius: 16px; + width: fit-content; + height: 56px; + color: colors.$on-secondary-container; + background-color: colors.$secondary-container; +} + +.layout-container { + height: 100%; +} + +.layout-container-left-panel { + background-color: colors.$surface-container-low; + width: 435px; + padding-right: 16px; +} + +.layout-container-left-panel-toolbar { + border-radius: variables.$corner-large; + background-color: colors.$surface; + padding: 12px 0 8px 16px; + ::ng-deep mat-toolbar-row:not(:first-child) { + margin-top: 32px; + } +} + +.search-field { + display: flex; + padding: 4px 4px 4px 20px; + align-items: center; + gap: 4px; + flex: 1 0 0; + align-self: stretch; + border-radius: variables.$corner-extra-large; + background-color: colors.$surface-container-high; + height: 40px; + width: 100%; + input { + width: calc(100% - #{variables.$icon-size * 2}); + height: 100%; + border: 0; + background: inherit; + font-size: 16px; + font-family: variables.$font-text; + color: colors.$on-surface-variant; + } +} + +.entity-list { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + padding: 8px 0; +} + +.add-entity-button { + font-family: variables.$font-text; + font-size: 16px; + border-radius: 16px; + width: fit-content; + height: 56px; + color: colors.$on-secondary-container; + background-color: colors.$secondary-container; +} + +.layout-container { + height: 100%; +} + +.layout-container-left-panel { + background-color: colors.$surface-container-low; + width: 435px; + padding-right: 16px; +} + +.layout-container-left-panel-toolbar { + border-radius: variables.$corner-large; + background-color: colors.$surface; + padding: 12px 0 8px 16px; + ::ng-deep mat-toolbar-row:not(:first-child) { + margin-top: 32px; + } +} + +.search-field { + display: flex; + padding: 4px 4px 4px 20px; + align-items: center; + gap: 4px; + flex: 1 0 0; + align-self: stretch; + border-radius: variables.$corner-extra-large; + background-color: colors.$surface-container-high; + height: 40px; + width: 100%; + input { + width: calc(100% - #{variables.$icon-size * 2}); + height: 100%; + border: 0; + background: inherit; + font-size: 16px; + font-family: variables.$font-text; + color: colors.$on-surface-variant; + } +} + +.entity-list { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + padding: 8px 0; +} diff --git a/modules/ui/src/app/components/list-layout/list-layout.component.spec.ts b/modules/ui/src/app/components/list-layout/list-layout.component.spec.ts index 14ab06cd5..02089934e 100644 --- a/modules/ui/src/app/components/list-layout/list-layout.component.spec.ts +++ b/modules/ui/src/app/components/list-layout/list-layout.component.spec.ts @@ -14,24 +14,137 @@ * limitations under the License. */ import { ComponentFixture, TestBed } from '@angular/core/testing'; - import { ListLayoutComponent } from './list-layout.component'; +import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { ListItemComponent } from '../list-item/list-item.component'; +import { Component } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +interface Entity { + id: number; + name: string; +} +@Component({ + selector: 'app-host-component', + imports: [ListLayoutComponent, ListItemComponent], + template: ` + + + +
{{ entity.name }}
+
+ + +
Empty
+
+ `, +}) +class HostComponent { + title = 'Test Title'; + addEntityText = 'Add Entity'; + entities: Entity[] = []; + actions = [{ label: 'Edit', value: 'edit' }]; + onAddEntity = jasmine.createSpy('onAddEntity'); + onMenuItemClicked = jasmine.createSpy('onMenuItemClicked'); +} describe('ListLayoutComponent', () => { - let component: ListLayoutComponent; - let fixture: ComponentFixture>; + let component: HostComponent; + let fixture: ComponentFixture; + let compiled: HTMLElement; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ListLayoutComponent], + imports: [ + HostComponent, + MatSidenavModule, + MatToolbarModule, + MatIconModule, + MatButtonModule, + NoopAnimationsModule, + ], }).compileComponents(); - fixture = TestBed.createComponent(ListLayoutComponent); + fixture = TestBed.createComponent(HostComponent); component = fixture.componentInstance; + compiled = fixture.nativeElement as HTMLElement; fixture.detectChanges(); }); - it('should create', () => { + it('should create the component', () => { expect(component).toBeTruthy(); }); + + describe('with no entities', () => { + beforeEach(() => { + component.entities = []; + fixture.detectChanges(); + }); + + it('should display the title', () => { + const titleElement = compiled.querySelector('.title'); + + expect(titleElement?.textContent).toBe('Test Title'); + }); + + it('should has empty content', () => { + const emptyContent = compiled.querySelector('.content-empty'); + + expect(emptyContent).toBeTruthy(); + }); + }); + + describe('with entities', () => { + beforeEach(() => { + component.entities = [ + { id: 1, name: 'Entity 1' }, + { id: 2, name: 'Entity 2' }, + ]; + fixture.detectChanges(); + }); + + it('should display the title', () => { + const titleElement = compiled.querySelector('.title'); + + expect(titleElement?.textContent).toBe('Test Title'); + }); + + it('should display add entity button', () => { + const buttonElement = compiled.querySelector('.add-entity-button'); + + expect(buttonElement?.textContent).toContain('Add Entity'); + }); + + it('should display search field', () => { + const searchElement = compiled.querySelector('.search-field'); + + expect(searchElement).toBeTruthy(); + }); + + it('should emit addEntity event when add entity button is clicked', () => { + const buttonElement = compiled.querySelector( + '.add-entity-button' + ) as HTMLButtonElement; + + buttonElement.click(); + expect(component.onAddEntity).toHaveBeenCalled(); + }); + + it('should have entity list', () => { + const listItemComponent = compiled.querySelectorAll('app-list-item'); + + expect(listItemComponent.length).toEqual(2); + }); + }); }); diff --git a/modules/ui/src/app/components/list-layout/list-layout.component.ts b/modules/ui/src/app/components/list-layout/list-layout.component.ts index 3c97549b4..c66da2ec5 100644 --- a/modules/ui/src/app/components/list-layout/list-layout.component.ts +++ b/modules/ui/src/app/components/list-layout/list-layout.component.ts @@ -13,18 +13,71 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Component, input, TemplateRef } from '@angular/core'; +import { + Component, + computed, + input, + output, + signal, + TemplateRef, +} from '@angular/core'; import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatIconModule } from '@angular/material/icon'; +import { ListItemComponent } from '../list-item/list-item.component'; +import { EntityAction, EntityActionResult } from '../../model/entity-action'; @Component({ selector: 'app-list-layout', - imports: [CommonModule], + imports: [ + CommonModule, + MatButtonModule, + MatSidenavModule, + MatToolbarModule, + MatIconModule, + ListItemComponent, + ], templateUrl: './list-layout.component.html', styleUrl: './list-layout.component.scss', }) -export class ListLayoutComponent { +export class ListLayoutComponent { title = input(''); + addEntityText = input(''); // eslint-disable-next-line @typescript-eslint/no-explicit-any emptyContent = input>(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + content = input>(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + itemTemplate = input>(); entities = input([]); + actions = input([]); + searchText = signal(''); + filtered = computed(() => { + return this.entities().filter(this.filter(this.searchText())); + }); + addEntity = output(); + menuItemClicked = output>(); + updateQuery(e: Event) { + this.searchText.set((e.target as HTMLInputElement).value); + } + + filter(searchText: string) { + return (item: T) => { + return Object.values(item).some(value => + typeof value === 'string' + ? value.toLowerCase().includes(searchText.toLowerCase()) + : false + ); + }; + } + + onMenuItemClick(action: string, entity: T, index: number) { + this.menuItemClicked.emit({ + action, + entity, + index, + }); + } } diff --git a/modules/ui/src/app/components/no-entity-selected/no-entity-selected.component.html b/modules/ui/src/app/components/no-entity-selected/no-entity-selected.component.html new file mode 100644 index 000000000..03556f807 --- /dev/null +++ b/modules/ui/src/app/components/no-entity-selected/no-entity-selected.component.html @@ -0,0 +1,21 @@ + + + diff --git a/modules/ui/src/app/components/no-entity-selected/no-entity-selected.component.scss b/modules/ui/src/app/components/no-entity-selected/no-entity-selected.component.scss new file mode 100644 index 000000000..491d24450 --- /dev/null +++ b/modules/ui/src/app/components/no-entity-selected/no-entity-selected.component.scss @@ -0,0 +1,43 @@ +/** + * 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 'colors'; +@use 'variables'; + +:host { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-image: url(/assets/icons/cornerstone.svg); + position: relative; +} + +.dog-image { + position: absolute; + bottom: 0; + right: 26px; +} + +::ng-deep app-empty-message { + .empty-message { + gap: 16px !important; + } + + .empty-message-header { + color: colors.$on-surface-variant !important; + font-family: variables.$font-secondary !important; + } +} diff --git a/modules/ui/src/app/components/no-entity-selected/no-entity-selected.component.ts b/modules/ui/src/app/components/no-entity-selected/no-entity-selected.component.ts new file mode 100644 index 000000000..3924c2aa3 --- /dev/null +++ b/modules/ui/src/app/components/no-entity-selected/no-entity-selected.component.ts @@ -0,0 +1,28 @@ +/** + * 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 { Component, input } from '@angular/core'; +import { EmptyMessageComponent } from '../empty-message/empty-message.component'; +@Component({ + selector: 'app-no-entity-selected', + imports: [EmptyMessageComponent], + templateUrl: './no-entity-selected.component.html', + styleUrl: './no-entity-selected.component.scss', +}) +export class NoEntitySelectedComponent { + image = input(); + header = input(); + message = input(); +} diff --git a/modules/ui/src/app/components/program-type-icon/program-type-icon.component.ts b/modules/ui/src/app/components/program-type-icon/program-type-icon.component.ts index 61dfb9649..449bb4225 100644 --- a/modules/ui/src/app/components/program-type-icon/program-type-icon.component.ts +++ b/modules/ui/src/app/components/program-type-icon/program-type-icon.component.ts @@ -25,7 +25,6 @@ import { MatIcon } from '@angular/material/icon'; :host { display: inline-flex; align-items: center; - padding-right: 4px; } .icon { display: flex; diff --git a/modules/ui/src/app/model/device.ts b/modules/ui/src/app/model/device.ts index 55d383401..5f73d3c28 100644 --- a/modules/ui/src/app/model/device.ts +++ b/modules/ui/src/app/model/device.ts @@ -72,3 +72,8 @@ export enum TestingType { Pilot = 'Pilot Assessment', Qualification = 'Device Qualification', } + +export enum DeviceAction { + StartNewTestrun = 'Start new Testrun', + Delete = 'Delete', +} diff --git a/modules/ui/src/app/model/entity-action.ts b/modules/ui/src/app/model/entity-action.ts new file mode 100644 index 000000000..17e3062f1 --- /dev/null +++ b/modules/ui/src/app/model/entity-action.ts @@ -0,0 +1,26 @@ +/** + * 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 EntityAction { + action: string; + icon?: string; + svgIcon?: string; +} + +export interface EntityActionResult { + action: string; + entity: T; + index: number; +} diff --git a/modules/ui/src/app/pages/devices/devices.component.html b/modules/ui/src/app/pages/devices/devices.component.html index 18db165cc..28fb015db 100644 --- a/modules/ui/src/app/pages/devices/devices.component.html +++ b/modules/ui/src/app/pages/devices/devices.component.html @@ -14,32 +14,25 @@ limitations under the License. --> - - -

Devices

- -
-
- - - -
-
+ + - - + + + + Devices Create New Device + + + +
diff --git a/modules/ui/src/app/pages/devices/devices.component.scss b/modules/ui/src/app/pages/devices/devices.component.scss index 4e49b90f8..65cd331c8 100644 --- a/modules/ui/src/app/pages/devices/devices.component.scss +++ b/modules/ui/src/app/pages/devices/devices.component.scss @@ -13,35 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@use 'colors'; @use 'variables'; -@use 'mixins'; - -:host { - overflow: hidden; - flex-direction: column; - display: flex; -} - -.device-repository-content-empty { - @include mixins.content-empty; -} - -.device-repository-toolbar { - gap: 16px; - background: colors.$white; - height: 74px; - padding: 24px 0 8px 32px; -} - -.device-repository-content { - align-content: start; - padding: 24px 32px; - display: grid; - grid-template-columns: repeat(auto-fit, variables.$device-item-width); - gap: 16px; - overflow-y: auto; -} .device-add-button { font-family: variables.$font-text; 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 2de0b9bb4..9c46d8c81 100644 --- a/modules/ui/src/app/pages/devices/devices.component.spec.ts +++ b/modules/ui/src/app/pages/devices/devices.component.spec.ts @@ -20,7 +20,7 @@ import { tick, } from '@angular/core/testing'; import { of } from 'rxjs'; -import { Device } from '../../model/device'; +import { Device, DeviceAction } from '../../model/device'; import { DevicesComponent, FormAction } from './devices.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; @@ -95,6 +95,13 @@ describe('DevicesComponent', () => { selectedDevice: null, deviceInProgress: null, testModules: [], + actions: [ + { + action: DeviceAction.StartNewTestrun, + svgIcon: 'testrun_logo_small', + }, + { action: DeviceAction.Delete, icon: 'delete' }, + ], }); mockDevicesStore.devices$ = of([]); mockDevicesStore.testModules$ = of([]); @@ -124,6 +131,13 @@ describe('DevicesComponent', () => { selectedDevice: device, deviceInProgress: device, testModules: [], + actions: [ + { + action: DeviceAction.StartNewTestrun, + svgIcon: 'testrun_logo_small', + }, + { action: DeviceAction.Delete, icon: 'delete' }, + ], }); fixture.detectChanges(); }); @@ -141,7 +155,7 @@ describe('DevicesComponent', () => { } as MatDialogRef); fixture.detectChanges(); const button = compiled.querySelector( - '.device-add-button' + '.add-entity-button' ) as HTMLButtonElement; button?.click(); @@ -222,6 +236,13 @@ describe('DevicesComponent', () => { selectedDevice: device, deviceInProgress: null, testModules: [], + actions: [ + { + action: DeviceAction.StartNewTestrun, + svgIcon: 'testrun_logo_small', + }, + { action: DeviceAction.Delete, icon: 'delete' }, + ], }); fixture.detectChanges(); }); @@ -281,6 +302,13 @@ describe('DevicesComponent', () => { selectedDevice: device, deviceInProgress: null, testModules: [], + actions: [ + { + action: DeviceAction.StartNewTestrun, + svgIcon: 'testrun_logo_small', + }, + { action: DeviceAction.Delete, icon: 'delete' }, + ], }); fixture.detectChanges(); }); @@ -290,48 +318,13 @@ describe('DevicesComponent', () => { beforeClosed: () => of(true), } as MatDialogRef); - component.openDeleteDialog( - [device], - MOCK_TEST_MODULES, - device, - device, - false, - 0, - 0 - ); + component.openDeleteDialog([device], MOCK_TEST_MODULES, device, 0); const args = mockDevicesStore.deleteDevice.calls.argsFor(0); // @ts-expect-error config is in object expect(args[0].device).toEqual(device); expect(mockDevicesStore.deleteDevice).toHaveBeenCalled(); }); - - it('should open device dialog when dialog return null', () => { - const openDeviceDialogSpy = spyOn(component, 'openDialog'); - spyOn(component.dialog, 'open').and.returnValue({ - beforeClosed: () => of(null), - } as MatDialogRef); - - component.openDeleteDialog( - [device], - MOCK_TEST_MODULES, - device, - device, - false, - 0, - 0 - ); - - expect(openDeviceDialogSpy).toHaveBeenCalledWith( - [device], - MOCK_TEST_MODULES, - device, - device, - false, - 0, - 0 - ); - }); }); describe('#openStartTestrun', () => { diff --git a/modules/ui/src/app/pages/devices/devices.component.ts b/modules/ui/src/app/pages/devices/devices.component.ts index 685a8ba12..c71e571db 100644 --- a/modules/ui/src/app/pages/devices/devices.component.ts +++ b/modules/ui/src/app/pages/devices/devices.component.ts @@ -28,6 +28,7 @@ import { } from '@angular/material/dialog'; import { Device, + DeviceAction, DeviceStatus, DeviceView, TestModule, @@ -54,6 +55,8 @@ import { MatInputModule } from '@angular/material/input'; import { DeviceItemComponent } from '../../components/device-item/device-item.component'; import { EmptyPageComponent } from '../../components/empty-page/empty-page.component'; import { ListLayoutComponent } from '../../components/list-layout/list-layout.component'; +import { EntityActionResult } from '../../model/entity-action'; +import { NoEntitySelectedComponent } from '../../components/no-entity-selected/no-entity-selected.component'; export enum FormAction { Delete = 'Delete', @@ -84,6 +87,7 @@ export interface FormResponse { DeviceItemComponent, EmptyPageComponent, ListLayoutComponent, + NoEntitySelectedComponent, ], providers: [DevicesStore], }) @@ -134,6 +138,21 @@ export class DevicesComponent ); } + menuItemClicked( + { action, entity, index }: EntityActionResult, + devices: Device[], + testModules: TestModule[] + ) { + switch (action) { + case DeviceAction.StartNewTestrun: + this.openStartTestrun(entity, devices, testModules); + break; + case DeviceAction.Delete: + this.openDeleteDialog(devices, testModules, entity, index); + break; + } + } + openStartTestrun( selectedDevice: Device, devices: Device[], @@ -171,6 +190,37 @@ export class DevicesComponent }); } + openDeleteDialog( + devices: Device[], + testModules: TestModule[], + initialDevice: Device, + deviceIndex: number + ) { + const dialogRef = this.dialog.open(SimpleDialogComponent, { + ariaLabel: 'Delete device', + data: { + title: 'Delete device?', + content: `You are about to delete ${ + initialDevice.manufacturer + ' ' + initialDevice.model + }. Are you sure?`, + }, + autoFocus: true, + hasBackdrop: true, + disableClose: true, + panelClass: 'simple-dialog', + }); + dialogRef?.beforeClosed().subscribe(deleteDevice => { + if (deleteDevice) { + this.devicesStore.deleteDevice({ + device: initialDevice, + onDelete: () => { + this.focusNextButton(deviceIndex); + }, + }); + } + }); + } + openDialog( devices: Device[] = [], testModules: TestModule[], @@ -228,9 +278,6 @@ export class DevicesComponent devices, testModules, initialDevice, - response.device, - isEditDevice, - response.index, deviceIndex! ); } @@ -278,51 +325,6 @@ export class DevicesComponent }); } - openDeleteDialog( - devices: Device[], - testModules: TestModule[], - initialDevice: Device, - device: Device, - isEditDevice = false, - index = 0, - deviceIndex: number - ) { - const dialogRef = this.dialog.open(SimpleDialogComponent, { - ariaLabel: 'Delete device', - data: { - title: 'Delete device?', - content: `You are about to delete ${ - initialDevice.manufacturer + ' ' + initialDevice.model - }. Are you sure?`, - device: device, - }, - autoFocus: true, - hasBackdrop: true, - disableClose: true, - panelClass: 'simple-dialog', - }); - dialogRef?.beforeClosed().subscribe(deleteDevice => { - if (deleteDevice) { - this.devicesStore.deleteDevice({ - device: initialDevice, - onDelete: () => { - this.focusNextButton(deviceIndex); - }, - }); - } else { - this.openDialog( - devices, - testModules, - initialDevice, - device, - isEditDevice, - index, - deviceIndex - ); - } - }); - } - private focusSelectedButton(index: number) { const selected = this.element.nativeElement.querySelectorAll( 'app-device-item .button-edit' diff --git a/modules/ui/src/app/pages/devices/devices.store.spec.ts b/modules/ui/src/app/pages/devices/devices.store.spec.ts index 7dec601f8..e355963e6 100644 --- a/modules/ui/src/app/pages/devices/devices.store.spec.ts +++ b/modules/ui/src/app/pages/devices/devices.store.spec.ts @@ -33,6 +33,7 @@ import { import { selectDevices, selectIsOpenAddDevice } from '../../store/selectors'; import { DevicesStore } from './devices.store'; import { MOCK_PROGRESS_DATA_IN_PROGRESS } from '../../mocks/testrun.mock'; +import { DeviceAction } from '../../model/device'; describe('DevicesStore', () => { let devicesStore: DevicesStore; @@ -92,6 +93,13 @@ describe('DevicesStore', () => { selectedDevice: null, deviceInProgress: device, testModules: [], + actions: [ + { + action: DeviceAction.StartNewTestrun, + svgIcon: 'testrun_logo_small', + }, + { action: DeviceAction.Delete, icon: 'delete' }, + ], }); done(); }); diff --git a/modules/ui/src/app/pages/devices/devices.store.ts b/modules/ui/src/app/pages/devices/devices.store.ts index 301703158..f164d643d 100644 --- a/modules/ui/src/app/pages/devices/devices.store.ts +++ b/modules/ui/src/app/pages/devices/devices.store.ts @@ -19,7 +19,7 @@ import { ComponentStore } from '@ngrx/component-store'; import { TestRunService } from '../../services/test-run.service'; import { exhaustMap } from 'rxjs'; import { tap, withLatestFrom } from 'rxjs/operators'; -import { Device, TestModule } from '../../model/device'; +import { Device, DeviceAction, TestModule } from '../../model/device'; import { AppState } from '../../store/state'; import { Store } from '@ngrx/store'; import { @@ -35,12 +35,14 @@ import { } from '../../store/actions'; import { TestrunStatus } from '../../model/testrun-status'; import { DeviceQuestionnaireSection } from '../../model/device'; +import { EntityAction } from '../../model/entity-action'; export interface DevicesComponentState { devices: Device[]; selectedDevice: Device | null; testModules: TestModule[]; questionnaireFormat: DeviceQuestionnaireSection[]; + actions: EntityAction[]; } @Injectable() @@ -54,12 +56,14 @@ export class DevicesStore extends ComponentStore { questionnaireFormat$ = this.select(state => state.questionnaireFormat); private deviceInProgress$ = this.store.select(selectDeviceInProgress); private selectedDevice$ = this.select(state => state.selectedDevice); + private actions$ = this.select(state => state.actions); viewModel$ = this.select({ devices: this.devices$, selectedDevice: this.selectedDevice$, deviceInProgress: this.deviceInProgress$, testModules: this.testModules$, + actions: this.actions$, }); selectDevice = this.updater((state, device: Device | null) => ({ @@ -200,6 +204,10 @@ export class DevicesStore extends ComponentStore { selectedDevice: null, testModules: [], questionnaireFormat: [], + actions: [ + { action: DeviceAction.StartNewTestrun, svgIcon: 'testrun_logo_small' }, + { action: DeviceAction.Delete, icon: 'delete' }, + ], }); } } diff --git a/modules/ui/src/assets/icons/cornerstone.svg b/modules/ui/src/assets/icons/cornerstone.svg new file mode 100644 index 000000000..14e082421 --- /dev/null +++ b/modules/ui/src/assets/icons/cornerstone.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/modules/ui/src/assets/icons/dog.svg b/modules/ui/src/assets/icons/dog.svg new file mode 100644 index 000000000..91347731c --- /dev/null +++ b/modules/ui/src/assets/icons/dog.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/modules/ui/src/assets/icons/switch.svg b/modules/ui/src/assets/icons/switch.svg new file mode 100644 index 000000000..24c53d4a9 --- /dev/null +++ b/modules/ui/src/assets/icons/switch.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/ui/src/theming/variables.scss b/modules/ui/src/theming/variables.scss index 6be3f1bcd..a9415a167 100644 --- a/modules/ui/src/theming/variables.scss +++ b/modules/ui/src/theming/variables.scss @@ -18,6 +18,7 @@ $profiles-drawer-width: 320px; $form-max-width: 732px; $icon-size: 24px; $corner-large: 16px; +$corner-extra-large: 28px; $corner-medium: 12px; $reports-table-header-size: 32px;