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
},