Ma77Ball commented on code in PR #4216: URL: https://github.com/apache/texera/pull/4216#discussion_r3368838408
########## frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts: ########## @@ -0,0 +1,450 @@ +/** + * 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. + */ + +import { + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges, + ViewChild, +} from "@angular/core"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { NgIf } from "@angular/common"; +import { FormsModule } from "@angular/forms"; +import { RouterLink } from "@angular/router"; +import { NzCardComponent } from "ng-zorro-antd/card"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzCheckboxComponent } from "ng-zorro-antd/checkbox"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzPopconfirmDirective } from "ng-zorro-antd/popconfirm"; +import { NzWaveDirective } from "ng-zorro-antd/core/wave"; +import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; +import { NzModalRef, NzModalService } from "ng-zorro-antd/modal"; +import { DashboardEntry } from "src/app/dashboard/type/dashboard-entry"; +import { ShareAccessComponent } from "../../share-access/share-access.component"; +import { UserAvatarComponent } from "../../user-avatar/user-avatar.component"; +import { + DEFAULT_WORKFLOW_NAME, + WorkflowPersistService, +} from "src/app/common/service/workflow-persist/workflow-persist.service"; +import { firstValueFrom } from "rxjs"; +import { HubWorkflowDetailComponent } from "../../../../../hub/component/workflow/detail/hub-workflow-detail.component"; +import { ActionType, HubService } from "../../../../../hub/service/hub.service"; +import { DownloadService } from "src/app/dashboard/service/user/download/download.service"; +import { formatSize } from "src/app/common/util/size-formatter.util"; +import { DatasetService, DEFAULT_DATASET_NAME } from "../../../../service/user/dataset/dataset.service"; +import { NotificationService } from "../../../../../common/service/notification/notification.service"; +import { + HUB_DATASET_RESULT_DETAIL, + HUB_WORKFLOW_RESULT_DETAIL, + USER_DATASET, + USER_PROJECT, + USER_WORKSPACE, +} from "../../../../../app-routing.constant"; +import { isDefined } from "../../../../../common/util/predicate"; + +@UntilDestroy() +@Component({ + selector: "texera-card-item", + templateUrl: "./card-item.component.html", + styleUrls: ["./card-item.component.scss"], + imports: [ + NzCardComponent, + RouterLink, + NgIf, + FormsModule, + NzCheckboxComponent, + UserAvatarComponent, + NzIconDirective, + NzButtonComponent, + NzPopconfirmDirective, + NzWaveDirective, + ɵNzTransitionPatchDirective, + ], +}) +export class CardItemComponent implements OnChanges { + private owners: number[] = []; + public originalName: string = ""; + public originalDescription: string | undefined = undefined; + public disableDelete: boolean = false; + @Input() currentUid: number | undefined; + @ViewChild("nameInput") nameInput!: ElementRef; + @ViewChild("descriptionInput") descriptionInput!: ElementRef; + editingName = false; + editingDescription = false; + likeCount: number = 0; + viewCount = 0; + entryLink: string[] = []; + size: number | undefined = 0; + public iconType: string = ""; + isLiked: boolean = false; + @Input() isPrivateSearch = false; + @Input() editable = false; + private _entry?: DashboardEntry; + hovering: boolean = false; + + @Input() + get entry(): DashboardEntry { + if (!this._entry) { + throw new Error("entry property must be provided."); + } + return this._entry; + } + + set entry(value: DashboardEntry) { + this._entry = value; + } + + @Output() checkboxChanged = new EventEmitter<void>(); + @Output() deleted = new EventEmitter<void>(); + @Output() duplicated = new EventEmitter<void>(); + @Output() refresh = new EventEmitter<void>(); + + constructor( + private modalService: NzModalService, + private workflowPersistService: WorkflowPersistService, + private datasetService: DatasetService, + private modal: NzModalService, + private hubService: HubService, + private downloadService: DownloadService, + private cdr: ChangeDetectorRef, + private notificationService: NotificationService + ) {} + + initializeEntry() { + if (this.entry.type === "workflow") { + if (typeof this.entry.id === "number") { + this.disableDelete = !this.entry.workflow.isOwner; + this.owners = this.entry.accessibleUserIds; + if (this.currentUid !== undefined && this.owners.includes(this.currentUid)) { + this.entryLink = [USER_WORKSPACE, String(this.entry.id)]; + } else { + this.entryLink = [HUB_WORKFLOW_RESULT_DETAIL, String(this.entry.id)]; + } + this.size = this.entry.size; + } + this.iconType = "project"; + } else if (this.entry.type === "project") { + this.entryLink = [USER_PROJECT, String(this.entry.id)]; + this.iconType = "container"; + } else if (this.entry.type === "dataset") { + if (typeof this.entry.id === "number") { + this.disableDelete = !this.entry.dataset.isOwner; + this.owners = this.entry.accessibleUserIds; + if (this.currentUid !== undefined && this.owners.includes(this.currentUid)) { + this.entryLink = [USER_DATASET, String(this.entry.id)]; + } else { + this.entryLink = [HUB_DATASET_RESULT_DETAIL, String(this.entry.id)]; + } + this.iconType = "database"; + this.size = this.entry.size; + } + } else if (this.entry.type === "file") { + // not sure where to redirect + this.iconType = "folder-open"; + } else { + throw new Error("Unexpected type in DashboardEntry."); + } + this.likeCount = this.entry.likeCount; + this.viewCount = this.entry.viewCount; + this.isLiked = this.entry.isLiked; + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes["entry"]) { + this.initializeEntry(); + } + } + + onCheckboxChange(entry: DashboardEntry): void { + entry.checked = !entry.checked; + this.cdr.markForCheck(); + this.checkboxChanged.emit(); + } + + public async onClickOpenShareAccess(): Promise<void> { + let modal: NzModalRef<ShareAccessComponent> | undefined; + + if (this.entry.type === "workflow") { + modal = this.modalService.create({ + nzContent: ShareAccessComponent, + nzData: { + writeAccess: this.entry.workflow.accessLevel === "WRITE", + type: this.entry.type, + id: this.entry.id, + allOwners: await firstValueFrom(this.workflowPersistService.retrieveOwners()), + inWorkspace: false, + }, + nzFooter: null, + nzTitle: "Share this workflow with others", + nzCentered: true, + nzWidth: "700px", + }); + } else if (this.entry.type === "dataset") { + modal = this.modalService.create({ + nzContent: ShareAccessComponent, + nzData: { + writeAccess: this.entry.accessLevel === "WRITE", + type: "dataset", + id: this.entry.id, + allOwners: await firstValueFrom(this.datasetService.retrieveOwners()), + }, + nzFooter: null, + nzTitle: "Share this dataset with others", + nzCentered: true, + nzWidth: "700px", + }); + } + if (modal) { + modal.componentInstance?.refresh.pipe(untilDestroyed(this)).subscribe(() => { + this.refresh.emit(); + }); + } + } + + public onClickDownload = (): void => { + if (!this.entry.id) return; + + if (this.entry.type === "workflow") { + this.downloadService + .downloadWorkflow(this.entry.id, this.entry.workflow.workflow.name) + .pipe(untilDestroyed(this)) + .subscribe(); + } else if (this.entry.type === "dataset") { + this.downloadService.downloadDataset(this.entry.id, this.entry.name).pipe(untilDestroyed(this)).subscribe(); + } + }; + + onEditName(): void { + this.originalName = this.entry.name; + this.editingName = true; + setTimeout(() => { + if (this.nameInput) { + const inputElement = this.nameInput.nativeElement; + const valueLength = inputElement.value.length; + inputElement.focus(); + inputElement.setSelectionRange(valueLength, valueLength); + } + }, 0); + } + + onEditDescription(): void { + this.originalDescription = this.entry.description; + this.editingDescription = true; + setTimeout(() => { + if (this.descriptionInput) { + const textareaElement = this.descriptionInput.nativeElement; + const valueLength = textareaElement.value.length; + textareaElement.focus(); + textareaElement.setSelectionRange(valueLength, valueLength); + } + }, 0); + } + + private updateProperty( + updateMethod: (id: number, value: string) => any, + propertyName: "name" | "description", + newValue: string, + originalValue: string | undefined + ): void { + if (!this.entry.id) { + this.notificationService.error("Id is missing"); + return; + } + + updateMethod(this.entry.id, newValue) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.entry[propertyName] = newValue; // Dynamic property assignment + }, + error: () => { + this.notificationService.error("Update failed"); + (this.entry as any)[propertyName] = originalValue ?? ""; // Fallback to original value + this.setEditingState(propertyName, false); + }, + complete: () => { + this.setEditingState(propertyName, false); + }, + }); + } + + private setEditingState(propertyName: "name" | "description", state: boolean): void { + if (propertyName === "name") { + this.editingName = state; + } else if (propertyName === "description") { + this.editingDescription = state; + } + } + + public confirmUpdateCustomName(name: string): void { + if (this.entry.name === this.originalName) { + this.editingName = false; + return; + } + const newName = this.entry.type === "workflow" ? name || DEFAULT_WORKFLOW_NAME : name || DEFAULT_DATASET_NAME; + + if (this.entry.type === "workflow") { + this.updateProperty( + this.workflowPersistService.updateWorkflowName.bind(this.workflowPersistService), + "name", + newName, + this.originalName + ); + } else if (this.entry.type === "dataset") { + this.updateProperty( + this.datasetService.updateDatasetName.bind(this.datasetService), + "name", + newName, + this.originalName + ); + } + } + + public confirmUpdateCustomDescription(description: string | undefined): void { + if (this.entry.description === this.originalDescription) { + this.editingDescription = false; + return; + } + const updatedDescription = description ?? ""; + + if (this.entry.type === "workflow") { + this.updateProperty( + this.workflowPersistService.updateWorkflowDescription.bind(this.workflowPersistService), + "description", + updatedDescription, + this.originalDescription + ); + } else if (this.entry.type === "dataset") { + this.updateProperty( + this.datasetService.updateDatasetDescription.bind(this.datasetService), + "description", + updatedDescription, + this.originalDescription + ); + } + } + + formatTime(timestamp: number | undefined): string { Review Comment: Done. The card had its own copy of the relative-time logic, which duplicated `formatRelativeTime` in `format.util.ts`. I deleted the local `formatTime`/`formatCount` methods and now point to the shared utils instead, so there is a single source of truth. The template calls are unchanged and the existing unit tests still pass. ########## frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html: ########## @@ -0,0 +1,245 @@ +<!-- + 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. +--> +<nz-card + class="card-item" + [class.selected]="entry.checked" + [nzBodyStyle]="{ padding: '0' }" + (mouseenter)="hovering = true" + (mouseleave)="hovering = false"> + <!-- Preview Section (Blank Space) --> + <div + class="card-preview" + [routerLink]="entryLink"> + <!-- Checkbox overlay --> + <div + class="card-checkbox" + *ngIf="isPrivateSearch && entry.type === 'workflow'" + (click)="$event.stopPropagation()"> + <label + nz-checkbox + [(ngModel)]="entry.checked" + (ngModelChange)="onCheckboxChange(entry)"></label> + </div> + <!-- User Avatar Overlay --> + <div + class="card-user-avatar" + title="{{ entry.ownerName || 'User' }}"> + <texera-user-avatar + [googleAvatar]="entry.ownerGoogleAvatar" + userColor="#1E90FF" + [userName]="entry.ownerName || 'User'" + [isOwner]="entry.ownerId === this.currentUid"></texera-user-avatar> + </div> + <!-- Placeholder or Preview Image --> + <img + class="card-preview-image" + src="assets/card_background.jpg" + alt="Workflow Preview" /> + </div> + + <!-- Content Section --> + <div class="card-content"> + <!-- Header: Icon, Name, ID --> + <div + class="card-header" + [routerLink]="entryLink"> + <div class="title-container"> + <i + nz-icon + [nzType]="iconType" + class="type-icon"></i> + + <div class="name-container"> + <div + class="resource-name truncate-single-line" + *ngIf="!editingName" + title="{{ entry.name }}"> + {{ entry.name }} + </div> + <input + *ngIf="editingName" + #nameInput + class="resource-name-edit-input" + [(ngModel)]="entry.name" + (blur)="confirmUpdateCustomName(entry.name)" + (keydown.enter)="confirmUpdateCustomName(entry.name)" + (click)="$event.stopPropagation()" + autofocus /> + </div> + </div> + + <!-- Edit Name Button --> + <button + *ngIf="isPrivateSearch" + nz-button + nzType="text" + size="small" + class="edit-btn" + (click)="onEditName(); $event.stopPropagation()"> + <i + nz-icon + nzType="edit"></i> + </button> + </div> + + <!-- Description --> Review Comment: Done. Removed the description block from the card and its now-unused styles, since it was not adding much value yet. I kept the supporting TS code (edit/save handlers) in place so it is easy to bring back as a subtitle later without rewriting anything. ########## frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html: ########## @@ -0,0 +1,245 @@ +<!-- + 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. +--> +<nz-card + class="card-item" + [class.selected]="entry.checked" + [nzBodyStyle]="{ padding: '0' }" + (mouseenter)="hovering = true" + (mouseleave)="hovering = false"> + <!-- Preview Section (Blank Space) --> + <div + class="card-preview" + [routerLink]="entryLink"> + <!-- Checkbox overlay --> + <div + class="card-checkbox" + *ngIf="isPrivateSearch && entry.type === 'workflow'" + (click)="$event.stopPropagation()"> + <label + nz-checkbox + [(ngModel)]="entry.checked" + (ngModelChange)="onCheckboxChange(entry)"></label> + </div> + <!-- User Avatar Overlay --> + <div Review Comment: Done. The avatar used to float on top of the preview image. I moved it into the metadata section as an owner row (avatar plus owner name) and scaled it down so it lines up with the other meta rows like created/edited. This keeps all the owner info in one place. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
