From df935c4065bd33ac0ec7a81060a6e99dde8f495d Mon Sep 17 00:00:00 2001 From: Nizamudeen A Date: Thu, 17 Jul 2025 16:43:08 +0530 Subject: [PATCH] mgr/dashboard: support inline edit for datatable adding a new celltransformation that will transform the cell to a form control when you click on the edit button which is inside a cell. Added a new CellTemplate `editing` which if set to a cell, then it'll add the edit button. You can also add validators to the control by using the `customTemplateConfig` like ``` customTemplateConfig: { validators: [Validators.required] } ``` Also using a `EditState` to keep track of the different cells I can edit simultaneously in a single time. Fixes: https://tracker.ceph.com/issues/72171 Signed-off-by: Nizamudeen A --- .../app/shared/datatable/datatable.module.ts | 12 +++- .../datatable/table/table.component.html | 61 +++++++++++++++++ .../datatable/table/table.component.scss | 8 +++ .../datatable/table/table.component.spec.ts | 66 ++++++++++++++++++- .../shared/datatable/table/table.component.ts | 42 ++++++++++++ .../src/app/shared/enum/cell-template.enum.ts | 15 ++++- .../src/app/shared/enum/icons.enum.ts | 4 +- .../src/app/shared/models/cd-table-editing.ts | 5 ++ .../mgr/dashboard/frontend/src/styles.scss | 1 + .../src/styles/ceph-custom/_index.scss | 1 + .../src/styles/ceph-custom/_spacings.scss | 9 +++ 11 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-editing.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts index 072c66220f020..8df7b98b69d2b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts @@ -15,7 +15,9 @@ import { DialogModule, SelectModule, TagModule, - LayerModule + LayerModule, + InputModule, + GridModule } from 'carbon-components-angular'; import AddIcon from '@carbon/icons/es/add/16'; import FilterIcon from '@carbon/icons/es/filter/16'; @@ -26,6 +28,7 @@ import CloseIcon from '@carbon/icons/es/close/16'; import MaximizeIcon from '@carbon/icons/es/maximize/16'; import ArrowDown from '@carbon/icons/es/caret--down/16'; import ChevronDwon from '@carbon/icons/es/chevron--down/16'; +import CheckMarkIcon from '@carbon/icons/es/checkmark/32'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormlyModule } from '@ngx-formly/core'; @@ -99,7 +102,9 @@ import { TableDetailDirective } from './directives/table-detail.directive'; ThemeModule, SelectModule, TagModule, - LayerModule + LayerModule, + InputModule, + GridModule ], declarations: [ TableComponent, @@ -138,7 +143,8 @@ export class DataTableModule { CloseIcon, MaximizeIcon, ArrowDown, - ChevronDwon + ChevronDwon, + CheckMarkIcon ]); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html index c0409b1ac5a7f..75a47529b8c8e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html @@ -405,3 +405,64 @@ [text]="value"> + + + @if (isCellEditing(row?.id, column?.prop)) { +
+
+
+ + + + + + This field is required. + + + The field format is invalid. + + +
+
+ +
+
+
+ } @else { +
+
+ {{ value }} +
+
+ +
+
+ } +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss index 1cc0a0e9ae4b9..d1a943d22730f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss @@ -99,3 +99,11 @@ .scrollable-expanded-row::-webkit-scrollbar-track { background: transparent; } + +tr .edit-btn { + opacity: 0; +} + +tr:hover .edit-btn { + opacity: 1; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts index bf9240938f29a..4eb46549512d4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; @@ -44,6 +44,7 @@ describe('TableComponent', () => { imports: [ BrowserAnimationsModule, FormsModule, + ReactiveFormsModule, ComponentsModule, RouterTestingModule, NgbDropdownModule, @@ -582,6 +583,61 @@ describe('TableComponent', () => { expect(executingElement.nativeElement.textContent.trim()).toBe(`(${state})`); }; + const testEditingTemplate = (editing = false) => { + component.autoReload = -1; + + const data = createFakeData(10); + // add id to every row so that we can use it to save the state. + component.data = data.map((item, i) => ({ + ...item, + id: `id-${i}` + })); + component.localColumns = component.columns = [ + { + prop: 'a', + name: 'Name', + cellTransformation: CellTemplate.editing, + customTemplateConfig: { + validators: [] + } + } + ]; + + // trigger an editing by setting the edit state. + if (editing) { + component.editCellItem('id-0', component.localColumns[0], '0'); + } + + component.ngOnInit(); + component.ngAfterViewInit(); + fixture.detectChanges(); + + if (editing) { + const inputElement = fixture.debugElement + .query(By.css('[cdstablerow] [cdstabledata]')) + .query(By.css('input')); + expect(inputElement).not.toBeNull(); + + const saveButton = fixture.debugElement + .query(By.css('[cdstablerow] [cdstabledata]')) + .query(By.css('#cell-inline-save-btn')); + expect(saveButton).not.toBeNull(); + + const svgElement = saveButton.nativeElement.querySelector('svg'); + expect(svgElement).not.toBeNull(); + expect(svgElement.classList.contains('check-icon')).toBeTruthy(); + } else { + const editButton = fixture.debugElement + .query(By.css('[cdstablerow] [cdstabledata]')) + .query(By.css('#cell-inline-edit-btn')); + expect(editButton).not.toBeNull(); + + const svgElement = editButton.nativeElement.querySelector('svg'); + expect(svgElement).not.toBeNull(); + expect(svgElement.classList.contains('edit-icon')).toBeTruthy(); + } + }; + it('should display executing template', () => { testExecutingTemplate(); }); @@ -589,6 +645,14 @@ describe('TableComponent', () => { it('should display executing template with custom classes', () => { testExecutingTemplate({ valueClass: 'a b', executingClass: 'c d' }); }); + + it('should display an edit icon on the cell', () => { + testEditingTemplate(); + }); + + it('should display input element and save button if editing', () => { + testEditingTemplate(true); + }); }); describe('reload data', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts index 7932c4da5d1d9..3058a3bc2d361 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts @@ -35,6 +35,9 @@ import { TableDetailDirective } from '../directives/table-detail.directive'; import { filter, map } from 'rxjs/operators'; import { CdSortDirection } from '../../enum/cd-sort-direction'; import { CdSortPropDir } from '../../models/cd-sort-prop-dir'; +import { EditState } from '../../models/cd-table-editing'; +import { CdFormGroup } from '../../forms/cd-form-group'; +import { FormControl } from '@angular/forms'; const TABLE_LIST_LIMIT = 10; type TPaginationInput = { page: number; size: number; filteredData: any[] }; @@ -85,6 +88,8 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr rowDetailTpl: TemplateRef; @ViewChild('tableActionTpl', { static: true }) tableActionTpl: TemplateRef; + @ViewChild('editingTpl', { static: true }) + editingTpl: TemplateRef; @ContentChild(TableDetailDirective) rowDetail!: TableDetailDirective; @ContentChild(TableActionsComponent) tableActions!: TableActionsComponent; @@ -247,6 +252,9 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr */ @Output() columnFiltersChanged = new EventEmitter(); + @Output() + editSubmitAction = new EventEmitter<{ [field: string]: string }>(); + /** * Use this variable to access the selected row(s). */ @@ -384,6 +392,10 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr } private previousRows = new Map(); + editingCells = new Set(); + editStates: EditState = {}; + formGroup: CdFormGroup = new CdFormGroup({}); + constructor( // private ngZone: NgZone, private cdRef: ChangeDetectorRef, @@ -829,6 +841,7 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr this.cellTemplates.path = this.pathTpl; this.cellTemplates.tooltip = this.tooltipTpl; this.cellTemplates.copy = this.copyTpl; + this.cellTemplates.editing = this.editingTpl; } useCustomClass(value: any): string { @@ -1371,4 +1384,33 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr this.selectAllCheckboxSomeSelected = false; } } + + editCellItem(rowId: string, column: CdTableColumn, value: string) { + const key = `${rowId}-${column.prop}`; + this.formGroup.addControl(key, new FormControl('', column.customTemplateConfig?.validators)); + this.editingCells.add(key); + if (!this.editStates[rowId]) { + this.editStates[rowId] = {}; + } + this.formGroup?.get(key).setValue(value); + this.editStates[rowId][column.prop] = value; + } + + saveCellItem(rowId: string, colProp: string) { + if (this.formGroup?.invalid) { + this.formGroup.setErrors({ cdSubmitButton: true }); + return; + } + this.editSubmitAction.emit(this.editStates[rowId]); + this.editingCells.delete(`${rowId}-${colProp}`); + delete this.editStates[rowId][colProp]; + } + + isCellEditing(rowId: string, colProp: string): boolean { + return this.editingCells.has(`${rowId}-${colProp}`); + } + + valueChange(rowId: string, colProp: string, value: string) { + this.editStates[rowId][colProp] = value; + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts index bda66f6004e6a..97cd9f84d98bb 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts @@ -79,5 +79,18 @@ export enum CellTemplate { // ... // cellTransformation: CellTemplate.copy, */ - copy = 'copy' + copy = 'copy', + /* + This template will let you edit the cell value inline. You can pass the validators in the + customTemplateConfig. + // { + // ... + // cellTransformation: CellTemplate.editing, + // customTemplateConfig: { + // validators: [Validators.required] + // } + // ... + // } + */ + editing = 'editing' } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index c6656ff89cabb..197e1ab239658 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -114,5 +114,7 @@ export const ICON_TYPE = { danger: 'danger', infoCircle: 'info-circle', success: 'success', - warning: 'warning' + warning: 'warning', + edit: 'edit', + check: 'check' } as const; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-editing.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-editing.ts new file mode 100644 index 0000000000000..089a69a99ef89 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-editing.ts @@ -0,0 +1,5 @@ +export interface EditState { + [rowId: string]: { + [field: string]: string; + }; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/styles.scss b/src/pybind/mgr/dashboard/frontend/src/styles.scss index 69481a297e39c..cb38072b712a9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles.scss @@ -35,6 +35,7 @@ $grid-breakpoints: ( @import './src/styles/ceph-custom/icons'; @import './src/styles/ceph-custom/navs'; @import './src/styles/ceph-custom/toast'; +@import './src/styles/ceph-custom/spacings'; /* If javascript is disabled. */ .noscript { diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_index.scss b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_index.scss index ec9c5b28bacdb..2c2255f1e60d0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_index.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_index.scss @@ -3,3 +3,4 @@ @forward 'dropdown'; @forward 'forms'; @forward 'icons'; +@forward 'spacings'; diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss new file mode 100644 index 0000000000000..48c8452f64f89 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss @@ -0,0 +1,9 @@ +@use '@carbon/layout'; + +.cds-p-0 { + padding: 0; +} + +.cds-pt-3 { + padding-top: layout.$spacing-03; +} -- 2.39.5