This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new a89740810b feat(frontend, amber): offer the owners of what each page
actually shows (#8386)
a89740810b is described below
commit a89740810bec0d8487f33e53370e40a9adbbdfff
Author: Tanishq Gandhi <[email protected]>
AuthorDate: Tue Sep 8 22:51:47 2026 +0000
feat(frontend, amber): offer the owners of what each page actually shows
(#8386)
### What changes were proposed in this PR?
Every page has an Owner dropdown, and it should list the people who own
the things on that page. On the unified search page and on the hub, it
didn't.
| Page | What the page shows | What the dropdown listed | |
|---|---|---|---|
| Your Work → Workflows / Datasets / Models | what you own or were
granted | owners of those | ✅ |
| Search — Workflow tab | your + public workflows | owners of your
workflows | ⚠️ missing public owners |
| Search — Dataset tab | your + public datasets | owners of your
**workflows** | ❌ wrong kind |
| Search — Model tab | your + public models | owners of your
**workflows** | ❌ wrong kind |
| Search — All tab | your + public, all kinds | owners of your
**workflows** | ❌ wrong kind |
| Hub → Workflows / Datasets / Models | **public** resources | owners of
**your private** resources | ❌ wrong people |
Two causes:
1. **The search page never told the filter which tab was active.**
`search.component.html` rendered `<texera-filters>` with no
`[entityType]`, so the bar kept its `EntityType.Workflow` default on
every tab.
2. **Nothing could ask "who owns the published ones?"** All three
`*-owners` endpoints filter on the caller's own access rows, which is
right for Your Work and wrong for the hub.
Reproduced before the fix, with `texera` owning 2 workflows / 3 datasets
/ 3 models, `bob` owning 1 workflow and 1 private dataset, and `alice`
owning 1 public model:
| | Before | After |
|---|---|---|
| Search → Model tab, dropdown | `texera`, `[email protected]` — bob owns no
model | `texera`, `[email protected]` — the public model's owner |
| …ticking the wrong name | tab goes **empty** | no wrong name is
offered |
| Hub → Datasets as bob, dropdown | `[email protected]`, `texera` — bob has
nothing public | `texera` — exactly the owner on screen |
| …ticking `bob` | hub goes **empty** | bob is no longer offered |
**Backend — one endpoint, not three.** `HubResource.getCount` already
runs `where(isPublicColumn.eq(true))` over a per-kind registry covering
all three kinds, so `GET /hub/owners?entityType=` is its sibling.
`BaseEntityTable` gains a `joinWithOwner`, implemented once in
`VersionedResourceTables` for dataset and model, and once for workflow.
Deliberately not
`joinWithAccessAndOwner(None)`, which left-joins the access table and
fans out a row per grant. The three existing `*-owners` endpoints are
untouched.
**Frontend.** `FiltersComponent` gains an `ownerScope` input, and
`entityType` accepts `null` for a page listing every kind.
`ResourceRegistryService.ownersFor(type, scope)` resolves the rest:
`FiltersComponent` now implements `OnChanges`, so switching tabs
refetches; the reload runs through a `switchMap`, or a fast run of tab
clicks would let a stale response land last and repopulate the facet
with the previous kind's owners.
This is a `feat` rather than a `fix`: it adds an endpoint and a new
input, and it touches model and registry code that `release/v1.2` does
not have, so it should not be backported.
**Deliberately unchanged:** signed-out visitors still get no Owner
dropdown. The list is email addresses, and serving those to anonymous
callers on a public hub is email harvesting — so the new endpoint is
`@RolesAllowed` like its siblings.
**Behaviour worth calling out in review:** switching tabs with an owner
chip selected that the new kind has no owner for drops the chip and
shows the existing "Invalid owner name" toast. Silently keeping it is
what produced the mystery-empty page this PR is fixing.
#### Unified search, Model tab
In both shots the page lists one model, `empty-model`, owned by
`texera`. Look at the Owner dropdown
open at the top right.
**Before** — it offers `texera` and `[email protected]`. Those are the
*workflow* owners: `bob` owns no
model, and ticking him empties the tab.
<img width="1440" height="900" alt="Search, Model tab before the fix:
the Owner dropdown lists texera and [email protected]"
src="https://github.com/user-attachments/assets/a2c2d195-4297-4285-910c-6cb0d077b3e7"
/>
**After** — it offers `texera` and `[email protected]`. Those are the
*model* owners: `bob` is gone, and
`alice` appears because she owns a public model, which this page lists.
<img width="1440" height="900" alt="Search, Model tab after the fix: the
Owner dropdown lists texera and [email protected]"
src="https://github.com/user-attachments/assets/87ac9eb1-4990-4e71-84b4-b7abfaca0b71"
/>
#### Hub → Datasets, signed in as `bob`
In both shots the hub lists one dataset, `public-weather`, which is
public and owned by `texera`.
**Before** — the dropdown offers `[email protected]` and `texera`. `bob` is
offered because he owns a
*private* dataset of his own, which cannot appear on this page; ticking
him empties the hub.
<img width="1440" height="900" alt="Hub datasets before the fix: the
Owner dropdown lists [email protected] and texera"
src="https://github.com/user-attachments/assets/bf3b69fa-4e80-4a88-86ea-3b9e19a6dd16"
/>
**After** — the dropdown offers `texera` alone: exactly the owner of
what is on screen.
<img width="1440" height="900" alt="Hub datasets after the fix: the
Owner dropdown lists texera only"
src="https://github.com/user-attachments/assets/10ed7a5e-d1c2-402b-8a6c-55e5bcd67b7a"
/>
### Any related issues, documentation, discussions?
Closes #8385.
### How was this PR tested?
**Backend** — 6 new cases in `HubResourceSpec` (82 passed):
- owners of published workflows and no one else
- an owner named once however many public entities they have
- **a grant is ignored** — the bug itself: a user holding a grant but
publishing nothing is not offered
- each kind kept to its own table, with a different owner per kind so a
mix-up cannot pass
- owners of private entities left out
- nobody named for a public workflow with no owner row
**Frontend** — 277 passed across the eight affected specs. New cases
cover: per-scope routing
(asserting the *other* endpoint is not called), the union across kinds,
a person owning several kinds
named once, a kind whose request fails contributing nothing rather than
blanking the facet, reload on
kind change, exactly one load on first render, and both the dropped and
the surviving owner chip.
```
sbt 'testOnly *HubResourceSpec *EntityTablesSpec'
cd frontend && npx ng test --include
src/app/dashboard/component/user/filters/filters.component.spec.ts \
--include
src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts
\
--include
src/app/dashboard/component/user/search/search.component.spec.ts \
--include
src/app/hub/component/hub-search-result/hub-search-result.component.spec.ts
```
End-to-end against a local stack: the table above is the actual
before/after, and
`GET /hub/owners` returns `["texera"]` for datasets where the old
access-scoped endpoint returned
`["[email protected]","texera"]`.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 5)
---
.../dashboard/VersionedResourceTables.scala | 4 +
.../web/resource/dashboard/hub/EntityTables.scala | 10 ++
.../web/resource/dashboard/hub/HubResource.scala | 15 ++
.../resource/dashboard/hub/HubResourceSpec.scala | 46 +++++
.../stub-workflow-persist.service.ts | 2 +
.../user/filters/filters.component.spec.ts | 186 ++++++++++++++++++++-
.../component/user/filters/filters.component.ts | 136 ++++++++++++---
.../component/user/search/search.component.html | 5 +-
.../component/user/search/search.component.spec.ts | 17 ++
.../component/user/search/search.component.ts | 10 ++
.../dataset-resource.descriptor.ts | 4 +-
.../resource-registry/model-resource.descriptor.ts | 4 +-
.../resource-registry.service.spec.ts | 56 ++++++-
.../resource-registry/resource-registry.service.ts | 35 ++++
.../workflow-resource.descriptor.ts | 4 +-
frontend/src/app/dashboard/type/owner-scope.ts | 21 +++
.../src/app/dashboard/type/resource-descriptor.ts | 2 +
.../hub-search-result.component.html | 1 +
.../hub-search-result.component.spec.ts | 1 +
frontend/src/app/hub/service/hub.service.spec.ts | 12 ++
frontend/src/app/hub/service/hub.service.ts | 7 +
21 files changed, 543 insertions(+), 35 deletions(-)
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTables.scala
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTables.scala
index 7394c6db8a..7a6d423215 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTables.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTables.scala
@@ -69,6 +69,10 @@ trait VersionedResourceTables[Rec <: Record, P] extends
BaseEntityTable with Laz
size: Long
): DashboardClickableFileEntry
+ /** Owner-only; `joinWithAccessAndOwner` would fan out a row per grant. */
+ final override def joinWithOwner: Table[_ <: Record] =
+ table.join(USER).on(USER.UID.eq(ownerUidColumn))
+
/** `accessCondition` narrows the access join: search to the caller, the hub
to nothing. */
final def joinWithAccessAndOwner(accessCondition: Option[Condition]):
Table[Record] = {
val accessJoin = table
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityTables.scala
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityTables.scala
index 1e1bc472cb..b76a6cb863 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityTables.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityTables.scala
@@ -95,6 +95,9 @@ object EntityTables {
val table: Table[R]
val isPublicColumn: TableField[R, java.lang.Boolean]
val idColumn: TableField[R, Integer]
+
+ /** The entity joined to its owner's USER row. A def: a val here reads the
subclass's fields too early. */
+ def joinWithOwner: Table[_ <: Record]
}
object BaseEntityTable {
@@ -104,6 +107,13 @@ object EntityTables {
override val isPublicColumn: TableField[WorkflowRecord,
java.lang.Boolean] =
WORKFLOW.IS_PUBLIC
override val idColumn: TableField[WorkflowRecord, Integer] = WORKFLOW.WID
+ // Inner: a workflow with no owner row contributes nobody.
+ override def joinWithOwner: Table[_ <: Record] =
+ WORKFLOW
+ .join(WORKFLOW_OF_USER)
+ .on(WORKFLOW_OF_USER.WID.eq(WORKFLOW.WID))
+ .join(USER)
+ .on(USER.UID.eq(WORKFLOW_OF_USER.UID))
}
def apply(entityType: EntityType): BaseEntityTable =
EntityTables(entityType).base
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
index 424eb9af85..42ae99d9ce 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
@@ -39,6 +39,7 @@ import org.jooq.impl.DSL
import org.jooq.{Record, Table, TableField}
import java.util.regex.Pattern
+import javax.annotation.security.RolesAllowed
import javax.servlet.http.HttpServletRequest
import javax.ws.rs._
import javax.ws.rs.core.{Context, MediaType}
@@ -333,6 +334,20 @@ class HubResource {
.fetchOne(0, classOf[Integer])
}
+ /** Owners of the published entities of one kind; the `*-owners` endpoints
answer who granted the caller. */
+ @GET
+ @Path("/owners")
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ def getPublicOwners(@QueryParam("entityType") entityType: EntityType):
java.util.List[String] = {
+ val entityTables = EntityTables(entityType).base
+
+ context
+ .selectDistinct(USER.EMAIL)
+ .from(entityTables.joinWithOwner)
+ .where(entityTables.isPublicColumn.eq(true))
+ .fetchInto(classOf[String])
+ }
+
@GET
@Path("/isLiked")
@Produces(Array(MediaType.APPLICATION_JSON))
diff --git
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubResourceSpec.scala
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubResourceSpec.scala
index 027716ee2d..88b910107e 100644
---
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubResourceSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubResourceSpec.scala
@@ -716,6 +716,52 @@ class HubResourceSpec
hub.getCount(Wf).intValue() shouldBe 3
}
+ "getPublicOwners" should "offer the owners of published workflows, and no
one else" in {
+ // hub_liker's workflow is private, so only hub_owner has anything the hub
can show.
+ seedWorkflow(810601, "wf_private_of_liker", isPublic = false, owner =
likerUid)
+
+ hub.getPublicOwners(Wf).asScala should contain theSameElementsAs
Seq("[email protected]")
+ }
+
+ it should "name an owner once however many public entities they have" in {
+ seedWorkflow(810602, "wf_public_b", isPublic = true)
+ seedWorkflow(810603, "wf_public_c", isPublic = true)
+
+ hub.getPublicOwners(Wf).asScala shouldBe Seq("[email protected]")
+ }
+
+ it should "ignore a grant, which is what the per-kind owners endpoints
answer instead" in {
+ // The bug itself: a grant without anything published must not make you an
owner here.
+ grantWorkflowAccess(wid, likerUid, PrivilegeEnum.READ)
+
+ hub.getPublicOwners(Wf).asScala should not contain "[email protected]"
+ }
+
+ it should "keep each kind to its own table" in {
+ // Deliberately different owners per kind, so a table mix-up cannot pass.
+ seedDataset(820601, "ds_public", owner = likerUid)
+ seedModel(840601, "md_public", owner = thirdUid)
+
+ hub.getPublicOwners(Ds).asScala shouldBe Seq("[email protected]")
+ hub.getPublicOwners(Md).asScala shouldBe Seq("[email protected]")
+ hub.getPublicOwners(Wf).asScala shouldBe Seq("[email protected]")
+ }
+
+ it should "leave out the owners of private entities" in {
+ seedDataset(820602, "ds_private", isPublic = false, owner = likerUid)
+ seedModel(840602, "md_private", isPublic = false, owner = thirdUid)
+
+ hub.getPublicOwners(Ds).asScala shouldBe empty
+ hub.getPublicOwners(Md).asScala shouldBe empty
+ }
+
+ it should "name nobody for a public workflow that has no owner row" in {
+ seedWorkflow(810604, "wf_ownerless", isPublic = true, withOwnerRows =
false)
+
+ // The fixture workflow's owner, and not a null for the ownerless one.
+ hub.getPublicOwners(Wf).asScala shouldBe Seq("[email protected]")
+ }
+
"postView" should "increment the view count and record a view action" in {
val resource = new HubResource()
resource.postView(
diff --git
a/frontend/src/app/common/service/workflow-persist/stub-workflow-persist.service.ts
b/frontend/src/app/common/service/workflow-persist/stub-workflow-persist.service.ts
index 4c873fa2ba..b725060d5a 100644
---
a/frontend/src/app/common/service/workflow-persist/stub-workflow-persist.service.ts
+++
b/frontend/src/app/common/service/workflow-persist/stub-workflow-persist.service.ts
@@ -49,6 +49,7 @@ export class StubWorkflowPersistService {
const names = this.testWorkflows.filter(i => i).map(i =>
i.workflow.ownerName) as string[];
return new Observable(observer => {
observer.next([...new Set(names)]);
+ observer.complete();
});
}
@@ -58,6 +59,7 @@ export class StubWorkflowPersistService {
public retrieveWorkflowIDs(): Observable<number[]> {
return new Observable(observer => {
observer.next(this.testWorkflows.map(i => i.workflow.workflow.wid as
number).filter(i => i));
+ observer.complete();
});
}
diff --git
a/frontend/src/app/dashboard/component/user/filters/filters.component.spec.ts
b/frontend/src/app/dashboard/component/user/filters/filters.component.spec.ts
index 3ab48e48c2..5001e7689e 100644
---
a/frontend/src/app/dashboard/component/user/filters/filters.component.spec.ts
+++
b/frontend/src/app/dashboard/component/user/filters/filters.component.spec.ts
@@ -37,9 +37,11 @@ import { UserService } from
"src/app/common/service/user/user.service";
import { StubUserService } from
"src/app/common/service/user/stub-user.service";
import { NotificationService } from
"src/app/common/service/notification/notification.service";
import { DatasetService } from
"src/app/dashboard/service/user/dataset/dataset.service";
+import { ModelService } from "../../../service/user/model/model.service";
import { EntityType } from "src/app/hub/service/hub.service";
import { By } from "@angular/platform-browser";
-import { of } from "rxjs";
+import { of, throwError, Subject } from "rxjs";
+import { SimpleChange } from "@angular/core";
describe("FiltersComponent", () => {
let component: FiltersComponent;
@@ -152,6 +154,25 @@ describe("FiltersComponent", () => {
stubUser.user = previousUser;
}
});
+
+ it("offers no owner facet to a signed-out hub visitor", () => {
+ // The hub is reachable signed out, but /hub/owners is @RolesAllowed and
the list is emails.
+ const stubUser = TestBed.inject(UserService) as unknown as
StubUserService;
+ const previousUser = stubUser.user;
+ try {
+ stubUser.user = undefined;
+ const anonFixture = TestBed.createComponent(FiltersComponent);
+ anonFixture.componentInstance.ownerScope = "public";
+ anonFixture.detectChanges();
+
+ expect(anonFixture.componentInstance.owners).toEqual([]);
+ const ownerButton =
anonFixture.nativeElement.querySelector(".search-owners-button") as HTMLElement;
+ expect(ownerButton.hidden).toBe(true);
+ anonFixture.destroy();
+ } finally {
+ stubUser.user = previousUser;
+ }
+ });
});
describe("dropdown checkbox handlers build the master filter list", () => {
@@ -439,10 +460,12 @@ describe("FiltersComponent per-resource owners", () => {
let datasetOwners: ReturnType<typeof vi.fn>;
let workflowOwners: ReturnType<typeof vi.fn>;
let workflowIds: ReturnType<typeof vi.fn>;
+ let modelOwners: ReturnType<typeof vi.fn>;
/** The input has to be set before ngOnInit reads it. */
- async function render(entityType?: EntityType): Promise<void> {
+ async function render(entityType?: EntityType | null): Promise<void> {
datasetOwners = vi.fn(() => of(["dataset-owner"]));
+ modelOwners = vi.fn(() => of(["model-owner"]));
workflowOwners = vi.fn(() => of(["workflow-owner"]));
workflowIds = vi.fn(() => of([7]));
@@ -455,6 +478,7 @@ describe("FiltersComponent per-resource owners", () => {
useValue: { retrieveOwners: workflowOwners, retrieveWorkflowIDs:
workflowIds },
},
{ provide: DatasetService, useValue: { retrieveOwners: datasetOwners }
},
+ { provide: ModelService, useValue: { retrieveOwners: modelOwners } },
{ provide: OperatorMetadataService, useClass:
StubOperatorMetadataService },
{ provide: UserService, useClass: StubUserService },
provideNzI18n(en_US),
@@ -478,7 +502,7 @@ describe("FiltersComponent per-resource owners", () => {
}
});
- it("defaults to workflows, so the call sites that pass nothing are
unaffected", async () => {
+ it("defaults to workflows, which the Your Work workflows page still relies
on", async () => {
await render();
expect(component.entityType).toBe(EntityType.Workflow);
@@ -521,4 +545,160 @@ describe("FiltersComponent per-resource owners", () => {
expect(fixture.debugElement.query(By.css(".search-wids-button"))).not.toBeNull();
});
+
+ it("unions every kind's owners for a page that lists them all", async () => {
+ // The search page's All tab, where there is no single kind to ask about.
+ await render(null);
+
+ expect(workflowOwners).toHaveBeenCalled();
+ expect(datasetOwners).toHaveBeenCalled();
+ expect(component.owners.map(owner =>
owner.userName)).toEqual(["workflow-owner", "dataset-owner", "model-owner"]);
+ });
+
+ it("keeps the workflow id filter on a page that lists every kind", async ()
=> {
+ // That page lists workflows too, and the backend binds `id=` to the
workflow arm.
+ await render(null);
+
+ expect(component.hasIdFilter).toBe(true);
+ expect(component.wids.map(wid => wid.id)).toEqual(["7"]);
+ });
+
+ it("reloads the owners when the listed kind changes", async () => {
+ await render(EntityType.Workflow);
+ expect(component.owners.map(owner =>
owner.userName)).toEqual(["workflow-owner"]);
+
+ component.entityType = EntityType.Dataset;
+ component.ngOnChanges({ entityType: new SimpleChange(EntityType.Workflow,
EntityType.Dataset, false) });
+ fixture.detectChanges();
+
+ expect(component.owners.map(owner =>
owner.userName)).toEqual(["dataset-owner"]);
+ });
+
+ it("loads the owners once on first render, not twice", async () => {
+ await render(EntityType.Workflow);
+ // ngOnChanges runs before ngOnInit; only ngOnInit may load, or every page
pays two requests.
+ component.ngOnChanges({ entityType: new SimpleChange(undefined,
EntityType.Workflow, true) });
+
+ expect(workflowOwners).toHaveBeenCalledTimes(1);
+ });
+
+ it("reloads the ids too, so the id filter works after a tab switch", async
() => {
+ // Datasets have no id endpoint, so switching back to workflows must
refetch them, or the id
+ // button opens on an empty menu and a typed id is rejected as invalid.
+ await render(EntityType.Dataset);
+ expect(component.wids).toEqual([]);
+
+ component.entityType = EntityType.Workflow;
+ component.ngOnChanges({ entityType: new SimpleChange(EntityType.Dataset,
EntityType.Workflow, false) });
+
+ expect(workflowIds).toHaveBeenCalled();
+ expect(component.wids.map(wid => wid.id)).toEqual(["7"]);
+ });
+
+ it("lets a tab switch cancel the load already in flight", async () => {
+ // The init fetch runs through the same subject as a reload, so switchMap
can cancel it. A slow
+ // init response landing after a switch would otherwise refill the facet
with the old kind.
+ await render(EntityType.Dataset);
+ const slowWorkflowOwners = new Subject<string[]>();
+ workflowOwners.mockReturnValue(slowWorkflowOwners.asObservable());
+
+ // A second component, so the slow response is the one its init is waiting
on.
+ const pending = TestBed.createComponent(FiltersComponent);
+ pending.componentInstance.entityType = EntityType.Workflow;
+ pending.detectChanges();
+ expect(pending.componentInstance.owners).toEqual([]);
+
+ pending.componentInstance.entityType = EntityType.Dataset;
+ pending.componentInstance.ngOnChanges({
+ entityType: new SimpleChange(EntityType.Workflow, EntityType.Dataset,
false),
+ });
+ expect(pending.componentInstance.owners.map(owner =>
owner.userName)).toEqual(["dataset-owner"]);
+
+ // The superseded request answering late must not put workflow owners on
the Datasets tab.
+ slowWorkflowOwners.next(["workflow-owner"]);
+ slowWorkflowOwners.complete();
+
+ expect(pending.componentInstance.owners.map(owner =>
owner.userName)).toEqual(["dataset-owner"]);
+ pending.destroy();
+ });
+
+ it("survives a failing id request, and keeps reloading afterwards", async ()
=> {
+ // The error has to happen on a reload that actually fetches ids, so start
on datasets, which
+ // have no id endpoint, and switch to workflows with the id request
failing.
+ await render(EntityType.Dataset);
+ workflowIds.mockReturnValue(throwError(() => new Error("boom")));
+
+ component.entityType = EntityType.Workflow;
+ component.ngOnChanges({ entityType: new SimpleChange(EntityType.Dataset,
EntityType.Workflow, false) });
+
+ // The owner facet still lands: a failed id request costs its own facet,
not the other one.
+ expect(component.owners.map(owner =>
owner.userName)).toEqual(["workflow-owner"]);
+ expect(component.wids).toEqual([]);
+
+ // And the subscription is still alive, so later switches keep working.
+ workflowIds.mockReturnValue(of([7]));
+ component.entityType = EntityType.Dataset;
+ component.ngOnChanges({ entityType: new SimpleChange(EntityType.Workflow,
EntityType.Dataset, false) });
+ expect(component.owners.map(owner =>
owner.userName)).toEqual(["dataset-owner"]);
+
+ component.entityType = EntityType.Workflow;
+ component.ngOnChanges({ entityType: new SimpleChange(EntityType.Dataset,
EntityType.Workflow, false) });
+ expect(component.wids.map(wid => wid.id)).toEqual(["7"]);
+ });
+
+ it("keeps an id tag that the new kind still offers", async () => {
+ await render(EntityType.Dataset);
+ component.entityType = EntityType.Workflow;
+ component.ngOnChanges({ entityType: new SimpleChange(EntityType.Dataset,
EntityType.Workflow, false) });
+ component.masterFilterList = ["id: 7"];
+
+ expect(component.selectedIDs).toEqual(["7"]);
+ });
+
+ it("leaves nothing applied after signing out, whose facets it cannot
refetch", async () => {
+ await render(EntityType.Workflow);
+ component.masterFilterList = ["owner: workflow-owner"];
+ expect(component.selectedOwners).toEqual(["workflow-owner"]);
+
+ // The stub's logout() is a no-op; a sign-out is the user going away and
the subject firing.
+ const userService = TestBed.inject(UserService) as unknown as
StubUserService;
+ userService.user = undefined;
+ userService.userChangeSubject.next(undefined);
+
+ // Otherwise the anonymous hub stays filtered by an owner its facet no
longer offers, with the
+ // dropdown hidden and no way to clear it.
+ expect(component.selectedOwners).toEqual([]);
+ expect(component.owners).toEqual([]);
+ });
+
+ it("drops a selection belonging to the previous kind, without scolding the
user for switching", async () => {
+ await render(EntityType.Workflow);
+ const error = vi.spyOn(TestBed.inject(NotificationService), "error");
+ component.masterFilterList = ["owner: workflow-owner"];
+ expect(component.selectedOwners).toEqual(["workflow-owner"]);
+
+ component.entityType = EntityType.Dataset;
+ component.ngOnChanges({ entityType: new SimpleChange(EntityType.Workflow,
EntityType.Dataset, false) });
+
+ // Cleared with the facet it came from, so no search carries it into the
new kind.
+ expect(component.selectedOwners).toEqual([]);
+ expect(component.masterFilterList).not.toContain("owner: workflow-owner");
+ // Switching tabs is not a mistake, so it is not reported as one.
+ expect(error).not.toHaveBeenCalled();
+ });
+
+ it("drops a selection even when the new kind offers the same owner", async
() => {
+ // A deliberate trade: the bar cannot know the selection is still valid
until the new facet
+ // lands, and by then the host has already searched with it. Losing a
still-valid owner costs
+ // one re-tick; keeping it costs an empty tab on every switch.
+ await render(EntityType.Workflow);
+ datasetOwners.mockReturnValue(of(["workflow-owner"]));
+ component.masterFilterList = ["owner: workflow-owner"];
+
+ component.entityType = EntityType.Dataset;
+ component.ngOnChanges({ entityType: new SimpleChange(EntityType.Workflow,
EntityType.Dataset, false) });
+
+ expect(component.selectedOwners).toEqual([]);
+ expect(component.owners.map(owner =>
owner.userName)).toEqual(["workflow-owner"]);
+ });
});
diff --git
a/frontend/src/app/dashboard/component/user/filters/filters.component.ts
b/frontend/src/app/dashboard/component/user/filters/filters.component.ts
index 58674bbcd4..60cd27c31b 100644
--- a/frontend/src/app/dashboard/component/user/filters/filters.component.ts
+++ b/frontend/src/app/dashboard/component/user/filters/filters.component.ts
@@ -17,12 +17,24 @@
* under the License.
*/
-import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output }
from "@angular/core";
+import {
+ ChangeDetectorRef,
+ Component,
+ EventEmitter,
+ Input,
+ OnChanges,
+ OnInit,
+ Output,
+ SimpleChanges,
+} from "@angular/core";
import { OperatorMetadataService } from
"src/app/workspace/service/operator-metadata/operator-metadata.service";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { NotificationService } from
"src/app/common/service/notification/notification.service";
import { ResourceRegistryService } from
"../../../service/user/resource-registry/resource-registry.service";
import { EntityType } from "../../../../hub/service/hub.service";
+import { OwnerScope } from "../../../type/owner-scope";
+import { forkJoin, Observable, of, Subject } from "rxjs";
+import { catchError, switchMap } from "rxjs/operators";
import { SearchFilterParameters } from
"../../../type/search-filter-parameters";
import { UserService } from "../../../../common/service/user/user.service";
import { NzDropdownADirective, NzDropdownDirective, NzDropdownMenuComponent }
from "ng-zorro-antd/dropdown";
@@ -63,11 +75,15 @@ import { NzCheckboxComponent } from
"ng-zorro-antd/checkbox";
NzSpaceCompactComponent,
],
})
-export class FiltersComponent implements OnInit {
+export class FiltersComponent implements OnInit, OnChanges {
public isLogin = this.userService.isLogin();
+ /** Fires when the kind or scope changes, to refetch the owner facet. */
+ private readonly facetReload$ = new Subject<void>();
private _masterFilterList: ReadonlyArray<string> = [];
- /** Which resource kind this page lists; decides whose owners and ids are
offered. */
- @Input() public entityType: EntityType = EntityType.Workflow;
+ /** Which kind this page lists; decides whose owners and ids are offered.
`null` spans every kind. */
+ @Input() public entityType: EntityType | null = EntityType.Workflow;
+ /** Which people the Owner facet offers, matching the results the host page
shows. */
+ @Input() public ownerScope: OwnerScope = "accessible";
@Output()
public masterFilterListChange = new EventEmitter<typeof
this._masterFilterList>();
public get masterFilterList(): ReadonlyArray<string> {
@@ -113,14 +129,93 @@ export class FiltersComponent implements OnInit {
private cdr: ChangeDetectorRef
) {}
- /** The id dropdown is hidden for kinds with no id-listing endpoint. */
+ /**
+ * The id dropdown is hidden for kinds with no id-listing endpoint. A page
listing every kind keeps
+ * the workflow ids it had before: that page lists workflows too, and the
backend binds `id=` to
+ * the workflow arm, exactly as the operator facet is workflow-only.
+ */
public get hasIdFilter(): boolean {
- return this.resourceRegistry.get(this.entityType).retrieveIds !==
undefined;
+ return this.resourceRegistry.get(this.entityType ??
EntityType.Workflow).retrieveIds !== undefined;
}
ngOnInit(): void {
this.trackLoginState();
this.searchParameterBackendSetup();
+ this.facetReload$
+ .pipe(
+ // switchMap: without it a stale response can land last and refill the
facet.
+ switchMap(() =>
+ forkJoin({
+ owners: this.ownersForCurrentScope(),
+ ids: this.idsForCurrentKind(),
+ })
+ ),
+ untilDestroyed(this)
+ )
+ .subscribe(facets => this.applyFacets(facets));
+ // Through the subject, not a separate subscribe: an init response that
landed after a tab
+ // switch would otherwise overwrite the new kind's facets.
+ this.facetReload$.next();
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ const changed = changes["entityType"] ?? changes["ownerScope"];
+ // ngOnChanges runs before ngOnInit, so the first pass is left to
ngOnInit's single load.
+ if (changed && !changed.firstChange) {
+ this.reloadFacets();
+ }
+ }
+
+ /**
+ * Drops the owner and id selections, and the tags carrying them. The host
calls this before it
+ * searches a new kind: the selections belong to the facet being replaced,
and a search that still
+ * carries them asks the new kind for an owner it has no facet for.
+ */
+ public clearFacetSelections(): void {
+ this.selectedOwners = [];
+ this.selectedIDs = [];
+ this.owners.forEach(owner => (owner.checked = false));
+ this.wids.forEach(wid => (wid.checked = false));
+ this.setMasterFilterList(
+ this.masterFilterList.filter(tag => !tag.startsWith("owner: ") &&
!tag.startsWith("id: ")),
+ false
+ );
+ }
+
+ /** Refetches both facets for the kind and scope now in effect. */
+ private reloadFacets(): void {
+ this.clearFacets();
+ this.facetReload$.next();
+ }
+
+ /** Empties both facets and whatever was selected from them. */
+ private clearFacets(): void {
+ this.owners = [];
+ this.wids = [];
+ this.clearFacetSelections();
+ }
+
+ /** The owners this page should offer; none for a signed-out visitor, whose
endpoints are gated. */
+ private ownersForCurrentScope(): Observable<string[]> {
+ return this.isLogin ? this.resourceRegistry.ownersFor(this.entityType,
this.ownerScope) : of([]);
+ }
+
+ /** The ids this kind offers, or none for a kind with no id endpoint. */
+ private idsForCurrentKind(): Observable<number[]> {
+ if (!this.isLogin) {
+ return of([]);
+ }
+ // Same rule as the owner leg: a failed request costs its own facet, not
the subscription, which
+ // would otherwise end here and leave every later tab switch blanking both
dropdowns.
+ const descriptor = this.resourceRegistry.get(this.entityType ??
EntityType.Workflow);
+ return (descriptor.retrieveIds?.() ?? of([])).pipe(catchError(() =>
of([])));
+ }
+
+ /** Re-runs the tag list: a surviving selection stays checked, one that does
not is dropped and reported. */
+ private applyFacets({ owners, ids }: { owners: string[]; ids: number[] }):
void {
+ this.owners = owners.map(name => ({ userName: name, checked: false }));
+ this.wids = ids.map(id => ({ id: id.toString(), checked: false }));
+ this.updateDropdownMenus(this.masterFilterList);
}
private trackLoginState(): void {
@@ -129,6 +224,15 @@ export class FiltersComponent implements OnInit {
.pipe(untilDestroyed(this))
.subscribe(() => {
this.isLogin = this.userService.isLogin();
+ if (this.isLogin) {
+ // Signing in mid-page used to leave the facet empty until a reload.
+ this.reloadFacets();
+ } else {
+ // Signing out: clear rather than refetch, or an expired session
fires the authenticated
+ // endpoints anyway and the empty result drops the chips with an
"Invalid owner name" toast.
+ // The selections go too, or the anonymous hub stays filtered by an
owner it no longer offers.
+ this.clearFacets();
+ }
this.cdr.detectChanges();
});
}
@@ -156,26 +260,6 @@ export class FiltersComponent implements OnInit {
});
this.operatorGroups = opdata.groups.map(group => group.groupName);
});
- if (this.isLogin) {
- const descriptor = this.resourceRegistry.get(this.entityType);
- descriptor
- .retrieveOwners?.()
- .pipe(untilDestroyed(this))
- .subscribe(list_of_owners => {
- this.owners = list_of_owners.map(i => ({ userName: i, checked: false
}));
- });
- descriptor
- .retrieveIds?.()
- .pipe(untilDestroyed(this))
- .subscribe(ids => {
- this.wids = ids.map(id => {
- return {
- id: id.toString(),
- checked: false,
- };
- });
- });
- }
}
/**
diff --git
a/frontend/src/app/dashboard/component/user/search/search.component.html
b/frontend/src/app/dashboard/component/user/search/search.component.html
index 421a2799a5..167b3fcd93 100644
--- a/frontend/src/app/dashboard/component/user/search/search.component.html
+++ b/frontend/src/app/dashboard/component/user/search/search.component.html
@@ -77,7 +77,10 @@
</button>
</div>
- <texera-filters #filters></texera-filters>
+ <texera-filters
+ [entityType]="filterEntityType"
+ ownerScope="accessibleAndPublic"
+ #filters></texera-filters>
</div>
</div>
diff --git
a/frontend/src/app/dashboard/component/user/search/search.component.spec.ts
b/frontend/src/app/dashboard/component/user/search/search.component.spec.ts
index 2ce35c4326..6a5b244f24 100644
--- a/frontend/src/app/dashboard/component/user/search/search.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/search/search.component.spec.ts
@@ -44,6 +44,7 @@ import { WorkflowPersistService } from
"src/app/common/service/workflow-persist/
import { StubWorkflowPersistService } from
"src/app/common/service/workflow-persist/stub-workflow-persist.service";
import { SortButtonComponent } from "../sort-button/sort-button.component";
import { MODEL_ICON } from "../../../../common/icon/model-icon";
+import { EntityType } from "../../../../hub/service/hub.service";
// Lightweight stand-in for FiltersComponent. It registers itself under the
real
// FiltersComponent token so SearchComponent's `@ViewChild(FiltersComponent)`
@@ -56,10 +57,13 @@ import { MODEL_ICON } from
"../../../../common/icon/model-icon";
providers: [{ provide: FiltersComponent, useExisting: forwardRef(() =>
MockFiltersComponent) }],
})
class MockFiltersComponent {
+ @Input() entityType: EntityType | null = null;
+ @Input() ownerScope?: string;
masterFilterListChange = EMPTY;
masterFilterList: ReadonlyArray<string> = [];
getSearchKeywords = (): string[] => [...this.masterFilterList];
getSearchFilterParameters = () => ({});
+ clearFacetSelections = vi.fn();
}
@Component({
@@ -289,6 +293,19 @@ describe("SearchComponent", () => {
expect(searchSpy).toHaveBeenCalledTimes(1);
});
+ it("clears the previous tab's facet selections before it searches", () => {
+ // search() reads the filter parameters in the same turn, long before the
new facet lands, so a
+ // selection cleared afterwards would still go out with the first request.
+ fixture.detectChanges(); // resolves the filters ViewChild
+ const order: string[] = [];
+ vi.spyOn(component.filters, "clearFacetSelections").mockImplementation(()
=> void order.push("clear"));
+ vi.spyOn(component, "search").mockImplementation(async () => void
order.push("search"));
+
+ component.filterByType("dataset");
+
+ expect(order).toEqual(["clear", "search"]);
+ });
+
it("navigates back on goBack", () => {
component.goBack();
expect(locationStub.back).toHaveBeenCalledTimes(1);
diff --git
a/frontend/src/app/dashboard/component/user/search/search.component.ts
b/frontend/src/app/dashboard/component/user/search/search.component.ts
index 37c970ca24..7ef7d22c90 100644
--- a/frontend/src/app/dashboard/component/user/search/search.component.ts
+++ b/frontend/src/app/dashboard/component/user/search/search.component.ts
@@ -35,6 +35,7 @@ import { NzIconDirective } from "ng-zorro-antd/icon";
import { SortButtonComponent } from "../sort-button/sort-button.component";
import { NzWaveDirective } from "ng-zorro-antd/core/wave";
import { MODEL_ICON } from "../../../../common/icon/model-icon";
+import { EntityType } from "../../../../hub/service/hub.service";
@UntilDestroy()
@Component({
@@ -64,6 +65,11 @@ export class SearchComponent implements AfterViewInit {
searchKeywords: string[] = [];
selectedType: "workflow" | "dataset" | "model" | null = null;
+
+ /** The selected tab as the bar's kind; the literals are the enum's values,
`null` on the All tab. */
+ public get filterEntityType(): EntityType | null {
+ return this.selectedType === null ? null : (this.selectedType as
EntityType);
+ }
lastSelectedType: "workflow" | "dataset" | "model" | null = null;
public masterFilterList: ReadonlyArray<string> = [];
@@ -150,6 +156,10 @@ export class SearchComponent implements AfterViewInit {
filterByType(type: "workflow" | "dataset" | "model" | null): void {
this.selectedType = type;
+ // Before searching, not after: search() reads the filter parameters in
the same turn, so a
+ // selection left over from the previous tab would go out with the first
request. Guarded on the
+ // backing field, since the getter throws until the ViewChild resolves
(#6328).
+ this._filters?.clearFacetSelections();
this.search();
}
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 164a48c693..d4da5b153a 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
@@ -20,7 +20,7 @@
import { Injectable } from "@angular/core";
import { DashboardEntry } from "../../../type/dashboard-entry";
import { ResourceAffordances, ResourceDescriptor } from
"../../../type/resource-descriptor";
-import { EntityType } from "../../../../hub/service/hub.service";
+import { EntityType, HubService } 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";
@@ -41,6 +41,7 @@ export class DatasetResourceDescriptor implements
ResourceDescriptor {
constructor(
private datasetService: DatasetService,
+ private hubService: HubService,
private downloadService: DownloadService
) {}
@@ -50,6 +51,7 @@ export class DatasetResourceDescriptor implements
ResourceDescriptor {
updateDescription = (id: number, description: string) =>
this.datasetService.updateDatasetDescription(id, description);
retrieveOwners = () => this.datasetService.retrieveOwners();
+ retrievePublicOwners = () =>
this.hubService.getPublicOwners(EntityType.Dataset);
download = (id: number, name: string) =>
this.downloadService.downloadDataset(id, name);
retrieveSingleFile = (filePath: string, isLogin: boolean) =>
this.datasetService.retrieveDatasetVersionSingleFile(filePath, isLogin);
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 877abec967..b0bfd84aec 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
@@ -20,7 +20,7 @@
import { Injectable } from "@angular/core";
import { DashboardEntry } from "../../../type/dashboard-entry";
import { ResourceAffordances, ResourceDescriptor } from
"../../../type/resource-descriptor";
-import { EntityType } from "../../../../hub/service/hub.service";
+import { EntityType, HubService } from "../../../../hub/service/hub.service";
import { DEFAULT_MODEL_NAME, ModelService, validateModelName } from
"../model/model.service";
import { MODEL_ICON } from "../../../../common/icon/model-icon";
import { HUB_MODEL_RESULT_DETAIL, USER_MODEL } from
"../../../../app-routing.constant";
@@ -42,6 +42,7 @@ export class ModelResourceDescriptor implements
ResourceDescriptor {
constructor(
private modelService: ModelService,
+ private hubService: HubService,
private downloadService: DownloadService
) {}
@@ -53,6 +54,7 @@ export class ModelResourceDescriptor implements
ResourceDescriptor {
retrieveSingleFile = (filePath: string, isLogin: boolean) =>
this.modelService.retrieveModelVersionSingleFile(filePath, isLogin);
retrieveOwners = () => this.modelService.retrieveOwners();
+ retrievePublicOwners = () =>
this.hubService.getPublicOwners(EntityType.Model);
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);
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 0b15d9be65..4e83312d7a 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,10 +19,11 @@
import { TestBed } from "@angular/core/testing";
import { HttpClientTestingModule } from "@angular/common/http/testing";
-import { firstValueFrom, of } from "rxjs";
+import { firstValueFrom, of, throwError } from "rxjs";
import { ResourceRegistryService } from "./resource-registry.service";
import { DashboardEntry } from "../../../type/dashboard-entry";
-import { EntityType } from "../../../../hub/service/hub.service";
+import { EntityType, HubService } from "../../../../hub/service/hub.service";
+import { OwnerScope } from "../../../type/owner-scope";
import { DatasetService } from "../dataset/dataset.service";
import { ModelService } from "../model/model.service";
import { WorkflowPersistService } from
"../../../../common/service/workflow-persist/workflow-persist.service";
@@ -48,6 +49,7 @@ describe("ResourceRegistryService", () => {
let datasetService: { [k: string]: ReturnType<typeof vi.fn> };
let modelService: { [k: string]: ReturnType<typeof vi.fn> };
let downloadService: { [k: string]: ReturnType<typeof vi.fn> };
+ let hubService: { [k: string]: ReturnType<typeof vi.fn> };
beforeEach(() => {
// Partial spies on purpose: the descriptors must not touch these until a
caller asks.
@@ -80,11 +82,15 @@ describe("ResourceRegistryService", () => {
downloadDataset: vi.fn().mockReturnValue(of(new Blob())),
downloadModel: vi.fn().mockReturnValue(of(new Blob())),
};
+ hubService = {
+ getPublicOwners: vi.fn((type: EntityType) => of([`${type}-publisher`])),
+ };
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
{ provide: DownloadService, useValue: downloadService },
+ { provide: HubService, useValue: hubService },
{ provide: WorkflowPersistService, useValue: workflowPersistService },
{ provide: DatasetService, useValue: datasetService },
{ provide: ModelService, useValue: modelService },
@@ -94,6 +100,52 @@ describe("ResourceRegistryService", () => {
registry = TestBed.inject(ResourceRegistryService);
});
+ // ─── owners for the filter facet ──────────────────────────────────────────
+
+ /** Collects what ownersFor emits, which is synchronous for these doubles. */
+ const ownersOf = (type: EntityType | null, scope: OwnerScope): string[] => {
+ let names: string[] = [];
+ registry.ownersFor(type, scope).subscribe(list => (names = list));
+ return names;
+ };
+
+ it("asks only the access-scoped endpoint for a Your Work page", () => {
+ expect(ownersOf(EntityType.Dataset, "accessible")).toEqual(["ds-owner"]);
+ expect(hubService["getPublicOwners"]).not.toHaveBeenCalled();
+ });
+
+ it("asks only the published endpoint for a hub page", () => {
+ expect(ownersOf(EntityType.Dataset,
"public")).toEqual(["dataset-publisher"]);
+ expect(datasetService["retrieveOwners"]).not.toHaveBeenCalled();
+ });
+
+ it("merges both for unified search, which lists both", () => {
+ expect(ownersOf(EntityType.Dataset,
"accessibleAndPublic")).toEqual(["ds-owner", "dataset-publisher"]);
+ });
+
+ it("names a person once when they own several kinds", () => {
+
workflowPersistService["retrieveOwners"].mockReturnValue(of(["[email protected]"]));
+ datasetService["retrieveOwners"].mockReturnValue(of(["[email protected]"]));
+ modelService["retrieveOwners"].mockReturnValue(of(["[email protected]"]));
+
+ expect(ownersOf(null, "accessible")).toEqual(["[email protected]"]);
+ });
+
+ it("unions every kind for a page that lists them all", () => {
+ expect(ownersOf(null, "accessible")).toEqual(["wf-owner", "ds-owner",
"m-owner"]);
+ });
+
+ it("lets a kind whose request fails contribute nothing, rather than blanking
the facet", () => {
+ // One 500 must not cost the other kinds their owners.
+ datasetService["retrieveOwners"].mockReturnValue(throwError(() => new
Error("boom")));
+
+ expect(ownersOf(null, "accessible")).toEqual(["wf-owner", "m-owner"]);
+ });
+
+ it("offers nothing for a kind the registry does not carry", () => {
+ expect(ownersOf(EntityType.ComputingUnit, "accessible")).toEqual([]);
+ });
+
// ─── lookup ───────────────────────────────────────────────────────────────
it("resolves every kind the dashboard renders", () => {
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 592a7f2c0c..40ef1fbc86 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
@@ -21,6 +21,9 @@ import { Injectable } from "@angular/core";
import { DashboardEntry } from "../../../type/dashboard-entry";
import { ResourceDescriptor } from "../../../type/resource-descriptor";
import { EntityType } from "../../../../hub/service/hub.service";
+import { forkJoin, Observable, of } from "rxjs";
+import { catchError, map } from "rxjs/operators";
+import { OwnerScope } from "../../../type/owner-scope";
import { WorkflowResourceDescriptor } from "./workflow-resource.descriptor";
import { DatasetResourceDescriptor } from "./dataset-resource.descriptor";
import { FileResourceDescriptor } from "./file-resource.descriptor";
@@ -62,6 +65,38 @@ export class ResourceRegistryService {
return descriptor;
}
+ /**
+ * Names for the Owner facet of a page listing `type` under `scope`; `null`
unions every kind.
+ * A kind whose request fails contributes nothing rather than blanking the
facet.
+ */
+ public ownersFor(type: EntityType | null, scope: OwnerScope):
Observable<string[]> {
+ const kinds = type === null ? [EntityType.Workflow, EntityType.Dataset,
EntityType.Model] : [type];
+ const requests = kinds.flatMap(kind => this.ownerRequests(kind, scope));
+ if (requests.length === 0) {
+ return of([]);
+ }
+ // Unsorted, so a single kind keeps its endpoint's order.
+ return forkJoin(requests).pipe(map(lists => [...new Set(lists.flat())]));
+ }
+
+ /** The one or two lists a scope is made of, skipping any the descriptor
cannot answer. */
+ private ownerRequests(type: EntityType, scope: OwnerScope):
Observable<string[]>[] {
+ const descriptor = this.find(type);
+ if (!descriptor) {
+ return [];
+ }
+ const wanted: (Observable<string[]> | undefined)[] = [];
+ if (scope !== "public") {
+ wanted.push(descriptor.retrieveOwners?.());
+ }
+ if (scope !== "accessible") {
+ wanted.push(descriptor.retrievePublicOwners?.());
+ }
+ return wanted
+ .filter((request): request is Observable<string[]> => request !==
undefined)
+ .map(request => request.pipe(catchError(() => of([] as string[]))));
+ }
+
/**
* Where an entry's card links to: the owner-facing page when the viewer can
reach it, the hub page
* otherwise. An entry with no route, or one not yet persisted, links
nowhere.
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 5930673c42..e4c1851b91 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
@@ -20,7 +20,7 @@
import { Injectable } from "@angular/core";
import { DashboardEntry } from "../../../type/dashboard-entry";
import { ResourceAffordances, ResourceDescriptor } from
"../../../type/resource-descriptor";
-import { EntityType } from "../../../../hub/service/hub.service";
+import { EntityType, HubService } from "../../../../hub/service/hub.service";
import {
DEFAULT_WORKFLOW_NAME,
WorkflowPersistService,
@@ -43,6 +43,7 @@ export class WorkflowResourceDescriptor implements
ResourceDescriptor {
constructor(
private workflowPersistService: WorkflowPersistService,
+ private hubService: HubService,
private downloadService: DownloadService
) {}
@@ -53,6 +54,7 @@ export class WorkflowResourceDescriptor implements
ResourceDescriptor {
updateDescription = (id: number, description: string) =>
this.workflowPersistService.updateWorkflowDescription(id, description);
retrieveOwners = () => this.workflowPersistService.retrieveOwners();
+ retrievePublicOwners = () =>
this.hubService.getPublicOwners(EntityType.Workflow);
retrieveIds = () => this.workflowPersistService.retrieveWorkflowIDs();
download = (id: number, name: string) =>
this.downloadService.downloadWorkflow(id, name);
isPublic = (id: number) =>
diff --git a/frontend/src/app/dashboard/type/owner-scope.ts
b/frontend/src/app/dashboard/type/owner-scope.ts
new file mode 100644
index 0000000000..dd597a5ed1
--- /dev/null
+++ b/frontend/src/app/dashboard/type/owner-scope.ts
@@ -0,0 +1,21 @@
+/**
+ * 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.
+ */
+
+/** Which people a page's Owner facet offers: Your Work, the hub, and unified
search in turn. */
+export type OwnerScope = "accessible" | "public" | "accessibleAndPublic";
diff --git a/frontend/src/app/dashboard/type/resource-descriptor.ts
b/frontend/src/app/dashboard/type/resource-descriptor.ts
index 85ecada475..ac4c7a29d4 100644
--- a/frontend/src/app/dashboard/type/resource-descriptor.ts
+++ b/frontend/src/app/dashboard/type/resource-descriptor.ts
@@ -55,6 +55,8 @@ export interface ResourceDescriptor {
retrieveSingleFile?(filePath: string, isLogin: boolean): Observable<Blob>;
/** Owners of this kind, for the filter dropdown and the share modal. */
retrieveOwners?(): Observable<string[]>;
+ /** Owners of the *published* entries of this kind, for the filter dropdown
on the hub. */
+ retrievePublicOwners?(): 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. */
diff --git
a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html
b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html
index 14c4caa044..18604d4273 100644
---
a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html
+++
b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html
@@ -26,6 +26,7 @@
(sortMethodChange)="sortMethod = $event;
search()"></texera-sort-button>
<texera-filters
[entityType]="entityType"
+ ownerScope="public"
#filters></texera-filters>
<div
*ngIf="isVersionedResource"
diff --git
a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.spec.ts
b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.spec.ts
index a1c34fa140..56df200a84 100644
---
a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.spec.ts
+++
b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.spec.ts
@@ -77,6 +77,7 @@ class StubSortButtonComponent {
})
class StubFiltersComponent {
@Input() entityType?: EntityType;
+ @Input() ownerScope?: string;
masterFilterList: ReadonlyArray<string> = [];
masterFilterListChange = new Subject<ReadonlyArray<string>>();
getSearchKeywords = vi.fn(() => [] as string[]);
diff --git a/frontend/src/app/hub/service/hub.service.spec.ts
b/frontend/src/app/hub/service/hub.service.spec.ts
index cf7cdcf85c..fca170a08b 100644
--- a/frontend/src/app/hub/service/hub.service.spec.ts
+++ b/frontend/src/app/hub/service/hub.service.spec.ts
@@ -62,6 +62,18 @@ describe("HubService", () => {
expect(result).toBe(5);
});
+ it("getPublicOwners GETs /owners with the entityType param and emits the
names", () => {
+ let result: string[] | undefined;
+ service.getPublicOwners(EntityType.Dataset).subscribe(names => (result =
names));
+
+ const req = httpMock.expectOne(r => r.url ===
`${service.BASE_URL}/owners`);
+ expect(req.request.method).toBe("GET");
+ expect(req.request.params.get("entityType")).toBe("dataset");
+
+ req.flush(["[email protected]", "[email protected]"]);
+ expect(result).toEqual(["[email protected]", "[email protected]"]);
+ });
+
it("cloneWorkflow POSTs to /workflow/clone/:wid with a null body and emits
the new wid", () => {
let result: number | undefined;
service.cloneWorkflow(42).subscribe(n => (result = n));
diff --git a/frontend/src/app/hub/service/hub.service.ts
b/frontend/src/app/hub/service/hub.service.ts
index 2a589481d7..7618616e21 100644
--- a/frontend/src/app/hub/service/hub.service.ts
+++ b/frontend/src/app/hub/service/hub.service.ts
@@ -73,6 +73,13 @@ export class HubService {
});
}
+ /** Owners of the published entries of one kind; `*-owners` answers who
granted the caller. */
+ public getPublicOwners(entityType: EntityType): Observable<string[]> {
+ return this.http.get<string[]>(`${this.BASE_URL}/owners`, {
+ params: { entityType: entityType },
+ });
+ }
+
public cloneWorkflow(wid: number): Observable<number> {
return this.http.post<number>(`${WORKFLOW_BASE_URL}/clone/${wid}`, null);
}