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. Removed the duplicate local `formatTime`/`formatCount` and now reuse the shared `formatRelativeTime`/`formatCount` from `format.util.ts`. ########## 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 (and its unused styles) for now. Left the underlying code in place so it can be re-wired to a subtitle later. ########## 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. Moved the user avatar out of the preview overlay and into the metadata section as an owner row (scaled down to fit). -- 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]
