This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6953-6a5885c1bce0005a41525ba42f6995de4db14331 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 7f3bba502a5b71ce129924d86784841ac56b170f Author: Xuan Gu <[email protected]> AuthorDate: Mon Aug 3 13:30:25 2026 -0700 feat(dataset): add dataset contributor display and editing (#6953) ### What changes were proposed in this PR? This PR adds the frontend for dataset contributor metadata so that dataset authors and other contributors can be properly acknowledged, on top of the backend added in #6952. Changes: - Dataset detail page: a new "Metadata" card in the Data Card tab with a collapsible "Contributors" section. Each contributor is shown as a card with name, email, affiliation, and comments; the dataset creator is marked with a star. Long field values are truncated to two lines with the full content shown in tooltips. - Users with WRITE access can add, edit, and delete contributors through a dropdown menu on each card, with optimistic updates and rollback if saving fails. - A new `user-dataset-contributor-editor` modal component for adding and editing a contributor, backed by a shared Formly field group (`contributor-form-fields.ts`) with client-side validation (required name, email format, length limits). - Dataset creation: the create-dataset form accepts an optional initial list of contributors, sent with the create request. - `DatasetService`: `createDataset` gains a contributors argument, and a new `updateDatasetContributors` method posts the full list to `POST /dataset/update/contributors`. - Types: `Contributor` interface, `DashboardDataset.contributors`, and an optional `isPlaceholder` flag on `User` with a "placeholder" tag in the admin user list. The tag stays hidden until the follow-up backend that auto-creates placeholder accounts for contributor emails lands; it is included here so the admin UI is ready for it. #### Demo | Create a dataset with contributor metadata | Add a contributor from the dataset detail page | | --- | --- | |  |  | Contributor list: <img width="1261" height="563" alt="contributor_list" src="https://github.com/user-attachments/assets/580bb431-b724-445d-b1b7-b0023fee8827" /> ### Any related issues, documentation, discussions? Depends on #6952 Closes #6926 ### How was this PR tested? New vitest cases across four spec files: contributor card rendering and add/edit/delete flows with rollback on failure (dataset-detail), editor form validation and modal results (contributor-editor), request shapes for create/update (dataset.service), and the extended create-dataset form fields (version-creator). The flow was also manually tested in the UI. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Fable 5) --------- Co-authored-by: Claude Fable 5 <[email protected]> --- frontend/src/app/common/type/dataset.ts | 8 ++ frontend/src/app/common/type/user.ts | 1 + .../component/admin/user/admin-user.component.html | 1 + .../admin/user/admin-user.component.spec.ts | 13 ++ .../component/admin/user/admin-user.component.ts | 2 + .../dataset-detail.component.html | 120 ++++++++++++++++ .../dataset-detail.component.scss | 112 +++++++++++++++ .../dataset-detail.component.spec.ts | 158 ++++++++++++++++++++- .../dataset-detail.component.ts | 65 ++++++++- .../contributor-form-fields.ts | 90 ++++++++++++ .../user-dataset-contributor-editor.component.html | 45 ++++++ ...user-dataset-contributor-editor.component.scss} | 52 ++++--- ...er-dataset-contributor-editor.component.spec.ts | 133 +++++++++++++++++ .../user-dataset-contributor-editor.component.ts | 63 ++++++++ .../user-dataset-version-creator.component.spec.ts | 9 +- .../user-dataset-version-creator.component.ts | 13 +- .../service/user/dataset/dataset.service.spec.ts | 72 +++++++++- .../service/user/dataset/dataset.service.ts | 23 ++- .../dashboard/type/dashboard-dataset.interface.ts | 3 +- 19 files changed, 954 insertions(+), 29 deletions(-) diff --git a/frontend/src/app/common/type/dataset.ts b/frontend/src/app/common/type/dataset.ts index 97ff370302..908916984b 100644 --- a/frontend/src/app/common/type/dataset.ts +++ b/frontend/src/app/common/type/dataset.ts @@ -29,6 +29,14 @@ export interface DatasetVersion { fileNodes: DatasetFileNode[] | undefined; } +export interface Contributor { + name: string; + creator: boolean; + affiliation?: string; + email?: string; + comments?: string; +} + export interface Dataset { did: number | undefined; ownerUid: number | undefined; diff --git a/frontend/src/app/common/type/user.ts b/frontend/src/app/common/type/user.ts index 58e34b6800..85ead94a0a 100644 --- a/frontend/src/app/common/type/user.ts +++ b/frontend/src/app/common/type/user.ts @@ -48,6 +48,7 @@ export interface User accountCreation?: Second; affiliation?: string; joiningReason: string; + isPlaceholder?: boolean; }> {} export interface File diff --git a/frontend/src/app/dashboard/component/admin/user/admin-user.component.html b/frontend/src/app/dashboard/component/admin/user/admin-user.component.html index 4070cb9308..d7cb2da8ea 100644 --- a/frontend/src/app/dashboard/component/admin/user/admin-user.component.html +++ b/frontend/src/app/dashboard/component/admin/user/admin-user.component.html @@ -207,6 +207,7 @@ class="container" (click)="startEdit(user, 'name')"> {{user.name}} + <nz-tag *ngIf="user.isPlaceholder">placeholder</nz-tag> </div> </ng-container> <ng-template #editNameTemplate> diff --git a/frontend/src/app/dashboard/component/admin/user/admin-user.component.spec.ts b/frontend/src/app/dashboard/component/admin/user/admin-user.component.spec.ts index 7834231761..5b26fe372b 100644 --- a/frontend/src/app/dashboard/component/admin/user/admin-user.component.spec.ts +++ b/frontend/src/app/dashboard/component/admin/user/admin-user.component.spec.ts @@ -100,6 +100,19 @@ describe("AdminUserComponent", () => { expect(component).toBeTruthy(); })); + it("shows a placeholder tag only for placeholder accounts", () => { + component.listOfDisplayUser = [ + { uid: 1, name: "test1", email: "[email protected]", isPlaceholder: true } as any, + { uid: 2, name: "test2", email: "[email protected]" } as any, + ]; + fixture.detectChanges(); + + const tags: NodeListOf<HTMLElement> = fixture.nativeElement.querySelectorAll("nz-tag"); + const placeholderTags = Array.from(tags).filter(tag => tag.textContent?.includes("placeholder")); + expect(placeholderTags.length).toBe(1); + expect(placeholderTags[0].closest("tr")?.textContent).toContain("test1"); + }); + it("should search email case-insensitively", () => { component.userList = [ { diff --git a/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts b/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts index c6758f88df..1a7934a6d3 100644 --- a/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts +++ b/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts @@ -43,6 +43,7 @@ import { NzBadgeComponent } from "ng-zorro-antd/badge"; import { GuiConfigService } from "../../../../common/service/gui-config.service"; import { replaceOneImmutable } from "../../../../common/util/array-utils"; import { NzCardComponent } from "ng-zorro-antd/card"; +import { NzTagComponent } from "ng-zorro-antd/tag"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; import { NzIconDirective } from "ng-zorro-antd/icon"; import { NzDropdownMenuComponent } from "ng-zorro-antd/dropdown"; @@ -62,6 +63,7 @@ import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; styleUrls: ["./admin-user.component.scss"], imports: [ NzCardComponent, + NzTagComponent, NzTableComponent, NzTheadComponent, NzTrDirective, diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html index 365bdcee51..33a6182dc5 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html @@ -128,6 +128,126 @@ </div> </nz-card> </div> + + <nz-card class="data-card data-card-metadata"> + <h3 class="data-card-heading">Metadata</h3> + <nz-divider class="metadata-divider"></nz-divider> + <nz-collapse + nzGhost + nzExpandIconPosition="end" + class="contributors-collapse"> + <nz-collapse-panel nzHeader="Contributors"> + <div class="contributor-list"> + <div + *ngFor="let contributor of datasetContributors" + class="contributor-card"> + <button + *ngIf="userHasWriteAccess()" + nz-button + nzType="text" + nzSize="small" + class="contributor-actions" + nz-dropdown + nzTrigger="click" + nzPlacement="bottomRight" + [nzDropdownMenu]="contributorActionsMenu"> + <i + nz-icon + nzType="more"></i> + </button> + <nz-dropdown-menu #contributorActionsMenu="nzDropdownMenu"> + <ul + nz-menu + class="contributor-actions-menu"> + <li + nz-menu-item + (click)="onEditContributor(contributor)"> + <i + nz-icon + nzType="edit"></i> + Edit + </li> + <li + nz-menu-item + nzDanger + nz-popconfirm + [nzPopconfirmTitle]="'Delete contributor "' + contributor.name + '"?'" + nzOkText="Delete" + nzOkDanger + (nzOnConfirm)="onDeleteContributor(contributor)"> + <i + nz-icon + nzType="delete"></i> + Delete + </li> + </ul> + </nz-dropdown-menu> + <div class="contributor-name"> + <span + nz-tooltip + [nzTooltipTitle]="contributor.name" + >{{ contributor.name }}</span + > + <i + *ngIf="contributor.creator" + nz-icon + nzType="star" + nzTheme="fill" + class="creator-star" + nz-tooltip + nzTooltipTitle="Original creator"></i> + </div> + <div + class="contributor-row" + nz-tooltip + [nzTooltipTitle]="contributor.email"> + <span class="contributor-label">Email</span> + <span + class="contributor-value" + [class.empty]="!contributor.email" + >{{ contributor.email || "—" }}</span + > + </div> + <div + class="contributor-row" + nz-tooltip + [nzTooltipTitle]="contributor.affiliation"> + <span class="contributor-label">Affiliation</span> + <span + class="contributor-value" + [class.empty]="!contributor.affiliation" + >{{ contributor.affiliation || "—" }}</span + > + </div> + <div + class="contributor-row" + nz-tooltip + [nzTooltipTitle]="contributor.comments"> + <span class="contributor-label">Comments</span> + <span + class="contributor-value" + [class.empty]="!contributor.comments" + >{{ contributor.comments || "—" }}</span + > + </div> + </div> + <div + *ngIf="userHasWriteAccess()" + class="contributor-card-add" + role="button" + tabindex="0" + (click)="onAddContributor()" + (keydown.enter)="onAddContributor()" + (keydown.space)="$event.preventDefault(); onAddContributor()"> + <i + nz-icon + nzType="plus" + class="add-icon"></i> + </div> + </div> + </nz-collapse-panel> + </nz-collapse> + </nz-card> </div> </nz-tab> <nz-tab nzTitle="Versions & Files"> diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss index c0b17cfee1..b9378d661e 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss @@ -534,3 +534,115 @@ nz-tabs { ::ng-deep .settings-general-card button { border-radius: 8px; } + +.data-card-metadata { + margin-top: 16px; + // Match .data-card-columns' left inset so the box lines up with the + // Description card, not the page's outer edge. + margin-left: 16px; + + .metadata-divider { + margin: 8px 0 12px; + } +} + +@mixin line-clamp-2 { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.contributors-collapse ::ng-deep { + .ant-collapse-header { + padding: 0 0 8px !important; + font-size: 14px; + font-weight: 600; + } + + .ant-collapse-content-box { + padding: 0 !important; + } +} + +.contributor-list { + display: grid; + grid-template-columns: repeat(auto-fill, 220px); + gap: 12px; +} + +.contributor-card { + padding: 12px 16px; + background: #fff; + border: 1px solid #e6e8ec; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(16, 24, 40, 0.06); + display: flex; + flex-direction: column; + gap: 4px; + position: relative; + word-break: break-word; + + .contributor-actions { + position: absolute; + top: 6px; + right: 6px; + } + + .contributor-name { + font-weight: 600; + padding-right: 24px; + @include line-clamp-2; + } + + .creator-star { + color: #faad14; + font-size: 13px; + margin-left: 4px; + } + + .contributor-row { + font-size: 13px; + + .contributor-label { + display: block; + color: rgba(0, 0, 0, 0.45); + font-size: 12px; + } + + .contributor-value { + @include line-clamp-2; + + &.empty { + color: rgba(0, 0, 0, 0.25); + } + } + } +} + +.contributor-actions-menu { + .anticon { + margin-right: 8px; + } +} + +.contributor-card-add { + width: 40px; + height: 40px; + align-self: center; + margin-left: 15px; + color: #8c8c8c; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: #1890ff; + } + + .add-icon { + font-size: 18px; + } +} diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts index d7faeaa920..88e0937ac8 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts @@ -34,7 +34,7 @@ import { FileUploadItem } from "../../../../type/dashboard-file.interface"; import { DatasetFileNode, getFullPathFromDatasetFileNode } from "../../../../../common/type/datasetVersionFileTree"; import { DatasetStagedObject } from "../../../../../common/type/dataset-staged-object"; import { commonTestImports, commonTestProviders } from "../../../../../common/testing/test-utils"; -import { Dataset, DatasetVersion } from "../../../../../common/type/dataset"; +import { Contributor, Dataset, DatasetVersion } from "../../../../../common/type/dataset"; import { DashboardDataset } from "../../../../type/dashboard-dataset.interface"; import { HttpErrorResponse } from "@angular/common/http"; import { format } from "date-fns"; @@ -131,6 +131,56 @@ describe("DatasetDetailComponent upload queue", () => { fixture.detectChanges(); }); + describe("contributor cards", () => { + const full: Contributor = { + name: "Contributor A", + creator: true, + affiliation: "Test Lab", + email: "[email protected]", + comments: "notes", + }; + const blank: Contributor = { name: "Contributor B", creator: false }; + + beforeEach(() => { + component.datasetContributors = [full, blank]; + component.userDatasetAccessLevel = "WRITE"; + fixture.detectChanges(); + }); + + it("renders one card per contributor with values, a creator star, and dashes for blanks", () => { + const cards: NodeListOf<HTMLElement> = fixture.nativeElement.querySelectorAll(".contributor-card"); + expect(cards.length).toBe(2); + + expect(cards[0].querySelector(".contributor-name")?.textContent).toContain("Contributor A"); + expect(cards[0].querySelector(".creator-star")).not.toBeNull(); + expect(cards[0].textContent).toContain("[email protected]"); + + expect(cards[1].querySelector(".creator-star")).toBeNull(); + const blankValues: NodeListOf<HTMLElement> = cards[1].querySelectorAll(".contributor-value.empty"); + expect(blankValues.length).toBe(3); + blankValues.forEach(value => expect(value.textContent?.trim()).toBe("—")); + }); + + it("shows edit controls only with write access", () => { + expect(fixture.nativeElement.querySelector(".contributor-actions")).not.toBeNull(); + expect(fixture.nativeElement.querySelector(".contributor-card-add")).not.toBeNull(); + + component.userDatasetAccessLevel = "READ"; + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector(".contributor-actions")).toBeNull(); + expect(fixture.nativeElement.querySelector(".contributor-card-add")).toBeNull(); + }); + + it("starts adding a contributor when the add tile is clicked", () => { + const onAdd = vi.spyOn(component, "onAddContributor").mockImplementation(() => {}); + + (fixture.nativeElement.querySelector(".contributor-card-add") as HTMLElement).click(); + + expect(onAdd).toHaveBeenCalledTimes(1); + }); + }); + it("starts at most maxConcurrentFiles uploads immediately and queues the rest", () => { dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); @@ -370,6 +420,7 @@ describe("DatasetDetailComponent behavior", () => { let downloadServiceStub: MockService; let hubServiceStub: MockService; let adminSettingsServiceStub: MockService; + let modalServiceStub: MockService; const CREATION_TS = 1_700_000_000_000; @@ -418,7 +469,7 @@ describe("DatasetDetailComponent behavior", () => { imports: [DatasetDetailComponent, ...commonTestImports], providers: [ { provide: ActivatedRoute, useValue: { params: of(params), data: of({}) } }, - { provide: NzModalService, useValue: {} }, + { provide: NzModalService, useValue: modalServiceStub }, { provide: DatasetService, useValue: datasetServiceStub }, { provide: NotificationService, useValue: notificationServiceStub }, { provide: DownloadService, useValue: downloadServiceStub }, @@ -451,6 +502,7 @@ describe("DatasetDetailComponent behavior", () => { updateDatasetDownloadable: vi.fn(() => of({})), updateDatasetCoverImage: vi.fn(() => of({})), updateDatasetDescription: vi.fn(() => of({})), + updateDatasetContributors: vi.fn(() => of(undefined)), updateDatasetName: vi.fn(() => of({})), deleteDatasets: vi.fn(() => of({})), deleteDatasetFile: vi.fn(() => of({})), @@ -459,6 +511,7 @@ describe("DatasetDetailComponent behavior", () => { finalizeMultipartUpload: vi.fn(() => of({})), }; notificationServiceStub = { success: vi.fn(), error: vi.fn(), info: vi.fn() }; + modalServiceStub = { create: vi.fn() }; downloadServiceStub = { downloadDatasetVersion: vi.fn(() => of(new Blob())), downloadSingleFile: vi.fn(() => of(new Blob())), @@ -1375,4 +1428,105 @@ describe("DatasetDetailComponent behavior", () => { expect(button.disabled).toBe(false); }); }); + + describe("contributors", () => { + const contributorA: Contributor = { + name: "Contributor A", + creator: true, + affiliation: "Test Lab", + email: "[email protected]", + comments: "", + }; + const contributorB: Contributor = { + name: "Contributor B", + creator: false, + affiliation: "Test Lab", + email: "[email protected]", + comments: "notes", + }; + + it("maps contributors from the dashboard dataset and falls back to an empty list", () => { + datasetServiceStub.getDataset.mockReturnValue(of(makeDashboardDataset({ contributors: [contributorA] }))); + createComponent(); + component.did = 5; + + component.retrieveDatasetInfo(); + expect(component.datasetContributors).toEqual([contributorA]); + + datasetServiceStub.getDataset.mockReturnValue(of(makeDashboardDataset())); + component.retrieveDatasetInfo(); + expect(component.datasetContributors).toEqual([]); + }); + + it("onAddContributor appends the modal result and persists the list", () => { + modalServiceStub.create.mockReturnValue({ afterClose: of(contributorB) }); + createComponent(); + component.did = 5; + component.datasetContributors = [contributorA]; + + component.onAddContributor(); + + expect(component.datasetContributors).toEqual([contributorA, contributorB]); + expect(datasetServiceStub.updateDatasetContributors).toHaveBeenCalledWith(5, [contributorA, contributorB]); + expect(notificationServiceStub.success).toHaveBeenCalledWith("Contributors updated"); + }); + + it("onAddContributor does not persist when the modal is cancelled", () => { + modalServiceStub.create.mockReturnValue({ afterClose: of(undefined) }); + createComponent(); + component.did = 5; + component.datasetContributors = [contributorA]; + + component.onAddContributor(); + + expect(component.datasetContributors).toEqual([contributorA]); + expect(datasetServiceStub.updateDatasetContributors).not.toHaveBeenCalled(); + }); + + it("onEditContributor replaces the edited row and persists the list", () => { + const updated = { ...contributorA, affiliation: "Another Test Lab" }; + modalServiceStub.create.mockReturnValue({ afterClose: of(updated) }); + createComponent(); + component.did = 5; + component.datasetContributors = [contributorA, contributorB]; + + component.onEditContributor(contributorA); + + expect(component.datasetContributors).toEqual([updated, contributorB]); + expect(datasetServiceStub.updateDatasetContributors).toHaveBeenCalledWith(5, [updated, contributorB]); + }); + + it("onDeleteContributor removes the row and persists the list", () => { + createComponent(); + component.did = 5; + component.datasetContributors = [contributorA, contributorB]; + + component.onDeleteContributor(contributorA); + + expect(component.datasetContributors).toEqual([contributorB]); + expect(datasetServiceStub.updateDatasetContributors).toHaveBeenCalledWith(5, [contributorB]); + }); + + it("rolls the list back and notifies when persisting fails", () => { + datasetServiceStub.updateDatasetContributors.mockReturnValue(throwError(() => new Error("boom"))); + createComponent(); + component.did = 5; + component.datasetContributors = [contributorA, contributorB]; + + component.onDeleteContributor(contributorB); + + expect(component.datasetContributors).toEqual([contributorA, contributorB]); + expect(notificationServiceStub.error).toHaveBeenCalledWith("Failed to update contributors"); + }); + + it("does not call the service when did is missing", () => { + createComponent(); + component.did = undefined; + component.datasetContributors = [contributorA]; + + component.onDeleteContributor(contributorA); + + expect(datasetServiceStub.updateDatasetContributors).not.toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts index 142d6a5e63..437f01d8bd 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts @@ -33,7 +33,7 @@ import { getFullPathFromDatasetFileNode, getRelativePathFromDatasetFileNode, } from "../../../../../common/type/datasetVersionFileTree"; -import { DatasetVersion } from "../../../../../common/type/dataset"; +import { Contributor, DatasetVersion } from "../../../../../common/type/dataset"; import { switchMap, throttleTime } from "rxjs/operators"; import { NotificationService } from "../../../../../common/service/notification/notification.service"; import { DownloadService } from "../../../../service/user/download/download.service"; @@ -48,8 +48,12 @@ import { AdminSettingsService } from "../../../../service/admin/settings/admin-s import { HttpErrorResponse, HttpStatusCode } from "@angular/common/http"; import { EMPTY, Subscription } from "rxjs"; import { formatCount, formatSpeed, formatTime, parseIntOrDefault } from "src/app/common/util/format.util"; +import { replaceOneImmutable } from "src/app/common/util/array-utils"; import { format } from "date-fns"; import { NgIf, NgClass, NgFor } from "@angular/common"; +import { NzDropdownDirective, NzDropdownMenuComponent } from "ng-zorro-antd/dropdown"; +import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu"; +import { UserDatasetContributorEditorComponent } from "./user-dataset-contributor-editor/user-dataset-contributor-editor.component"; import { NzCardComponent, NzCardMetaComponent } from "ng-zorro-antd/card"; import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { NzTagComponent } from "ng-zorro-antd/tag"; @@ -123,6 +127,10 @@ export const ABORT_RETRY_BACKOFF_BASE_MS = 100; CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf, + NzDropdownDirective, + NzDropdownMenuComponent, + NzMenuDirective, + NzMenuItemComponent, ], }) export class DatasetDetailComponent implements OnInit { @@ -138,6 +146,7 @@ export class DatasetDetailComponent implements OnInit { public userDatasetAccessLevel: "READ" | "WRITE" | "NONE" = "NONE"; public ownerEmail: string = ""; public isOwner: boolean = false; + public datasetContributors: ReadonlyArray<Contributor> = []; public currentDisplayedFileName: string = ""; public currentFileSize: number | undefined; @@ -410,6 +419,7 @@ export class DatasetDetailComponent implements OnInit { .pop() || ""; this.datasetCreationTimeTooltip = `${format(date, "zzzz")} (${timeZoneName})`; } + this.datasetContributors = dashboardDataset.contributors || []; }); } } @@ -1043,4 +1053,57 @@ export class DatasetDetailComponent implements OnInit { this.notificationService.error("Failed to copy file path"); } } + + onAddContributor(): void { + this.openContributorEditor("Add Contributor", null, newContributor => [ + ...this.datasetContributors, + newContributor, + ]); + } + + onEditContributor(contributor: Contributor): void { + this.openContributorEditor("Edit Contributor", contributor, updated => + replaceOneImmutable(this.datasetContributors, c => c === contributor, updated) + ); + } + + onDeleteContributor(contributor: Contributor): void { + this.saveContributors(this.datasetContributors.filter(c => c !== contributor)); + } + + private openContributorEditor( + title: string, + data: Contributor | null, + apply: (result: Contributor) => ReadonlyArray<Contributor> + ): void { + const modal = this.modalService.create({ + nzTitle: title, + nzContent: UserDatasetContributorEditorComponent, + nzFooter: null, + nzData: data, + }); + modal.afterClose.pipe(untilDestroyed(this)).subscribe(result => { + if (result) { + this.saveContributors(apply(result)); + } + }); + } + + private saveContributors(next: ReadonlyArray<Contributor>): void { + if (!this.did) { + return; + } + const previous = this.datasetContributors; + this.datasetContributors = next; + this.datasetService + .updateDatasetContributors(this.did, next) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => this.notificationService.success("Contributors updated"), + error: () => { + this.datasetContributors = previous; + this.notificationService.error("Failed to update contributors"); + }, + }); + } } diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/contributor-form-fields.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/contributor-form-fields.ts new file mode 100644 index 0000000000..106236daa9 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/contributor-form-fields.ts @@ -0,0 +1,90 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Validators } from "@angular/forms"; +import { FormlyFieldConfig } from "@ngx-formly/core"; + +// Formly attaches per-form runtime state to field-config objects, so every +// consumer must get a fresh copy instead of sharing one array. +export function contributorFieldGroup(): FormlyFieldConfig[] { + return [ + { + key: "name", + type: "input", + defaultValue: "", + templateOptions: { + label: "Name", + required: true, + placeholder: "Enter name", + maxLength: 256, + }, + }, + { + key: "creator", + type: "checkbox", + defaultValue: false, + templateOptions: { + label: "Creator", + }, + }, + { + key: "email", + type: "input", + defaultValue: "", + templateOptions: { + label: "Email", + type: "email", + placeholder: "Enter email", + maxLength: 256, + }, + validators: { + validation: [Validators.email], + }, + validation: { + messages: { + email: "Please enter a valid email address", + }, + }, + }, + { + key: "affiliation", + type: "input", + defaultValue: "", + templateOptions: { + label: "Affiliation", + placeholder: "Enter affiliation", + maxLength: 256, + }, + }, + { + key: "comments", + type: "textarea", + defaultValue: "", + templateOptions: { + label: "Comments", + placeholder: "Additional information", + rows: 3, + maxLength: 500, + attributes: { + style: "resize: none", + }, + }, + }, + ]; +} diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/user-dataset-contributor-editor.component.html b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/user-dataset-contributor-editor.component.html new file mode 100644 index 0000000000..fb25167e77 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/user-dataset-contributor-editor.component.html @@ -0,0 +1,45 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +--> + +<div class="contributor-form"> + <form + [formGroup]="contributorForm" + (ngSubmit)="submit()"> + <formly-form + [form]="contributorForm" + [model]="model" + [fields]="fields"></formly-form> + + <button + nz-button + nzType="primary" + type="submit" + class="save-btn"> + Save + </button> + <button + nz-button + nzType="default" + type="button" + (click)="cancel()" + class="cancel-btn"> + Cancel + </button> + </form> +</div> diff --git a/frontend/src/app/common/type/dataset.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/user-dataset-contributor-editor.component.scss similarity index 55% copy from frontend/src/app/common/type/dataset.ts copy to frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/user-dataset-contributor-editor.component.scss index 97ff370302..ef30ab1156 100644 --- a/frontend/src/app/common/type/dataset.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/user-dataset-contributor-editor.component.scss @@ -17,26 +17,40 @@ * under the License. */ -import { DatasetFileNode } from "./datasetVersionFileTree"; +.contributor-form { + width: 80%; + margin: auto; +} -export interface DatasetVersion { - dvid: number | undefined; - did: number; - creatorUid: number; - name: string; - versionHash: string | undefined; - creationTime: number | undefined; - fileNodes: DatasetFileNode[] | undefined; +.save-btn { + background-color: #ffffff; + color: #007bff; + border: 1px solid lightgray; + padding: 5px 20px; + cursor: pointer; + margin-left: 15%; + margin-right: 5%; + width: 30%; + text-align: center; + &:hover { + background-color: darken(#007bff, 10%); + color: #ffffff; + } } -export interface Dataset { - did: number | undefined; - ownerUid: number | undefined; - name: string; - isPublic: boolean; - isDownloadable: boolean; - storagePath: string | undefined; - description: string; - creationTime: number | undefined; - coverImage: string | undefined; +.cancel-btn { + background-color: #ffffff; + color: #ff0000; + border: 1px solid lightgray; + padding: 5px 20px; + cursor: pointer; + margin-right: 15%; + margin-left: 5%; + margin-top: 5%; + width: 30%; + text-align: center; + &:hover { + background-color: darken(#ff0000, 10%); + color: #ffffff; + } } diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/user-dataset-contributor-editor.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/user-dataset-contributor-editor.component.spec.ts new file mode 100644 index 0000000000..d66712cdf0 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/user-dataset-contributor-editor.component.spec.ts @@ -0,0 +1,133 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { ReactiveFormsModule } from "@angular/forms"; +import { FieldType, FieldTypeConfig, FormlyModule } from "@ngx-formly/core"; +import { NZ_MODAL_DATA, NzModalRef } from "ng-zorro-antd/modal"; +import { UserDatasetContributorEditorComponent } from "./user-dataset-contributor-editor.component"; +import { Contributor } from "../../../../../../common/type/dataset"; +import { commonTestProviders } from "../../../../../../common/testing/test-utils"; + +@Component({ template: "", standalone: true }) +class StubFormlyFieldComponent extends FieldType<FieldTypeConfig> {} + +describe("UserDatasetContributorEditorComponent", () => { + let modalClose: ReturnType<typeof vi.fn>; + + async function createFixture( + modalData: Contributor | null + ): Promise<ComponentFixture<UserDatasetContributorEditorComponent>> { + modalClose = vi.fn(); + + await TestBed.configureTestingModule({ + imports: [ + UserDatasetContributorEditorComponent, + BrowserAnimationsModule, + ReactiveFormsModule, + FormlyModule.forRoot({ + types: ["input", "checkbox", "textarea"].map(name => ({ name, component: StubFormlyFieldComponent })), + }), + ], + providers: [ + { provide: NZ_MODAL_DATA, useValue: modalData }, + { provide: NzModalRef, useValue: { close: modalClose } }, + ...commonTestProviders, + ], + }).compileComponents(); + + return TestBed.createComponent(UserDatasetContributorEditorComponent); + } + + it("starts with an empty model when adding a new contributor", async () => { + const fixture = await createFixture(null); + fixture.detectChanges(); + + expect(fixture.componentInstance.model).toEqual({ + name: "", + creator: false, + email: "", + affiliation: "", + comments: "", + }); + }); + + it("prefills the model from the modal data when editing", async () => { + const contributor: Contributor = { + name: "Contributor A", + creator: true, + affiliation: "Test Lab", + email: "[email protected]", + comments: "collected the data", + }; + const fixture = await createFixture(contributor); + fixture.detectChanges(); + + expect(fixture.componentInstance.model).toEqual(contributor); + }); + + it("submit closes the modal with a copy of the model when the form is valid", async () => { + const fixture = await createFixture(null); + fixture.detectChanges(); + const component = fixture.componentInstance; + component.contributorForm.get("name")?.setValue("Bob"); + + component.submit(); + + expect(modalClose).toHaveBeenCalledTimes(1); + expect(modalClose.mock.calls[0][0]).toMatchObject({ name: "Bob" }); + }); + + it("submit does nothing when the email is invalid", async () => { + const fixture = await createFixture(null); + fixture.detectChanges(); + const component = fixture.componentInstance; + component.contributorForm.get("name")?.setValue("Contributor A"); + component.contributorForm.get("email")?.setValue("not-an-email"); + + component.submit(); + + expect(modalClose).not.toHaveBeenCalled(); + + component.contributorForm.get("email")?.setValue("[email protected]"); + component.submit(); + + expect(modalClose).toHaveBeenCalledTimes(1); + }); + + it("submit does nothing when the required name is empty (invalid form)", async () => { + const fixture = await createFixture(null); + fixture.detectChanges(); + + fixture.componentInstance.submit(); + + expect(modalClose).not.toHaveBeenCalled(); + }); + + it("cancel closes the modal without a value", async () => { + const fixture = await createFixture(null); + fixture.detectChanges(); + + fixture.componentInstance.cancel(); + + expect(modalClose).toHaveBeenCalledWith(); + }); +}); diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/user-dataset-contributor-editor.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/user-dataset-contributor-editor.component.ts new file mode 100644 index 0000000000..6276a6f7c4 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-contributor-editor/user-dataset-contributor-editor.component.ts @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, OnInit, Inject } from "@angular/core"; +import { FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { NZ_MODAL_DATA, NzModalRef } from "ng-zorro-antd/modal"; +import { FormlyFieldConfig, FormlyModule } from "@ngx-formly/core"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzWaveDirective } from "ng-zorro-antd/core/wave"; +import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; +import { Contributor } from "../../../../../../common/type/dataset"; +import { contributorFieldGroup } from "./contributor-form-fields"; + +@Component({ + selector: "texera-user-dataset-contributor-editor", + templateUrl: "./user-dataset-contributor-editor.component.html", + styleUrls: ["./user-dataset-contributor-editor.component.scss"], + imports: [ReactiveFormsModule, FormlyModule, NzButtonComponent, NzWaveDirective, ɵNzTransitionPatchDirective], +}) +export class UserDatasetContributorEditorComponent implements OnInit { + contributorForm: FormGroup = new FormGroup({}); + model: Partial<Contributor> = {}; + + fields: FormlyFieldConfig[] = contributorFieldGroup(); + + constructor( + @Inject(NZ_MODAL_DATA) private contributorData: Contributor | null, + private modalRef: NzModalRef + ) {} + + ngOnInit() { + if (this.contributorData) { + this.model = { ...this.contributorData }; + } + } + + submit() { + if (this.contributorForm.invalid) { + return; + } + this.modalRef.close({ ...this.model }); + } + + cancel() { + this.modalRef.close(); + } +} diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.spec.ts index 17369b27f7..9be52667c6 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.spec.ts @@ -59,7 +59,12 @@ describe("UserDatasetVersionCreatorComponent", () => { BrowserAnimationsModule, FormsModule, ReactiveFormsModule, - FormlyModule.forRoot({ types: [{ name: "input", component: StubFormlyInputComponent }] }), + FormlyModule.forRoot({ + types: ["input", "checkbox", "textarea", "array"].map(name => ({ + name, + component: StubFormlyInputComponent, + })), + }), HttpClientTestingModule, ], providers: [ @@ -94,7 +99,7 @@ describe("UserDatasetVersionCreatorComponent", () => { const fixture = await createFixture({ isCreatingVersion: false }); fixture.detectChanges(); - expect(fixture.componentInstance.fields.map(f => f.key)).toEqual(["name", "description"]); + expect(fixture.componentInstance.fields.map(f => f.key)).toEqual(["name", "description", "contributors"]); }); it("onClickCreate does nothing when the required name is empty (invalid form)", async () => { diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.ts index f223a2798d..4606ff3baa 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.ts @@ -33,6 +33,7 @@ import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; import { NzButtonComponent } from "ng-zorro-antd/button"; import { NzWaveDirective } from "ng-zorro-antd/core/wave"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; +import { contributorFieldGroup } from "../user-dataset-contributor-editor/contributor-form-fields"; @UntilDestroy() @Component({ @@ -115,6 +116,16 @@ export class UserDatasetVersionCreatorComponent implements OnInit { label: "Description", }, }, + { + key: "contributors", + type: "array", + templateOptions: { + label: "Add a New Contributor", + }, + fieldArray: { + fieldGroup: contributorFieldGroup(), + }, + }, ]; } get formControlNames(): string[] { @@ -196,7 +207,7 @@ export class UserDatasetVersionCreatorComponent implements OnInit { coverImage: undefined, }; this.datasetService - .createDataset(ds) + .createDataset(ds, this.form.get("contributors")?.value) .pipe(untilDestroyed(this)) .subscribe({ next: res => { diff --git a/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts b/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts index b71ea4a5d2..1af4773e6d 100644 --- a/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts +++ b/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts @@ -25,7 +25,7 @@ import { DATASET_BASE_URL, DatasetService, MultipartUploadProgress, validateData import { AppSettings } from "../../../../common/app-setting"; import { AuthService } from "../../../../common/service/user/auth.service"; import { commonTestProviders } from "../../../../common/testing/test-utils"; -import { Dataset, DatasetVersion } from "../../../../common/type/dataset"; +import { Contributor, Dataset, DatasetVersion } from "../../../../common/type/dataset"; import { DashboardDataset } from "../../../type/dashboard-dataset.interface"; import { DatasetFileNode } from "../../../../common/type/datasetVersionFileTree"; import { DatasetStagedObject } from "../../../../common/type/dataset-staged-object"; @@ -203,11 +203,81 @@ describe("DatasetService", () => { datasetDescription: "desc", isDatasetPublic: true, isDatasetDownloadable: true, + contributors: [], }); req.flush(dashboard); expect(await pending).toEqual(dashboard); }); + it("createDataset includes contributors in the request body when present", () => { + const contributors: Contributor[] = [ + { + name: "Contributor A", + creator: true, + affiliation: "Test Lab", + email: "[email protected]", + comments: "collected the data", + }, + ]; + service.createDataset(buildDataset(), contributors).subscribe(); + + const req = http.expectOne(`${API}/${DATASET_BASE_URL}/create`); + expect(req.request.body.contributors).toEqual(contributors); + req.flush(buildDashboardDataset()); + }); + + it("createDataset omits blank optional contributor fields", () => { + const contributors: Contributor[] = [ + { name: "Contributor A", creator: false, affiliation: " ", email: "", comments: undefined }, + ]; + service.createDataset(buildDataset(), contributors).subscribe(); + + const req = http.expectOne(`${API}/${DATASET_BASE_URL}/create`); + expect(req.request.body.contributors).toEqual([ + { name: "Contributor A", creator: false, affiliation: undefined, email: undefined, comments: undefined }, + ]); + req.flush(buildDashboardDataset()); + }); + + // ─── updateDatasetContributors ──────────────────────────────────────────── + + it("updateDatasetContributors POSTs the did and list, omitting blank optional fields", () => { + const contributors: Contributor[] = [ + { + name: "Contributor B", + creator: false, + affiliation: "Test Lab", + email: " [email protected] ", + comments: "", + }, + ]; + service.updateDatasetContributors(7, contributors).subscribe(); + + const req = http.expectOne(`${API}/${DATASET_BASE_URL}/update/contributors`); + expect(req.request.method).toBe("POST"); + expect(req.request.body).toEqual({ + did: 7, + contributors: [ + { + name: "Contributor B", + creator: false, + affiliation: "Test Lab", + email: "[email protected]", + comments: undefined, + }, + ], + }); + req.flush(null); + }); + + it("updateDatasetContributors sends an empty list when all contributors are removed", () => { + service.updateDatasetContributors(7, []).subscribe(); + + const req = http.expectOne(`${API}/${DATASET_BASE_URL}/update/contributors`); + expect(req.request.body).toEqual({ did: 7, contributors: [] }); + req.flush(null); + }); + // ─── getDataset (login vs public branch) ────────────────────────────────── it("getDataset hits /dataset/{did} when logged in", () => { diff --git a/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts b/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts index 38cb3f2fce..9d21e41977 100644 --- a/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts +++ b/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts @@ -20,7 +20,7 @@ import { Injectable } from "@angular/core"; import { HttpClient, HttpErrorResponse, HttpParams } from "@angular/common/http"; import { catchError, map, mergeMap, switchMap, tap, toArray } from "rxjs/operators"; -import { Dataset, DatasetVersion } from "../../../../common/type/dataset"; +import { Contributor, Dataset, DatasetVersion } from "../../../../common/type/dataset"; import { AppSettings } from "../../../../common/app-setting"; import { EMPTY, from, Observable, throwError } from "rxjs"; import { DashboardDataset } from "../../../type/dashboard-dataset.interface"; @@ -55,6 +55,17 @@ export function validateDatasetName(name: string): string | null { return null; } +// Blank optional fields are omitted from requests so they are stored as NULL +// instead of empty strings. +function normalizeContributor(contributor: Contributor): Contributor { + return { + ...contributor, + email: contributor.email?.trim() || undefined, + affiliation: contributor.affiliation?.trim() || undefined, + comments: contributor.comments?.trim() || undefined, + }; +} + export const DATASET_PUBLIC_VERSION_BASE_URL = "publicVersion"; export const DATASET_PUBLIC_VERSION_RETRIEVE_LIST_URL = DATASET_PUBLIC_VERSION_BASE_URL + "/list"; export const DATASET_GET_OWNERS_URL = DATASET_BASE_URL + "/user-dataset-owners"; @@ -77,12 +88,13 @@ export class DatasetService { private config: GuiConfigService ) {} - public createDataset(dataset: Dataset): Observable<DashboardDataset> { + public createDataset(dataset: Dataset, contributors: Contributor[] = []): Observable<DashboardDataset> { return this.http.post<DashboardDataset>(`${AppSettings.getApiEndpoint()}/${DATASET_CREATE_URL}`, { datasetName: dataset.name, datasetDescription: dataset.description, isDatasetPublic: dataset.isPublic, isDatasetDownloadable: dataset.isDownloadable, + contributors: contributors.map(normalizeContributor), }); } @@ -583,4 +595,11 @@ export class DatasetService { public getDatasetCoverUrl(did: number): Observable<{ url: string | null }> { return this.http.get<{ url: string | null }>(`${AppSettings.getApiEndpoint()}/dataset/${did}/cover-url`); } + + public updateDatasetContributors(did: number, contributors: ReadonlyArray<Contributor>): Observable<void> { + return this.http.post<void>(`${AppSettings.getApiEndpoint()}/${DATASET_BASE_URL}/update/contributors`, { + did, + contributors: contributors.map(normalizeContributor), + }); + } } diff --git a/frontend/src/app/dashboard/type/dashboard-dataset.interface.ts b/frontend/src/app/dashboard/type/dashboard-dataset.interface.ts index 335f744dab..f9051eb3f7 100644 --- a/frontend/src/app/dashboard/type/dashboard-dataset.interface.ts +++ b/frontend/src/app/dashboard/type/dashboard-dataset.interface.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Dataset } from "../../common/type/dataset"; +import { Contributor, Dataset } from "../../common/type/dataset"; export interface DashboardDataset { isOwner: boolean; @@ -25,4 +25,5 @@ export interface DashboardDataset { dataset: Dataset; accessPrivilege: "READ" | "WRITE" | "NONE"; size: number; + contributors?: Contributor[]; }
