Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -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))
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
</nz-dropdown-menu>

<a
*ngIf="this.isLogin"
*ngIf="this.isLogin && hasIdFilter"
[nzDropdownMenu]="IDSearchOptions"
nz-dropdown
nzTrigger="click"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ import { MOCK_USER, StubUserService } from "src/app/common/service/user/stub-use
import { UserProjectService } from "src/app/dashboard/service/user/project/user-project.service";
import { StubUserProjectService } from "src/app/dashboard/service/user/project/stub-user-project.service";
import { NotificationService } from "src/app/common/service/notification/notification.service";
import { DatasetService } from "src/app/dashboard/service/user/dataset/dataset.service";
import { EntityType } from "src/app/hub/service/hub.service";
import { By } from "@angular/platform-browser";
import { of } from "rxjs";

describe("FiltersComponent", () => {
let component: FiltersComponent;
Expand Down Expand Up @@ -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,
],
Expand Down Expand Up @@ -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<FiltersComponent>;
let component: FiltersComponent;
let datasetOwners: ReturnType<typeof vi.fn>;
let workflowOwners: ReturnType<typeof vi.fn>;
let workflowIds: ReturnType<typeof vi.fn>;

/** The input has to be set before ngOnInit reads it. */
async function render(entityType?: EntityType): Promise<void> {
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -71,6 +73,8 @@ export class FiltersComponent implements OnInit {
private _masterFilterList: ReadonlyArray<string> = [];
// 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<typeof this._masterFilterList>();
public get masterFilterList(): ReadonlyArray<string> {
Expand Down Expand Up @@ -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<string[]> {
return this.entityType === EntityType.Dataset
? this.datasetService.retrieveOwners()
: this.workflowPersistService.retrieveOwners();
}

ngOnInit(): void {
this.setupUserProject();
this.searchParameterBackendSetup();
Expand Down Expand Up @@ -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()
Expand All @@ -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,
};
});
});
});
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ <h2 class="page-title">Datasets</h2>
nzTheme="outline"></i>
</button>
</nz-space-compact>
<texera-filters #filters></texera-filters>
<texera-filters
[entityType]="entityType"
#filters></texera-filters>
</div>
</nz-card>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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([]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
[showEditTime]="searchType !== 'dataset'"
[showExecutionTime]="searchType !== 'dataset'"
(sortMethodChange)="sortMethod = $event; search()"></texera-sort-button>
<texera-filters #filters></texera-filters>
<texera-filters
[entityType]="filterEntityType"
#filters></texera-filters>
<div
*ngIf="searchType === 'dataset'"
class="view-toggle">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -77,6 +78,7 @@ class StubSortButtonComponent {
providers: [{ provide: FiltersComponent, useExisting: forwardRef(() => StubFiltersComponent) }],
})
class StubFiltersComponent {
@Input() entityType?: EntityType;
masterFilterList: ReadonlyArray<string> = [];
masterFilterListChange = new Subject<ReadonlyArray<string>>();
getSearchKeywords = vi.fn(() => [] as string[]);
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 =
Expand Down
Loading