]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: support inline edit for datatable
authorNizamudeen A <nia@redhat.com>
Thu, 17 Jul 2025 11:13:08 +0000 (16:43 +0530)
committerNizamudeen A <nia@redhat.com>
Fri, 19 Sep 2025 07:49:14 +0000 (13:19 +0530)
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 <nia@redhat.com>
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-editing.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/styles.scss
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_index.scss
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss [new file with mode: 0644]

index 072c66220f020908349d32eb1c3bfb6f9a082551..8df7b98b69d2b8fcc47d10c03876a334f445428d 100644 (file)
@@ -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
     ]);
   }
 }
index c0409b1ac5a7f2355a685b02afa6dc5ad94807cb..75a47529b8c8e8a11596e63d5480646db14573c5 100644 (file)
                               [text]="value">
   </cd-copy-2-clipboard-button>
 </ng-template>
+
+<ng-template #editingTpl
+             let-value="data.value"
+             let-row="data.row"
+             let-column="data.column">
+  @if (isCellEditing(row?.id, column?.prop)) {
+  <form [formGroup]="formGroup"
+        #formDir="ngForm">
+    <div cdsRow>
+      <div cdsCol>
+        <cds-text-label [invalid]="formGroup.controls[row?.id + '-' + column?.prop]?.invalid && formGroup.controls[row?.id + '-' + column?.prop]?.dirty"
+                        [invalidText]="errorTpl">
+          <input type="text"
+                 cdsText
+                 size="sm"
+                 [id]="row?.id + '-' + column?.prop"
+                 [formControlName]="row?.id + '-' + column?.prop"
+                 (input)="valueChange(row?.id, column?.prop, $event.target.value)"
+                 [invalid]="formGroup.controls[row?.id + '-' + column?.prop]?.invalid && formGroup.controls[row?.id + '-' + column?.prop]?.dirty">
+        </cds-text-label>
+        <ng-template #errorTpl>
+          <span *ngIf="formGroup?.showError(row?.id + '-' + column?.prop, formDir, 'required')">
+            <ng-container i18n>This field is required.</ng-container>
+          </span>
+          <span *ngIf="column?.customTemplateConfig?.formGroup?.showError(row?.id + '-' + column?.prop, formDir, 'pattern')">
+            <ng-container i18n>The field format is invalid.</ng-container>
+          </span>
+        </ng-template>
+      </div>
+      <div cdsCol
+           [columnNumbers]="{sm:1}"
+           class="cds-p-0 cds-pt-3">
+        <button cdsButton="ghost"
+                size="sm"
+                id="cell-inline-save-btn"
+                type="button"
+                (click)="saveCellItem(row?.id, column?.prop)">
+          <cd-icon type="check"></cd-icon>
+        </button>
+      </div>
+    </div>
+  </form>
+  } @else {
+  <div cdsRow>
+    <div cdsCol
+         class="cds-pt-3">
+      {{ value }}
+    </div>
+    <div cdsCol
+         [columnNumbers]="{lg: 5}"
+         class="edit-btn">
+      <button cdsButton="ghost"
+              size="sm"
+              id="cell-inline-edit-btn"
+              (click)="editCellItem(row?.id, column, value)">
+        <cd-icon type="edit"></cd-icon>
+      </button>
+    </div>
+  </div>
+  }
+</ng-template>
index 1cc0a0e9ae4b9f7ae83cc25ff3efe3a30fabdb82..d1a943d22730f76db073db5f02f01476f76efe5d 100644 (file)
 .scrollable-expanded-row::-webkit-scrollbar-track {
   background: transparent;
 }
+
+tr .edit-btn {
+  opacity: 0;
+}
+
+tr:hover .edit-btn {
+  opacity: 1;
+}
index bf9240938f29a6cce2da9a8151014dbcaabce769..4eb46549512d4dcf7f1d1c609355e7205b30551b 100644 (file)
@@ -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', () => {
index 7932c4da5d1d9382502b439781bd85a80bdc1f11..3058a3bc2d361dad0985e8c1e84356b78576419f 100644 (file)
@@ -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<any>;
   @ViewChild('tableActionTpl', { static: true })
   tableActionTpl: TemplateRef<any>;
+  @ViewChild('editingTpl', { static: true })
+  editingTpl: TemplateRef<any>;
 
   @ContentChild(TableDetailDirective) rowDetail!: TableDetailDirective;
   @ContentChild(TableActionsComponent) tableActions!: TableActionsComponent;
@@ -247,6 +252,9 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr
    */
   @Output() columnFiltersChanged = new EventEmitter<CdTableColumnFiltersChange>();
 
+  @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<string | number, TableItem[]>();
 
+  editingCells = new Set<string>();
+  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;
+  }
 }
index bda66f6004e6a44731cad177af3d87ac84dff18b..97cd9f84d98bb1859e66415b7d144ffea4b87a96 100644 (file)
@@ -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'
 }
index c6656ff89cabb1fb22addc86bc0e5e3ab16727af..197e1ab2396589c660674d8fd1ef0124969ca7cc 100644 (file)
@@ -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 (file)
index 0000000..089a69a
--- /dev/null
@@ -0,0 +1,5 @@
+export interface EditState {
+  [rowId: string]: {
+    [field: string]: string;
+  };
+}
index 69481a297e39cfb07b5f02e0e8366b94c4dcc559..cb38072b712a9c91ab14d60b4b92a26a5e2ab707 100644 (file)
@@ -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 {
index ec9c5b28bacdb1db5566783e4dc31fe153364896..2c2255f1e60d0cd0e5ced441c26489d59dab7888 100644 (file)
@@ -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 (file)
index 0000000..48c8452
--- /dev/null
@@ -0,0 +1,9 @@
+@use '@carbon/layout';
+
+.cds-p-0 {
+  padding: 0;
+}
+
+.cds-pt-3 {
+  padding-top: layout.$spacing-03;
+}