diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/EntityTables.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/EntityTables.scala index bb42647cca2..259441cbe97 100644 --- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/EntityTables.scala +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/EntityTables.scala @@ -19,6 +19,7 @@ object EntityTables { type R <: Record val table: Table[R] val isPublicColumn: TableField[R, java.lang.Byte] + val idColumn: TableField[R, UInteger] } object BaseEntityTable { @@ -26,12 +27,14 @@ object EntityTables { override type R = WorkflowRecord override val table: Table[WorkflowRecord] = WORKFLOW override val isPublicColumn: TableField[WorkflowRecord, java.lang.Byte] = WORKFLOW.IS_PUBLIC + override val idColumn: TableField[WorkflowRecord, UInteger] = WORKFLOW.WID } case object DatasetTable extends BaseEntityTable { override type R = DatasetRecord override val table: Table[DatasetRecord] = DATASET override val isPublicColumn: TableField[DatasetRecord, java.lang.Byte] = DATASET.IS_PUBLIC + override val idColumn: TableField[DatasetRecord, UInteger] = DATASET.DID } def apply(entityType: String): BaseEntityTable = { diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/HubResource.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/HubResource.scala index ad95146902b..d26374fe4e3 100644 --- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/HubResource.scala +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/HubResource.scala @@ -3,8 +3,8 @@ package edu.uci.ics.texera.web.resource.dashboard.hub import edu.uci.ics.amber.core.storage.StorageConfig import edu.uci.ics.texera.dao.SqlServer import edu.uci.ics.texera.dao.jooq.generated.Tables._ -import edu.uci.ics.texera.dao.jooq.generated.tables.pojos.Workflow import HubResource.{ + fetchDashboardDatasetsByDids, fetchDashboardWorkflowsByWids, getUserLCCount, isLikedHelper, @@ -13,18 +13,27 @@ import HubResource.{ userRequest, validateEntityType } -import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowResource.DashboardWorkflow +import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowResource.{ + DashboardWorkflow, + baseWorkflowSelect, + mapWorkflowEntries +} import org.jooq.impl.DSL import org.jooq.types.UInteger import java.util -import java.util.Collections import java.util.regex.Pattern import javax.servlet.http.HttpServletRequest import javax.ws.rs._ import javax.ws.rs.core.{Context, MediaType} import scala.jdk.CollectionConverters._ import EntityTables._ +import edu.uci.ics.texera.web.resource.dashboard.DashboardResource.DashboardClickableFileEntry +import edu.uci.ics.texera.web.resource.dashboard.user.dataset.DatasetResource.{ + DashboardDataset, + baseDatasetSelect, + mapDashboardDataset +} object HubResource { case class userRequest(entityId: UInteger, userId: UInteger, entityType: String) @@ -228,52 +237,31 @@ object HubResource { .fetchOne(0, classOf[Int]) } - // todo: refactor api related to landing page - def fetchDashboardWorkflowsByWids(wids: Seq[UInteger]): util.List[DashboardWorkflow] = { - if (wids.nonEmpty) { - context - .select( - WORKFLOW.NAME, - WORKFLOW.DESCRIPTION, - WORKFLOW.WID, - WORKFLOW.CREATION_TIME, - WORKFLOW.LAST_MODIFIED_TIME, - USER.NAME.as("ownerName"), - WORKFLOW_OF_USER.UID.as("ownerId") - ) - .from(WORKFLOW) - .join(WORKFLOW_OF_USER) - .on(WORKFLOW.WID.eq(WORKFLOW_OF_USER.WID)) - .join(USER) - .on(WORKFLOW_OF_USER.UID.eq(USER.UID)) - .where(WORKFLOW.WID.in(wids: _*)) - .fetch() - .asScala - .map(record => { - val workflow = new Workflow( - record.get(WORKFLOW.NAME), - record.get(WORKFLOW.DESCRIPTION), - record.get(WORKFLOW.WID), - null, - record.get(WORKFLOW.CREATION_TIME), - record.get(WORKFLOW.LAST_MODIFIED_TIME), - null - ) + def fetchDashboardWorkflowsByWids(wids: Seq[UInteger], uid: UInteger): List[DashboardWorkflow] = { + if (wids.isEmpty) { + return List.empty[DashboardWorkflow] + } - DashboardWorkflow( - isOwner = false, - accessLevel = "", - ownerName = record.get("ownerName", classOf[String]), - workflow = workflow, - projectIDs = List(), - ownerId = record.get("ownerId", classOf[UInteger]) - ) - }) - .toList - .asJava - } else { - Collections.emptyList[DashboardWorkflow]() + val records = baseWorkflowSelect() + .where(WORKFLOW.WID.in(wids: _*)) + .groupBy(WORKFLOW.WID) + .fetch() + + mapWorkflowEntries(records, uid) + } + + def fetchDashboardDatasetsByDids(dids: Seq[UInteger], uid: UInteger): List[DashboardDataset] = { + if (dids.isEmpty) { + return List.empty[DashboardDataset] } + + val records = baseDatasetSelect() + .where(DATASET.DID.in(dids: _*)) + .groupBy(DATASET.DID) + .fetch() + + println(mapDashboardDataset(records, uid)) + mapDashboardDataset(records, uid) } } @@ -411,42 +399,65 @@ class HubResource { } @GET - @Path("/topLovedWorkflows") + @Path("/getTops") @Produces(Array(MediaType.APPLICATION_JSON)) - def getTopLovedWorkflows: util.List[DashboardWorkflow] = { - val topLovedWorkflowsWids = context - .select(WORKFLOW_USER_LIKES.WID) - .from(WORKFLOW_USER_LIKES) - .join(WORKFLOW) - .on(WORKFLOW_USER_LIKES.WID.eq(WORKFLOW.WID)) - .where(WORKFLOW.IS_PUBLIC.eq(1.toByte)) - .groupBy(WORKFLOW_USER_LIKES.WID) - .orderBy(DSL.count(WORKFLOW_USER_LIKES.WID).desc()) - .limit(8) - .fetchInto(classOf[UInteger]) - .asScala - .toSeq + def getTops( + @QueryParam("entityType") entityType: String, + @QueryParam("actionType") actionType: String, + @QueryParam("uid") uid: Integer + ): util.List[DashboardClickableFileEntry] = { + validateEntityType(entityType) - fetchDashboardWorkflowsByWids(topLovedWorkflowsWids) - } + val baseTable = BaseEntityTable(entityType) + val entityTables = actionType match { + case "like" => LikeTable(entityType) + case "clone" => CloneTable(entityType) + case _ => throw new IllegalArgumentException(s"Invalid action type: $actionType") + } - @GET - @Path("/topClonedWorkflows") - @Produces(Array(MediaType.APPLICATION_JSON)) - def getTopClonedWorkflows: util.List[DashboardWorkflow] = { - val topClonedWorkflowsWids = context - .select(WORKFLOW_USER_CLONES.WID) - .from(WORKFLOW_USER_CLONES) - .join(WORKFLOW) - .on(WORKFLOW_USER_CLONES.WID.eq(WORKFLOW.WID)) - .where(WORKFLOW.IS_PUBLIC.eq(1.toByte)) - .groupBy(WORKFLOW_USER_CLONES.WID) - .orderBy(DSL.count(WORKFLOW_USER_CLONES.WID).desc()) + val (table, idColumn) = (entityTables.table, entityTables.idColumn) + val (isPublicColumn, baseIdColumn) = (baseTable.isPublicColumn, baseTable.idColumn) + + val topEntityIds = context + .select(idColumn) + .from(table) + .join(baseTable.table) + .on(idColumn.eq(baseIdColumn)) + .where(isPublicColumn.eq(1.toByte)) + .groupBy(idColumn) + .orderBy(DSL.count(idColumn).desc()) .limit(8) .fetchInto(classOf[UInteger]) .asScala .toSeq - fetchDashboardWorkflowsByWids(topClonedWorkflowsWids) + val currentUid: UInteger = if (uid == null || uid == -1) null else UInteger.valueOf(uid) + + val clickableFileEntries = + if (entityType == "workflow") { + val workflows = fetchDashboardWorkflowsByWids(topEntityIds, currentUid) + workflows.map { w => + DashboardClickableFileEntry( + resourceType = "workflow", + workflow = Some(w), + project = None, + dataset = None + ) + } + } else if (entityType == "dataset") { + val datasets = fetchDashboardDatasetsByDids(topEntityIds, currentUid) + datasets.map { d => + DashboardClickableFileEntry( + resourceType = "dataset", + workflow = None, + project = None, + dataset = Some(d) + ) + } + } else { + Seq.empty[DashboardClickableFileEntry] + } + + clickableFileEntries.toList.asJava } } diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/dataset/DatasetResource.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/dataset/DatasetResource.scala index 98cb2b3724e..ac9165ec546 100644 --- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/dataset/DatasetResource.scala +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/dataset/DatasetResource.scala @@ -32,7 +32,7 @@ import io.dropwizard.auth.Auth import org.apache.commons.lang3.StringUtils import org.glassfish.jersey.media.multipart.{FormDataMultiPart, FormDataParam} import org.jooq.types.UInteger -import org.jooq.{DSLContext, EnumType} +import org.jooq.{DSLContext, EnumType, Record, Result, SelectJoinStep} import play.api.libs.json.Json import java.io.{IOException, InputStream, OutputStream} @@ -342,6 +342,34 @@ object DatasetResource { fileNodes: List[DatasetFileNode], size: Long ) + + def baseDatasetSelect(): SelectJoinStep[Record] = { + context + .select() + .from( + DATASET + .leftJoin(DATASET_USER_ACCESS) + .on(DATASET_USER_ACCESS.DID.eq(DATASET.DID)) + .leftJoin(USER) + .on(USER.UID.eq(DATASET.OWNER_UID)) + ) + } + + def mapDashboardDataset(records: Result[Record], uid: UInteger): List[DashboardDataset] = { + records.asScala.map { record => + val dataset = record.into(DATASET).into(classOf[Dataset]) + val datasetAccess = record.into(DATASET_USER_ACCESS).into(classOf[DatasetUserAccess]) + val ownerEmail = record.into(USER).getEmail + DashboardDataset( + isOwner = if (uid == null) false else dataset.getOwnerUid == uid, + dataset = dataset, + accessPrivilege = datasetAccess.getPrivilege, + versions = List(), + ownerEmail = ownerEmail, + size = calculateDatasetVersionSize(dataset.getDid) + ) + }.toList + } } @Produces(Array(MediaType.APPLICATION_JSON, "image/jpeg", "application/pdf")) @@ -626,35 +654,14 @@ class DatasetResource { ): List[DashboardDataset] = { val uid = user.getUid withTransaction(context)(ctx => { - var accessibleDatasets: ListBuffer[DashboardDataset] = ListBuffer() // first fetch all datasets user have explicit access to - accessibleDatasets = ListBuffer.from( - ctx - .select() - .from( - DATASET - .leftJoin(DATASET_USER_ACCESS) - .on(DATASET_USER_ACCESS.DID.eq(DATASET.DID)) - .leftJoin(USER) - .on(USER.UID.eq(DATASET.OWNER_UID)) - ) - .where(DATASET_USER_ACCESS.UID.eq(uid)) - .fetch() - .map(record => { - val dataset = record.into(DATASET).into(classOf[Dataset]) - val datasetAccess = record.into(DATASET_USER_ACCESS).into(classOf[DatasetUserAccess]) - val ownerEmail = record.into(USER).getEmail - DashboardDataset( - isOwner = dataset.getOwnerUid == uid, - dataset = dataset, - accessPrivilege = datasetAccess.getPrivilege, - versions = List(), - ownerEmail = ownerEmail, - size = calculateDatasetVersionSize(dataset.getDid) - ) - }) - .asScala - ) + + val userDatasetRecords = baseDatasetSelect() + .where(DATASET_USER_ACCESS.UID.eq(uid)) + .fetch() + + var accessibleDatasets: ListBuffer[DashboardDataset] = + ListBuffer.from(mapDashboardDataset(userDatasetRecords, uid)) // then we fetch the public datasets and merge it as a part of the result if not exist val publicDatasets = ctx diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala index 687dde8cb9b..d5c714ff3a8 100644 --- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala @@ -19,9 +19,10 @@ import edu.uci.ics.texera.web.resource.dashboard.hub.HubResource.recordCloneActi import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowAccessResource.hasReadAccess import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowResource._ import io.dropwizard.auth.Auth -import org.jooq.Condition +import org.jooq.{Condition, Record9, SelectOnConditionStep} import org.jooq.impl.DSL.{groupConcatDistinct, noCondition} import org.jooq.types.UInteger +import org.jooq.{Record, Result} import java.sql.Timestamp import java.util @@ -160,6 +161,77 @@ object WorkflowResource { updatedContent.replace(oldId, newId) } } + + def baseWorkflowSelect(): SelectOnConditionStep[Record9[ + UInteger, + String, + String, + Timestamp, + Timestamp, + WorkflowUserAccessPrivilege, + UInteger, + String, + String + ]] = { + context + .select( + WORKFLOW.WID, + WORKFLOW.NAME, + WORKFLOW.DESCRIPTION, + WORKFLOW.CREATION_TIME, + WORKFLOW.LAST_MODIFIED_TIME, + WORKFLOW_USER_ACCESS.PRIVILEGE, + WORKFLOW_OF_USER.UID, + USER.NAME, + groupConcatDistinct(WORKFLOW_OF_PROJECT.PID).as("projects") + ) + .from(WORKFLOW) + .leftJoin(WORKFLOW_USER_ACCESS) + .on(WORKFLOW_USER_ACCESS.WID.eq(WORKFLOW.WID)) + .leftJoin(WORKFLOW_OF_USER) + .on(WORKFLOW_OF_USER.WID.eq(WORKFLOW.WID)) + .leftJoin(USER) + .on(USER.UID.eq(WORKFLOW_OF_USER.UID)) + .leftJoin(WORKFLOW_OF_PROJECT) + .on(WORKFLOW.WID.eq(WORKFLOW_OF_PROJECT.WID)) + } + + def mapWorkflowEntries( + workflowEntries: Result[Record9[ + UInteger, + String, + String, + Timestamp, + Timestamp, + WorkflowUserAccessPrivilege, + UInteger, + String, + String + ]], + uid: UInteger + ): List[DashboardWorkflow] = { + workflowEntries + .map(workflowRecord => + DashboardWorkflow( + if (uid != null) + workflowRecord.into(WORKFLOW_OF_USER).getUid.eq(uid) + else false, + workflowRecord + .into(WORKFLOW_USER_ACCESS) + .into(classOf[WorkflowUserAccess]) + .getPrivilege + .toString, + workflowRecord.into(USER).getName, + workflowRecord.into(WORKFLOW).into(classOf[Workflow]), + if (workflowRecord.component9() == null) List[UInteger]() + else + workflowRecord.component9().split(',').map(number => UInteger.valueOf(number)).toList, + workflowRecord.into(WORKFLOW_OF_USER).getUid + ) + ) + .asScala + .toList + } } @Produces(Array(MediaType.APPLICATION_JSON)) @@ -267,49 +339,11 @@ class WorkflowResource extends LazyLogging { @Auth sessionUser: SessionUser ): List[DashboardWorkflow] = { val user = sessionUser.getUser - val workflowEntries = context - .select( - WORKFLOW.WID, - WORKFLOW.NAME, - WORKFLOW.DESCRIPTION, - WORKFLOW.CREATION_TIME, - WORKFLOW.LAST_MODIFIED_TIME, - WORKFLOW_USER_ACCESS.PRIVILEGE, - WORKFLOW_OF_USER.UID, - USER.NAME, - groupConcatDistinct(WORKFLOW_OF_PROJECT.PID).as("projects") - ) - .from(WORKFLOW) - .leftJoin(WORKFLOW_USER_ACCESS) - .on(WORKFLOW_USER_ACCESS.WID.eq(WORKFLOW.WID)) - .leftJoin(WORKFLOW_OF_USER) - .on(WORKFLOW_OF_USER.WID.eq(WORKFLOW.WID)) - .leftJoin(USER) - .on(USER.UID.eq(WORKFLOW_OF_USER.UID)) - .leftJoin(WORKFLOW_OF_PROJECT) - .on(WORKFLOW.WID.eq(WORKFLOW_OF_PROJECT.WID)) + val workflowEntries = baseWorkflowSelect() .where(WORKFLOW_USER_ACCESS.UID.eq(user.getUid)) .groupBy(WORKFLOW.WID, WORKFLOW_OF_USER.UID) .fetch() - workflowEntries - .map(workflowRecord => - DashboardWorkflow( - workflowRecord.into(WORKFLOW_OF_USER).getUid.eq(user.getUid), - workflowRecord - .into(WORKFLOW_USER_ACCESS) - .into(classOf[WorkflowUserAccess]) - .getPrivilege - .toString, - workflowRecord.into(USER).getName, - workflowRecord.into(WORKFLOW).into(classOf[Workflow]), - if (workflowRecord.component9() == null) List[UInteger]() - else - workflowRecord.component9().split(',').map(number => UInteger.valueOf(number)).toList, - workflowRecord.into(WORKFLOW_OF_USER).getUid - ) - ) - .asScala - .toList + mapWorkflowEntries(workflowEntries, user.getUid) } /** diff --git a/core/gui/src/app/hub/component/browse-section/browse-section.component.html b/core/gui/src/app/hub/component/browse-section/browse-section.component.html index cb1371735ad..cf599458340 100644 --- a/core/gui/src/app/hub/component/browse-section/browse-section.component.html +++ b/core/gui/src/app/hub/component/browse-section/browse-section.component.html @@ -1,22 +1,22 @@

{{ sectionTitle }}

-
+
-

{{ workflow.name }}

-

{{ workflow.description || 'No description available' }}

+ class="entity-card" + *ngFor="let entity of entities"> +

{{ entity.name }}

+

{{ entity.description || 'No description available' }}

@@ -27,10 +27,10 @@

{{ workflow.name }}

class="card-cover-image" [src]="defaultBackground" />
diff --git a/core/gui/src/app/hub/component/browse-section/browse-section.component.scss b/core/gui/src/app/hub/component/browse-section/browse-section.component.scss index 1d8fd960ffa..8d4cb208a5e 100644 --- a/core/gui/src/app/hub/component/browse-section/browse-section.component.scss +++ b/core/gui/src/app/hub/component/browse-section/browse-section.component.scss @@ -1,92 +1,3 @@ -//.results-container { -// display: flex; -// flex-direction: column; -// align-items: flex-start; -// width: 100%; -// padding: 20px; -// -// .results-title { -// font-size: 24px; -// margin-bottom: 20px; -// text-align: left; -// width: 100%; -// padding-left: 10px; -// } -// -// .workflow-cards { -// display: grid; -// grid-template-columns: repeat(4, 1fr); -// grid-gap: 10px; -// width: 100%; -// max-width: 1350px; -// -// .workflow-card { -// margin: 10px; -// box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); -// border-radius: 8px; -// overflow: hidden; -// display: flex; -// flex-direction: column; -// -// .card-title { -// font-size: 18px; -// margin: 10px 0; -// text-align: center; -// white-space: nowrap; -// overflow: hidden; -// text-overflow: ellipsis; -// } -// -// .card-description { -// padding: 0 15px; -// font-size: 14px; -// color: #666; -// display: -webkit-box; -// -webkit-box-orient: vertical; -// -webkit-line-clamp: 2; -// overflow: hidden; -// text-overflow: ellipsis; -// } -// -// .footer { -// margin-top: auto; -// display: flex; -// justify-content: space-between; -// padding: 10px 15px; -// border-top: 1px solid #eee; -// -// .footer-text { -// font-size: 12px; -// color: #999; -// } -// } -// -// .cover-container { -// position: relative; -// width: 100%; -// height: 150px; -// overflow: hidden; -// display: flex; -// justify-content: center; -// align-items: center; -// -// img.card-cover-image { -// width: 100%; -// height: 100%; -// object-fit: cover; -// } -// -// .workflow-avatar { -// position: absolute; -// bottom: 10px; -// left: 10px; -// background-color: grey; -// } -// } -// } -// } -//} - .results-container { display: flex; flex-direction: column; @@ -102,14 +13,14 @@ padding-left: 10px; } - .workflow-cards { + .entity-cards { display: grid; grid-template-columns: repeat(4, 1fr); grid-gap: 10px; width: 100%; max-width: 1350px; - .workflow-card { + .entity-card { margin: 10px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); border-radius: 8px; @@ -173,7 +84,7 @@ object-fit: cover; } - .workflow-avatar { + .entity-avatar { position: absolute; bottom: 10px; left: 10px; diff --git a/core/gui/src/app/hub/component/browse-section/browse-section.component.spec.ts b/core/gui/src/app/hub/component/browse-section/browse-section.component.spec.ts index 86c8c9a06c8..1eaa5b19f87 100644 --- a/core/gui/src/app/hub/component/browse-section/browse-section.component.spec.ts +++ b/core/gui/src/app/hub/component/browse-section/browse-section.component.spec.ts @@ -1,6 +1,8 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; - import { BrowseSectionComponent } from "./browse-section.component"; +import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; +import { DatasetService } from "../../../dashboard/service/user/dataset/dataset.service"; +import { ChangeDetectorRef } from "@angular/core"; describe("BrowseSectionComponent", () => { let component: BrowseSectionComponent; @@ -9,6 +11,11 @@ describe("BrowseSectionComponent", () => { beforeEach(() => { TestBed.configureTestingModule({ declarations: [BrowseSectionComponent], + providers: [ + { provide: WorkflowPersistService, useValue: {} }, + { provide: DatasetService, useValue: {} }, + { provide: ChangeDetectorRef, useValue: {} }, + ], }); fixture = TestBed.createComponent(BrowseSectionComponent); component = fixture.componentInstance; diff --git a/core/gui/src/app/hub/component/browse-section/browse-section.component.ts b/core/gui/src/app/hub/component/browse-section/browse-section.component.ts index ff5fcb67508..dffedc6c77d 100644 --- a/core/gui/src/app/hub/component/browse-section/browse-section.component.ts +++ b/core/gui/src/app/hub/component/browse-section/browse-section.component.ts @@ -1,15 +1,84 @@ -import { Component, Input } from "@angular/core"; +import { ChangeDetectorRef, Component, Input, OnInit } from "@angular/core"; import { DashboardEntry } from "../../../dashboard/type/dashboard-entry"; -import { DASHBOARD_HUB_WORKFLOW_RESULT_DETAIL } from "../../../app-routing.constant"; +import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; +import { DatasetService } from "../../../dashboard/service/user/dataset/dataset.service"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { + DASHBOARD_HUB_WORKFLOW_RESULT_DETAIL, + DASHBOARD_USER_WORKSPACE, + DASHBOARD_USER_DATASET, + DASHBOARD_HUB_DATASET_RESULT_DETAIL, +} from "../../../app-routing.constant"; +@UntilDestroy() @Component({ selector: "texera-browse-section", templateUrl: "./browse-section.component.html", styleUrls: ["./browse-section.component.scss"], }) -export class BrowseSectionComponent { - @Input() workflows: DashboardEntry[] = []; +export class BrowseSectionComponent implements OnInit { + @Input() entities: DashboardEntry[] = []; @Input() sectionTitle: string = ""; + @Input() currentUid: number | undefined; + defaultBackground: string = "../../../../../assets/card_background.jpg"; protected readonly DASHBOARD_HUB_WORKFLOW_RESULT_DETAIL = DASHBOARD_HUB_WORKFLOW_RESULT_DETAIL; + protected readonly DASHBOARD_USER_WORKSPACE = DASHBOARD_USER_WORKSPACE; + protected readonly DASHBOARD_HUB_DATASET_RESULT_DETAIL = DASHBOARD_HUB_DATASET_RESULT_DETAIL; + protected readonly DASHBOARD_USER_DATASET = DASHBOARD_USER_DATASET; + entityRoutes: { [key: number]: string[] } = {}; + + constructor( + private workflowPersistService: WorkflowPersistService, + private datasetService: DatasetService, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit(): void { + this.entities.forEach(entity => { + this.initializeEntry(entity); + }); + } + + ngOnChanges(): void { + this.entities.forEach(entity => { + this.initializeEntry(entity); + }); + } + + private initializeEntry(entity: DashboardEntry): void { + if (typeof entity.id !== "number") { + return; + } + + const entityId = entity.id; + + if (entity.type === "workflow") { + this.workflowPersistService + .getWorkflowOwners(entityId) + .pipe(untilDestroyed(this)) + .subscribe((owners: number[]) => { + if (this.currentUid !== undefined && owners.includes(this.currentUid)) { + this.entityRoutes[entityId] = [this.DASHBOARD_USER_WORKSPACE, String(entityId)]; + } else { + this.entityRoutes[entityId] = [this.DASHBOARD_HUB_WORKFLOW_RESULT_DETAIL, String(entityId)]; + } + this.cdr.detectChanges(); + }); + } else if (entity.type === "dataset") { + this.datasetService + .getDatasetOwners(entityId) + .pipe(untilDestroyed(this)) + .subscribe((owners: number[]) => { + if (this.currentUid !== undefined && owners.includes(this.currentUid)) { + this.entityRoutes[entityId] = [this.DASHBOARD_USER_DATASET, String(entityId)]; + } else { + this.entityRoutes[entityId] = [this.DASHBOARD_HUB_DATASET_RESULT_DETAIL, String(entityId)]; + } + this.cdr.detectChanges(); + }); + } else { + throw new Error("Unexpected type in DashboardEntry."); + } + } } diff --git a/core/gui/src/app/hub/component/landing-page/landing-page.component.html b/core/gui/src/app/hub/component/landing-page/landing-page.component.html index 6d35e8d79a8..2d17015a304 100644 --- a/core/gui/src/app/hub/component/landing-page/landing-page.component.html +++ b/core/gui/src/app/hub/component/landing-page/landing-page.component.html @@ -20,12 +20,21 @@

Texera Hub

alt="hub icon" />
- - - - +
+ + + + + + +
diff --git a/core/gui/src/app/hub/component/landing-page/landing-page.component.ts b/core/gui/src/app/hub/component/landing-page/landing-page.component.ts index fe663daeb1f..afbd98ff797 100644 --- a/core/gui/src/app/hub/component/landing-page/landing-page.component.ts +++ b/core/gui/src/app/hub/component/landing-page/landing-page.component.ts @@ -1,13 +1,12 @@ import { Component, OnInit } from "@angular/core"; -import { Observable } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { HubService } from "../../service/hub.service"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { Router } from "@angular/router"; -import { DashboardWorkflow } from "../../../dashboard/type/dashboard-workflow.interface"; import { SearchService } from "../../../dashboard/service/user/search.service"; import { DashboardEntry, UserInfo } from "../../../dashboard/type/dashboard-entry"; -import { map, switchMap } from "rxjs/operators"; import { DASHBOARD_HUB_WORKFLOW_RESULT } from "../../../app-routing.constant"; +import { UserService } from "../../../common/service/user/user.service"; @UntilDestroy() @Component({ @@ -16,28 +15,41 @@ import { DASHBOARD_HUB_WORKFLOW_RESULT } from "../../../app-routing.constant"; styleUrls: ["./landing-page.component.scss"], }) export class LandingPageComponent implements OnInit { + public isLogin = this.userService.isLogin(); + public currentUid = this.userService.getCurrentUser()?.uid; public workflowCount: number = 0; public topLovedWorkflows: DashboardEntry[] = []; public topClonedWorkflows: DashboardEntry[] = []; + public topLovedDatasets: DashboardEntry[] = []; constructor( private hubService: HubService, private router: Router, - private searchService: SearchService - ) {} + private searchService: SearchService, + private userService: UserService + ) { + this.userService + .userChanged() + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.isLogin = this.userService.isLogin(); + this.currentUid = this.userService.getCurrentUser()?.uid; + }); + } ngOnInit(): void { this.getWorkflowCount(); - this.fetchTopWorkflows( - this.hubService.getTopLovedWorkflows(), - workflows => (this.topLovedWorkflows = workflows), - "Top Loved Workflows" - ); - this.fetchTopWorkflows( - this.hubService.getTopClonedWorkflows(), - workflows => (this.topClonedWorkflows = workflows), - "Top Cloned Workflows" - ); + this.loadTops(); + } + + async loadTops() { + try { + this.topLovedWorkflows = await this.getTopLovedEntries("workflow", "like"); + this.topClonedWorkflows = await this.getTopLovedEntries("workflow", "clone"); + this.topLovedDatasets = await this.getTopLovedEntries("dataset", "like"); + } catch (error) { + console.error("Failed to load top loved workflows:", error); + } } getWorkflowCount(): void { @@ -49,48 +61,55 @@ export class LandingPageComponent implements OnInit { }); } - /** - * Helper function to fetch top workflows and associate user info with them. - * @param workflowsObservable Observable that returns workflows (Top Loved or Top Cloned) - * @param updateWorkflowsFn Function to update the component's workflow state - * @param workflowType Label for logging - */ - fetchTopWorkflows( - workflowsObservable: Observable, - updateWorkflowsFn: (entries: DashboardEntry[]) => void, - workflowType: string - ): void { - workflowsObservable - .pipe( - // eslint-disable-next-line rxjs/no-unsafe-takeuntil - untilDestroyed(this), - map((workflows: DashboardWorkflow[]) => { - const userIds = new Set(); - workflows.forEach(workflow => { - userIds.add(workflow.ownerId); - }); - return { workflows, userIds: Array.from(userIds) }; - }), - switchMap(({ workflows, userIds }) => - this.searchService.getUserInfo(userIds).pipe( - map((userIdToInfoMap: { [key: number]: UserInfo }) => { - const dashboardEntries = workflows.map(workflow => { - const userInfo = userIdToInfoMap[workflow.ownerId]; - const entry = new DashboardEntry(workflow); - if (userInfo) { - entry.setOwnerName(userInfo.userName); - entry.setOwnerGoogleAvatar(userInfo.googleAvatar ?? ""); - } - return entry; - }); - return dashboardEntries; - }) - ) - ) - ) - .subscribe((dashboardEntries: DashboardEntry[]) => { - updateWorkflowsFn(dashboardEntries); - }); + // todo: same as the function in search. refactor together + public async getTopLovedEntries(entityType: string, actionType: string): Promise { + const searchResultItems = await firstValueFrom(this.hubService.getTops(entityType, actionType, this.currentUid)); + + const userIds = new Set(); + searchResultItems.forEach(i => { + if (i.workflow) { + userIds.add(i.workflow.ownerId); + } else if (i.project) { + userIds.add(i.project.ownerId); + } else if (i.dataset) { + const ownerUid = i.dataset.dataset?.ownerUid; + if (ownerUid !== undefined) { + userIds.add(ownerUid); + } + } + }); + + let userIdToInfoMap: { [key: number]: UserInfo } = {}; + if (userIds.size > 0) { + userIdToInfoMap = await firstValueFrom(this.searchService.getUserInfo(Array.from(userIds))); + } + + return searchResultItems.map(i => { + let entry: DashboardEntry; + + if (i.workflow) { + entry = new DashboardEntry(i.workflow); + const userInfo = userIdToInfoMap[i.workflow.ownerId]; + if (userInfo) { + entry.setOwnerName(userInfo.userName); + entry.setOwnerGoogleAvatar(userInfo.googleAvatar ?? ""); + } + } else if (i.dataset) { + entry = new DashboardEntry(i.dataset); + const ownerUid = i.dataset.dataset?.ownerUid; + if (ownerUid !== undefined) { + const userInfo = userIdToInfoMap[ownerUid]; + if (userInfo) { + entry.setOwnerName(userInfo.userName); + entry.setOwnerGoogleAvatar(userInfo.googleAvatar ?? ""); + } + } + } else { + throw new Error("Unexpected type in SearchResultItem."); + } + + return entry; + }); } navigateToSearch(): void { diff --git a/core/gui/src/app/hub/service/hub.service.ts b/core/gui/src/app/hub/service/hub.service.ts index 9f8a6fa2334..609f2d6bd26 100644 --- a/core/gui/src/app/hub/service/hub.service.ts +++ b/core/gui/src/app/hub/service/hub.service.ts @@ -2,7 +2,7 @@ import { HttpClient, HttpHeaders, HttpParams } from "@angular/common/http"; import { Injectable } from "@angular/core"; import { Observable } from "rxjs"; import { AppSettings } from "../../common/app-setting"; -import { DashboardWorkflow } from "../../dashboard/type/dashboard-workflow.interface"; +import { SearchResultItem } from "../../dashboard/type/search-result"; export const WORKFLOW_BASE_URL = `${AppSettings.getApiEndpoint()}/workflow`; @@ -69,11 +69,14 @@ export class HubService { return this.http.get(`${this.BASE_URL}/viewCount`, { params }); } - public getTopLovedWorkflows(): Observable { - return this.http.get(`${this.BASE_URL}/topLovedWorkflows`); - } + public getTops(entityType: string, actionType: string, currentUid?: number): Observable { + const params: any = { + entityType, + actionType, + }; + + params.uid = currentUid !== undefined ? currentUid : -1; - public getTopClonedWorkflows(): Observable { - return this.http.get(`${this.BASE_URL}/topClonedWorkflows`); + return this.http.get(`${this.BASE_URL}/getTops`, { params }); } }