diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceSearchQueryBuilder.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceSearchQueryBuilder.scala index a1e00ec45a0..8893c203951 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceSearchQueryBuilder.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceSearchQueryBuilder.scala @@ -24,6 +24,7 @@ import org.apache.texera.web.resource.dashboard.FulltextSearchQueryUtils.{ getDateFilter, getFullTextSearchFilter } +import org.apache.texera.dao.jooq.generated.tables.User.USER import org.jooq.impl.DSL import org.jooq.{Condition, GroupField, Record, TableLike} @@ -78,6 +79,7 @@ abstract class VersionedResourceSearchQueryBuilder[Rec <: Record, P]( tables.creationTimeColumn ) .and(getContainsFilter(tables.searchIds(params), tables.idColumn)) + .and(getContainsFilter(params.owners, USER.EMAIL)) .and( getFullTextSearchFilter(splitKeywords, List(tables.nameColumn, tables.descriptionColumn)) ) diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilderSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilderSpec.scala index cc60e1853ec..e585848d3cc 100644 --- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilderSpec.scala +++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilderSpec.scala @@ -448,6 +448,33 @@ class DatasetSearchQueryBuilderSpec sql should include(s"texera_db.dataset.did = $sizedDid and (") } + it should "filter on the owner's email, the way the workflow builder does" in { + val p = SearchQueryParams(owners = List("dataset_search_owner@texera.com").asJava) + + sqlFor(uid, includePublic = true, p) should include( + "texera_db.user.email = 'dataset_search_owner@texera.com'" + ) + } + + it should "OR several owners together, and AND them with the other filters" in { + val p = SearchQueryParams( + owners = List("a@texera.com", "b@texera.com").asJava, + datasetIds = List(sizedDid).asJava + ) + val sql = sqlFor(uid, includePublic = true, p) + + // ORed with each other: ANDing two owners would always return nothing. + sql should include("texera_db.user.email = 'a@texera.com'") + sql should include("texera_db.user.email = 'b@texera.com'") + sql should include("or texera_db.user.email") + // ... but ANDed with the id filter. + sql should include(s"texera_db.dataset.did = $sizedDid and (") + } + + it should "add no owner predicate when the caller selected none" in { + sqlFor(uid, includePublic = true) should not include "texera_db.user.email =" + } + "the query" should "dedupe with selectDistinct rather than a group by" in { // getGroupByFields is empty here, unlike the workflow and project builders, so the DISTINCT is // the only thing collapsing the rows the access join multiplies out. diff --git a/frontend/src/app/dashboard/component/user/filters/filters.component.html b/frontend/src/app/dashboard/component/user/filters/filters.component.html index 01d89c8282f..977e204cf6a 100644 --- a/frontend/src/app/dashboard/component/user/filters/filters.component.html +++ b/frontend/src/app/dashboard/component/user/filters/filters.component.html @@ -106,7 +106,7 @@ { let component: FiltersComponent; @@ -66,6 +70,7 @@ describe("FiltersComponent", () => { { provide: OperatorMetadataService, useClass: StubOperatorMetadataService }, { provide: UserService, useClass: StubUserService }, { provide: UserProjectService, useClass: StubUserProjectService }, + { provide: DatasetService, useValue: { retrieveOwners: vi.fn(() => of([])) } }, provideNzI18n(en_US), ...commonTestProviders, ], @@ -493,3 +498,95 @@ describe("FiltersComponent", () => { }); }); }); + +/** The bar is shared by several pages; these pin that it sources owners and ids per kind. */ +describe("FiltersComponent per-resource owners", () => { + let fixture: ComponentFixture; + let component: FiltersComponent; + let datasetOwners: ReturnType; + let workflowOwners: ReturnType; + let workflowIds: ReturnType; + + /** The input has to be set before ngOnInit reads it. */ + async function render(entityType?: EntityType): Promise { + datasetOwners = vi.fn(() => of(["dataset-owner"])); + workflowOwners = vi.fn(() => of(["workflow-owner"])); + workflowIds = vi.fn(() => of([7])); + + await TestBed.configureTestingModule({ + providers: [ + JwtHelperService, + { provide: JWT_OPTIONS, useValue: {} }, + { + provide: WorkflowPersistService, + useValue: { retrieveOwners: workflowOwners, retrieveWorkflowIDs: workflowIds }, + }, + { provide: DatasetService, useValue: { retrieveOwners: datasetOwners } }, + { provide: OperatorMetadataService, useClass: StubOperatorMetadataService }, + { provide: UserService, useClass: StubUserService }, + { provide: UserProjectService, useClass: StubUserProjectService }, + provideNzI18n(en_US), + ...commonTestProviders, + ], + imports: [FiltersComponent, NzModalModule, NzDropDownModule, FormsModule, HttpClientTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(FiltersComponent); + component = fixture.componentInstance; + if (entityType !== undefined) { + component.entityType = entityType; + } + fixture.detectChanges(); + } + + afterEach(() => { + const overlayContainer = TestBed.inject(OverlayContainer, null); + if (overlayContainer) { + overlayContainer.getContainerElement().innerHTML = ""; + } + }); + + it("defaults to workflows, so the call sites that pass nothing are unaffected", async () => { + await render(); + + expect(component.entityType).toBe(EntityType.Workflow); + expect(workflowOwners).toHaveBeenCalled(); + expect(datasetOwners).not.toHaveBeenCalled(); + expect(component.owners.map(owner => owner.userName)).toEqual(["workflow-owner"]); + }); + + it("lists dataset owners, not workflow owners, when filtering datasets", async () => { + await render(EntityType.Dataset); + + expect(datasetOwners).toHaveBeenCalled(); + expect(workflowOwners).not.toHaveBeenCalled(); + expect(component.owners.map(owner => owner.userName)).toEqual(["dataset-owner"]); + }); + + it("offers workflow ids only when filtering workflows", async () => { + await render(EntityType.Workflow); + expect(component.hasIdFilter).toBe(true); + expect(workflowIds).toHaveBeenCalled(); + expect(component.wids.map(wid => wid.id)).toEqual(["7"]); + }); + + it("asks for no ids at all when filtering datasets, rather than showing workflow ids", async () => { + await render(EntityType.Dataset); + + expect(component.hasIdFilter).toBe(false); + expect(workflowIds).not.toHaveBeenCalled(); + expect(component.wids).toEqual([]); + }); + + it("hides the id dropdown for a kind that has no ids to offer", async () => { + await render(EntityType.Dataset); + + expect(fixture.debugElement.query(By.css(".search-wids-button"))).toBeNull(); + }); + + it("still renders the id dropdown for workflows", async () => { + await render(EntityType.Workflow); + + expect(fixture.debugElement.query(By.css(".search-wids-button"))).not.toBeNull(); + }); +}); 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 85da98d0e05..824ace5d440 100644 --- a/frontend/src/app/dashboard/component/user/filters/filters.component.ts +++ b/frontend/src/app/dashboard/component/user/filters/filters.component.ts @@ -25,6 +25,8 @@ import { DashboardProject } from "../../../type/dashboard-project.interface"; import { NotificationService } from "src/app/common/service/notification/notification.service"; import { UserProjectService } from "../../../service/user/project/user-project.service"; import { WorkflowPersistService } from "src/app/common/service/workflow-persist/workflow-persist.service"; +import { DatasetService } from "../../../service/user/dataset/dataset.service"; +import { EntityType } from "../../../../hub/service/hub.service"; import { SearchFilterParameters } from "../../../type/search-filter-parameters"; import { UserService } from "../../../../common/service/user/user.service"; import { switchMap } from "rxjs/operators"; @@ -71,6 +73,8 @@ export class FiltersComponent implements OnInit { private _masterFilterList: ReadonlyArray = []; // receive input from parent components (UserProjectSection), if any @Input() public pid?: number = undefined; + /** Which resource kind this page lists; decides whose owners and ids are offered. */ + @Input() public entityType: EntityType = EntityType.Workflow; @Output() public masterFilterListChange = new EventEmitter(); public get masterFilterList(): ReadonlyArray { @@ -121,9 +125,21 @@ export class FiltersComponent implements OnInit { private notificationService: NotificationService, private userProjectService: UserProjectService, private workflowPersistService: WorkflowPersistService, + private datasetService: DatasetService, private cdr: ChangeDetectorRef ) {} + /** Only workflows expose an id-listing endpoint. */ + public get hasIdFilter(): boolean { + return this.entityType === EntityType.Workflow; + } + + private retrieveOwners(): Observable { + return this.entityType === EntityType.Dataset + ? this.datasetService.retrieveOwners() + : this.workflowPersistService.retrieveOwners(); + } + ngOnInit(): void { this.setupUserProject(); this.searchParameterBackendSetup(); @@ -157,9 +173,7 @@ export class FiltersComponent implements OnInit { }); } - /** - * Backend calls for Workflow IDs, Owners, and Operators in saved workflow component - */ + /** Backend calls for the filtered kind's owners and ids, plus the operator metadata. */ private searchParameterBackendSetup() { this.operatorMetadataService .getOperatorMetadata() @@ -183,23 +197,24 @@ export class FiltersComponent implements OnInit { this.operatorGroups = opdata.groups.map(group => group.groupName); }); if (this.isLogin) { - this.workflowPersistService - .retrieveOwners() + this.retrieveOwners() .pipe(untilDestroyed(this)) .subscribe(list_of_owners => { this.owners = list_of_owners.map(i => ({ userName: i, checked: false })); }); - this.workflowPersistService - .retrieveWorkflowIDs() - .pipe(untilDestroyed(this)) - .subscribe(wids => { - this.wids = wids.map(wid => { - return { - id: wid.toString(), - checked: false, - }; + if (this.hasIdFilter) { + this.workflowPersistService + .retrieveWorkflowIDs() + .pipe(untilDestroyed(this)) + .subscribe(wids => { + this.wids = wids.map(wid => { + return { + id: wid.toString(), + checked: false, + }; + }); }); - }); + } } } diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.html b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.html index d9f6f62cbba..87a1db132b7 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.html +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.html @@ -58,7 +58,9 @@

Datasets

nzTheme="outline"> - + diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.spec.ts index 30d27d96d60..f80b956e31e 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.spec.ts @@ -504,7 +504,10 @@ describe("UserDatasetComponent rendering", () => { { provide: NzModalService, useValue: { create: vi.fn() } }, { provide: UserService, useClass: StubUserService }, { provide: SearchService, useValue: { executeSearch: searchSpy } }, - { provide: DatasetService, useValue: { deleteDatasets: vi.fn(() => of({} as Response)) } }, + { + provide: DatasetService, + useValue: { deleteDatasets: vi.fn(() => of({} as Response)), retrieveOwners: vi.fn(() => of([])) }, + }, { provide: NzMessageService, useValue: { warning: vi.fn() } }, // ng-zorro defaults to zh-cn and throws NG0701 without locale data; the app registers en_US. { provide: NZ_I18N, useValue: en_US }, @@ -705,7 +708,10 @@ describe("UserDatasetComponent card view", () => { ), }, }, - { provide: DatasetService, useValue: { deleteDatasets: deleteDatasetsSpy } }, + { + provide: DatasetService, + useValue: { deleteDatasets: deleteDatasetsSpy, retrieveOwners: vi.fn(() => of([])) }, + }, { provide: NzMessageService, useValue: { warning: vi.fn() } }, { provide: NZ_I18N, useValue: en_US }, provideRouter([]), diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts index 88ace52081f..409f55218fe 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts @@ -45,6 +45,7 @@ import { NzIconDirective } from "ng-zorro-antd/icon"; import { FiltersInstructionsComponent } from "../filters-instructions/filters-instructions.component"; import { NzSelectComponent } from "ng-zorro-antd/select"; import { FormsModule } from "@angular/forms"; +import { EntityType } from "../../../../hub/service/hub.service"; @UntilDestroy() @Component({ @@ -69,6 +70,7 @@ import { FormsModule } from "@angular/forms"; ], }) export class UserDatasetComponent implements AfterViewInit { + public readonly entityType = EntityType.Dataset; private static readonly VIEW_MODE_STORAGE_KEY = "texera.userDataset.viewMode"; // Datasets have no "last modified" timestamp, so EditTimeDesc leaves the sort key NULL for every // row and produces an undefined order. Default to CreateTimeDesc so newly created datasets appear first. 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 112b65d580b..6f4a273ad2d 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 @@ -24,7 +24,9 @@ [showEditTime]="searchType !== 'dataset'" [showExecutionTime]="searchType !== 'dataset'" (sortMethodChange)="sortMethod = $event; search()"> - +
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 cfc5a4936af..5d221442bc1 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 @@ -41,6 +41,7 @@ import { UserService } from "../../../common/service/user/user.service"; import { MOCK_USER_ID, StubUserService } from "../../../common/service/user/stub-user.service"; import { SearchService } from "../../../dashboard/service/user/search.service"; import { commonTestProviders } from "../../../common/testing/test-utils"; +import { EntityType } from "../../service/hub.service"; import { OperatorMetadataService } from "../../../workspace/service/operator-metadata/operator-metadata.service"; import { StubOperatorMetadataService } from "../../../workspace/service/operator-metadata/stub-operator-metadata.service"; import { UserProjectService } from "../../../dashboard/service/user/project/user-project.service"; @@ -77,6 +78,7 @@ class StubSortButtonComponent { providers: [{ provide: FiltersComponent, useExisting: forwardRef(() => StubFiltersComponent) }], }) class StubFiltersComponent { + @Input() entityType?: EntityType; masterFilterList: ReadonlyArray = []; masterFilterListChange = new Subject>(); getSearchKeywords = vi.fn(() => [] as string[]); @@ -482,7 +484,10 @@ describe("HubSearchResultComponent rendered template", () => { { provide: OperatorMetadataService, useClass: StubOperatorMetadataService }, { provide: UserProjectService, useClass: StubUserProjectService }, { provide: WorkflowPersistService, useValue: new StubWorkflowPersistService([]) }, - { provide: DatasetService, useValue: { getDatasetCoverUrl: vi.fn(() => of({ url: undefined })) } }, + { + provide: DatasetService, + useValue: { getDatasetCoverUrl: vi.fn(() => of({ url: undefined })), retrieveOwners: vi.fn(() => of([])) }, + }, { provide: WorkflowCoverService, useValue: { getCover: vi.fn(() => of(undefined)) } }, NzModalService, provideNzI18n(en_US), diff --git a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts index 15f576bb012..35e916b05e4 100644 --- a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts +++ b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts @@ -37,6 +37,7 @@ import { isDefined } from "../../../common/util/predicate"; import { firstValueFrom } from "rxjs"; import { map } from "rxjs/operators"; import { SortButtonComponent } from "../../../dashboard/component/user/sort-button/sort-button.component"; +import { EntityType } from "../../service/hub.service"; const HUB_DATASET_VIEW_MODE_STORAGE_KEY = "texera.hub.dataset.viewMode"; @@ -58,6 +59,12 @@ const HUB_DATASET_VIEW_MODE_STORAGE_KEY = "texera.hub.dataset.viewMode"; }) export class HubSearchResultComponent implements OnInit, AfterViewInit { public searchType: "dataset" | "workflow" = "workflow"; + + /** `searchType` as an EntityType, for the filters bar. */ + public get filterEntityType(): EntityType { + return this.searchType === "dataset" ? EntityType.Dataset : EntityType.Workflow; + } + public searchKeywords: string[] = []; currentUid = this.userService.getCurrentUser()?.uid; public viewMode: SearchResultsViewMode =