diff --git a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/package-lock.json b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/package-lock.json index 2b518f00..9767f1a5 100644 --- a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/package-lock.json +++ b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/package-lock.json @@ -3304,6 +3304,21 @@ "keyv": "*" } }, + "@types/lodash": { + "version": "4.14.184", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.184.tgz", + "integrity": "sha512-RoZphVtHbxPZizt4IcILciSWiC6dcn+eZ8oX9IWEYfDMcocdd42f7NPI6fQj+6zI8y4E0L7gu2pcZKLGTRaV9Q==", + "dev": true + }, + "@types/lodash-es": { + "version": "4.17.6", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.6.tgz", + "integrity": "sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, "@types/minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.4.tgz", diff --git a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/package.json b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/package.json index d68724e7..bd8954b5 100644 --- a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/package.json +++ b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/package.json @@ -54,6 +54,7 @@ "@kubernetes/client-node": "^0.16.3", "@types/jasmine": "~3.6.0", "@types/jasminewd2": "^2.0.9", + "@types/lodash-es": "^4.17.6", "@types/node": "^12.11.1", "@typescript-eslint/eslint-plugin": "4.28.2", "@typescript-eslint/parser": "4.28.2", diff --git a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/help-popover/help-popover.component.html b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/help-popover/help-popover.component.html index 0cc4e395..ae4b3961 100644 --- a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/help-popover/help-popover.component.html +++ b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/help-popover/help-popover.component.html @@ -33,40 +33,44 @@

-

Status

-

Filter based on the objects' status, by using:

- + +

Status

+

Filter based on the objects' status, by using:

+ +
-

Date

-

Filter timestamp values based on:

- + +

Date

+

Filter timestamp values based on:

+ +
diff --git a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/help-popover/help-popover.component.ts b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/help-popover/help-popover.component.ts index e4c3fa92..c45293b0 100644 --- a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/help-popover/help-popover.component.ts +++ b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/help-popover/help-popover.component.ts @@ -8,6 +8,12 @@ import { Component, OnInit, Input } from '@angular/core'; export class HelpPopoverComponent implements OnInit { @Input() popoverPosition = 'below'; + @Input() + showStatus: boolean; + + @Input() + showDate: boolean; + constructor() {} ngOnInit(): void {} diff --git a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.html b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.html index f8a041f0..c02422f8 100644 --- a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.html +++ b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.html @@ -41,17 +41,15 @@ aria-label="Help" (click)="$event.stopPropagation()" > - + + - + {{ header.title }} @@ -249,7 +247,13 @@ - + + {{row}} + diff --git a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.scss b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.scss index 7c8a2062..6c11ce34 100644 --- a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.scss +++ b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.scss @@ -72,3 +72,7 @@ lib-action { .filter-header { margin-right: 8px; } + +.highlight-row { + background-color: #ffffcd; +} diff --git a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.spec.ts b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.spec.ts index a2fc30e2..b88ff9f0 100644 --- a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.spec.ts +++ b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.spec.ts @@ -2,8 +2,93 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { TableComponent } from './table.component'; import { ResourceTableModule } from '../resource-table.module'; -import { PropertyValue, DateTimeValue } from '../types'; import { quantityToScalar } from '@kubernetes/client-node/dist/util'; +import { + PropertyValue, + DateTimeValue, + StatusValue, + ComponentValue, +} from '../types'; +import { MatChipInputEvent } from '@angular/material/chips'; +import { TableColumnComponent } from '../component-value/component-value.component'; +import { Component, SimpleChange } from '@angular/core'; +import subMonths from 'date-fns/sub_months'; +import { cloneDeep } from 'lodash-es'; + +@Component({ + selector: 'lib-server-type', + template: ``, +}) +export class ServerTypeComponent implements TableColumnComponent { + constructor() {} + + notebookServerType: string; + + set element(notebook) { + this.notebookServerType = notebook.serverType; + } +} + +const tableConfig = { + title: 'test', + columns: [ + { + matHeaderCellDef: `Status`, + matColumnDef: 'status', + value: new StatusValue(), + }, + { + matHeaderCellDef: `Name`, + matColumnDef: 'name', + value: new PropertyValue({ + field: 'name', + tooltipField: 'name', + truncate: true, + }), + }, + { + matHeaderCellDef: `Type`, + matColumnDef: 'type', + value: new ComponentValue({ + component: ServerTypeComponent, + }), + filteringPreprocessorFn: element => element.serverType, + }, + { + matHeaderCellDef: `Created at`, + matColumnDef: 'age', + value: new DateTimeValue({ field: 'age' }), + }, + ], +}; + +const tableData = [ + { + status: { + phase: 'ready', + message: 'Running', + }, + name: 'a-notebook', + serverType: 'jupyter', + age: '2022-02-25T16:57:23Z', + }, + { + status: { + phase: 'stopped', + message: 'No Pods are currently running for this Notebook Server.', + }, + name: 'b-notebook', + serverType: 'group-one', + age: '2022-01-23T14:51:29Z', + }, +]; + +function checkCell(compiled) { + const nameCell = compiled.querySelector( + '[data-cy-resource-table-row="Name"]', + ); + expect(nameCell.textContent.replace(/\s+/g, '')).toBe('b-notebook'); +} describe('TableComponent', () => { let component: TableComponent; @@ -129,4 +214,154 @@ describe('TableComponent', () => { // your assertions, i.e. expect to see the first element being sorted in the table expect(columnCells[0].textContent.replace(/\s+/g, '')).toBe('1'); }); + + it('should filter property values based on all columns', () => { + component.config = tableConfig; + component.data = tableData; + + const compiled = fixture.debugElement.nativeElement; + const inputElement = compiled.querySelector('#filterInput'); + component.add({ + input: inputElement, + value: 'b-not', + } as MatChipInputEvent); + + fixture.detectChanges(); + + checkCell(compiled); + }); + + it('should filter property values based on one column', () => { + component.config = tableConfig; + component.data = tableData; + + const compiled = fixture.debugElement.nativeElement; + const inputElement = compiled.querySelector('#filterInput'); + component.add({ + input: inputElement, + value: 'Name: b-not', + } as MatChipInputEvent); + + fixture.detectChanges(); + + checkCell(compiled); + }); + + it('should filter date values based on one column using UTC timestamp', () => { + component.config = tableConfig; + component.data = tableData; + + const compiled = fixture.debugElement.nativeElement; + const inputElement = compiled.querySelector('#filterInput'); + component.add({ + input: inputElement, + value: 'Created at: 2022-01-23', + } as MatChipInputEvent); + + fixture.detectChanges(); + + checkCell(compiled); + }); + + it('should filter date values based on one column using X months ago', () => { + component.config = tableConfig; + const tableDataCopy = cloneDeep(tableData); + tableDataCopy[1].age = subMonths(new Date(), 2).toISOString(); + component.data = tableDataCopy; + + const compiled = fixture.debugElement.nativeElement; + const inputElement = compiled.querySelector('#filterInput'); + component.add({ + input: inputElement, + value: 'Created at: 2 months ago', + } as MatChipInputEvent); + + fixture.detectChanges(); + + checkCell(compiled); + }); + + it('should filter component values based on one column', () => { + component.config = tableConfig; + component.data = tableData; + + const compiled = fixture.debugElement.nativeElement; + const inputElement = compiled.querySelector('#filterInput'); + component.add({ + input: inputElement, + value: 'Type: group', + } as MatChipInputEvent); + + fixture.detectChanges(); + + checkCell(compiled); + }); + + it('should filter status values based on one column using status phase', () => { + component.config = tableConfig; + component.data = tableData; + + const compiled = fixture.debugElement.nativeElement; + const inputElement = compiled.querySelector('#filterInput'); + component.add({ + input: inputElement, + value: 'Status: stopped', + } as MatChipInputEvent); + + fixture.detectChanges(); + + checkCell(compiled); + }); + + it('should filter status values based on one column using status message', () => { + component.config = tableConfig; + component.data = tableData; + + const compiled = fixture.debugElement.nativeElement; + const inputElement = compiled.querySelector('#filterInput'); + component.add({ + input: inputElement, + value: 'Status: No Pods', + } as MatChipInputEvent); + + fixture.detectChanges(); + + checkCell(compiled); + }); + + it('should properly configure filter section', () => { + component.config = tableConfig; + + expect(component.filteredHeaders).toEqual([]); + expect(component.showStatus).toEqual(false); + expect(component.showDate).toEqual(false); + + fixture.detectChanges(); + component.ngOnChanges({ + config: new SimpleChange(null, component.config, true), + }); + + expect(component.filteredHeaders).toEqual([ + { title: 'Status' }, + { title: 'Name' }, + { title: 'Type' }, + { title: 'Created at' }, + ]); + expect(component.showStatus).toEqual(true); + expect(component.showDate).toEqual(true); + + const configCopy = cloneDeep(tableConfig); + configCopy.columns.pop(); + component.ngOnChanges({ + config: new SimpleChange(null, configCopy, false), + }); + + expect(component.filteredHeaders).toEqual([ + { title: 'Status' }, + { title: 'Name' }, + { title: 'Type' }, + ]); + expect(component.showStatus).toEqual(true); + expect(component.showDate).toEqual(false); + }); }); diff --git a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.ts b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.ts index 2f88e573..d9a3996e 100644 --- a/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.ts +++ b/notebooks/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/src/lib/resource-table/table/table.component.ts @@ -9,6 +9,8 @@ import { OnDestroy, OnInit, ElementRef, + SimpleChanges, + OnChanges, } from '@angular/core'; import { MatTableDataSource } from '@angular/material/table'; import { MatPaginator } from '@angular/material/paginator'; @@ -28,24 +30,26 @@ import { import { DateTimeValue } from '../types/date-time'; import { TemplateValue } from '../types/template'; import { NamespaceService } from '../../services/namespace.service'; -import { Observable, Subscription } from 'rxjs'; +import { Subscription } from 'rxjs'; import { addColumn, NAMESPACE_COLUMN, removeColumn } from './utils'; import { MatSort } from '@angular/material/sort'; import { MatChipInputEvent } from '@angular/material/chips'; import { FormControl } from '@angular/forms'; -import { map, startWith } from 'rxjs/operators'; import { MatAutocompleteSelectedEvent, MatAutocompleteTrigger, } from '@angular/material/autocomplete'; import { DateTimeService } from '../../services/date-time.service'; +import { isEqual } from 'lodash-es'; @Component({ selector: 'lib-table', templateUrl: './table.component.html', styleUrls: ['./table.component.scss'], }) -export class TableComponent implements AfterViewInit, OnInit, OnDestroy { +export class TableComponent + implements AfterViewInit, OnInit, OnDestroy, OnChanges +{ private nsSub = new Subscription(); private innerData: any[] = []; public dataSource = new MatTableDataSource(); @@ -64,10 +68,12 @@ export class TableComponent implements AfterViewInit, OnInit, OnDestroy { chipList = []; chips = []; - headers = []; + headers: { title: string }[] = []; isClear: boolean; - filteredHeaders: Observable; + filteredHeaders: { title: string }[] = []; chipCtrl = new FormControl(); + showDate = false; + showStatus = false; @HostBinding('class.lib-table') selfClass = true; @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; @@ -91,18 +97,18 @@ export class TableComponent implements AfterViewInit, OnInit, OnDestroy { this.dataSource.data = newData; } + @Input() + highlightedRow: unknown = {}; + // Whenever a button in a row is pressed the component will emit an event // with information regarding the button that was pressed as well as the // row's object. @Output() actionsEmitter = new EventEmitter(); constructor(public ns: NamespaceService, private dtService: DateTimeService) { - this.filteredHeaders = this.chipCtrl.valueChanges.pipe( - startWith(null), - map((chip: string | null) => - chip ? this.filter(chip) : this.headers.slice(), - ), - ); + this.chipCtrl.valueChanges.subscribe(chip => { + this.filteredHeaders = this.filter(chip); + }); } ngOnInit() { @@ -123,8 +129,19 @@ export class TableComponent implements AfterViewInit, OnInit, OnDestroy { }); this.sort.sort({ disableClear: true, id: 'name', start: 'asc' }); + } - this.config.columns.forEach(column => { + ngOnChanges(changes: SimpleChanges): void { + if (changes.config) { + this.configureFilter(changes.config.currentValue); + } + } + + configureFilter(config: TableConfig): void { + this.headers = []; + this.showStatus = false; + this.showDate = false; + config.columns.forEach(column => { if ( !this.isMenuValue(column.value) && !this.isActionListValue(column.value) && @@ -137,7 +154,16 @@ export class TableComponent implements AfterViewInit, OnInit, OnDestroy { title: column.matHeaderCellDef, }); } + + if (this.isStatusValue(column.value)) { + this.showStatus = true; + } + + if (this.isDateTimeValue(column.value)) { + this.showDate = true; + } }); + this.filteredHeaders = this.filter(this.chipCtrl.value); } ngOnDestroy() { @@ -463,7 +489,10 @@ export class TableComponent implements AfterViewInit, OnInit, OnDestroy { } } - private filter(value: string): string[] { + private filter(value: string | null): { title: string }[] { + if (value === null) { + return this.headers.slice(); + } const filterValue = value.toLowerCase(); return this.headers.filter(chip => @@ -471,6 +500,15 @@ export class TableComponent implements AfterViewInit, OnInit, OnDestroy { ); } + highlightRow(row: unknown, highlightedRow: unknown): string { + try { + return isEqual(row, highlightedRow) ? 'highlight-row' : ''; + } catch (error) { + console.error(error); + return ''; + } + } + public isActionListValue(obj) { return obj instanceof ActionListValue; }