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 |
    | --- | --- |
    | ![Create a dataset with contributor
    
metadata](https://github.com/user-attachments/assets/82695b9d-f490-47a3-94f1-03313e83428e)
    | ![Add a contributor from the dataset detail
    
page](https://github.com/user-attachments/assets/d7753a51-c424-458c-87d2-14418f5f2bf4)
    |
    
    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 &quot;' + 
contributor.name + '&quot;?'"
+                      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[];
 }

Reply via email to