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-8299-7d57cd6ef1edc45c0c2b8ff73c36bc7eda67a3e1 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 11e3b969a70063d17bfd670b1da85dba100e4339 Author: Tanishq Gandhi <[email protected]> AuthorDate: Mon Aug 31 23:48:46 2026 +0000 feat(frontend): add model sharing, publishing and cover images (#8299) ### What changes were proposed in this PR? Models can now be shared, published and given a cover image. The three surfaces that grew a per-type branch for each new resource kind move onto the resource-descriptor registry instead, so the model arm is a descriptor rather than another `if (type === ...)`. **Refactor:** `ResourceDescriptor` gains `isPublic`/`setPublished`, `coverUrl`/`setCover` and `affordances`. `ShareAccessComponent`'s four publish/unpublish methods become one `setPublished(next)`, and it stops injecting `WorkflowPersistService` and `DatasetService`. `card-item` and `list-item` build one share-modal config instead of two and gate the Share button on the descriptor, which retires the dead button on file cards. `browse-section` asks the descriptor for a cover instead of concatenating a URL, so a dataset's cover is the same picture in the hub as on its card, and it no longer needs a per-kind branch to add one. **Feature:** `ModelService` gains publicity, downloadable, cover and owners calls; the model detail page gets an **Access & visibility** card; and the file tree's existing "Set as cover" control is wired up. Four deliberate behavior changes, worth a reviewer's attention: - Publishing a **dataset or model** no longer warns about "the right to clone your work". Only workflows are clonable — `cloneWorkflow` is the only clone in the codebase, and `cloneCount` is hardcoded to 0 for every other kind — so datasets were being warned about a capability that does not exist. - The share modal's Private card read `this workflow {{ type }}`, rendering "this workflow dataset". The stray word is removed. - A `file`-typed share email no longer appends a broken `<origin>/<id>` link, since the file descriptor has no private route. - **Publishing and download permission now report the state the server ended up in, not the one that was requested.** Both endpoints toggle rather than set, so a surface holding a stale value could ask to publish and silently unpublish instead, while the switch and the toast claimed success — and for downloads that value also gates the download buttons through `isDownloadAllowed()`. `ShareAccessComponent.setPublished` and both switches on the model page now read the flag back after the write and render that, and `setPublished(id, next)` documents that `next` is only what the caller asked for. A read-back that itself fails keeps the requested value and still reports success: `GET /model/{mid}` sizes the repository through `withLakeFSErrorHandling` and so can fail where `/model/list` (which falls back to `0`) would not, and reporting that as a failure invited a retry that toggled the resource straight back. Knock-on effects: a failed publish inside the workspace no longer leaves a false published badge on the canvas; the toast names the real outcome; and the Settings hint text now updates on click rather than after the round trip. That last one is load-bearing, not cosmetic — the switch value is written optimistically so the correction is a real change, since with one-way `[ngModel]` writing the value the field already holds never reaches the control. Published models are not discoverable yet: `listModels` passes `includePublic = false` and models have no hub surface, so publishing makes a model readable by direct URL only. Discovery arrives with the hub PR. <img width="1238" height="672" alt="Screenshot 2026-08-31 at 11 37 25 AM" src="https://github.com/user-attachments/assets/5786cfca-9582-4213-a2fb-165146ee41aa" /> <img width="910" height="525" alt="Screenshot 2026-08-31 at 11 37 35 AM" src="https://github.com/user-attachments/assets/a0865622-b80f-4375-b406-4a48908e3178" /> <img width="695" height="806" alt="Screenshot 2026-08-31 at 11 41 34 AM" src="https://github.com/user-attachments/assets/3fa68e48-d974-4ac2-8847-e9f42cef4919" /> <img width="770" height="249" alt="Screenshot 2026-08-31 at 11 42 05 AM" src="https://github.com/user-attachments/assets/31a5d4f1-7720-4356-b483-3966249dd59c" /> ### Any related issues, documentation, discussions? Part of #6501. Fourth in the model frontend series. ### How was this PR tested? New tests: model publicity/downloadable/cover/owners requests in `model.service.spec`; both toggles with a rejected-request rollback, cover path prefixing and the no-version guard in `model-detail.component.spec`; descriptor slot presence, the `clonable` affordance and model delegation in `resource-registry.service.spec`; and the clone-warning copy in `share-access.component.spec`. The share-access publish tests moved onto `setPublished`, and the browse-section cover specs now assert the resolved URL rather than a concatenated one. For the read-back above, the share-access doubles became stateful — the workflow endpoint sets absolutely, the dataset one toggles — so a read after a write sees the new value rather than a constant. Both surfaces pin the stale case directly: a modal that loaded a private dataset, published elsewhere in the meantime, must report *unpublished* after asking to publish. Also covered on both: a landed toggle whose read-back throws still reports success, and a failed toggle rolls the switch back. The model page's stale case is asserted against the **rendered switch**, not just the field, since that is where it would surface. Two details make that assertion real rather than vacuous: the read-back is deferred through a `Subject`, because a synchronous one collapses both writes into a single change-detection pass, and `NgModel` pushes to the control in a microtask, so the test flushes one before reading the DOM. 2752 tests pass across `dashboard`, `hub`, `common` and the workspace menu/power-button specs. `tsc --noEmit` and Prettier are clean. Manually verified: sharing a model with a second user (grant, level change, revoke), publishing from both the share modal and the Settings tab, the downloadable toggle as owner and as a collaborator, setting a cover from a committed image and seeing it on the detail page and the model card, and regression checks on dataset and workflow sharing, publishing and covers including the workspace's in-canvas publish state. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5) --- .../list-item/card-item/card-item.component.html | 1 + .../card-item/card-item.component.spec.ts | 3 +- .../list-item/card-item/card-item.component.ts | 87 ++++------ .../user/list-item/list-item.component.html | 1 + .../user/list-item/list-item.component.spec.ts | 2 +- .../user/list-item/list-item.component.ts | 65 +++----- .../user/share-access/share-access.component.html | 4 +- .../share-access/share-access.component.spec.ts | 181 ++++++++++++++------- .../user/share-access/share-access.component.ts | 164 ++++++------------- .../model-detail.component.html | 44 ++++- .../model-detail.component.spec.ts | 133 ++++++++++++++- .../user-model-explorer/model-detail.component.ts | 91 ++++++++++- .../service/user/model/model.service.spec.ts | 26 +++ .../dashboard/service/user/model/model.service.ts | 33 ++++ .../dataset-resource.descriptor.ts | 10 +- .../resource-registry/model-resource.descriptor.ts | 12 +- .../resource-registry.service.spec.ts | 47 +++++- .../resource-registry/resource-registry.service.ts | 5 + .../workflow-resource.descriptor.ts | 9 +- .../src/app/dashboard/type/resource-descriptor.ts | 22 ++- .../browse-section.component.spec.ts | 21 ++- .../browse-section/browse-section.component.ts | 38 +++-- 22 files changed, 695 insertions(+), 304 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html index 3846055a1f..383cc2ef95 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html @@ -211,6 +211,7 @@ nzType="text" class="action-btn" title="Share" + *ngIf="canShare" (click)="onClickOpenShareAccess(); $event.stopPropagation()"> <i nz-icon diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts index e1410a6b0b..255e40b0d2 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts @@ -705,7 +705,7 @@ describe("CardItemComponent", () => { .spyOn(modalService, "create") .mockReturnValue({ componentInstance: { refresh: refresh$ } } as any); (workflowPersistService as any).retrieveOwners = vi.fn().mockReturnValue(of(["alice", "bob"])); - component.entry = makeWorkflowEntry({ id: 7, workflow: { isOwner: true, accessLevel: "WRITE" } } as any); + component.entry = makeWorkflowEntry({ id: 7, accessLevel: "WRITE", workflow: { isOwner: true } } as any); await component.onClickOpenShareAccess(); @@ -744,6 +744,7 @@ describe("CardItemComponent", () => { type: "dataset", id: 5, allOwners: ["carol"], + inWorkspace: false, }); expect(cfg.nzTitle).toBe("Share this dataset with others"); }); diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts index 5b3dfe16a5..03cf31147e 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts @@ -38,17 +38,15 @@ import { NzCheckboxComponent } from "ng-zorro-antd/checkbox"; import { NzIconDirective } from "ng-zorro-antd/icon"; import { NzPopconfirmDirective } from "ng-zorro-antd/popconfirm"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; -import { NzModalRef, NzModalService } from "ng-zorro-antd/modal"; +import { NzModalService } from "ng-zorro-antd/modal"; import { DashboardEntry } from "src/app/dashboard/type/dashboard-entry"; import { ShareAccessComponent } from "../../share-access/share-access.component"; import { UserAvatarComponent } from "../../user-avatar/user-avatar.component"; -import { WorkflowPersistService } from "src/app/common/service/workflow-persist/workflow-persist.service"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Observable } from "rxjs"; import { HubWorkflowDetailComponent } from "../../../../../hub/component/workflow/detail/hub-workflow-detail.component"; import { ActionType, HubService } from "../../../../../hub/service/hub.service"; import { formatSize } from "src/app/common/util/size-formatter.util"; import { formatRelativeTime, formatCount } from "src/app/common/util/format.util"; -import { DatasetService } from "../../../../service/user/dataset/dataset.service"; import { NotificationService } from "../../../../../common/service/notification/notification.service"; import { extractErrorMessage } from "../../../../../common/util/error"; import { WorkflowCoverService } from "../../../../service/user/workflow-cover/workflow-cover.service"; @@ -95,7 +93,9 @@ export class CardItemComponent implements OnChanges { hovering: boolean = false; /** The default top image, used when the user has not uploaded a custom one. */ static readonly DEFAULT_PREVIEW_IMAGE = "assets/card_background.jpg"; - /** Resolved preview/cover image; stays the placeholder until a dataset cover loads. */ + /** Whether this kind can be shared at all; the button is hidden when it cannot. */ + public canShare: boolean = false; + /** Resolved preview/cover image; stays the placeholder until a file-backed cover loads. */ coverImageSrc: string = CardItemComponent.DEFAULT_PREVIEW_IMAGE; /** The workflow's custom cover image data URL, if one has been set. */ @@ -121,8 +121,6 @@ export class CardItemComponent implements OnChanges { constructor( private modalService: NzModalService, - private workflowPersistService: WorkflowPersistService, - private datasetService: DatasetService, private modal: NzModalService, private hubService: HubService, private cdr: ChangeDetectorRef, @@ -191,16 +189,17 @@ export class CardItemComponent implements OnChanges { this.iconType = descriptor.iconType; this.disableDelete = !descriptor.isOwner(this.entry); this.canDownload = descriptor.download !== undefined; + this.canShare = descriptor.retrieveOwners !== undefined; this.entryLink = this.resourceRegistry.entryLink(this.entry, this.currentUid); if (descriptor.hasSize && typeof this.entry.id === "number") { this.size = this.entry.size; } - // Covers are still per-type; they move onto the descriptor with the rest of the sharing surface. + // A workflow cover is a data URL already on the entry; the file-backed kinds have to fetch one. if (this.entry.type === "workflow") { this.coverImageSrc = this.entry.coverImageUrl ?? CardItemComponent.DEFAULT_PREVIEW_IMAGE; this.customImage = this.entry.coverImageUrl ?? undefined; - } else if (this.entry.type === "dataset" && typeof this.entry.id === "number") { - this.loadDatasetCover(this.entry.id); + } else if (descriptor.coverUrl && this.entry.coverImageUrl && typeof this.entry.id === "number") { + this.loadCover(descriptor.coverUrl, this.entry.id); } this.likeCount = this.entry.likeCount; this.viewCount = this.entry.viewCount; @@ -213,16 +212,12 @@ export class CardItemComponent implements OnChanges { } } - /** Loads the dataset cover into the preview slot, falling back to the placeholder. */ - private loadDatasetCover(did: number): void { - if (!this.entry.coverImageUrl) { - return; - } - this.datasetService - .getDatasetCoverUrl(did) + /** Loads a file-backed cover into the preview slot, falling back to the placeholder. */ + private loadCover(coverUrl: (id: number) => Observable<string | null>, id: number): void { + coverUrl(id) .pipe(untilDestroyed(this)) .subscribe({ - next: ({ url }) => { + next: url => { this.coverImageSrc = url ?? CardItemComponent.DEFAULT_PREVIEW_IMAGE; this.cdr.markForCheck(); }, @@ -245,43 +240,27 @@ export class CardItemComponent implements OnChanges { } public async onClickOpenShareAccess(): Promise<void> { - let modal: NzModalRef<ShareAccessComponent> | undefined; - - if (this.entry.type === "workflow") { - modal = this.modalService.create({ - nzContent: ShareAccessComponent, - nzData: { - writeAccess: this.entry.workflow.accessLevel === "WRITE", - type: this.entry.type, - id: this.entry.id, - allOwners: await firstValueFrom(this.workflowPersistService.retrieveOwners()), - inWorkspace: false, - }, - nzFooter: null, - nzTitle: "Share this workflow with others", - nzCentered: true, - nzWidth: "700px", - }); - } else if (this.entry.type === "dataset") { - modal = this.modalService.create({ - nzContent: ShareAccessComponent, - nzData: { - writeAccess: this.entry.accessLevel === "WRITE", - type: "dataset", - id: this.entry.id, - allOwners: await firstValueFrom(this.datasetService.retrieveOwners()), - }, - nzFooter: null, - nzTitle: "Share this dataset with others", - nzCentered: true, - nzWidth: "700px", - }); - } - if (modal) { - modal.componentInstance?.refresh.pipe(untilDestroyed(this)).subscribe(() => { - this.refresh.emit(); - }); + const retrieveOwners = this.resourceRegistry.get(this.entry.type).retrieveOwners; + if (!retrieveOwners) { + return; } + const modal = this.modalService.create({ + nzContent: ShareAccessComponent, + nzData: { + writeAccess: this.entry.accessLevel === "WRITE", + type: this.entry.type, + id: this.entry.id, + allOwners: await firstValueFrom(retrieveOwners()), + inWorkspace: false, + }, + nzFooter: null, + nzTitle: `Share this ${this.entry.type} with others`, + nzCentered: true, + nzWidth: "700px", + }); + modal.componentInstance?.refresh.pipe(untilDestroyed(this)).subscribe(() => { + this.refresh.emit(); + }); } public onClickDownload = (): void => { diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html index a223afe6e4..7e67b6438e 100644 --- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html +++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html @@ -198,6 +198,7 @@ nz-button nzType="text" title="Share" + *ngIf="canShare" (click)="onClickOpenShareAccess()"> <i nz-icon diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts b/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts index 1482ca3429..e520343ce4 100644 --- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts @@ -436,7 +436,7 @@ describe("ListItemComponent", () => { (workflowPersistService as any).retrieveOwners = vi.fn().mockReturnValue(of([])); let refreshed = false; component.refresh.subscribe(() => (refreshed = true)); - feed(entryOf({ type: "workflow", workflow: { isOwner: true, accessLevel: "WRITE" } })); + feed(entryOf({ type: "workflow", accessLevel: "WRITE", workflow: { isOwner: true } })); await component.onClickOpenShareAccess(); diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts index 4e7227e6ea..2226f2e1db 100644 --- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts +++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts @@ -29,17 +29,15 @@ import { } from "@angular/core"; import { Component } from "@angular/core"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { NzModalRef, NzModalService } from "ng-zorro-antd/modal"; +import { NzModalService } from "ng-zorro-antd/modal"; import { DashboardEntry } from "src/app/dashboard/type/dashboard-entry"; import { MarkdownDescriptionComponent } from "../markdown-description/markdown-description.component"; import { ShareAccessComponent } from "../share-access/share-access.component"; -import { WorkflowPersistService } from "src/app/common/service/workflow-persist/workflow-persist.service"; import { firstValueFrom } from "rxjs"; import { HubWorkflowDetailComponent } from "../../../../hub/component/workflow/detail/hub-workflow-detail.component"; import { ActionType, HubService } from "../../../../hub/service/hub.service"; import { formatSize } from "src/app/common/util/size-formatter.util"; import { formatCount, formatRelativeTime } from "src/app/common/util/format.util"; -import { DatasetService } from "../../../service/user/dataset/dataset.service"; import { NotificationService } from "../../../../common/service/notification/notification.service"; import { extractErrorMessage } from "../../../../common/util/error"; import { isDefined } from "../../../../common/util/predicate"; @@ -84,6 +82,8 @@ export class ListItemComponent implements OnChanges { public originalDescription: string | undefined = undefined; public disableDelete: boolean = false; public canDownload: boolean = false; + /** Whether this kind can be shared at all; the button is hidden when it cannot. */ + public canShare: boolean = false; @Input() currentUid: number | undefined; @ViewChild("nameInput") nameInput!: ElementRef; @ViewChild("descriptionInput") descriptionInput!: ElementRef; @@ -121,8 +121,6 @@ export class ListItemComponent implements OnChanges { constructor( private modalService: NzModalService, - private workflowPersistService: WorkflowPersistService, - private datasetService: DatasetService, private modal: NzModalService, private hubService: HubService, private cdr: ChangeDetectorRef, @@ -135,6 +133,7 @@ export class ListItemComponent implements OnChanges { this.iconType = descriptor.iconType; this.disableDelete = !descriptor.isOwner(this.entry); this.canDownload = descriptor.download !== undefined; + this.canShare = descriptor.retrieveOwners !== undefined; this.entryLink = this.resourceRegistry.entryLink(this.entry, this.currentUid); if (descriptor.hasSize && typeof this.entry.id === "number") { this.size = this.entry.size; @@ -171,43 +170,27 @@ export class ListItemComponent implements OnChanges { } public async onClickOpenShareAccess(): Promise<void> { - let modal: NzModalRef<ShareAccessComponent> | undefined; - - if (this.entry.type === "workflow") { - modal = this.modalService.create({ - nzContent: ShareAccessComponent, - nzData: { - writeAccess: this.entry.workflow.accessLevel === "WRITE", - type: this.entry.type, - id: this.entry.id, - allOwners: await firstValueFrom(this.workflowPersistService.retrieveOwners()), - inWorkspace: false, - }, - nzFooter: null, - nzTitle: "Share this workflow with others", - nzCentered: true, - nzWidth: "700px", - }); - } else if (this.entry.type === "dataset") { - modal = this.modalService.create({ - nzContent: ShareAccessComponent, - nzData: { - writeAccess: this.entry.accessLevel === "WRITE", - type: "dataset", - id: this.entry.id, - allOwners: await firstValueFrom(this.datasetService.retrieveOwners()), - }, - nzFooter: null, - nzTitle: "Share this dataset with others", - nzCentered: true, - nzWidth: "700px", - }); - } - if (modal) { - modal.componentInstance?.refresh.pipe(untilDestroyed(this)).subscribe(() => { - this.refresh.emit(); - }); + const retrieveOwners = this.resourceRegistry.get(this.entry.type).retrieveOwners; + if (!retrieveOwners) { + return; } + const modal = this.modalService.create({ + nzContent: ShareAccessComponent, + nzData: { + writeAccess: this.entry.accessLevel === "WRITE", + type: this.entry.type, + id: this.entry.id, + allOwners: await firstValueFrom(retrieveOwners()), + inWorkspace: false, + }, + nzFooter: null, + nzTitle: `Share this ${this.entry.type} with others`, + nzCentered: true, + nzWidth: "700px", + }); + modal.componentInstance?.refresh.pipe(untilDestroyed(this)).subscribe(() => { + this.refresh.emit(); + }); } public onClickDownload = (): void => { diff --git a/frontend/src/app/dashboard/component/user/share-access/share-access.component.html b/frontend/src/app/dashboard/component/user/share-access/share-access.component.html index 2c155f1a5a..8128f33363 100644 --- a/frontend/src/app/dashboard/component/user/share-access/share-access.component.html +++ b/frontend/src/app/dashboard/component/user/share-access/share-access.component.html @@ -19,7 +19,7 @@ <div class="access-button-group" - *ngIf="isPublic !== null && type !== 'computing-unit'"> + *ngIf="isPublic !== null"> <button nz-button class="access-button" @@ -33,7 +33,7 @@ class="button-icon"></div> <div class="button-text"> <div class="button-text-header">Private</div> - <p>Only collaborators can view this workflow {{ type }}</p> + <p>Only collaborators can view this {{ type }}</p> </div> </button> diff --git a/frontend/src/app/dashboard/component/user/share-access/share-access.component.spec.ts b/frontend/src/app/dashboard/component/user/share-access/share-access.component.spec.ts index d11be498a2..202465b25f 100644 --- a/frontend/src/app/dashboard/component/user/share-access/share-access.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/share-access/share-access.component.spec.ts @@ -66,6 +66,10 @@ describe("ShareAccessComponent", () => { }; let workflowActionSpy: { setWorkflowIsPublished: ReturnType<typeof vi.fn> }; let userServiceCurrentEmail: string | undefined; + // The component reads publicity back after writing it, so the doubles have to hold state: + // the workflow endpoint sets it absolutely, the dataset one toggles. + let workflowPublished: boolean; + let datasetPublished: boolean; let capturedModalConfigs: any[]; /** The NzModalRef stubs handed back by modalService.create, in creation order. */ let capturedModalRefs: { close: ReturnType<typeof vi.fn> }[]; @@ -129,13 +133,21 @@ describe("ShareAccessComponent", () => { return ref; }), }; + workflowPublished = false; + datasetPublished = false; workflowPersistSpy = { - getWorkflowIsPublished: vi.fn().mockReturnValue(of("Private")), - updateWorkflowIsPublished: vi.fn().mockReturnValue(of(null)), + getWorkflowIsPublished: vi.fn(() => of(workflowPublished ? "Public" : "Private")), + updateWorkflowIsPublished: vi.fn((_id: number, next: boolean) => { + workflowPublished = next; + return of(null); + }), }; datasetServiceSpy = { - getDataset: vi.fn().mockReturnValue(of({ dataset: { isPublic: false } })), - updateDatasetPublicity: vi.fn().mockReturnValue(of(null)), + getDataset: vi.fn(() => of({ dataset: { isPublic: datasetPublished } })), + updateDatasetPublicity: vi.fn(() => { + datasetPublished = !datasetPublished; + return of(null); + }), }; workflowActionSpy = { setWorkflowIsPublished: vi.fn() }; }); @@ -157,20 +169,20 @@ describe("ShareAccessComponent", () => { }); it("loads publish state for workflow via WorkflowPersistService", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Public")); + workflowPublished = true; const c = setupComponent({ type: "workflow", id: 9 }); expect(workflowPersistSpy.getWorkflowIsPublished).toHaveBeenCalledWith(9); expect(c.isPublic).toBe(true); }); it("sets isPublic to false when workflow publish state is Private", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Private")); + workflowPublished = false; const c = setupComponent({ type: "workflow" }); expect(c.isPublic).toBe(false); }); it("loads publish state for dataset via DatasetService.getDataset", () => { - datasetServiceSpy.getDataset.mockReturnValue(of({ dataset: { isPublic: true } })); + datasetPublished = true; const c = setupComponent({ type: "dataset", id: 12 }); expect(datasetServiceSpy.getDataset).toHaveBeenCalledWith(12); expect(c.isPublic).toBe(true); @@ -411,7 +423,7 @@ describe("ShareAccessComponent", () => { describe("verifyPublish / verifyUnpublish", () => { it("publishes a workflow on confirm and updates the action service when inWorkspace", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Private")); + workflowPublished = false; const c = setupComponent({ type: "workflow", id: 8, inWorkspace: true }); c.verifyPublish(); getFooterButton(capturedModalConfigs[0], "Publish").onClick(); @@ -420,7 +432,7 @@ describe("ShareAccessComponent", () => { }); it("does not call WorkflowActionService.setWorkflowIsPublished when not inWorkspace", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Private")); + workflowPublished = false; const c = setupComponent({ type: "workflow", id: 8, inWorkspace: false }); c.verifyPublish(); getFooterButton(capturedModalConfigs[0], "Publish").onClick(); @@ -428,22 +440,35 @@ describe("ShareAccessComponent", () => { }); it("publishes a dataset on confirm", () => { - datasetServiceSpy.getDataset.mockReturnValue(of({ dataset: { isPublic: false } })); + datasetPublished = false; const c = setupComponent({ type: "dataset", id: 9 }); c.verifyPublish(); getFooterButton(capturedModalConfigs[0], "Publish").onClick(); expect(datasetServiceSpy.updateDatasetPublicity).toHaveBeenCalledWith(9); }); + it("warns about cloning only for a clonable kind", () => { + workflowPublished = false; + setupComponent({ type: "workflow" }).verifyPublish(); + expect(capturedModalConfigs[0].nzContent).toContain("the right to clone your work"); + + TestBed.resetTestingModule(); + capturedModalConfigs = []; + datasetPublished = false; + setupComponent({ type: "dataset" }).verifyPublish(); + expect(capturedModalConfigs[0].nzContent).toContain("read access"); + expect(capturedModalConfigs[0].nzContent).not.toContain("clone"); + }); + it("does not open the publish modal when the item is already public", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Public")); + workflowPublished = true; const c = setupComponent({ type: "workflow" }); c.verifyPublish(); expect(modalServiceSpy.create).not.toHaveBeenCalled(); }); it("unpublishes a workflow on confirm and updates the action service when inWorkspace", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Public")); + workflowPublished = true; const c = setupComponent({ type: "workflow", id: 8, inWorkspace: true }); c.verifyUnpublish(); getFooterButton(capturedModalConfigs[0], "Unpublish").onClick(); @@ -452,7 +477,7 @@ describe("ShareAccessComponent", () => { }); it("unpublishes a dataset on confirm", () => { - datasetServiceSpy.getDataset.mockReturnValue(of({ dataset: { isPublic: true } })); + datasetPublished = true; const c = setupComponent({ type: "dataset", id: 9 }); c.verifyUnpublish(); getFooterButton(capturedModalConfigs[0], "Unpublish").onClick(); @@ -460,65 +485,107 @@ describe("ShareAccessComponent", () => { }); it("does not open the unpublish modal when the item is already private", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Private")); + workflowPublished = false; const c = setupComponent({ type: "workflow" }); c.verifyUnpublish(); expect(modalServiceSpy.create).not.toHaveBeenCalled(); }); }); - describe("publish / unpublish methods", () => { - it("publishWorkflow flips isPublic and shows a success notification", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Private")); + describe("setPublished", () => { + it("publishing a workflow flips isPublic and shows a success notification", () => { + workflowPublished = false; const c = setupComponent({ type: "workflow" }); - c.publishWorkflow(); + c.setPublished(true); expect(c.isPublic).toBe(true); expect(notificationSpy.success).toHaveBeenCalledWith("Workflow published successfully"); }); - it("publishWorkflow surfaces HttpErrorResponse via NotificationService.error", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Private")); + it("a failed workflow publish surfaces HttpErrorResponse via NotificationService.error", () => { + workflowPublished = false; workflowPersistSpy.updateWorkflowIsPublished.mockReturnValue( throwError(() => new HttpErrorResponse({ error: { message: "publish failed" }, status: 500 })) ); const c = setupComponent({ type: "workflow" }); - c.publishWorkflow(); + c.setPublished(true); expect(notificationSpy.error).toHaveBeenCalledWith("publish failed"); }); - it("unpublishWorkflow flips isPublic to false and shows a success notification", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Public")); + it("unpublishing a workflow flips isPublic to false and shows a success notification", () => { + workflowPublished = true; const c = setupComponent({ type: "workflow" }); - c.unpublishWorkflow(); + c.setPublished(false); expect(c.isPublic).toBe(false); expect(notificationSpy.success).toHaveBeenCalledWith("Workflow unpublished successfully"); }); - it("publishDataset flips isPublic and shows a success notification", () => { - datasetServiceSpy.getDataset.mockReturnValue(of({ dataset: { isPublic: false } })); + it("publishing a dataset flips isPublic and shows a success notification", () => { + datasetPublished = false; const c = setupComponent({ type: "dataset" }); - c.publishDataset(); + c.setPublished(true); expect(c.isPublic).toBe(true); expect(notificationSpy.success).toHaveBeenCalledWith("Dataset published successfully"); }); - it("publishDataset surfaces HttpErrorResponse via NotificationService.error", () => { - datasetServiceSpy.getDataset.mockReturnValue(of({ dataset: { isPublic: false } })); + it("a failed dataset publish surfaces HttpErrorResponse via NotificationService.error", () => { + datasetPublished = false; datasetServiceSpy.updateDatasetPublicity.mockReturnValue( throwError(() => new HttpErrorResponse({ error: { message: "dataset publish failed" }, status: 500 })) ); const c = setupComponent({ type: "dataset" }); - c.publishDataset(); + c.setPublished(true); expect(notificationSpy.error).toHaveBeenCalledWith("dataset publish failed"); }); - it("unpublishDataset flips isPublic to false and shows a success notification", () => { - datasetServiceSpy.getDataset.mockReturnValue(of({ dataset: { isPublic: true } })); + it("unpublishing a dataset flips isPublic to false and shows a success notification", () => { + datasetPublished = true; const c = setupComponent({ type: "dataset" }); - c.unpublishDataset(); + c.setPublished(false); expect(c.isPublic).toBe(false); expect(notificationSpy.success).toHaveBeenCalledWith("Dataset unpublished successfully"); }); + + it("reports what the server has, not what was asked for, when the toggle disagrees", () => { + datasetPublished = false; + const c = setupComponent({ type: "dataset", id: 9 }); + datasetPublished = true; + + c.setPublished(true); + + expect(c.isPublic).toBe(false); + expect(notificationSpy.success).toHaveBeenCalledWith("Dataset unpublished successfully"); + }); + + it("keeps a landed publish when the read-back fails", () => { + // The write succeeded; only the confirming read broke. Calling that a failure would invite a + // retry, and the retry would toggle the resource straight back. + datasetPublished = false; + const c = setupComponent({ type: "dataset", id: 9 }); + datasetServiceSpy.getDataset.mockReturnValue( + throwError(() => new HttpErrorResponse({ error: { message: "lakefs down" }, status: 500 })) + ); + + c.setPublished(true); + + expect(datasetServiceSpy.updateDatasetPublicity).toHaveBeenCalledWith(9); + expect(c.isPublic).toBe(true); + expect(notificationSpy.success).toHaveBeenCalledWith("Dataset published successfully"); + expect(notificationSpy.error).not.toHaveBeenCalled(); + }); + + it("does nothing for a registered kind that cannot be published", () => { + const c = setupComponent({ type: "file", id: 4 }); + c.setPublished(true); + expect(c.isPublic).toBeNull(); + expect(notificationSpy.success).not.toHaveBeenCalled(); + }); + + it("does nothing for a kind the registry does not carry at all", () => { + const c = setupComponent({ type: "computing-unit", id: 4 }); + c.setPublished(true); + expect(c.isPublic).toBeNull(); + expect(notificationSpy.success).not.toHaveBeenCalled(); + }); }); describe("hasWriteAccess without a resolved email", () => { @@ -615,7 +682,7 @@ describe("ShareAccessComponent", () => { }); it("closes the publish modal without publishing when Cancel is clicked", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Private")); + workflowPublished = false; const modalRefs = captureModalRefs(); const c = setupComponent({ type: "workflow", inWorkspace: true }); c.verifyPublish(); @@ -626,7 +693,7 @@ describe("ShareAccessComponent", () => { }); it("closes the unpublish modal without unpublishing when Cancel is clicked", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Public")); + workflowPublished = true; const modalRefs = captureModalRefs(); const c = setupComponent({ type: "workflow" }); c.verifyUnpublish(); @@ -651,24 +718,24 @@ describe("ShareAccessComponent", () => { }); describe("unpublish error branches", () => { - it("unpublishWorkflow surfaces HttpErrorResponse and leaves isPublic unchanged", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Public")); + it("a failed workflow unpublish surfaces HttpErrorResponse and leaves isPublic unchanged", () => { + workflowPublished = true; workflowPersistSpy.updateWorkflowIsPublished.mockReturnValue( throwError(() => new HttpErrorResponse({ error: { message: "unpublish failed" }, status: 500 })) ); const c = setupComponent({ type: "workflow" }); - c.unpublishWorkflow(); + c.setPublished(false); expect(notificationSpy.error).toHaveBeenCalledWith("unpublish failed"); expect(c.isPublic).toBe(true); }); - it("unpublishDataset surfaces HttpErrorResponse and leaves isPublic unchanged", () => { - datasetServiceSpy.getDataset.mockReturnValue(of({ dataset: { isPublic: true } })); + it("a failed dataset unpublish surfaces HttpErrorResponse and leaves isPublic unchanged", () => { + datasetPublished = true; datasetServiceSpy.updateDatasetPublicity.mockReturnValue( throwError(() => new HttpErrorResponse({ error: { message: "dataset unpublish failed" }, status: 500 })) ); const c = setupComponent({ type: "dataset" }); - c.unpublishDataset(); + c.setPublished(false); expect(notificationSpy.error).toHaveBeenCalledWith("dataset unpublish failed"); expect(c.isPublic).toBe(true); }); @@ -692,35 +759,35 @@ describe("ShareAccessComponent", () => { expect(gmailSpy.sendEmail).not.toHaveBeenCalled(); }); - it("publishWorkflow is a no-op when the workflow is already public", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Public")); + it("publishing is a no-op when the workflow is already public", () => { + workflowPublished = true; const c = setupComponent({ type: "workflow" }); workflowPersistSpy.updateWorkflowIsPublished.mockClear(); - c.publishWorkflow(); + c.setPublished(true); expect(workflowPersistSpy.updateWorkflowIsPublished).not.toHaveBeenCalled(); }); - it("unpublishWorkflow is a no-op when the workflow is already private", () => { - workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(of("Private")); + it("unpublishing is a no-op when the workflow is already private", () => { + workflowPublished = false; const c = setupComponent({ type: "workflow" }); workflowPersistSpy.updateWorkflowIsPublished.mockClear(); - c.unpublishWorkflow(); + c.setPublished(false); expect(workflowPersistSpy.updateWorkflowIsPublished).not.toHaveBeenCalled(); }); - it("publishDataset is a no-op when the dataset is already public", () => { - datasetServiceSpy.getDataset.mockReturnValue(of({ dataset: { isPublic: true } })); + it("publishing is a no-op when the dataset is already public", () => { + datasetPublished = true; const c = setupComponent({ type: "dataset" }); datasetServiceSpy.updateDatasetPublicity.mockClear(); - c.publishDataset(); + c.setPublished(true); expect(datasetServiceSpy.updateDatasetPublicity).not.toHaveBeenCalled(); }); - it("unpublishDataset is a no-op when the dataset is already private", () => { - datasetServiceSpy.getDataset.mockReturnValue(of({ dataset: { isPublic: false } })); + it("unpublishing is a no-op when the dataset is already private", () => { + datasetPublished = false; const c = setupComponent({ type: "dataset" }); datasetServiceSpy.updateDatasetPublicity.mockClear(); - c.unpublishDataset(); + c.setPublished(false); expect(datasetServiceSpy.updateDatasetPublicity).not.toHaveBeenCalled(); }); }); @@ -936,7 +1003,7 @@ describe("ShareAccessComponent", () => { workflowPersistSpy.updateWorkflowIsPublished.mockReturnValue(offline()); const c = setupComponent({ type: "workflow" }); - c.publishWorkflow(); + c.setPublished(true); expect(workflowPersistSpy.updateWorkflowIsPublished).toHaveBeenCalledWith(1, true); expect(notificationSpy.error).not.toHaveBeenCalled(); @@ -948,7 +1015,7 @@ describe("ShareAccessComponent", () => { workflowPersistSpy.updateWorkflowIsPublished.mockReturnValue(offline()); const c = setupComponent({ type: "workflow" }); - c.unpublishWorkflow(); + c.setPublished(false); expect(workflowPersistSpy.updateWorkflowIsPublished).toHaveBeenCalledWith(1, false); expect(notificationSpy.error).not.toHaveBeenCalled(); @@ -960,7 +1027,7 @@ describe("ShareAccessComponent", () => { datasetServiceSpy.updateDatasetPublicity.mockReturnValue(offline()); const c = setupComponent({ type: "dataset" }); - c.publishDataset(); + c.setPublished(true); expect(datasetServiceSpy.updateDatasetPublicity).toHaveBeenCalledWith(1); expect(notificationSpy.error).not.toHaveBeenCalled(); @@ -972,7 +1039,7 @@ describe("ShareAccessComponent", () => { datasetServiceSpy.updateDatasetPublicity.mockReturnValue(offline()); const c = setupComponent({ type: "dataset" }); - c.unpublishDataset(); + c.setPublished(false); expect(datasetServiceSpy.updateDatasetPublicity).toHaveBeenCalledWith(1); expect(notificationSpy.error).not.toHaveBeenCalled(); diff --git a/frontend/src/app/dashboard/component/user/share-access/share-access.component.ts b/frontend/src/app/dashboard/component/user/share-access/share-access.component.ts index 26c35b7115..d5ebb4c595 100644 --- a/frontend/src/app/dashboard/component/user/share-access/share-access.component.ts +++ b/frontend/src/app/dashboard/component/user/share-access/share-access.component.ts @@ -27,11 +27,12 @@ import { GmailService } from "../../../../common/service/gmail/gmail.service"; import { NZ_MODAL_DATA, NzModalRef, NzModalService } from "ng-zorro-antd/modal"; import { NotificationService } from "../../../../common/service/notification/notification.service"; import { HttpErrorResponse } from "@angular/common/http"; -import { USER_DATASET, USER_WORKFLOW } from "../../../../app-routing.constant"; +import { catchError, of, switchMap } from "rxjs"; import { NzMessageService } from "ng-zorro-antd/message"; -import { DatasetService } from "../../../service/user/dataset/dataset.service"; -import { WorkflowPersistService } from "src/app/common/service/workflow-persist/workflow-persist.service"; import { WorkflowActionService } from "src/app/workspace/service/workflow-graph/model/workflow-action.service"; +import { ResourceRegistryService } from "../../../service/user/resource-registry/resource-registry.service"; +import { ResourceDescriptor } from "../../../type/resource-descriptor"; +import { EntityType } from "../../../../hub/service/hub.service"; import { NgIf, NgFor } from "@angular/common"; import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; import { NzButtonComponent } from "ng-zorro-antd/button"; @@ -88,6 +89,8 @@ export class ShareAccessComponent implements OnInit, OnDestroy { public emailTags: string[] = []; currentEmail: string | undefined = ""; isPublic: boolean | null = null; + /** Undefined for kinds the registry does not carry, i.e. computing units. */ + private readonly descriptor: ResourceDescriptor | undefined; private shouldRefresh = false; @Output() refresh = new EventEmitter<void>(); @@ -99,9 +102,8 @@ export class ShareAccessComponent implements OnInit, OnDestroy { private notificationService: NotificationService, private message: NzMessageService, private modalService: NzModalService, - private workflowPersistService: WorkflowPersistService, - private datasetService: DatasetService, private workflowActionService: WorkflowActionService, + private resourceRegistry: ResourceRegistryService, private modalRef: NzModalRef ) { this.validateForm = this.formBuilder.group({ @@ -109,6 +111,7 @@ export class ShareAccessComponent implements OnInit, OnDestroy { accessLevel: ["WRITE"], }); this.currentEmail = this.userService.getCurrentUser()?.email; + this.descriptor = this.resourceRegistry.find(this.type as EntityType); } get hasWriteAccess(): boolean { @@ -133,21 +136,11 @@ export class ShareAccessComponent implements OnInit, OnDestroy { .subscribe(name => { this.owner = name; }); - if (this.type === "workflow") { - this.workflowPersistService - .getWorkflowIsPublished(this.id) - .pipe(untilDestroyed(this)) - .subscribe(dashboardWorkflow => { - this.isPublic = dashboardWorkflow === "Public"; - }); - } else if (this.type === "dataset") { - this.datasetService - .getDataset(this.id) - .pipe(untilDestroyed(this)) - .subscribe(dashboardDataset => { - this.isPublic = dashboardDataset.dataset.isPublic; - }); - } + // Stays null for kinds that cannot be published, which is what hides the publish buttons. + this.descriptor + ?.isPublic?.(this.id) + .pipe(untilDestroyed(this)) + .subscribe(isPublic => (this.isPublic = isPublic)); } ngOnDestroy(): void { @@ -190,10 +183,8 @@ export class ShareAccessComponent implements OnInit, OnDestroy { if (this.emailTags.length > 0) { this.emailTags.forEach(email => { let message = `${this.userService.getCurrentUser()?.email} shared a ${this.type} with you`; - if (this.type !== "computing-unit") { - let routePath = ""; - if (this.type === "workflow") routePath = USER_WORKFLOW; - if (this.type === "dataset") routePath = USER_DATASET; + const routePath = this.descriptor?.privateRoute; + if (routePath !== undefined) { message += `, access the ${this.type} at ${location.origin}${routePath}/${this.id}`; } this.accessService @@ -341,9 +332,11 @@ export class ShareAccessComponent implements OnInit, OnDestroy { public verifyPublish(): void { if (!this.isPublic) { + // Only a clonable kind hands out more than read access, so only it carries the warning. + const cloneWarning = this.descriptor?.affordances?.clonable ? ", along with the right to clone your work" : ""; const modal: NzModalRef = this.modalService.create({ nzTitle: "Notice", - nzContent: `Publishing your ${this.type} would grant all Texera users read access to your ${this.type} along with the right to clone your work.`, + nzContent: `Publishing your ${this.type} would grant all Texera users read access to your ${this.type}${cloneWarning}.`, nzFooter: [ { label: "Cancel", @@ -353,15 +346,7 @@ export class ShareAccessComponent implements OnInit, OnDestroy { label: "Publish", type: "primary", onClick: () => { - if (this.type === "workflow") { - this.publishWorkflow(); - - if (this.inWorkspace) { - this.workflowActionService.setWorkflowIsPublished(1); - } - } else if (this.type === "dataset") { - this.publishDataset(); - } + this.setPublished(true); modal.close(); }, }, @@ -384,14 +369,7 @@ export class ShareAccessComponent implements OnInit, OnDestroy { label: "Unpublish", type: "primary", onClick: () => { - if (this.type === "workflow") { - this.unpublishWorkflow(); - if (this.inWorkspace) { - this.workflowActionService.setWorkflowIsPublished(0); - } - } else if (this.type === "dataset") { - this.unpublishDataset(); - } + this.setPublished(false); modal.close(); }, }, @@ -400,79 +378,35 @@ export class ShareAccessComponent implements OnInit, OnDestroy { } } - public publishWorkflow(): void { - if (!this.isPublic) { - this.workflowPersistService - .updateWorkflowIsPublished(this.id, true) - .pipe(untilDestroyed(this)) - .subscribe({ - next: () => { - this.isPublic = true; - this.notificationService.success("Workflow published successfully"); - }, - error: (error: unknown) => { - if (error instanceof HttpErrorResponse) { - this.notificationService.error(error.error.message); - } - }, - }); - } - } - - public unpublishWorkflow(): void { - if (this.isPublic) { - this.workflowPersistService - .updateWorkflowIsPublished(this.id, false) - .pipe(untilDestroyed(this)) - .subscribe({ - next: () => { - this.isPublic = false; - this.notificationService.success("Workflow unpublished successfully"); - }, - error: (error: unknown) => { - if (error instanceof HttpErrorResponse) { - this.notificationService.error(error.error.message); - } - }, - }); - } - } - - public publishDataset(): void { - if (!this.isPublic) { - this.datasetService - .updateDatasetPublicity(this.id) - .pipe(untilDestroyed(this)) - .subscribe({ - next: (res: Response) => { - this.isPublic = true; - this.notificationService.success("Dataset published successfully"); - }, - error: (error: unknown) => { - if (error instanceof HttpErrorResponse) { - this.notificationService.error(error.error.message); - } - }, - }); - } - } - - public unpublishDataset(): void { - if (this.isPublic) { - this.datasetService - .updateDatasetPublicity(this.id) - .pipe(untilDestroyed(this)) - .subscribe({ - next: (res: Response) => { - this.isPublic = false; - this.notificationService.success("Dataset unpublished successfully"); - }, - error: (error: unknown) => { - if (error instanceof HttpErrorResponse) { - this.notificationService.error(error.error.message); - } - }, - }); + public setPublished(next: boolean): void { + const descriptor = this.descriptor; + if (!descriptor?.setPublished || this.isPublic === next) { + return; } + const readBack = descriptor.isPublic; + const label = this.type.charAt(0).toUpperCase() + this.type.slice(1); + descriptor + .setPublished(this.id, next) + // Toggle-style backends ignore `next`, so the server's own answer decides what is reported. + // A read-back that fails falls back to `next`: the write already landed, and reporting it as + // a failure would invite a retry that toggles it straight back. + .pipe( + switchMap(() => (readBack ? readBack(this.id).pipe(catchError(() => of(next))) : of(next))), + untilDestroyed(this) + ) + .subscribe({ + next: published => { + this.isPublic = published; + if (this.inWorkspace) { + this.workflowActionService.setWorkflowIsPublished(published ? 1 : 0); + } + this.notificationService.success(`${label} ${published ? "published" : "unpublished"} successfully`); + }, + error: (error: unknown) => { + if (error instanceof HttpErrorResponse) { + this.notificationService.error(error.error.message); + } + }, + }); } } diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html index 94be936b12..1ab224dc02 100644 --- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html +++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html @@ -347,7 +347,8 @@ [fileTreeNodes]="fileTreeNodeList" [isTreeNodeDeletable]="userHasWriteAccess()" (selectedTreeNode)="onVersionFileTreeNodeSelected($event)" - (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)"> + (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)" + (setCoverImage)="onSetCoverImage($event)"> </texera-user-dataset-version-filetree> </nz-collapse-panel> </nz-collapse> @@ -458,6 +459,47 @@ </div> </div> </nz-card> + + <nz-card class="settings-general-card"> + <h3 class="settings-card-title">Access & visibility</h3> + + <div class="settings-name-row"> + <div class="settings-name-label"> + <label>Visibility</label> + <p class="settings-hint"> + {{ modelIsPublic ? "Public — anyone can view this model." : "Private — only you and invited collaborators + can see this model." }} + </p> + </div> + <div class="settings-name-controls"> + <nz-switch + [ngModel]="modelIsPublic" + (ngModelChange)="onPublicStatusChange($event)" + nzCheckedChildren="public" + nzUnCheckedChildren="private"></nz-switch> + </div> + </div> + + <nz-divider></nz-divider> + + <div class="settings-name-row"> + <div class="settings-name-label"> + <label>Downloadable</label> + <p class="settings-hint"> + {{ modelIsDownloadable ? "Viewers can download this model." : "Viewers can browse files but cannot + download them." }} + </p> + </div> + <div class="settings-name-controls"> + <nz-switch + [ngModel]="modelIsDownloadable" + (ngModelChange)="onDownloadableStatusChange($event)" + [nzDisabled]="!isOwner" + nzCheckedChildren="allowed" + nzUnCheckedChildren="blocked"></nz-switch> + </div> + </div> + </nz-card> </div> </nz-tab> </nz-tabs> diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts index 6555ccb75a..95f24e6ecb 100644 --- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts @@ -20,7 +20,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { ActivatedRoute } from "@angular/router"; -import { of, throwError } from "rxjs"; +import { of, Subject, throwError } from "rxjs"; import { MarkdownService } from "ngx-markdown"; import { NzModalService } from "ng-zorro-antd/modal"; import { NgModel } from "@angular/forms"; @@ -161,6 +161,13 @@ describe("ModelDetailComponent", () => { return fixture.nativeElement as HTMLElement; }; + /** Runs change detection, flushes NgModel's microtask write, and renders the result. */ + const settle = async (): Promise<void> => { + fixture.detectChanges(); + await Promise.resolve(); + fixture.detectChanges(); + }; + // nz-tabs only instantiates the active tab, so a tab has to be opened before // anything inside it exists to assert on. const openTab = (title: string): HTMLElement => { @@ -796,6 +803,130 @@ describe("ModelDetailComponent", () => { expect(component.formats).toEqual(MODEL_FORMATS); }); + it("toggles visibility and downloadability, keeping the switch on the server's answer", () => { + let published = false; + modelService["getModel"] = vi.fn(() => of(dashboardModel({ model: { isPublic: published } }))); + modelService["updateModelPublicity"] = vi.fn(() => { + published = !published; + return of({}); + }); + modelService["updateModelDownloadable"] = vi.fn(() => throwError(() => new Error("denied"))); + create(); + + component.onPublicStatusChange(true); + component.onDownloadableStatusChange(false); + + expect(modelService["updateModelPublicity"]).toHaveBeenCalledWith(MID); + expect(component.modelIsPublic).toBe(true); + // The rejected toggle leaves the model downloadable, so the switch snaps back. + expect(component.modelIsDownloadable).toBe(true); + expect(notificationService["error"]).toHaveBeenCalled(); + }); + + it("follows the server when a stale switch toggles the wrong way, and moves the switch back", async () => { + // The page loaded a private model; something else published it meanwhile. The endpoint toggles, + // so asking for "public" makes it private again — and the rendered switch has to follow, not + // just the field. The read-back is deferred here because that is what makes it work: with + // one-way [ngModel] the correction is only seen as a change if a detection pass observed the + // clicked value first, which a real HTTP round trip guarantees. + const readBack = new Subject<unknown>(); + modelService["getModel"] = vi + .fn() + .mockReturnValueOnce(of(dashboardModel({ model: { isPublic: false } }))) + .mockReturnValue(readBack); + modelService["updateModelPublicity"] = vi.fn(() => of({})); + create(); + const root = openTab("Settings"); + const visibility = root.querySelectorAll<HTMLElement>("nz-switch button.ant-switch")[0]; + expect(visibility.classList.contains("ant-switch-checked")).toBe(false); + + visibility.click(); + // NgModel pushes a new value to the control in a microtask, so each write needs one flushed + // before the switch reflects it. + await settle(); + expect(visibility.classList.contains("ant-switch-checked")).toBe(true); + + readBack.next(dashboardModel({ model: { isPublic: false } })); + await settle(); + + expect(component.modelIsPublic).toBe(false); + expect(visibility.classList.contains("ant-switch-checked")).toBe(false); + }); + + it("reads the downloadable flag back too, since that endpoint also toggles", () => { + // A stale switch would otherwise flip downloads the wrong way and mis-gate the download buttons. + let downloadable = false; + modelService["getModel"] = vi.fn(() => of(dashboardModel({ model: { isDownloadable: downloadable } }))); + modelService["updateModelDownloadable"] = vi.fn(() => { + downloadable = !downloadable; + return of({}); + }); + create(); + component.modelIsDownloadable = true; + + component.onDownloadableStatusChange(false); + + expect(modelService["updateModelDownloadable"]).toHaveBeenCalledWith(MID); + expect(component.modelIsDownloadable).toBe(true); + expect(component.isDownloadAllowed()).toBe(true); + }); + + it("keeps a landed toggle when the read-back fails", () => { + // GET /model/{mid} sizes the repository and can fail where the toggle did not. Reporting that + // as a failure would invite a retry, which would toggle the model straight back. + modelService["updateModelPublicity"] = vi.fn(() => of({})); + modelService["getModel"] = vi + .fn() + .mockReturnValueOnce(of(dashboardModel())) + .mockReturnValue(throwError(() => new Error("lakefs down"))); + create(); + + component.onPublicStatusChange(true); + + expect(component.modelIsPublic).toBe(true); + expect(notificationService["success"]).toHaveBeenCalledWith("Model resnet-50 is now public"); + expect(notificationService["error"]).not.toHaveBeenCalled(); + }); + + it("rolls the switch back when the toggle itself fails", () => { + modelService["updateModelPublicity"] = vi.fn(() => throwError(() => new Error("denied"))); + create(); + + component.onPublicStatusChange(true); + + expect(component.modelIsPublic).toBe(false); + expect(notificationService["error"]).toHaveBeenCalled(); + }); + + it("renders both visibility switches on the Settings tab", () => { + create(); + const root = openTab("Settings"); + + expect(root.querySelectorAll("nz-switch").length).toBe(2); + }); + + it("prefixes the cover path with the selected version and reloads the resolved url", () => { + modelService["updateModelCoverImage"] = vi.fn(() => of({})); + modelService["getModelCoverUrl"] = vi.fn(() => of({ url: "http://cover/new.png" })); + create(); + component.selectedVersion = { mvid: 3, mid: MID, creatorUid: 1, name: "v2" } as any; + + component.onSetCoverImage("images/preview.png"); + + expect(modelService["updateModelCoverImage"]).toHaveBeenCalledWith(MID, "v2/images/preview.png"); + expect(component.coverImageUrl).toBe("http://cover/new.png"); + }); + + it("does not set a cover while no version is selected", () => { + modelService["updateModelCoverImage"] = vi.fn(() => of({})); + create(); + component.selectedVersion = undefined; + + component.onSetCoverImage("images/preview.png"); + + expect(modelService["updateModelCoverImage"]).not.toHaveBeenCalled(); + }); + it("collapses and restores the right sider, and maximizes the preview", () => { create(); const root = openTab("Versions & Files"); diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts index cd5fa68a86..e21551c649 100644 --- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts +++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts @@ -20,8 +20,8 @@ import { Component, OnInit, ViewChild } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { switchMap } from "rxjs/operators"; -import { Observable } from "rxjs"; +import { catchError, map, switchMap } from "rxjs/operators"; +import { Observable, of } from "rxjs"; import { format } from "date-fns"; import { NgIf, NgClass, NgFor } from "@angular/common"; import { FormsModule } from "@angular/forms"; @@ -40,6 +40,7 @@ import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select"; import { NzTabsComponent, NzTabComponent } from "ng-zorro-antd/tabs"; import { NzDividerComponent } from "ng-zorro-antd/divider"; import { NzInputDirective } from "ng-zorro-antd/input"; +import { NzSwitchComponent } from "ng-zorro-antd/switch"; import { MODEL_FORMATS, @@ -56,7 +57,7 @@ import { EntityType } from "../../../../../hub/service/hub.service"; import { extractErrorMessage } from "../../../../../common/util/error"; import { formatCount } from "src/app/common/util/format.util"; import { formatSize } from "src/app/common/util/size-formatter.util"; -import { ModelVersion } from "../../../../../common/type/model"; +import { Model, ModelVersion } from "../../../../../common/type/model"; import { DatasetFileNode, getFullPathFromDatasetFileNode, @@ -98,6 +99,7 @@ import { UserDatasetVersionFiletreeComponent } from "../../user-dataset/user-dat NzTabComponent, NzDividerComponent, NzInputDirective, + NzSwitchComponent, MarkdownDescriptionComponent, VersionUploaderComponent, UserDatasetFileRendererComponent, @@ -546,6 +548,89 @@ export class ModelDetailComponent implements OnInit { }); } + onPublicStatusChange(checked: boolean): void { + if (!this.mid) { + return; + } + const previous = this.modelIsPublic; + // Written before the request so the confirmed value below can differ from it: with one-way + // `[ngModel]`, storing the value the field already holds never reaches the switch, which would + // then keep the clicked position while the hint and the toast said the opposite. + this.modelIsPublic = checked; + this.confirmToggle(this.modelService.updateModelPublicity(this.mid)) + .pipe(untilDestroyed(this)) + .subscribe({ + next: model => { + this.modelIsPublic = model?.isPublic ?? checked; + const state = this.modelIsPublic ? "public" : "private"; + this.notificationService.success(`Model ${this.modelName} is now ${state}`); + }, + error: (err: unknown) => { + this.modelIsPublic = previous; + this.notificationService.error(extractErrorMessage(err)); + }, + }); + } + + onDownloadableStatusChange(checked: boolean): void { + if (!this.mid) { + return; + } + const previous = this.modelIsDownloadable; + this.modelIsDownloadable = checked; + this.confirmToggle(this.modelService.updateModelDownloadable(this.mid)) + .pipe(untilDestroyed(this)) + .subscribe({ + next: model => { + this.modelIsDownloadable = model?.isDownloadable ?? checked; + const state = this.modelIsDownloadable ? "allowed" : "not allowed"; + this.notificationService.success(`Model downloads are now ${state}`); + }, + error: (err: unknown) => { + this.modelIsDownloadable = previous; + this.notificationService.error(extractErrorMessage(err)); + }, + }); + } + + /** + * Both visibility flags sit behind toggle endpoints, which cannot be told which way to go, so the + * model is re-read to find out where it landed. A failed re-read yields undefined rather than an + * error: the toggle itself already succeeded, and reporting a failure would invite a retry that + * toggles it straight back. + */ + private confirmToggle(toggle: Observable<unknown>): Observable<Model | undefined> { + const mid = this.mid; + return toggle.pipe( + switchMap(() => + mid === undefined + ? of(undefined) + : this.modelService.getModel(mid).pipe( + map(dashboardModel => dashboardModel.model), + catchError(() => of(undefined)) + ) + ) + ); + } + + /** The backend stores the cover relative to the model root, so the version name has to lead. */ + onSetCoverImage(filePath: string): void { + if (!this.mid || !this.selectedVersion) { + return; + } + const mid = this.mid; + this.modelService + .updateModelCoverImage(mid, `${this.selectedVersion.name}/${filePath}`) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.loadCoverImageUrl(mid); + this.notificationService.success("Cover image updated."); + }, + error: (err: unknown) => this.notificationService.error(extractErrorMessage(err)), + }); + } + onFormatChange(modelFormat: string): void { const previous = this.modelFormat; if (!this.mid || previous === modelFormat) { diff --git a/frontend/src/app/dashboard/service/user/model/model.service.spec.ts b/frontend/src/app/dashboard/service/user/model/model.service.spec.ts index 418052af20..d764bebb59 100644 --- a/frontend/src/app/dashboard/service/user/model/model.service.spec.ts +++ b/frontend/src/app/dashboard/service/user/model/model.service.spec.ts @@ -257,6 +257,32 @@ describe("ModelService", () => { format.flush({}); }); + it("toggles publicity and downloadability without a payload", () => { + service.updateModelPublicity(7).subscribe(); + const publicity = http.expectOne(`${API}/model/7/update/publicity`); + expect(publicity.request.method).toBe("POST"); + expect(publicity.request.body).toEqual({}); + publicity.flush({}); + + service.updateModelDownloadable(7).subscribe(); + const downloadable = http.expectOne(`${API}/model/7/update/downloadable`); + expect(downloadable.request.body).toEqual({}); + downloadable.flush({}); + }); + + it("points the cover at a path already committed to the model", () => { + service.updateModelCoverImage(7, "v2/preview.png").subscribe(); + const req = http.expectOne(`${API}/model/7/update/cover`); + expect(req.request.body).toEqual({ coverImage: "v2/preview.png" }); + req.flush({}); + }); + + it("lists the owners of the models the user can see", async () => { + const pending = firstValueFrom(service.retrieveOwners()); + http.expectOne(`${API}/model/user-model-owners`).flush(["[email protected]"]); + expect(await pending).toEqual(["[email protected]"]); + }); + it("surfaces a server error rather than swallowing it", async () => { const outcome = firstValueFrom(service.retrieveAccessibleModels()).catch((err: unknown) => err); http.expectOne(`${API}/model/list`).flush({ message: "nope" }, { status: 500, statusText: "Server Error" }); diff --git a/frontend/src/app/dashboard/service/user/model/model.service.ts b/frontend/src/app/dashboard/service/user/model/model.service.ts index b409513900..9bb9d19c88 100644 --- a/frontend/src/app/dashboard/service/user/model/model.service.ts +++ b/frontend/src/app/dashboard/service/user/model/model.service.ts @@ -33,7 +33,11 @@ export const MODEL_UPDATE_NAME_URL = MODEL_UPDATE_BASE_URL + "/name"; export const MODEL_UPDATE_DESCRIPTION_URL = MODEL_UPDATE_BASE_URL + "/description"; export const MODEL_UPDATE_FRAMEWORK_URL = MODEL_UPDATE_BASE_URL + "/framework"; export const MODEL_UPDATE_FORMAT_URL = MODEL_UPDATE_BASE_URL + "/format"; +export const MODEL_UPDATE_PUBLICITY_URL = "update/publicity"; +export const MODEL_UPDATE_DOWNLOADABLE_URL = "update/downloadable"; +export const MODEL_UPDATE_COVER_URL = "update/cover"; export const MODEL_LIST_URL = MODEL_BASE_URL + "/list"; +export const MODEL_GET_OWNERS_URL = MODEL_BASE_URL + "/user-model-owners"; export const MODEL_VERSION_BASE_URL = "version"; export const MODEL_VERSION_RETRIEVE_LIST_URL = MODEL_VERSION_BASE_URL + "/list"; @@ -189,6 +193,35 @@ export class ModelService { .pipe(switchMap(({ presignedUrl }) => this.http.get(presignedUrl, { responseType: "blob" }))); } + /** Flips the model between public and private; the endpoint toggles rather than taking a value. */ + public updateModelPublicity(mid: number): Observable<Response> { + return this.http.post<Response>( + `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/${MODEL_UPDATE_PUBLICITY_URL}`, + {} + ); + } + + public updateModelDownloadable(mid: number): Observable<Response> { + return this.http.post<Response>( + `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/${MODEL_UPDATE_DOWNLOADABLE_URL}`, + {} + ); + } + + public retrieveOwners(): Observable<string[]> { + return this.http.get<string[]>(`${AppSettings.getApiEndpoint()}/${MODEL_GET_OWNERS_URL}`); + } + + /** Points the model card at a committed image, given as "<version>/<path>". */ + public updateModelCoverImage(mid: number, coverImage: string): Observable<Response> { + return this.http.post<Response>( + `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/${MODEL_UPDATE_COVER_URL}`, + { + coverImage: coverImage, + } + ); + } + public getModelCoverUrl(mid: number): Observable<{ url: string | null }> { return this.http.get<{ url: string | null }>(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/cover-url`); } diff --git a/frontend/src/app/dashboard/service/user/resource-registry/dataset-resource.descriptor.ts b/frontend/src/app/dashboard/service/user/resource-registry/dataset-resource.descriptor.ts index 4c4078c1b2..164a48c693 100644 --- a/frontend/src/app/dashboard/service/user/resource-registry/dataset-resource.descriptor.ts +++ b/frontend/src/app/dashboard/service/user/resource-registry/dataset-resource.descriptor.ts @@ -19,11 +19,12 @@ import { Injectable } from "@angular/core"; import { DashboardEntry } from "../../../type/dashboard-entry"; -import { ResourceDescriptor } from "../../../type/resource-descriptor"; +import { ResourceAffordances, ResourceDescriptor } from "../../../type/resource-descriptor"; import { EntityType } from "../../../../hub/service/hub.service"; import { DatasetService, DEFAULT_DATASET_NAME, validateDatasetName } from "../dataset/dataset.service"; import { HUB_DATASET_RESULT_DETAIL, USER_DATASET } from "../../../../app-routing.constant"; import { DownloadService } from "../download/download.service"; +import { map } from "rxjs/operators"; @Injectable({ providedIn: "root", @@ -35,6 +36,8 @@ export class DatasetResourceDescriptor implements ResourceDescriptor { readonly hubRoute = HUB_DATASET_RESULT_DETAIL; readonly hasSize = true; readonly defaultName = DEFAULT_DATASET_NAME; + // Publishing a dataset grants read access only; there is no clone action for it. + readonly affordances: ResourceAffordances = { clonable: false }; constructor( private datasetService: DatasetService, @@ -50,5 +53,10 @@ export class DatasetResourceDescriptor implements ResourceDescriptor { download = (id: number, name: string) => this.downloadService.downloadDataset(id, name); retrieveSingleFile = (filePath: string, isLogin: boolean) => this.datasetService.retrieveDatasetVersionSingleFile(filePath, isLogin); + isPublic = (id: number) => this.datasetService.getDataset(id).pipe(map(dashboard => dashboard.dataset.isPublic)); + // The endpoint toggles, so `next` is the caller's expectation rather than a payload. + setPublished = (id: number) => this.datasetService.updateDatasetPublicity(id); + coverUrl = (id: number) => this.datasetService.getDatasetCoverUrl(id).pipe(map(({ url }) => url)); + setCover = (id: number, path: string) => this.datasetService.updateDatasetCoverImage(id, path); // No dataset-id endpoint exists, so `retrieveIds` stays absent and the id filter hides itself. } diff --git a/frontend/src/app/dashboard/service/user/resource-registry/model-resource.descriptor.ts b/frontend/src/app/dashboard/service/user/resource-registry/model-resource.descriptor.ts index 90258baa1f..6edd17d6ac 100644 --- a/frontend/src/app/dashboard/service/user/resource-registry/model-resource.descriptor.ts +++ b/frontend/src/app/dashboard/service/user/resource-registry/model-resource.descriptor.ts @@ -19,12 +19,13 @@ import { Injectable } from "@angular/core"; import { DashboardEntry } from "../../../type/dashboard-entry"; -import { ResourceDescriptor } from "../../../type/resource-descriptor"; +import { ResourceAffordances, ResourceDescriptor } from "../../../type/resource-descriptor"; import { EntityType } from "../../../../hub/service/hub.service"; import { DEFAULT_MODEL_NAME, ModelService, validateModelName } from "../model/model.service"; import { MODEL_ICON } from "../../../../common/icon/model-icon"; import { USER_MODEL } from "../../../../app-routing.constant"; import { DownloadService } from "../download/download.service"; +import { map } from "rxjs/operators"; @Injectable({ providedIn: "root", @@ -36,6 +37,8 @@ export class ModelResourceDescriptor implements ResourceDescriptor { // `hubRoute` is deliberately absent: models reach the hub with the rest of the hub UI. readonly hasSize = true; readonly defaultName = DEFAULT_MODEL_NAME; + // Publishing a model grants read access only; there is no clone action for it. + readonly affordances: ResourceAffordances = { clonable: false }; constructor( private modelService: ModelService, @@ -49,5 +52,10 @@ export class ModelResourceDescriptor implements ResourceDescriptor { download = (id: number, name: string) => this.downloadService.downloadModel(id, name); retrieveSingleFile = (filePath: string, isLogin: boolean) => this.modelService.retrieveModelVersionSingleFile(filePath, isLogin); - // `retrieveOwners` arrives with the share modal and the filters, which need it. + retrieveOwners = () => this.modelService.retrieveOwners(); + isPublic = (id: number) => this.modelService.getModel(id).pipe(map(dashboard => dashboard.model.isPublic)); + // The endpoint toggles, so `next` is the caller's expectation rather than a payload. + setPublished = (id: number) => this.modelService.updateModelPublicity(id); + coverUrl = (id: number) => this.modelService.getModelCoverUrl(id).pipe(map(({ url }) => url)); + setCover = (id: number, path: string) => this.modelService.updateModelCoverImage(id, path); } diff --git a/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts b/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts index 0dba95dec4..fbfd351fef 100644 --- a/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts +++ b/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts @@ -19,7 +19,7 @@ import { TestBed } from "@angular/core/testing"; import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { of } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; import { ResourceRegistryService } from "./resource-registry.service"; import { DashboardEntry } from "../../../type/dashboard-entry"; import { EntityType } from "../../../../hub/service/hub.service"; @@ -66,6 +66,11 @@ describe("ResourceRegistryService", () => { updateModelName: vi.fn().mockReturnValue(of({})), updateModelDescription: vi.fn().mockReturnValue(of({})), retrieveModelVersionSingleFile: vi.fn().mockReturnValue(of(new Blob())), + retrieveOwners: vi.fn().mockReturnValue(of(["m-owner"])), + getModel: vi.fn().mockReturnValue(of({ model: { isPublic: true } })), + updateModelPublicity: vi.fn().mockReturnValue(of({})), + getModelCoverUrl: vi.fn().mockReturnValue(of({ url: "http://cover" })), + updateModelCoverImage: vi.fn().mockReturnValue(of({})), }; downloadService = { @@ -102,6 +107,12 @@ describe("ResourceRegistryService", () => { expect(() => registry.get("quantum" as EntityType)).toThrowError("Unexpected type in DashboardEntry."); }); + it("answers with undefined instead of throwing when the caller can cope", () => { + // The share modal opens for computing units too, and asks the registry what they can do. + expect(registry.find(EntityType.ComputingUnit)).toBeUndefined(); + expect(registry.find(EntityType.Model)).toBeDefined(); + }); + // ─── capability checks ──────────────────────────────────────────────────── it("exposes rename and description only for the kinds that support them", () => { @@ -109,8 +120,6 @@ describe("ResourceRegistryService", () => { expect(registry.get(type).rename).toBeDefined(); expect(registry.get(type).updateDescription).toBeDefined(); } - // Models gain `retrieveOwners` with the share modal and the filters, which are the only callers. - expect(registry.get(EntityType.Model).retrieveOwners).toBeUndefined(); for (const type of [EntityType.File]) { expect(registry.get(type).rename).toBeUndefined(); expect(registry.get(type).updateDescription).toBeUndefined(); @@ -130,6 +139,26 @@ describe("ResourceRegistryService", () => { expect(registry.get(EntityType.Model).retrieveSingleFile).toBeDefined(); }); + it("offers publishing and covers only to the kinds the backend supports", () => { + for (const type of [EntityType.Workflow, EntityType.Dataset, EntityType.Model]) { + expect(registry.get(type).retrieveOwners).toBeDefined(); + expect(registry.get(type).isPublic).toBeDefined(); + expect(registry.get(type).setPublished).toBeDefined(); + } + expect(registry.get(EntityType.File).isPublic).toBeUndefined(); + expect(registry.get(EntityType.File).setPublished).toBeUndefined(); + // A workflow cover is a data URL on the entry, so only the file-backed kinds resolve one. + expect(registry.get(EntityType.Workflow).coverUrl).toBeUndefined(); + expect(registry.get(EntityType.Dataset).coverUrl).toBeDefined(); + expect(registry.get(EntityType.Model).coverUrl).toBeDefined(); + }); + + it("warns about cloning only for the kind that can be cloned", () => { + expect(registry.get(EntityType.Workflow).affordances?.clonable).toBe(true); + expect(registry.get(EntityType.Dataset).affordances?.clonable).toBe(false); + expect(registry.get(EntityType.Model).affordances?.clonable).toBe(false); + }); + it("offers an id filter only where the backend has an id endpoint", () => { expect(registry.get(EntityType.Workflow).retrieveIds).toBeDefined(); expect(registry.get(EntityType.Dataset).retrieveIds).toBeUndefined(); @@ -172,6 +201,18 @@ describe("ResourceRegistryService", () => { expect(modelService["retrieveModelVersionSingleFile"]).toHaveBeenCalledWith("/model/a/m/v1/f.pt", false); }); + it("delegates a model's publishing and cover work to ModelService", async () => { + const model = registry.get(EntityType.Model); + + expect(await firstValueFrom(model.isPublic!(3))).toBe(true); + model.setPublished!(3, false); + expect(await firstValueFrom(model.coverUrl!(3))).toBe("http://cover"); + model.setCover!(3, "v1/preview.png"); + + expect(modelService["updateModelPublicity"]).toHaveBeenCalledWith(3); + expect(modelService["updateModelCoverImage"]).toHaveBeenCalledWith(3, "v1/preview.png"); + }); + it("reads ownership off the kind's own payload", () => { expect(registry.get(EntityType.Workflow).isOwner(entry({ workflow: { isOwner: false } }))).toBe(false); expect(registry.get(EntityType.Dataset).isOwner(entry({ dataset: { isOwner: true } }))).toBe(true); diff --git a/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.ts b/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.ts index a850423cf3..592a7f2c0c 100644 --- a/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.ts +++ b/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.ts @@ -49,6 +49,11 @@ export class ResourceRegistryService { ); } + /** The descriptor for a kind, or undefined when the kind has none (computing units). */ + public find(type: EntityType): ResourceDescriptor | undefined { + return this.descriptors.get(type); + } + public get(type: EntityType): ResourceDescriptor { const descriptor = this.descriptors.get(type); if (!descriptor) { diff --git a/frontend/src/app/dashboard/service/user/resource-registry/workflow-resource.descriptor.ts b/frontend/src/app/dashboard/service/user/resource-registry/workflow-resource.descriptor.ts index 07b8614796..5930673c42 100644 --- a/frontend/src/app/dashboard/service/user/resource-registry/workflow-resource.descriptor.ts +++ b/frontend/src/app/dashboard/service/user/resource-registry/workflow-resource.descriptor.ts @@ -19,7 +19,7 @@ import { Injectable } from "@angular/core"; import { DashboardEntry } from "../../../type/dashboard-entry"; -import { ResourceDescriptor } from "../../../type/resource-descriptor"; +import { ResourceAffordances, ResourceDescriptor } from "../../../type/resource-descriptor"; import { EntityType } from "../../../../hub/service/hub.service"; import { DEFAULT_WORKFLOW_NAME, @@ -27,6 +27,7 @@ import { } from "../../../../common/service/workflow-persist/workflow-persist.service"; import { HUB_WORKFLOW_RESULT_DETAIL, USER_WORKSPACE } from "../../../../app-routing.constant"; import { DownloadService } from "../download/download.service"; +import { map } from "rxjs/operators"; @Injectable({ providedIn: "root", @@ -38,6 +39,7 @@ export class WorkflowResourceDescriptor implements ResourceDescriptor { readonly hubRoute = HUB_WORKFLOW_RESULT_DETAIL; readonly hasSize = true; readonly defaultName = DEFAULT_WORKFLOW_NAME; + readonly affordances: ResourceAffordances = { clonable: true }; constructor( private workflowPersistService: WorkflowPersistService, @@ -53,4 +55,9 @@ export class WorkflowResourceDescriptor implements ResourceDescriptor { retrieveOwners = () => this.workflowPersistService.retrieveOwners(); retrieveIds = () => this.workflowPersistService.retrieveWorkflowIDs(); download = (id: number, name: string) => this.downloadService.downloadWorkflow(id, name); + isPublic = (id: number) => + this.workflowPersistService.getWorkflowIsPublished(id).pipe(map(published => published === "Public")); + setPublished = (id: number, next: boolean) => this.workflowPersistService.updateWorkflowIsPublished(id, next); + // No `coverUrl`/`setCover`: a workflow cover is a data URL carried on the entry itself, not a + // committed file that has to be fetched. } diff --git a/frontend/src/app/dashboard/type/resource-descriptor.ts b/frontend/src/app/dashboard/type/resource-descriptor.ts index c2cdc39aa9..85ecada475 100644 --- a/frontend/src/app/dashboard/type/resource-descriptor.ts +++ b/frontend/src/app/dashboard/type/resource-descriptor.ts @@ -21,6 +21,12 @@ import { Observable } from "rxjs"; import { DashboardEntry } from "./dashboard-entry"; import { EntityType } from "../../hub/service/hub.service"; +/** What publishing an entry of a kind grants everyone else. */ +export interface ResourceAffordances { + /** Whether a published entry may also be cloned, not just read. */ + readonly clonable: boolean; +} + /** * What one dashboard resource kind can do. Optional members are the capability check: a component * asks `if (descriptor.rename)` instead of testing the entry type, so adding a resource is a new @@ -47,8 +53,22 @@ export interface ResourceDescriptor { download?(id: number, name: string): Observable<unknown>; /** Fetches one file of a version for preview, by its logical path. */ retrieveSingleFile?(filePath: string, isLogin: boolean): Observable<Blob>; - /** Owners of this kind, for the filter dropdown. */ + /** Owners of this kind, for the filter dropdown and the share modal. */ retrieveOwners?(): Observable<string[]>; + /** What other users get once an entry is published; absent when the kind cannot be published. */ + readonly affordances?: ResourceAffordances; + /** Whether the entry is currently published. */ + isPublic?(id: number): Observable<boolean>; + /** + * Requests `next` as the new published state. Some backends expose a toggle rather than an + * absolute set and so ignore `next`, which means the result is only what the caller asked for, + * never what it got: read `isPublic` back afterwards instead of assuming `next` took effect. + */ + setPublished?(id: number, next: boolean): Observable<unknown>; + /** A ready-to-render URL for the entry's cover image, or null when it has none. */ + coverUrl?(id: number): Observable<string | null>; + /** Points the cover at a committed file of the entry, as "<version>/<path>". */ + setCover?(id: number, path: string): Observable<unknown>; /** Entry ids of this kind; absent when the backend exposes no such endpoint. */ retrieveIds?(): Observable<number[]>; } diff --git a/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts b/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts index 68f4e4539c..a4b1ad301a 100644 --- a/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts +++ b/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts @@ -20,6 +20,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { RouterTestingModule } from "@angular/router/testing"; import { By } from "@angular/platform-browser"; +import { of } from "rxjs"; import { UserService } from "src/app/common/service/user/user.service"; import { StubUserService } from "src/app/common/service/user/stub-user.service"; import { BrowseSectionComponent } from "./browse-section.component"; @@ -27,7 +28,6 @@ import { WorkflowPersistService } from "../../../common/service/workflow-persist import { DatasetService } from "../../../dashboard/service/user/dataset/dataset.service"; import { commonTestProviders } from "../../../common/testing/test-utils"; import { DashboardEntry } from "../../../dashboard/type/dashboard-entry"; -import { AppSettings } from "../../../common/app-setting"; import { HUB_DATASET_RESULT_DETAIL, HUB_WORKFLOW_RESULT_DETAIL, @@ -35,6 +35,9 @@ import { USER_WORKSPACE, } from "../../../app-routing.constant"; +/** What the dataset service's cover-url endpoint hands back in these specs. */ +const PRESIGNED_COVER = "https://s3.example/cover.png?sig=abc"; + describe("BrowseSectionComponent", () => { let component: BrowseSectionComponent; let fixture: ComponentFixture<BrowseSectionComponent>; @@ -44,7 +47,8 @@ describe("BrowseSectionComponent", () => { imports: [BrowseSectionComponent], providers: [ { provide: WorkflowPersistService, useValue: {} }, - { provide: DatasetService, useValue: {} }, + // The cover now comes from the descriptor, so the double has to answer for it. + { provide: DatasetService, useValue: { getDatasetCoverUrl: () => of({ url: PRESIGNED_COVER }) } }, ...commonTestProviders, ], }); @@ -101,7 +105,7 @@ describe("BrowseSectionComponent", () => { }); describe("cover images", () => { - it("builds and caches the cover URL for a dataset that has a cover image", () => { + it("caches the cover URL the descriptor resolves for an entity that has a cover", () => { const entity = { id: 5, type: "dataset", @@ -111,11 +115,11 @@ describe("BrowseSectionComponent", () => { component.entities = [entity]; component.ngOnInit(); - expect(component.getCoverImage(entity)).toBe(`${AppSettings.getApiEndpoint()}/dataset/5/cover`); + expect(component.getCoverImage(entity)).toBe(PRESIGNED_COVER); }); it("falls back to the default background when no cover was cached", () => { - // No coverImageUrl -> loadCoverImages skips it -> getCoverImage returns the default. + // No coverImageUrl -> loadCoverImages never asks the descriptor -> getCoverImage defaults. const entity = { id: 6, type: "dataset", accessibleUserIds: [] } as unknown as DashboardEntry; component.entities = [entity]; component.ngOnInit(); @@ -141,7 +145,8 @@ describe("BrowseSectionComponent rendering", () => { // AuthService and its whole dependency chain, so the shared stub stands in for it. { provide: UserService, useClass: StubUserService }, { provide: WorkflowPersistService, useValue: {} }, - { provide: DatasetService, useValue: {} }, + // The cover now comes from the descriptor, so the double has to answer for it. + { provide: DatasetService, useValue: { getDatasetCoverUrl: () => of({ url: PRESIGNED_COVER }) } }, ...commonTestProviders, ], }); @@ -194,11 +199,11 @@ describe("BrowseSectionComponent rendering", () => { const el = render([entity({ id: 5, coverImageUrl: "has-cover" })]); const img = el.querySelector<HTMLImageElement>(".card-cover-image")!; - expect(img.getAttribute("src")).toBe(`${AppSettings.getApiEndpoint()}/dataset/5/cover`); + expect(img.getAttribute("src")).toBe(PRESIGNED_COVER); }); it("falls back to the default background when the cover image fails to load", () => { - // A cached cover URL can still 404; the inline error handler is the only thing that stops the + // A presigned cover URL can still 404; the inline error handler is the only thing that stops the // card from showing a broken image. const el = render([entity({ id: 5, coverImageUrl: "has-cover" })]); const img = el.querySelector<HTMLImageElement>(".card-cover-image")!; diff --git a/frontend/src/app/hub/component/browse-section/browse-section.component.ts b/frontend/src/app/hub/component/browse-section/browse-section.component.ts index 659e018e1e..42274e8244 100644 --- a/frontend/src/app/hub/component/browse-section/browse-section.component.ts +++ b/frontend/src/app/hub/component/browse-section/browse-section.component.ts @@ -19,16 +19,14 @@ import { ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges } from "@angular/core"; import { DashboardEntry } from "../../../dashboard/type/dashboard-entry"; -import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; -import { DatasetService } from "../../../dashboard/service/user/dataset/dataset.service"; -import { UntilDestroy } from "@ngneat/until-destroy"; +import { ResourceRegistryService } from "../../../dashboard/service/user/resource-registry/resource-registry.service"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { HUB_DATASET_RESULT_DETAIL, HUB_WORKFLOW_RESULT_DETAIL, USER_DATASET, USER_WORKSPACE, } from "../../../app-routing.constant"; -import { AppSettings } from "../../../common/app-setting"; import { NgIf, NgFor, NgStyle, DatePipe } from "@angular/common"; import { NzCardComponent } from "ng-zorro-antd/card"; import { RouterLink } from "@angular/router"; @@ -65,11 +63,11 @@ export class BrowseSectionComponent implements OnInit, OnChanges { protected readonly USER_DATASET = USER_DATASET; entityRoutes: { [key: number]: string[] } = {}; - private coverImageUrls = new Map<number, string>(); + /** Keyed by type and id: ids are only unique within a kind, and sections may mix kinds. */ + private coverImageUrls = new Map<string, string>(); constructor( - private workflowPersistService: WorkflowPersistService, - private datasetService: DatasetService, + private resourceRegistry: ResourceRegistryService, private cdr: ChangeDetectorRef ) {} @@ -112,24 +110,40 @@ export class BrowseSectionComponent implements OnInit, OnChanges { } } + private coverCacheKey(entity: DashboardEntry): string { + return `${entity.type}:${entity.id}`; + } + + /** Asks each kind's descriptor for its cover, so the hub renders the same picture as the cards. */ private loadCoverImages(): void { if (!this.entities) return; this.entities .filter( (entity): entity is DashboardEntry & { id: number } => - entity.type === "dataset" && entity.coverImageUrl !== undefined && entity.id !== undefined && - !this.coverImageUrls.has(entity.id) + !this.coverImageUrls.has(this.coverCacheKey(entity)) ) .forEach(entity => { - const coverUrl = `${AppSettings.getApiEndpoint()}/dataset/${entity.id}/cover`; - this.coverImageUrls.set(entity.id, coverUrl); + // `find`, not `get`: a section may hold a kind the registry does not carry. + const coverUrl = this.resourceRegistry.find(entity.type)?.coverUrl; + if (!coverUrl) { + return; + } + const key = this.coverCacheKey(entity); + coverUrl(entity.id) + .pipe(untilDestroyed(this)) + .subscribe(url => { + if (url) { + this.coverImageUrls.set(key, url); + this.cdr.markForCheck(); + } + }); }); } getCoverImage(entity: DashboardEntry): string { - return this.coverImageUrls.get(entity.id!) || this.defaultBackground; + return this.coverImageUrls.get(this.coverCacheKey(entity)) || this.defaultBackground; } }
