diff --git a/src/app/datasets/dataset-table/dataset-inline-edit-cell.component.html b/src/app/datasets/dataset-table/dataset-inline-edit-cell.component.html new file mode 100644 index 000000000..d1bf47715 --- /dev/null +++ b/src/app/datasets/dataset-table/dataset-inline-edit-cell.component.html @@ -0,0 +1,40 @@ +
+ +
+ + +
+
+ + + + {{ displayValue }} + + edit + +
diff --git a/src/app/datasets/dataset-table/dataset-inline-edit-cell.component.scss b/src/app/datasets/dataset-table/dataset-inline-edit-cell.component.scss new file mode 100644 index 000000000..47c7e2ff7 --- /dev/null +++ b/src/app/datasets/dataset-table/dataset-inline-edit-cell.component.scss @@ -0,0 +1,85 @@ +:host { + display: block; + width: 100%; +} + +.inline-edit-cell { + align-items: center; + display: flex; + gap: 4px; + justify-content: space-between; + min-height: 36px; + width: 100%; +} + +.inline-edit-text, +.inline-edit-input { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inline-edit-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inline-edit-editor { + align-items: center; + display: flex; + gap: 4px; + width: 100%; +} + +.edit-icon { + color: rgba(0, 0, 0, 0.54); + flex: 0 0 auto; + font-size: 18px; + height: 18px; + line-height: 18px; + margin-left: auto; + opacity: 0; + transition: opacity 150ms ease; + width: 18px; +} + +.inline-edit-cell.can-edit:hover .edit-icon { + opacity: 1; +} + +.inline-edit-cell.is-editing { + cursor: text; +} + +.inline-edit-input { + min-width: 0; + width: 100%; +} + +.save-button { + align-self: stretch; + background: transparent; + border-radius: 0; + color: inherit; + display: flex; + justify-content: center; + flex: 0 0 auto; + height: auto; + line-height: normal; + padding: 0; + width: 36px; +} + +.save-button .mat-icon { + margin: 0; + vertical-align: middle; +} + +.save-button:hover, +.save-button:focus, +.save-button:active { + background: transparent; +} diff --git a/src/app/datasets/dataset-table/dataset-inline-edit-cell.component.spec.ts b/src/app/datasets/dataset-table/dataset-inline-edit-cell.component.spec.ts new file mode 100644 index 000000000..83057a9cc --- /dev/null +++ b/src/app/datasets/dataset-table/dataset-inline-edit-cell.component.spec.ts @@ -0,0 +1,101 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { FormsModule } from "@angular/forms"; +import { MatIconModule } from "@angular/material/icon"; +import { MatInputModule } from "@angular/material/input"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { provideMockStore } from "@ngrx/store/testing"; +import { of } from "rxjs"; +import { AppConfigService } from "app-config.service"; +import { DatasetsService } from "@scicatproject/scicat-sdk-ts-angular"; +import { + selectIsAdmin, + selectProfile, +} from "state-management/selectors/user.selectors"; +import { DatasetInlineEditCellComponent } from "./dataset-inline-edit-cell.component"; + +describe("DatasetInlineEditCellComponent", () => { + let component: DatasetInlineEditCellComponent; + let fixture: ComponentFixture; + let datasetsService: jasmine.SpyObj; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + MatIconModule, + MatInputModule, + NoopAnimationsModule, + ], + declarations: [DatasetInlineEditCellComponent], + providers: [ + provideMockStore({ + selectors: [ + { + selector: selectProfile, + value: { accessGroups: ["owner-group"] }, + }, + { selector: selectIsAdmin, value: false }, + ], + }), + { + provide: AppConfigService, + useValue: { + getConfig: () => ({ editDatasetEnabled: true }), + }, + }, + { + provide: DatasetsService, + useValue: jasmine.createSpyObj("DatasetsService", [ + "datasetsControllerFindByIdAndUpdateV3", + ]), + }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DatasetInlineEditCellComponent); + component = fixture.componentInstance; + datasetsService = TestBed.inject( + DatasetsService, + ) as jasmine.SpyObj; + component.row = { + pid: "dataset-1", + ownerGroup: "owner-group", + comment: "original", + } as any; + component.column = { name: "comment" } as any; + fixture.detectChanges(); + }); + + it("should allow editing for users in the dataset owner group", () => { + expect(component.canEdit).toBeTrue(); + }); + + it("should persist the updated field and update the row locally", () => { + datasetsService.datasetsControllerFindByIdAndUpdateV3.and.returnValue( + of(null), + ); + + component.beginEdit(new MouseEvent("click")); + component.draftValue = "updated"; + component.saveValue(); + + expect( + datasetsService.datasetsControllerFindByIdAndUpdateV3, + ).toHaveBeenCalledWith("dataset-1", { comment: "updated" }); + expect(component.row.comment).toBe("updated"); + expect(component.isEditing).toBeFalse(); + }); + + it("should not allow editing when the user lacks access", () => { + component.row = { + pid: "dataset-1", + ownerGroup: "other-group", + comment: "original", + } as any; + fixture.detectChanges(); + + expect(component.canEdit).toBeFalse(); + }); +}); diff --git a/src/app/datasets/dataset-table/dataset-inline-edit-cell.component.ts b/src/app/datasets/dataset-table/dataset-inline-edit-cell.component.ts new file mode 100644 index 000000000..62c8e96c1 --- /dev/null +++ b/src/app/datasets/dataset-table/dataset-inline-edit-cell.component.ts @@ -0,0 +1,222 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + Input, + OnInit, + OnChanges, + OnDestroy, + SimpleChanges, + ViewChild, +} from "@angular/core"; +import { Store } from "@ngrx/store"; +import { + DatasetsService, + OutputDatasetObsoleteDto, +} from "@scicatproject/scicat-sdk-ts-angular"; +import { Subject, takeUntil } from "rxjs"; +import { get as lodashGet, set as lodashSet } from "lodash-es"; +import { AppConfigService } from "app-config.service"; +import { showMessageAction } from "state-management/actions/user.actions"; +import { MessageType } from "state-management/models"; +import { + selectIsAdmin, + selectProfile, +} from "state-management/selectors/user.selectors"; +import { TableField } from "shared/modules/dynamic-material-table/models/table-field.model"; +import { + DynamicMatTableComponent, + IDynamicCell, +} from "shared/modules/dynamic-material-table/table/dynamic-mat-table.component"; + +@Component({ + selector: "dataset-inline-edit-cell", + templateUrl: "./dataset-inline-edit-cell.component.html", + styleUrls: ["./dataset-inline-edit-cell.component.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class DatasetInlineEditCellComponent + implements IDynamicCell, OnInit, OnChanges, OnDestroy +{ + private destroy$ = new Subject(); + private accessGroups: string[] = []; + private isAdmin = false; + private appConfig = this.appConfigService.getConfig(); + + @Input() row: OutputDatasetObsoleteDto; + @Input() column: TableField; + @Input() parent: DynamicMatTableComponent; + @Input() onRowEvent = null; + + @ViewChild("valueInput") valueInput?: ElementRef; + + isEditing = false; + isSaving = false; + draftValue = ""; + + get canEdit(): boolean { + return ( + !!this.row?.pid && + ((this.appConfig.editDatasetEnabled && + !!this.row?.ownerGroup && + this.accessGroups.includes(this.row.ownerGroup)) || + this.isAdmin) + ); + } + + get fieldPath(): string { + return this.column?.path || this.column?.name || ""; + } + + get displayValue(): string { + if (!this.row || !this.column) { + return ""; + } + + const value = this.column.customRender + ? this.column.customRender(this.column, this.row) + : lodashGet(this.row, this.fieldPath); + + return value == null ? "" : String(value); + } + + constructor( + private appConfigService: AppConfigService, + private datasetsService: DatasetsService, + private store: Store, + private cdr: ChangeDetectorRef, + ) {} + + ngOnInit(): void { + this.store + .select(selectProfile) + .pipe(takeUntil(this.destroy$)) + .subscribe((profile) => { + this.accessGroups = profile?.accessGroups ?? []; + this.cdr.markForCheck(); + }); + + this.store + .select(selectIsAdmin) + .pipe(takeUntil(this.destroy$)) + .subscribe((isAdmin) => { + this.isAdmin = !!isAdmin; + this.cdr.markForCheck(); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.row || changes.column) { + this.draftValue = this.rawValue; + } + } + + beginEdit(event: Event): void { + event.stopPropagation(); + + if (!this.canEdit || this.isSaving) { + return; + } + + this.isEditing = true; + this.draftValue = this.rawValue; + this.cdr.markForCheck(); + + setTimeout(() => { + const input = this.valueInput?.nativeElement; + input?.focus(); + input?.select(); + }); + } + + saveValue(event?: Event): void { + event?.stopPropagation(); + + if (!this.isEditing || this.isSaving || !this.row?.pid) { + return; + } + + const nextValue = this.draftValue ?? ""; + const currentValue = this.rawValue; + + if (nextValue === currentValue) { + this.isEditing = false; + this.cdr.markForCheck(); + return; + } + + this.isSaving = true; + this.cdr.markForCheck(); + + this.datasetsService + .datasetsControllerFindByIdAndUpdateV3(this.row.pid, { + ...lodashSet({}, this.fieldPath, nextValue), + }) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + lodashSet(this.row, this.fieldPath, nextValue); + this.isEditing = false; + this.isSaving = false; + this.cdr.markForCheck(); + }, + error: () => { + this.isSaving = false; + this.store.dispatch( + showMessageAction({ + message: { + content: `Failed to update dataset ${this.column?.header || this.fieldPath}.`, + type: MessageType.Error, + duration: 5000, + }, + }), + ); + this.cdr.markForCheck(); + }, + }); + } + + cancelEdit(event?: Event): void { + event?.stopPropagation(); + this.draftValue = this.rawValue; + this.isEditing = false; + this.cdr.markForCheck(); + } + + onCellClick(event: Event): void { + if (!this.canEdit) { + return; + } + + if (this.isEditing) { + event.stopPropagation(); + return; + } + + this.beginEdit(event); + } + + onCellMouseDown(event: Event): void { + if (this.canEdit) { + event.stopPropagation(); + } + } + + onCellDoubleClick(event: Event): void { + if (this.canEdit) { + event.stopPropagation(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private get rawValue(): string { + const value = lodashGet(this.row, this.fieldPath); + return value == null ? "" : String(value); + } +} diff --git a/src/app/datasets/dataset-table/dataset-table.component.ts b/src/app/datasets/dataset-table/dataset-table.component.ts index 803f75528..e4eb3a588 100644 --- a/src/app/datasets/dataset-table/dataset-table.component.ts +++ b/src/app/datasets/dataset-table/dataset-table.component.ts @@ -69,6 +69,7 @@ import { TableConfigService } from "shared/services/table-config.service"; import { selectInstruments } from "state-management/selectors/instruments.selectors"; import { FormatNumberPipe } from "shared/pipes/format-number.pipe"; import { DatasetsListService } from "shared/services/datasets-list.service"; +import { DatasetInlineEditCellComponent } from "./dataset-inline-edit-cell.component"; export interface SortChangeEvent { active: string; @@ -164,6 +165,19 @@ export class DatasetTableComponent implements OnInit, OnDestroy { private datasetsListService: DatasetsListService, ) {} + private decorateColumns(columns: TableField[] = []): TableField[] { + return columns.map((column) => { + if (column.type !== "editable") { + return column; + } + + return { + ...column, + dynamicCellComponent: DatasetInlineEditCellComponent, + }; + }); + } + getTableSort(): ITableSetting["tableSort"] { const { queryParams } = this.route.snapshot; @@ -202,7 +216,7 @@ export class DatasetTableComponent implements OnInit, OnDestroy { currentColumnSetting = settingConfig.settingList[0].columnSetting; } - this.columns = currentColumnSetting; + this.columns = this.decorateColumns(currentColumnSetting); this.setting = settingConfig; this.pagination = paginationConfig; } diff --git a/src/app/datasets/datasets.module.ts b/src/app/datasets/datasets.module.ts index 09a173476..f07d563e8 100644 --- a/src/app/datasets/datasets.module.ts +++ b/src/app/datasets/datasets.module.ts @@ -82,6 +82,7 @@ import { JsonFormsModule } from "@jsonforms/angular"; import { JsonFormsAngularMaterialModule } from "@jsonforms/angular-material"; import { DatasetDetailDynamicComponent } from "./dataset-detail/dataset-detail-dynamic/dataset-detail-dynamic.component"; import { DatasetDetailWrapperComponent } from "./dataset-detail/dataset-detail-wrapper.component"; +import { DatasetInlineEditCellComponent } from "./dataset-table/dataset-inline-edit-cell.component"; import { JsonHeadPipe } from "shared/pipes/json-head.pipe"; import { ThumbnailPipe } from "shared/pipes/thumbnail.pipe"; import { IngestorModule } from "../ingestor/ingestor.module"; @@ -171,6 +172,7 @@ import { SharedConditionModule } from "shared/modules/shared-condition/shared-co DatasetDetailComponent, DatasetDetailDynamicComponent, DatasetTableComponent, + DatasetInlineEditCellComponent, DatasetsFilterComponent, PublishComponent, ReduceComponent, diff --git a/src/app/shared/MockStubs.ts b/src/app/shared/MockStubs.ts index cf0ce89f5..49951bc7e 100644 --- a/src/app/shared/MockStubs.ts +++ b/src/app/shared/MockStubs.ts @@ -129,6 +129,13 @@ export class MockDatasetApi { return of([]); } + datasetsControllerFindByIdAndUpdateV3( + pid: string, + body: Record, + ) { + return of(null); + } + datasetsControllerCountV3(data?: any) { return of(0); } diff --git a/src/app/shared/modules/dynamic-material-table/models/table-field.model.ts b/src/app/shared/modules/dynamic-material-table/models/table-field.model.ts index cc668671c..b2aa4d186 100644 --- a/src/app/shared/modules/dynamic-material-table/models/table-field.model.ts +++ b/src/app/shared/modules/dynamic-material-table/models/table-field.model.ts @@ -21,7 +21,8 @@ export declare type FieldType = | "category" | "hoverContent" | "custom" // It's from old code, remove it in the future if possible - | "standard"; // It's from old code, remove it in the future if possible + | "standard" // It's from old code, remove it in the future if possible + | "editable"; export declare type FieldDisplay = "visible" | "hidden" | "prevent-hidden"; export declare type FieldSticky = "start" | "end" | "none"; export declare type FieldFilter = "client-side" | "server-side" | "none"; @@ -42,6 +43,7 @@ export interface TableField extends AbstractField { export interface AbstractField { index?: number; name: string /* The key of the data */; + path?: string; type?: FieldType /* Type of data in the field */; minWidth?: number /* min width of column */; width?: number /* width of column */; diff --git a/src/app/shared/services/datasets-list.service.ts b/src/app/shared/services/datasets-list.service.ts index ffad15929..eec37e2f7 100644 --- a/src/app/shared/services/datasets-list.service.ts +++ b/src/app/shared/services/datasets-list.service.ts @@ -237,6 +237,12 @@ export class DatasetsListService implements OnDestroy { }; } + if (column.type === "editable") { + convertedColumn.toExport = + convertedColumn.toExport ?? + ((row) => lodashGet(row, column.path || column.name) ?? ""); + } + return convertedColumn; }); } diff --git a/src/app/state-management/models/index.ts b/src/app/state-management/models/index.ts index ba4927b45..9a2b5ab64 100644 --- a/src/app/state-management/models/index.ts +++ b/src/app/state-management/models/index.ts @@ -16,7 +16,7 @@ export interface TableColumn { header?: string; path?: string; order: number; - type: "standard" | "custom" | "date" | "hoverContent"; + type: "standard" | "custom" | "date" | "hoverContent" | "editable"; enabled: boolean; format?: string; tooltip?: string; diff --git a/src/assets/config.json b/src/assets/config.json index b7ab6a692..c02b0f24d 100644 --- a/src/assets/config.json +++ b/src/assets/config.json @@ -441,7 +441,7 @@ }, { "name": "comment", - "type": "standard", + "type": "editable", "width": 200, "enabled": false },