This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-8456-7190a8113ca9ec3ae30d7a1dfdbde4c288034e2c in repository https://gitbox.apache.org/repos/asf/texera.git
commit 5042d96ec85d98ed18bde841b2e74922c43f5c3b Author: yangzhang75 <[email protected]> AuthorDate: Sun Sep 13 23:53:23 2026 +0000 feat(gui): wire the Form View entry points (#8456) ### What changes were proposed in this PR? Closes #8028. Part of the Form View stack (parent issue #8011), on main now that #8516 and #8517 have merged. The review commit is the branch's single commit. Wires the Form View entry points. The flag stays off here; the stack's closing PR, #8528, flips it. - The dashboard opens a workflow in its `default_view` (form or canvas), with a toggle that persists the choice; the deep link goes to the existing `/workflow/:id/form` route. Both renderers of the dashboard, the list row and the card, follow one shared rule (`default-view-landing.ts`: mark, deep link, toggle), so switching the view mode does not lose the entry point. The toggle is offered only with WRITE access, which the endpoint requires, and the handler checks the same rule rather than trusting the template; it is a proper toggle button (constant accessible name, state in `aria-pressed`, the hover title spelling out what a click does); hub links are left untouched. - The canvas menu gains the same Canvas / Form View switch the form already shows, so the two views swap in place. It saves first and hands over only once the save has completed: the switch is a full-page load, which aborts a request still in flight. Two more things the hand-over must not lose: an autosave already in flight when the switch is clicked (`WorkflowPersistService` now sends saves one at a time and in call order, at the one place every save goes through, so the switch's save lands and completes after it; each caller still gets only its own result and a failed save does not hold up the next), and an edit made while the switch's save is out (the page stays editable until the load; `workflowChanged` marks it and the hand-over saves once more before leaving). A reader, who cannot save, goes straight over. A workflow the canvas holds but has never saved (the default id) is created by that save, and the hand-over opens the id the save answered with. On the card the toggle sits in the always-visible action footer, in the same slot as on the row (right after Detail); the row's hover-revealed action group also appears while the row has the keyboard focus, so the toggle can be reached without a pointer there too. A second click while the hand-over is in progress is a no-op. A failed save keeps the user on the canvas with the error shown. Every workflow offers both views whenever the flag is on: `default_view` only decides the landing view, and neither view gates the other. - Download/upload round-trips `defaultView` as a sibling key next to the workflow content, in one shared export shape (`exportedWorkflow`) used by the dashboard download and the canvas menu's export alike; an old export without the key imports unchanged. - The computing unit the user picks is remembered per workflow (localStorage) so it survives switching between the two views; a unit selected on load (the remembered one, the last execution's, a running one) is derived rather than chosen and is not stored, or a derived unit would later outrank a fresher last execution. On load the remembered unit is honoured only once the unit list has arrived and still holds it: a unit that has since been terminated is forgotten and the last execution's unit is used instead, and a decision still pending when the workflow changes underneath it is dropped (the remembered-unit check, the last-execution lookup and its running-unit fallback alike). ### Any related issues, documentation, discussions? Closes #8028. Part of the Form View feature (parent issue #8011). ### How was this PR tested? Unit tests (vitest) cover the menu's Canvas / Form View switch through the DOM (absent with the flag off, Canvas pressed, Form View handing over, hidden while an older version is displayed), the row's and the card's default-view behavior (mark and deep link, hub link untouched, flag off leaves the dashboard as today, WRITE-only toggle in the DOM, toggle on / off / failed request / no cached row), the menu switch (navigates only once the save completes, stays on the canvas with the error when it fails, saves once more when an edit lands while its save is out, takes a reader straight over without a save, ignores a second click mid hand-over), the canvas export carrying `defaultView` next to the content and omitting it when unset, the hand-over opening the id the save assigned when the canvas held a never-saved workflow, the persist service sending saves one at a time in order with each caller getting its own result and a failure not holding up the next, the dashboard toggle handlers refusing without WRITE access, the toggle's aria-pressed following the state, the export/import round-trip including a legacy file without `defaultView`, and the computing-unit recall (waits for the first non-empty unit list, forgets a terminated unit and falls back, drops a stale decision after the workflow changed, a late last-execution answer or fallback included, positive-integer validation, storage failures, only an explicit pick remembered). Each new guard was deletion-checked (removing it turns the corresponding test red). eslint, prettier and the production (AOT) build pass; every changed line, template lines included, is statement and function covered. #### Video ##### 1. Default view on the dashboard (row and card toggle, Form View icon, deep link into the form, toggle off again, no toggle without write access) https://github.com/user-attachments/assets/04f71ba5-27ad-489d-a5be-51cdd8d811aa ##### 2. Canvas / Form View switch (save first, then the hand-over; and back) https://github.com/user-attachments/assets/39c3d395-6329-403d-9719-ca7ef179889f ##### 3. Computing unit remembered across the switch https://github.com/user-attachments/assets/6fe70b5f-d936-4333-bcf0-b3fa44f84323 ##### 4. Download / upload keeps the default view https://github.com/user-attachments/assets/0519bfcd-0677-478f-866c-6eb0350d842c ##### 5. Default view on the card view (the same toggle in the card's action row, Form View icon, deep link into the form) https://github.com/user-attachments/assets/471192b3-7935-4da9-be87-fa08a7dd6c13 ### Was this PR authored or co-authored using generative AI tooling? Yes. Generated-by: Claude Code (Claude Fable 5.1, Anthropic). Co-authored with Claude, reviewed line by line by the author before submission. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FVvP3ttj22f9LB4p9u2anY Co-authored-by: Claude Fable 5.1 <[email protected]> --- .../workflow-persist.service.spec.ts | 57 +++++ .../workflow-persist/workflow-persist.service.ts | 50 ++++- frontend/src/app/common/type/workflow.ts | 16 +- .../list-item/card-item/card-item.component.html | 22 +- .../list-item/card-item/card-item.component.scss | 12 ++ .../card-item/card-item.component.spec.ts | 210 +++++++++++++++++- .../list-item/card-item/card-item.component.ts | 54 ++++- .../user/list-item/default-view-landing.ts | 42 ++++ .../user/list-item/list-item.component.html | 23 +- .../user/list-item/list-item.component.scss | 22 +- .../user/list-item/list-item.component.spec.ts | 146 +++++++++++++ .../user/list-item/list-item.component.ts | 60 +++++- .../user-workflow/user-workflow.component.spec.ts | 21 +- .../user/user-workflow/user-workflow.component.ts | 8 +- .../service/user/download/download.service.spec.ts | 30 +++ .../service/user/download/download.service.ts | 8 +- .../workspace/component/menu/menu.component.html | 24 +++ .../workspace/component/menu/menu.component.scss | 62 ++++++ .../component/menu/menu.component.spec.ts | 182 ++++++++++++++++ .../app/workspace/component/menu/menu.component.ts | 101 ++++++++- .../computing-unit-selection.component.html | 2 +- .../computing-unit-selection.component.spec.ts | 237 +++++++++++++++++++++ .../computing-unit-selection.component.ts | 145 +++++++++++-- 23 files changed, 1490 insertions(+), 44 deletions(-) diff --git a/frontend/src/app/common/service/workflow-persist/workflow-persist.service.spec.ts b/frontend/src/app/common/service/workflow-persist/workflow-persist.service.spec.ts index 1ae30d331c..a35c9ec106 100644 --- a/frontend/src/app/common/service/workflow-persist/workflow-persist.service.spec.ts +++ b/frontend/src/app/common/service/workflow-persist/workflow-persist.service.spec.ts @@ -221,6 +221,45 @@ describe("WorkflowPersistService", () => { expect(result?.isPublished).toBe(1); }); + it("sends saves one at a time, in order, each caller getting its own result", () => { + // Two saves in flight at once can land out of order and the older content would win; the + // autosave and a Save or a view switch are independent callers, so the ordering lives here. + const wf = (name: string) => ({ wid: 9, name, description: "", content: validContent }) as unknown as Workflow; + const seen: string[] = []; + service.persistWorkflow(wf("first")).subscribe(w => seen.push("first:" + w.name)); + service.persistWorkflow(wf("second")).subscribe(w => seen.push("second:" + w.name)); + + // Only the first request has gone out; the second waits for it. + const first = httpTestingController.expectOne(`${API}/${WORKFLOW_PERSIST_URL}`); + expect(first.request.body.name).toBe("first"); + expect(httpTestingController.match(`${API}/${WORKFLOW_PERSIST_URL}`)).toHaveLength(0); + + first.flush({ wid: 9, name: "first", content: "{}" }); + const second = httpTestingController.expectOne(`${API}/${WORKFLOW_PERSIST_URL}`); + expect(second.request.body.name).toBe("second"); + second.flush({ wid: 9, name: "second", content: "{}" }); + + expect(seen).toEqual(["first:first", "second:second"]); + }); + + it("fails only its own caller when a save fails, and still sends the next", () => { + const wf = (name: string) => ({ wid: 9, name, description: "", content: validContent }) as unknown as Workflow; + let firstError: unknown; + let secondName: string | undefined; + service.persistWorkflow(wf("first")).subscribe({ error: (e: unknown) => (firstError = e) }); + service.persistWorkflow(wf("second")).subscribe(w => (secondName = w.name)); + + httpTestingController + .expectOne(`${API}/${WORKFLOW_PERSIST_URL}`) + .flush("boom", { status: 500, statusText: "Server Error" }); + expect(firstError).toBeDefined(); + + httpTestingController + .expectOne(`${API}/${WORKFLOW_PERSIST_URL}`) + .flush({ wid: 9, name: "second", content: "{}" }); + expect(secondName).toBe("second"); + }); + it("persistWorkflow notifies the user when the workflow is broken but still POSTs", () => { const errorSpy = vi.spyOn(notificationService, "error").mockImplementation(() => {}); const workflow = { @@ -274,6 +313,24 @@ describe("WorkflowPersistService", () => { expect(result).toEqual(created); }); + it("createWorkflow sends the default view when given one, and omits it otherwise", () => { + const content = jsonCast<WorkflowContent>(testContent); + + service.createWorkflow(content, "form default", DefaultView.FORM).subscribe(); + const withView = httpTestingController.expectOne(`${API}/${WORKFLOW_CREATE_URL}`); + expect(withView.request.body).toEqual({ + name: "form default", + content: JSON.stringify(content), + defaultView: DefaultView.FORM, + }); + withView.flush({ workflow: { wid: 1 } } as unknown as DashboardWorkflow); + + service.createWorkflow(content, "no view").subscribe(); + const withoutView = httpTestingController.expectOne(`${API}/${WORKFLOW_CREATE_URL}`); + expect(withoutView.request.body).toEqual({ name: "no view", content: JSON.stringify(content) }); + withoutView.flush({ workflow: { wid: 2 } } as unknown as DashboardWorkflow); + }); + it("createWorkflow filters out a null response so no value is emitted", () => { let emitted = false; service.createWorkflow(jsonCast<WorkflowContent>(testContent)).subscribe(() => (emitted = true)); diff --git a/frontend/src/app/common/service/workflow-persist/workflow-persist.service.ts b/frontend/src/app/common/service/workflow-persist/workflow-persist.service.ts index 9b8f4741bd..66d267a673 100644 --- a/frontend/src/app/common/service/workflow-persist/workflow-persist.service.ts +++ b/frontend/src/app/common/service/workflow-persist/workflow-persist.service.ts @@ -19,8 +19,8 @@ import { HttpClient, HttpParams } from "@angular/common/http"; import { Injectable } from "@angular/core"; -import { Observable, throwError } from "rxjs"; -import { catchError, filter, map } from "rxjs/operators"; +import { EMPTY, Observable, ReplaySubject, Subject, throwError } from "rxjs"; +import { catchError, concatMap, filter, map, tap } from "rxjs/operators"; import { AppSettings } from "../../app-setting"; import { Workflow, WorkflowContent } from "../../type/workflow"; import { DashboardWorkflow } from "../../../dashboard/type/dashboard-workflow.interface"; @@ -59,13 +59,42 @@ export class WorkflowPersistService { // flag to disable workflow persist when displaying the read only particular version private workflowPersistFlag = true; + /** + * Saves, one at a time and in call order. Two saves in flight at once can reach the backend out + * of order, and then the older content wins: the canvas's autosave (debounced) and a Save or a + * view switch are independent requests, and the Form View's own queue only orders that page's + * saves. Ordering them here, at the one place every save goes through, covers all of them and + * lets a caller that hands over on completion (the view switches) know that everything asked for + * before it has landed too. Each request snapshots its payload when asked for; it is sent when its + * turn comes, and its outcome is relayed to that caller alone. A failed save fails its own caller + * and does not hold up the next. + */ + private readonly persistQueue = new Subject<{ send: Observable<Workflow>; result: Subject<Workflow> }>(); + constructor( private http: HttpClient, private notificationService: NotificationService - ) {} + ) { + this.persistQueue + .pipe( + concatMap(({ send, result }) => + send.pipe( + tap({ + next: updated => result.next(updated), + error: (err: unknown) => result.error(err), + complete: () => result.complete(), + }), + catchError(() => EMPTY) + ) + ) + ) + .subscribe(); + } /** - * persists a workflow to backend database and returns its updated information (e.g., new wid) + * persists a workflow to backend database and returns its updated information (e.g., new wid). + * The request is queued behind any save still in flight (see persistQueue); the returned + * observable completes once this save has come back. * @param workflow */ public persistWorkflow(workflow: Workflow): Observable<Workflow> { @@ -79,7 +108,7 @@ export class WorkflowPersistService { // backend does not read it on this endpoint (publishing goes through /public and /private), // and it is not reliably known here anyway, since the metadata fed back after a save names // it differently (see WorkflowUtilService.parseWorkflowInfo). - return this.http + const send = this.http .post<Workflow>(`${AppSettings.getApiEndpoint()}/${WORKFLOW_PERSIST_URL}`, { wid: workflow.wid, name: workflow.name, @@ -90,6 +119,11 @@ export class WorkflowPersistService { filter((updatedWorkflow: Workflow) => updatedWorkflow != null), map(WorkflowUtilService.parseWorkflowInfo) ); + // Replayed, so a caller that subscribes after the queue has already relayed the outcome (a + // save that was quick, or a synchronous test double) still receives it. + const result = new ReplaySubject<Workflow>(1); + this.persistQueue.next({ send, result }); + return result.asObservable(); } /** @@ -99,12 +133,16 @@ export class WorkflowPersistService { */ public createWorkflow( newWorkflowContent: WorkflowContent, - newWorkflowName: string = DEFAULT_WORKFLOW_NAME + newWorkflowName: string = DEFAULT_WORKFLOW_NAME, + defaultView?: DefaultView ): Observable<DashboardWorkflow> { return this.http .post<DashboardWorkflow>(`${AppSettings.getApiEndpoint()}/${WORKFLOW_CREATE_URL}`, { name: newWorkflowName, content: JSON.stringify(newWorkflowContent), + // Bound onto the workflow row on the server, so an uploaded form-default workflow + // still opens as a form. Omitted (server default CANVAS) when the file carries none. + ...(defaultView === undefined ? {} : { defaultView }), }) .pipe(filter((createdWorkflow: DashboardWorkflow) => createdWorkflow != null)); } diff --git a/frontend/src/app/common/type/workflow.ts b/frontend/src/app/common/type/workflow.ts index 4129346b33..701086e2a7 100644 --- a/frontend/src/app/common/type/workflow.ts +++ b/frontend/src/app/common/type/workflow.ts @@ -17,7 +17,7 @@ * under the License. */ -import { WorkflowMetadata } from "../../dashboard/type/workflow-metadata.interface"; +import { DefaultView, WorkflowMetadata } from "../../dashboard/type/workflow-metadata.interface"; import { CommentBox, OperatorLink, OperatorPredicate, Point } from "../../workspace/types/workflow-common.interface"; export enum ExecutionMode { @@ -104,3 +104,17 @@ export interface WorkflowContent }> {} export type Workflow = { content: WorkflowContent } & WorkflowMetadata; + +/** + * The JSON a workflow is exported as, from the dashboard download and the canvas menu alike: the + * content plus, when the workflow has one, the landing view as one extra top-level key next to the + * content's own (operators/links/...). The importer (upload) destructures it back out onto the + * workflow row, so a download-then-upload keeps a form-default workflow opening as a form; an + * older importer that reads the whole object as content simply ignores the unknown key, and an + * older export without it imports unchanged. + */ +export type ExportedWorkflow = WorkflowContent & { defaultView?: DefaultView }; + +export function exportedWorkflow(content: WorkflowContent, defaultView: DefaultView | undefined): ExportedWorkflow { + return defaultView === undefined ? content : { ...content, defaultView }; +} diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html index 4cd50dc8de..2c072e7830 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html @@ -36,7 +36,7 @@ [(ngModel)]="entry.checked" (ngModelChange)="onCheckboxChange(entry)"></label> </div> - <!-- Cover-image controls --> + <!-- Cover image controls (owner only) --> <div class="card-image-controls" *ngIf="canEditCover" @@ -52,7 +52,7 @@ nzType="camera"></i> </button> <button - *ngIf="hasCustomImage" + *ngIf="canEditCover && hasCustomImage" nz-button nzType="text" class="image-control-btn" @@ -204,6 +204,24 @@ nz-icon nzType="eye"></i> </button> + <!-- The default-view toggle, in the same slot as on the list row (after Detail), for anyone + with write access. A toggle button: constant name, state in aria-pressed (a name that + changed with the state would announce the opposite of what the state says); the title + spells out what a click does. --> + <button + nz-button + nzType="text" + class="action-btn default-view-toggle" + *ngIf="canToggleDefaultView" + aria-label="Open in the Form View by default" + [attr.aria-pressed]="defaultsToForm" + [class.defaults-to-form-on]="defaultsToForm" + [title]="defaultsToForm ? 'Open on the canvas by default' : 'Open in the Form View by default'" + (click)="onToggleDefaultView(); $event.stopPropagation()"> + <i + nz-icon + nzType="solution"></i> + </button> <button nz-button nzType="text" diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.scss b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.scss index 559e7a70c2..ef5f269557 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.scss +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.scss @@ -232,6 +232,18 @@ color: #1f1f1f; background: #f0f0f0; } + + /* The default-view toggle shows its state, the same cue the list row's toggle gives: the accent + while the workflow opens in the Form View. */ + &.default-view-toggle.defaults-to-form-on { + color: #1e90ff; + background: #e6f7ff; + + &:hover { + color: #1e90ff; + background: #cceeff; + } + } } .like-btn { diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts index 1b22b370a0..dd243bc9c8 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts @@ -43,6 +43,8 @@ import { } from "../../../../../app-routing.constant"; import { WorkflowCoverService } from "src/app/dashboard/service/user/workflow-cover/workflow-cover.service"; import { NotificationService } from "../../../../../common/service/notification/notification.service"; +import { GuiConfigService } from "../../../../../common/service/gui-config.service"; +import { DefaultView } from "src/app/dashboard/type/workflow-metadata.interface"; import { DatasetService, DEFAULT_DATASET_NAME } from "../../../../service/user/dataset/dataset.service"; import { DownloadService } from "src/app/dashboard/service/user/download/download.service"; @@ -86,7 +88,11 @@ describe("CardItemComponent", () => { let datasetService: Mocked<DatasetService>; beforeEach(async () => { - const workflowPersistServiceSpy = { updateWorkflowName: vi.fn(), updateWorkflowDescription: vi.fn() }; + const workflowPersistServiceSpy = { + updateWorkflowName: vi.fn(), + updateWorkflowDescription: vi.fn(), + setDefaultView: vi.fn(), + }; const workflowCoverServiceSpy = { getCover: vi.fn().mockReturnValue(of(undefined)), setCoverFromFile: vi.fn(), @@ -1165,4 +1171,206 @@ describe("CardItemComponent", () => { expect(getCountsSpy).not.toHaveBeenCalled(); }); }); + + /** + * The card is the second renderer of the same dashboard entries as the list row, so it follows + * the same default-view rule (default-view-landing.ts): mark, deep-link and toggle, all gated on + * the Form View flag. + */ + describe("default view", () => { + const enableFormView = () => + (TestBed.inject(GuiConfigService) as unknown as { setConfig: (c: object) => void }).setConfig({ + formViewEnabled: true, + }); + const formEntry = (defaultView: DefaultView | undefined, accessLevel = "WRITE", accessibleUserIds = [42]) => + makeWorkflowEntry({ + id: 7, + accessibleUserIds, + accessLevel, + workflow: { isOwner: true, workflow: defaultView === undefined ? undefined : { defaultView } }, + } as any); + + beforeEach(() => { + component.currentUid = 42; + component.isPrivateSearch = true; + (workflowPersistService as any).setDefaultView = vi.fn().mockReturnValue(of(undefined)); + }); + + it("opens a form-default workflow in its form and marks it with the Form View icon", () => { + enableFormView(); + component.entry = formEntry(DefaultView.FORM); + component.initializeEntry(); + + expect(component.defaultsToForm).toBe(true); + expect(component.iconType).toBe("solution"); + expect(component.entryLink).toEqual([USER_WORKSPACE, "7", "form"]); + }); + + it("leaves a canvas-default workflow as the descriptor set it", () => { + enableFormView(); + component.entry = formEntry(DefaultView.CANVAS); + component.initializeEntry(); + + expect(component.defaultsToForm).toBe(false); + expect(component.iconType).toBe("project"); + expect(component.entryLink).toEqual([USER_WORKSPACE, "7"]); + }); + + // Behind the flag the dashboard must look exactly as it does today. + it("ignores default_view entirely while the flag is off", () => { + component.entry = formEntry(DefaultView.FORM); + component.initializeEntry(); + + expect(component.defaultsToForm).toBe(false); + expect(component.iconType).toBe("project"); + expect(component.entryLink).toEqual([USER_WORKSPACE, "7"]); + expect(component.canToggleDefaultView).toBe(false); + }); + + // The mark is the owner's entry point; a hub visitor still lands on the detail page. + it("marks a form-default hub card but does not repoint its link", () => { + enableFormView(); + component.entry = formEntry(DefaultView.FORM, "READ", [99]); + component.initializeEntry(); + + expect(component.defaultsToForm).toBe(true); + expect(component.iconType).toBe("solution"); + expect(component.entryLink).toEqual([HUB_WORKFLOW_RESULT_DETAIL, "7"]); + }); + + it("offers the toggle only in private search, for a workflow the user can write", () => { + enableFormView(); + component.entry = formEntry(DefaultView.CANVAS, "WRITE"); + expect(component.canToggleDefaultView).toBe(true); + + component.entry = formEntry(DefaultView.CANVAS, "READ"); + expect(component.canToggleDefaultView).toBe(false); + + component.entry = formEntry(DefaultView.CANVAS, "WRITE"); + component.isPrivateSearch = false; + expect(component.canToggleDefaultView).toBe(false); + + component.isPrivateSearch = true; + component.entry = makeDatasetEntry({ accessLevel: "WRITE" } as any); + expect(component.canToggleDefaultView).toBe(false); + }); + + // A collaborator with write access but no ownership has no cover-image controls, yet still gets + // the toggle: it lives in the action footer, not among the owner's cover controls. + it("offers the toggle to a writer who does not own the card, without the owner's cover controls", () => { + enableFormView(); + component.entry = makeWorkflowEntry({ + id: 7, + accessibleUserIds: [42], + accessLevel: "WRITE", + workflow: { isOwner: false }, + } as any); + component.initializeEntry(); + + expect(component.canEditCover).toBe(false); + expect(component.canToggleDefaultView).toBe(true); + }); + + it("turns the default view on, marking the card and repointing it", () => { + enableFormView(); + component.entry = formEntry(DefaultView.CANVAS); + component.initializeEntry(); + + component.onToggleDefaultView(); + + expect(workflowPersistService.setDefaultView).toHaveBeenCalledWith(7, DefaultView.FORM); + expect(component.entry.workflow?.workflow?.defaultView).toBe(DefaultView.FORM); + expect(component.defaultsToForm).toBe(true); + expect(component.iconType).toBe("solution"); + expect(component.entryLink).toEqual([USER_WORKSPACE, "7", "form"]); + }); + + it("turns it back off, restoring the plain card", () => { + enableFormView(); + component.entry = formEntry(DefaultView.FORM); + component.initializeEntry(); + + component.onToggleDefaultView(); + + expect(workflowPersistService.setDefaultView).toHaveBeenCalledWith(7, DefaultView.CANVAS); + expect(component.defaultsToForm).toBe(false); + expect(component.iconType).toBe("project"); + expect(component.entryLink).toEqual([USER_WORKSPACE, "7"]); + }); + + // A failed call must not leave the card claiming a state the server never took. + it("keeps the previous state and reports it when the request fails", () => { + enableFormView(); + const notify = vi.spyOn(TestBed.inject(NotificationService), "error").mockImplementation(() => {}); + component.entry = formEntry(DefaultView.CANVAS); + component.initializeEntry(); + (workflowPersistService as any).setDefaultView = vi.fn().mockReturnValue(throwError(() => new Error("nope"))); + + component.onToggleDefaultView(); + + expect(component.defaultsToForm).toBe(false); + expect(component.iconType).toBe("project"); + expect(component.entryLink).toEqual([USER_WORKSPACE, "7"]); + expect(notify).toHaveBeenCalledWith("nope"); + }); + + // A card without the cached workflow row must still record the toggle, not crash. + it("still persists the toggle when the entry has no cached workflow row", () => { + enableFormView(); + component.entry = formEntry(undefined); + component.initializeEntry(); + + component.onToggleDefaultView(); + + expect(workflowPersistService.setDefaultView).toHaveBeenCalledWith(7, DefaultView.FORM); + expect(component.defaultsToForm).toBe(false); + }); + + // The permission lives in the method, not only in the button's *ngIf: setting the default view + // writes the workflow row, which needs WRITE access whoever calls. + it("refuses to toggle for a collaborator without write access", () => { + enableFormView(); + component.entry = formEntry(DefaultView.CANVAS, "READ"); + component.initializeEntry(); + + component.onToggleDefaultView(); + + expect(workflowPersistService.setDefaultView).not.toHaveBeenCalled(); + }); + + it("renders the toggle for a writer and routes its click, but not for a reader", () => { + enableFormView(); + const toggle = vi.spyOn(component, "onToggleDefaultView").mockImplementation(() => {}); + component.entry = formEntry(DefaultView.CANVAS, "WRITE"); + component.ngOnChanges({ entry: {} as any }); + fixture.detectChanges(); + + // In the always-visible action footer, in the list row's slot (right after Detail), not among the + // hover-only cover controls. + const button = fixture.debugElement.query(By.css(".private-actions button.default-view-toggle")); + expect(button).not.toBeNull(); + expect(fixture.debugElement.query(By.css(".card-image-controls button.default-view-toggle"))).toBeNull(); + const footerTitles = fixture.debugElement + .queryAll(By.css(".private-actions button")) + .map(b => b.nativeElement.getAttribute("title") ?? b.nativeElement.getAttribute("aria-label")); + expect(footerTitles.slice(0, 2)).toEqual(["Detail", "Open in the Form View by default"]); + // A toggle: constant name, state in aria-pressed. + expect(button.nativeElement.getAttribute("aria-label")).toBe("Open in the Form View by default"); + expect(button.nativeElement.getAttribute("aria-pressed")).toBe("false"); + button.triggerEventHandler("click", new MouseEvent("click")); + expect(toggle).toHaveBeenCalledTimes(1); + + component.entry = formEntry(DefaultView.FORM, "WRITE"); + component.ngOnChanges({ entry: {} as any }); + fixture.detectChanges(); + expect( + fixture.debugElement.query(By.css("button.default-view-toggle")).nativeElement.getAttribute("aria-pressed") + ).toBe("true"); + + component.entry = formEntry(DefaultView.CANVAS, "READ"); + component.ngOnChanges({ entry: {} as any }); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css("button.default-view-toggle"))).toBeNull(); + }); + }); }); diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts index 03cf31147e..a35b474208 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts @@ -52,6 +52,10 @@ import { extractErrorMessage } from "../../../../../common/util/error"; import { WorkflowCoverService } from "../../../../service/user/workflow-cover/workflow-cover.service"; import { isDefined } from "../../../../../common/util/predicate"; import { ResourceRegistryService } from "../../../../service/user/resource-registry/resource-registry.service"; +import { GuiConfigService } from "../../../../../common/service/gui-config.service"; +import { WorkflowPersistService } from "../../../../../common/service/workflow-persist/workflow-persist.service"; +import { DefaultView } from "../../../../type/workflow-metadata.interface"; +import { defaultsToFormView, landingLink } from "../default-view-landing"; @UntilDestroy() @Component({ @@ -126,7 +130,9 @@ export class CardItemComponent implements OnChanges { private cdr: ChangeDetectorRef, private notificationService: NotificationService, private workflowCoverService: WorkflowCoverService, - private resourceRegistry: ResourceRegistryService + private resourceRegistry: ResourceRegistryService, + private workflowPersistService: WorkflowPersistService, + private config: GuiConfigService ) {} get hasCustomImage(): boolean { @@ -138,6 +144,45 @@ export class CardItemComponent implements OnChanges { return this.isPrivateSearch && this.entry.type === "workflow" && this.entry.workflow.isOwner; } + /** Whether this card is a form-default workflow (Form View icon, deep-links into the form). */ + public defaultsToForm = false; + + /** + * Whether the default-view toggle is offered: the same rule as the list row. Setting the default + * view writes the workflow row, so it needs WRITE access, not just ownership of the card. + */ + get canToggleDefaultView(): boolean { + return ( + this.isPrivateSearch && + this.entry.type === "workflow" && + this.config.env.formViewEnabled && + this.entry.accessLevel === "WRITE" + ); + } + + public onToggleDefaultView(): void { + // The rule the button is gated on, checked here too: the write needs WRITE access whoever calls. + if (!this.canToggleDefaultView) { + return; + } + const next = this.defaultsToForm ? DefaultView.CANVAS : DefaultView.FORM; + this.workflowPersistService + .setDefaultView(this.entry.id as number, next) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + if (this.entry.workflow?.workflow) { + this.entry.workflow.workflow.defaultView = next; + } + // Re-derive from the descriptor so the icon and link fully reset when turning it + // off, not just when turning it on. + this.initializeEntry(); + this.cdr.detectChanges(); + }, + error: (err: unknown) => this.notificationService.error(extractErrorMessage(err)), + }); + } + openImagePicker(): void { this.backgroundInput?.nativeElement.click(); } @@ -191,6 +236,13 @@ export class CardItemComponent implements OnChanges { this.canDownload = descriptor.download !== undefined; this.canShare = descriptor.retrieveOwners !== undefined; this.entryLink = this.resourceRegistry.entryLink(this.entry, this.currentUid); + // Same landing rule as the list row (default-view-landing.ts): a form-default workflow shows + // the Form View icon and opens in its form, so the card and the row never disagree. + this.defaultsToForm = defaultsToFormView(this.entry, this.config.env.formViewEnabled); + if (this.defaultsToForm) { + this.iconType = "solution"; + } + this.entryLink = landingLink(this.entry, this.entryLink, this.config.env.formViewEnabled); if (descriptor.hasSize && typeof this.entry.id === "number") { this.size = this.entry.size; } diff --git a/frontend/src/app/dashboard/component/user/list-item/default-view-landing.ts b/frontend/src/app/dashboard/component/user/list-item/default-view-landing.ts new file mode 100644 index 0000000000..568fe77082 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/list-item/default-view-landing.ts @@ -0,0 +1,42 @@ +/** + * 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 { USER_WORKSPACE } from "../../../../app-routing.constant"; +import { DashboardEntry } from "../../../type/dashboard-entry"; +import { DefaultView } from "../../../type/workflow-metadata.interface"; + +/** + * Where a dashboard entry lands when opened, and whether it is a form-default workflow. Shared by + * the list row and the card so the two renderers of the same dashboard agree: with the Form View + * flag on, a workflow whose default_view is FORM is marked as such and, when its link points into + * the user's own workspace, deep-links straight into its form (the operator canvas stays one click + * away from there). Every other link, hub links included, is left exactly as the descriptor built it. + */ +export function defaultsToFormView(entry: DashboardEntry, formViewEnabled: boolean): boolean { + return formViewEnabled && entry.type === "workflow" && entry.workflow?.workflow?.defaultView === DefaultView.FORM; +} + +export function landingLink(entry: DashboardEntry, descriptorLink: string[], formViewEnabled: boolean): string[] { + if (!formViewEnabled || entry.type !== "workflow" || descriptorLink[0] !== USER_WORKSPACE) { + return descriptorLink; + } + return defaultsToFormView(entry, formViewEnabled) + ? [USER_WORKSPACE, String(entry.id), "form"] + : [USER_WORKSPACE, String(entry.id)]; +} diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html index 7e67b6438e..205cbe6c8d 100644 --- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html +++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html @@ -45,7 +45,8 @@ <div nz-col nzFlex="0" - class="type-icon"> + class="type-icon" + [class.defaults-to-form]="defaultsToForm"> <i nz-icon [nzType]="iconType"></i> @@ -194,6 +195,26 @@ nz-icon nzType="eye"></i> </button> + <!-- Setting the default view is a write on the workflow row (the endpoint requires WRITE), so a + read-only collaborator is not offered a control that could only fail (the handler checks + the same rule, canToggleDefaultView). --> + <!-- A toggle button: the name stays constant and aria-pressed carries the state (a name that + changed with the state would announce the opposite of what the state says); the title spells + out what a click does. --> + <button + *ngIf="canToggleDefaultView" + nz-button + nzType="text" + class="default-view-toggle" + aria-label="Open in the Form View by default" + [attr.aria-pressed]="defaultsToForm" + [title]="defaultsToForm ? 'Open on the canvas by default' : 'Open in the Form View by default'" + [class.defaults-to-form-on]="defaultsToForm" + (click)="onToggleDefaultView()"> + <i + nz-icon + nzType="solution"></i> + </button> <button nz-button nzType="text" diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.scss b/frontend/src/app/dashboard/component/user/list-item/list-item.component.scss index 97cec2e60c..82ad7c2e56 100644 --- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.scss +++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.scss @@ -120,7 +120,11 @@ } } -.list-item-card:hover .button-group { +// Shown on hover, and while anything in the row has the keyboard focus: the group is display:none +// otherwise, so a keyboard user reaches it by tabbing into the row (its checkbox, rename, like), +// which reveals the actions and lets the next Tab move into them. +.list-item-card:hover .button-group, +.list-item-card:focus-within .button-group { display: flex; background-color: transparent; } @@ -180,6 +184,22 @@ font-size: 30px; } +/* A form-default workflow carries a little colour so the row reads at a glance, + without shouting: same flask, tinted, in the row icon and in the hover action. */ +.type-icon.defaults-to-form { + color: #1e90ff; +} + +.button-group button.defaults-to-form-on { + background-color: #e6f7ff; + border-color: #1e90ff; + color: #1e90ff; +} + +.button-group button.defaults-to-form-on:hover { + background-color: #cceeff; +} + .workflow-id { padding: 6px; } diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts b/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts index e520343ce4..ce4a2310ef 100644 --- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts @@ -34,8 +34,10 @@ import { RouterTestingModule } from "@angular/router/testing"; import { StubUserService } from "../../../../common/service/user/stub-user.service"; import { UserService } from "../../../../common/service/user/user.service"; import { commonTestProviders } from "../../../../common/testing/test-utils"; +import { GuiConfigService } from "../../../../common/service/gui-config.service"; import type { Mocked } from "vitest"; import { DashboardEntry } from "src/app/dashboard/type/dashboard-entry"; +import { DefaultView } from "../../../type/workflow-metadata.interface"; import { DatasetService, DEFAULT_DATASET_NAME } from "../../../service/user/dataset/dataset.service"; import { NotificationService } from "../../../../common/service/notification/notification.service"; import { @@ -74,6 +76,12 @@ describe("ListItemComponent", () => { datasetService = TestBed.inject(DatasetService) as unknown as Mocked<DatasetService>; hubService = TestBed.inject(HubService); modalService = TestBed.inject(NzModalService); + // The Form View entry points (deep-link, solution icon, toggle) are gated on the + // form-view-enabled flag, which the stack's closing PR turns on. The shared config mock + // defaults it off (matching the production default), so enable it here for the form-default cases. + (TestBed.inject(GuiConfigService) as unknown as { setConfig: (c: object) => void }).setConfig({ + formViewEnabled: true, + }); // initializeEntry() needs a fully-formed workflow entry to avoid throwing // when the template renders for the first time. Each test below overwrites // component.entry directly, which exercises confirm methods without going @@ -143,6 +151,94 @@ describe("ListItemComponent", () => { expect(component.editingDescription).toBe(false); }); + describe("Form View toggle", () => { + const formEntry = (defaultView: DefaultView, accessLevel = "WRITE") => + ({ + id: 42, + type: "workflow", + workflow: { isOwner: true, workflow: { defaultView } }, + accessibleUserIds: [1], + accessLevel, + likeCount: 0, + viewCount: 0, + isLiked: false, + }) as unknown as DashboardEntry; + + beforeEach(() => { + component.currentUid = 1; + (workflowPersistService as any).setDefaultView = vi.fn().mockReturnValue(of(undefined)); + }); + + it("turns it on, showing the flask and repointing the row", () => { + component.entry = formEntry(DefaultView.CANVAS); + component.initializeEntry(); + expect(component.iconType).toBe("project"); + + component.onToggleDefaultView(); + + expect(workflowPersistService.setDefaultView).toHaveBeenCalledWith(42, DefaultView.FORM); + expect(component.defaultsToForm).toBe(true); + expect(component.iconType).toBe("solution"); + expect(component.entryLink).toEqual([USER_WORKSPACE, "42", "form"]); + }); + + it("turns it back off, restoring the plain row", () => { + component.entry = formEntry(DefaultView.FORM); + component.initializeEntry(); + + component.onToggleDefaultView(); + + expect(workflowPersistService.setDefaultView).toHaveBeenCalledWith(42, DefaultView.CANVAS); + expect(component.defaultsToForm).toBe(false); + expect(component.iconType).toBe("project"); + expect(component.entryLink).toEqual([USER_WORKSPACE, "42"]); + }); + + // A failed call must not leave the row claiming a state the server never took. + it("keeps the previous state when the request fails", () => { + component.entry = formEntry(DefaultView.CANVAS); + component.initializeEntry(); + (workflowPersistService as any).setDefaultView = vi.fn().mockReturnValue(throwError(() => new Error("nope"))); + + component.onToggleDefaultView(); + + expect(component.defaultsToForm).toBe(false); + expect(component.iconType).toBe("project"); + }); + + // A card without the cached workflow row must still record the toggle, not crash. + it("still persists the toggle when the entry has no cached workflow row", () => { + component.entry = { + id: 42, + type: "workflow", + workflow: { isOwner: true }, + accessibleUserIds: [1], + accessLevel: "WRITE", + likeCount: 0, + viewCount: 0, + isLiked: false, + } as unknown as DashboardEntry; + component.initializeEntry(); + + component.onToggleDefaultView(); + + expect(workflowPersistService.setDefaultView).toHaveBeenCalledWith(42, DefaultView.FORM); + expect(component.defaultsToForm).toBe(false); + }); + + // The permission lives in the method, not only in the button's *ngIf: setting the default view + // writes the workflow row, which needs WRITE access whoever calls. + it("refuses to toggle for a collaborator without write access", () => { + component.entry = formEntry(DefaultView.CANVAS, "READ"); + component.initializeEntry(); + + component.onToggleDefaultView(); + + expect(component.canToggleDefaultView).toBe(false); + expect(workflowPersistService.setDefaultView).not.toHaveBeenCalled(); + }); + }); + describe("initializeEntry routes", () => { const baseStats = { likeCount: 0, viewCount: 0, isLiked: false }; @@ -159,6 +255,38 @@ describe("ListItemComponent", () => { expect(component.entryLink).toEqual([USER_WORKSPACE, "100"]); }); + it("sends an owned form-default workflow straight to its form", () => { + component.currentUid = 1; + component.entry = { + id: 100, + type: "workflow", + workflow: { isOwner: true, workflow: { defaultView: DefaultView.FORM } }, + accessibleUserIds: [1], + ...baseStats, + } as unknown as DashboardEntry; + component.initializeEntry(); + + expect(component.entryLink).toEqual([USER_WORKSPACE, "100", "form"]); + expect(component.iconType).toBe("solution"); + expect(component.defaultsToForm).toBe(true); + }); + + // The flask is the owner's entry point; a hub visitor still lands on the detail page. + it("leaves the hub link alone for a form-default workflow the user does not own", () => { + component.currentUid = 1; + component.entry = { + id: 101, + type: "workflow", + workflow: { isOwner: false, workflow: { defaultView: DefaultView.FORM } }, + accessibleUserIds: [2], + ...baseStats, + } as unknown as DashboardEntry; + component.initializeEntry(); + + expect(component.entryLink).toEqual([HUB_WORKFLOW_RESULT_DETAIL, "101"]); + expect(component.iconType).toBe("solution"); + }); + it("routes non-owned workflows to the hub workflow detail page", () => { component.currentUid = 1; component.entry = { @@ -678,6 +806,24 @@ describe("ListItemComponent", () => { expect(edit).toHaveBeenCalledTimes(2); }); + // Setting the default view writes the workflow row, so the control is only offered to a + // collaborator who can write it; a reader would only ever see it fail. + it("offers the default-view toggle only to a collaborator with write access", () => { + const toggle = vi.spyOn(component, "onToggleDefaultView").mockImplementation(() => {}); + render({ accessLevel: "WRITE" }); + + const button = q("button.default-view-toggle"); + expect(button).not.toBeNull(); + // A toggle: constant name, state in aria-pressed. + expect(button.nativeElement.getAttribute("aria-label")).toBe("Open in the Form View by default"); + expect(button.nativeElement.getAttribute("aria-pressed")).toBe("false"); + button.triggerEventHandler("click", new MouseEvent("click")); + expect(toggle).toHaveBeenCalledTimes(1); + + render({ accessLevel: "READ" }); + expect(q("button.default-view-toggle")).toBeNull(); + }); + it("tracks hover over the row", () => { render(); const row = q("div[nz-row]"); diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts index 2226f2e1db..5f35b3d289 100644 --- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts +++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts @@ -54,6 +54,10 @@ import { FormsModule } from "@angular/forms"; import { UserAvatarComponent } from "../user-avatar/user-avatar.component"; import { NzWaveDirective } from "ng-zorro-antd/core/wave"; import { NzPopconfirmDirective } from "ng-zorro-antd/popconfirm"; +import { GuiConfigService } from "../../../../common/service/gui-config.service"; +import { DefaultView } from "../../../type/workflow-metadata.interface"; +import { defaultsToFormView, landingLink } from "./default-view-landing"; +import { WorkflowPersistService } from "../../../../common/service/workflow-persist/workflow-persist.service"; @UntilDestroy() @Component({ @@ -96,8 +100,19 @@ export class ListItemComponent implements OnChanges { entryLink: string[] = []; size: number | undefined = 0; public iconType: string = ""; + /** Whether this workflow opens in the Form View by default. */ + public defaultsToForm = false; isLiked: boolean = false; @Input() isPrivateSearch = false; + + /** + * Whether the default-view toggle is offered, and honoured: setting the default view writes the + * workflow row (the endpoint requires WRITE), so a read-only collaborator gets no control that + * could only fail, and the handler itself checks the same rule rather than trusting the template. + */ + get canToggleDefaultView(): boolean { + return this.entry.type === "workflow" && this.config.env.formViewEnabled && this.entry.accessLevel === "WRITE"; + } @Input() editable = false; private _entry?: DashboardEntry; hovering: boolean = false; @@ -125,7 +140,9 @@ export class ListItemComponent implements OnChanges { private hubService: HubService, private cdr: ChangeDetectorRef, private notificationService: NotificationService, - private resourceRegistry: ResourceRegistryService + private resourceRegistry: ResourceRegistryService, + protected config: GuiConfigService, + private workflowPersistService: WorkflowPersistService ) {} initializeEntry() { @@ -138,6 +155,9 @@ export class ListItemComponent implements OnChanges { if (descriptor.hasSize && typeof this.entry.id === "number") { this.size = this.entry.size; } + // A workflow opens in its default view: with the feature flag on, a form-default one shows + // the Form View icon and deep-links straight into the form; a canvas-default one is unchanged. + this.applyDefaultView(); this.likeCount = this.entry.likeCount; this.viewCount = this.entry.viewCount; this.isLiked = this.entry.isLiked; @@ -163,6 +183,44 @@ export class ListItemComponent implements OnChanges { } } + /** + * A workflow opens in its default view. A form-default one is marked with the Form View icon + * and its owner's row deep-links straight into the form; the operator canvas is still one + * click away from there. A canvas-default one is left as the descriptor set it. Hub links are + * untouched; only the owner's own entry point moves. The rule itself lives in + * default-view-landing.ts, shared with the card renderer so both views of the dashboard agree. + */ + private applyDefaultView(): void { + const formViewEnabled = this.config.env.formViewEnabled; + this.defaultsToForm = defaultsToFormView(this.entry, formViewEnabled); + if (this.defaultsToForm) { + this.iconType = "solution"; + } + this.entryLink = landingLink(this.entry, this.entryLink, formViewEnabled); + } + + public onToggleDefaultView(): void { + if (!this.canToggleDefaultView) { + return; + } + const next = this.defaultsToForm ? DefaultView.CANVAS : DefaultView.FORM; + this.workflowPersistService + .setDefaultView(this.entry.id as number, next) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + if (this.entry.workflow?.workflow) { + this.entry.workflow.workflow.defaultView = next; + } + // Re-derive from the descriptor so the icon and link fully reset when turning it + // off, not just when turning it on. + this.initializeEntry(); + this.cdr.detectChanges(); + }, + error: (err: unknown) => this.notificationService.error(extractErrorMessage(err)), + }); + } + onCheckboxChange(entry: DashboardEntry): void { entry.checked = !entry.checked; this.cdr.markForCheck(); diff --git a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.spec.ts b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.spec.ts index 6d59455394..0986d60b17 100644 --- a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.spec.ts @@ -745,12 +745,27 @@ describe("SavedWorkflowSectionComponent", () => { const result = await firstValueFrom(component.onClickUploadExistingWorkflowFromLocal(file as any)); expect(result).toBe(false); - expect(persist.createWorkflow).toHaveBeenCalledWith(content, "wf"); + expect(persist.createWorkflow).toHaveBeenCalledWith(content, "wf", undefined); expect(component.searchResultsComponent.entries.map(e => e.name)).toContain("wf"); expect(searchSpy).toHaveBeenCalledWith(true); expect(successSpy).toHaveBeenCalledWith("Upload Successful"); }); + it("restores a form-default workflow's landing view, keeping it out of the content", async () => { + const persist = TestBed.inject(WorkflowPersistService) as any; + persist.createWorkflow = vi.fn().mockReturnValue(of(makeDashboardWorkflow(43, "form-wf"))); + vi.spyOn(component, "search").mockResolvedValue(undefined); + vi.spyOn(TestBed.inject(NotificationService), "success").mockImplementation(() => undefined as any); + const content = testWorkflowContent([]); + const file = new File([JSON.stringify({ ...content, defaultView: "FORM" })], "form-wf.json"); + setEntries([]); + + await firstValueFrom(component.onClickUploadExistingWorkflowFromLocal(file as any)); + + // defaultView is pulled out and passed on its own; the content stored is unchanged. + expect(persist.createWorkflow).toHaveBeenCalledWith(content, "form-wf", "FORM"); + }); + it("toasts an error and errors the stream when the file is not JSON", async () => { const errorSpy = vi .spyOn(TestBed.inject(NotificationService), "error") @@ -773,7 +788,7 @@ describe("SavedWorkflowSectionComponent", () => { await firstValueFrom(component.onClickUploadExistingWorkflowFromLocal(file as any)); - expect(persist.createWorkflow).toHaveBeenCalledWith(content, DEFAULT_WORKFLOW_NAME); + expect(persist.createWorkflow).toHaveBeenCalledWith(content, DEFAULT_WORKFLOW_NAME, undefined); }); it("imports every workflow file inside an uploaded .zip", async () => { @@ -1105,7 +1120,7 @@ describe("SavedWorkflowSectionComponent", () => { await firstValueFrom(component.onClickUploadExistingWorkflowFromLocal(file as any)); - expect(persist.createWorkflow).toHaveBeenCalledWith(content, "noext"); + expect(persist.createWorkflow).toHaveBeenCalledWith(content, "noext", undefined); }); it("errors the upload stream and does not toast success when createWorkflow fails", async () => { diff --git a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts index 29ebb13afa..5aae5f7943 100644 --- a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts +++ b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.ts @@ -29,7 +29,7 @@ import { DashboardEntry, UserInfo } from "../../../type/dashboard-entry"; import { UserService } from "../../../../common/service/user/user.service"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { NotificationService } from "../../../../common/service/notification/notification.service"; -import { ExecutionMode, WorkflowContent } from "../../../../common/type/workflow"; +import { ExecutionMode, ExportedWorkflow, WorkflowContent } from "../../../../common/type/workflow"; import { NzUploadFile, NzUploadComponent } from "ng-zorro-antd/upload"; import JSZip from "jszip"; import { FiltersComponent } from "../filters/filters.component"; @@ -540,9 +540,11 @@ export class UserWorkflowComponent implements AfterViewInit, OnDestroy { if (typeof result !== "string") { throw new Error("Incorrect format: file is not a string"); } - const workflowContent = JSON.parse(result) as WorkflowContent; + // The landing view rides as a sibling key next to the content (see exportedWorkflow); + // pull it back out so it is stored on the workflow row, not inside content. + const { defaultView, ...workflowContent } = JSON.parse(result) as ExportedWorkflow; this.workflowPersistService - .createWorkflow(workflowContent, this.deriveWorkflowName(name)) + .createWorkflow(workflowContent, this.deriveWorkflowName(name), defaultView) .pipe(untilDestroyed(this)) .subscribe({ next: uploadedWorkflow => { diff --git a/frontend/src/app/dashboard/service/user/download/download.service.spec.ts b/frontend/src/app/dashboard/service/user/download/download.service.spec.ts index b295a16b6d..0d1c3ac734 100644 --- a/frontend/src/app/dashboard/service/user/download/download.service.spec.ts +++ b/frontend/src/app/dashboard/service/user/download/download.service.spec.ts @@ -237,6 +237,36 @@ describe("DownloadService", () => { // produced it. }); + // Blob.text() is missing in jsdom, but FileReader.readAsText works. + const readBlob = (blob: Blob) => + new Promise<string>((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); + + it("carries the default view into the downloaded workflow json so it round-trips on upload", async () => { + const content = { operators: [], links: [] }; + workflowPersistServiceSpy.retrieveWorkflow.mockReturnValue(of({ content, defaultView: "FORM" } as any)); + + const result = await firstValueFrom(downloadService.downloadWorkflow(42, "MyWorkflow")); + const parsed = JSON.parse(await readBlob(result.blob)); + + expect(parsed.defaultView).toBe("FORM"); + expect(parsed.operators).toEqual([]); + }); + + it("omits the default view from the json when the workflow has none", async () => { + const content = { operators: [], links: [] }; + workflowPersistServiceSpy.retrieveWorkflow.mockReturnValue(of({ content } as any)); + + const result = await firstValueFrom(downloadService.downloadWorkflow(42, "MyWorkflow")); + const parsed = JSON.parse(await readBlob(result.blob)); + + expect("defaultView" in parsed).toBe(false); + }); + // ─── downloadWorkflowsAsZip ─────────────────────────────────────────────── it("downloads the workflow ZIP and routes through createWorkflowsZip", async () => { diff --git a/frontend/src/app/dashboard/service/user/download/download.service.ts b/frontend/src/app/dashboard/service/user/download/download.service.ts index 65430e5b54..9acdd6ccdb 100644 --- a/frontend/src/app/dashboard/service/user/download/download.service.ts +++ b/frontend/src/app/dashboard/service/user/download/download.service.ts @@ -26,7 +26,7 @@ import { DatasetService } from "../dataset/dataset.service"; import { ModelService } from "../model/model.service"; import { WorkflowPersistService } from "src/app/common/service/workflow-persist/workflow-persist.service"; import JSZip from "jszip"; -import { Workflow } from "../../../../common/type/workflow"; +import { exportedWorkflow, Workflow } from "../../../../common/type/workflow"; import { HttpClient, HttpResponse } from "@angular/common/http"; import { WORKFLOW_EXECUTIONS_API_BASE_URL } from "../workflow-executions/workflow-executions.service"; import { DashboardWorkflowComputingUnit } from "../../../../common/type/workflow-computing-unit"; @@ -315,8 +315,10 @@ export class DownloadService { */ private retrieveWorkflowItem(id: number, name: string): Observable<DownloadableItem> { return this.workflowPersistService.retrieveWorkflow(id).pipe( - map(({ content }) => { - const workflowJson = JSON.stringify(content, null, 2); + map(({ content, defaultView }) => { + // The one export shape (see exportedWorkflow), shared with the canvas menu's export, so + // either file uploads alike. + const workflowJson = JSON.stringify(exportedWorkflow(content, defaultView), null, 2); const fileName = `${name}.json`; const blob = new Blob([workflowJson], { type: "text/plain;charset=utf-8" }); return { blob, fileName }; diff --git a/frontend/src/app/workspace/component/menu/menu.component.html b/frontend/src/app/workspace/component/menu/menu.component.html index cfdeea7710..cf4c053894 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.html +++ b/frontend/src/app/workspace/component/menu/menu.component.html @@ -75,6 +75,30 @@ <span *ngIf="!displayParticularWorkflowVersion"> {{autoSaveState}} </span> </div> + <!-- One workflow, two ways of working on it: the Canvas and the Form View. The + same control sits in the same place on both, so switching never means hunting + for a different affordance; the pressed segment is the view you are looking at. + Every workflow offers both views, so this shows wherever the feature flag is on. --> + <div + class="view-switch" + *ngIf="this.config.env.formViewEnabled && !displayParticularWorkflowVersion"> + <!-- The pressed segment stays a live button with no handler, deliberately: clicking the view + you are already on is a no-op, and a real button keeps the segment focusable and announced + with its pressed state. The Form View page mirrors this exactly, roles swapped. --> + <button + type="button" + class="on" + aria-pressed="true"> + Canvas + </button> + <button + type="button" + aria-pressed="false" + (click)="onClickOpenFormView()"> + Form View + </button> + </div> + <ng-container *ngFor="let user of coeditorPresenceService.coeditors"> <texera-coeditor-user-icon [coeditor]="user"></texera-coeditor-user-icon> </ng-container> diff --git a/frontend/src/app/workspace/component/menu/menu.component.scss b/frontend/src/app/workspace/component/menu/menu.component.scss index deb31c0258..12dafb6a2e 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.scss +++ b/frontend/src/app/workspace/component/menu/menu.component.scss @@ -81,6 +81,7 @@ #user-buttons, #execution-buttons, nz-button-group, +nz-upload, texera-computing-unit-selection { display: inline-flex; align-items: center; @@ -215,3 +216,64 @@ texera-coeditor-user-icon { width: auto; vertical-align: -0.2em; } + +/* One workflow, two ways of working on it. Rendered identically in the operator canvas + and the Form View, in the same slot of the same title row, so the control + never moves when the view does -- that stillness is what makes the two read as two + views of one thing rather than two pages. + + Deliberately quiet: the indicator is a rule sitting on the row's own bottom border, + not a filled button. This is secondary navigation and it shares a screen with Run, + which is the one thing here that should be solid blue. The current view is inert -- + clicking the view you are already in should do nothing. */ +.view-switch { + display: inline-flex; + align-self: stretch; + align-items: stretch; + flex: none; + gap: 20px; + margin-right: 20px; + + button { + appearance: none; + border: 0; + background: none; + cursor: pointer; + font: inherit; + font-size: 13px; + color: rgba(0, 0, 0, 0.45); + padding: 0; + position: relative; + display: inline-flex; + align-items: center; + white-space: nowrap; + transition: color 0.15s; + + /* Sits on the row's bottom rule, so the two read as tabs of the row rather than + as a widget dropped into it. */ + &::after { + content: ""; + position: absolute; + left: -2px; + right: -2px; + bottom: -1px; + height: 2px; + background: transparent; + transition: background 0.15s; + } + + &:hover:not(.on) { + color: rgba(0, 0, 0, 0.85); + } + } + + button.on { + color: rgba(0, 0, 0, 0.85); + font-weight: 500; + cursor: default; + + &::after { + background: #1890ff; + } + } +} diff --git a/frontend/src/app/workspace/component/menu/menu.component.spec.ts b/frontend/src/app/workspace/component/menu/menu.component.spec.ts index da69bf3adf..3e15706cf0 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.spec.ts +++ b/frontend/src/app/workspace/component/menu/menu.component.spec.ts @@ -116,6 +116,118 @@ describe("MenuComponent", () => { expect(component).toBeTruthy(); }); + it("does not open the Form View for a workflow that has not been saved yet", () => { + vi.spyOn(component["workflowActionService"], "getWorkflowMetadata").mockReturnValue({ wid: undefined } as any); + const href = window.location.href; + + component.onClickOpenFormView(); + + expect(window.location.href).toBe(href); + }); + + it("hands over to the id the save assigned when the canvas held a workflow never saved yet", () => { + // After "new workflow" the canvas holds the default workflow (wid 0); the switch's save creates + // it, and the page to open is the created one, not /workflow/0/form. + component.writeAccess = true; + vi.spyOn(component["workflowActionService"], "getWorkflowMetadata").mockReturnValue({ wid: 0 } as any); + vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(of({ wid: 42, name: "created" } as any)); + vi.spyOn(component["workflowActionService"], "setWorkflowMetadata").mockImplementation(() => {}); + const navigate = vi.spyOn(component as any, "openFormViewPage").mockImplementation(() => {}); + + component.onClickOpenFormView(); + + expect(navigate).toHaveBeenCalledWith(42); + }); + + it("saves, then hands over to the Form View only once the save has completed", () => { + component.writeAccess = true; + vi.spyOn(component["workflowActionService"], "getWorkflowMetadata").mockReturnValue({ wid: 7 } as any); + const saved = { wid: 7, name: "saved" } as any; + const persistSpy = vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(of(saved)); + const metadataSpy = vi + .spyOn(component["workflowActionService"], "setWorkflowMetadata") + .mockImplementation(() => {}); + const navigate = vi.spyOn(component as any, "openFormViewPage").mockImplementation(() => {}); + + component.onClickOpenFormView(); + + // The navigation unloads the document and aborts anything still in flight, so it must wait for + // the save's completion rather than be fired right after the request. + expect(persistSpy).toHaveBeenCalled(); + expect(metadataSpy).toHaveBeenCalledWith(saved); + expect(navigate).toHaveBeenCalledWith(7); + expect(component.isSaving).toBe(false); + }); + + it("stays on the canvas and reports the error when the save before the switch fails", () => { + component.writeAccess = true; + vi.spyOn(component["workflowActionService"], "getWorkflowMetadata").mockReturnValue({ wid: 7 } as any); + vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(throwError(() => new Error("nope"))); + const errorSpy = vi.spyOn(notificationService, "error").mockImplementation(() => {}); + const navigate = vi.spyOn(component as any, "openFormViewPage").mockImplementation(() => {}); + + component.onClickOpenFormView(); + + // Leaving would take the user away from changes that were never stored. + expect(navigate).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith("Could not save. Your latest changes are not stored yet."); + expect(component.isSaving).toBe(false); + }); + + it("saves once more when an edit lands while the switch's save is out, then hands over", () => { + // The page stays editable until the full-page load; an edit made after the click is not in the + // save's snapshot and its own autosave (debounced) would be aborted by the load. workflowChanged + // marks it, and the hand-over saves again before leaving. + const edits = new Subject<unknown>(); + vi.spyOn(component["workflowActionService"], "workflowChanged").mockReturnValue(edits.asObservable()); + component.ngOnInit(); + component.writeAccess = true; + vi.spyOn(component["workflowActionService"], "getWorkflowMetadata").mockReturnValue({ wid: 7 } as any); + vi.spyOn(component["workflowActionService"], "setWorkflowMetadata").mockImplementation(() => {}); + const first$ = new Subject<any>(); + const second$ = new Subject<any>(); + const persistSpy = vi + .spyOn(workflowPersistService, "persistWorkflow") + .mockReturnValueOnce(first$) + .mockReturnValueOnce(second$); + const navigate = vi.spyOn(component as any, "openFormViewPage").mockImplementation(() => {}); + + component.onClickOpenFormView(); + edits.next(undefined); // an edit while the first save is out + first$.complete(); + + expect(persistSpy).toHaveBeenCalledTimes(2); // saved once more + expect(navigate).not.toHaveBeenCalled(); + second$.complete(); + expect(navigate).toHaveBeenCalledWith(7); + expect(component.isSaving).toBe(false); + }); + + it("ignores a second click while the hand-over is already in progress", () => { + component.writeAccess = true; + vi.spyOn(component["workflowActionService"], "getWorkflowMetadata").mockReturnValue({ wid: 7 } as any); + const persistSpy = vi.spyOn(workflowPersistService, "persistWorkflow").mockReturnValue(new Subject<any>()); + vi.spyOn(component as any, "openFormViewPage").mockImplementation(() => {}); + + component.onClickOpenFormView(); + component.onClickOpenFormView(); + + expect(persistSpy).toHaveBeenCalledTimes(1); + }); + + it("takes a reader straight over without a save, which they could not make", () => { + // Every save of a reader's is a 403 that would keep them on the canvas with an error. + component.writeAccess = false; + vi.spyOn(component["workflowActionService"], "getWorkflowMetadata").mockReturnValue({ wid: 7 } as any); + const persistSpy = vi.spyOn(workflowPersistService, "persistWorkflow"); + const navigate = vi.spyOn(component as any, "openFormViewPage").mockImplementation(() => {}); + + component.onClickOpenFormView(); + + expect(persistSpy).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(7); + }); + describe("getRunButtonBehavior", () => { it("returns 'Invalid Workflow' when the workflow is invalid", () => { component.isWorkflowValid = false; @@ -542,6 +654,44 @@ describe("MenuComponent", () => { expect(blobArg).toBeInstanceOf(Blob); expect(blobArg.type).toBe("text/plain;charset=utf-8"); }); + + // Blob.text() is missing in jsdom, but FileReader.readAsText works. + const readBlob = (blob: Blob) => + new Promise<string>((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); + + it("carries the workflow's default view next to the content, as the dashboard download does", async () => { + const saveAs = vi.spyOn(TestBed.inject(FileSaverService), "saveAs").mockImplementation(() => {}); + vi.spyOn(workflowActionService, "getWorkflowContent").mockReturnValue({ + operators: [], + links: [], + } as unknown as WorkflowContent); + vi.spyOn(workflowActionService, "getWorkflowMetadata").mockReturnValue({ wid: 7, defaultView: "FORM" } as any); + + component.onClickExportWorkflow(); + + const parsed = JSON.parse(await readBlob(saveAs.mock.calls[0][0] as Blob)); + expect(parsed.defaultView).toBe("FORM"); + expect(parsed.operators).toEqual([]); + }); + + it("leaves the key out when the workflow has no default view", async () => { + const saveAs = vi.spyOn(TestBed.inject(FileSaverService), "saveAs").mockImplementation(() => {}); + vi.spyOn(workflowActionService, "getWorkflowContent").mockReturnValue({ + operators: [], + links: [], + } as unknown as WorkflowContent); + vi.spyOn(workflowActionService, "getWorkflowMetadata").mockReturnValue({ wid: 7 } as any); + + component.onClickExportWorkflow(); + + const parsed = JSON.parse(await readBlob(saveAs.mock.calls[0][0] as Blob)); + expect("defaultView" in parsed).toBe(false); + }); }); describe("version history", () => { @@ -1168,6 +1318,38 @@ describe("MenuComponent", () => { vi.restoreAllMocks(); }); + describe("view switch", () => { + const flag = (formViewEnabled: boolean) => + (TestBed.inject(GuiConfigService) as unknown as MockGuiConfigService).setConfig({ formViewEnabled }); + + it("shows Canvas pressed and hands Form View to onClickOpenFormView, only with the flag on", () => { + flag(false); + fixture.detectChanges(); + expect(q(".view-switch")).toBeNull(); + + flag(true); + fixture.detectChanges(); + const open = vi.spyOn(component, "onClickOpenFormView").mockImplementation(() => {}); + const buttons = fixture.debugElement.queryAll(By.css(".view-switch button")); + expect(buttons.map(b => (b.nativeElement.textContent ?? "").trim())).toEqual(["Canvas", "Form View"]); + // The current view is the pressed segment: announced as such, and a live button on purpose. + expect(buttons[0].nativeElement.getAttribute("aria-pressed")).toBe("true"); + expect(buttons[0].nativeElement.classList.contains("on")).toBe(true); + expect(buttons[1].nativeElement.getAttribute("aria-pressed")).toBe("false"); + buttons[1].triggerEventHandler("click", null); + expect(open).toHaveBeenCalledTimes(1); + }); + + it("hides the switch while an older version is displayed", () => { + flag(true); + component.displayParticularWorkflowVersion = true; + fixture.detectChanges(); + + // A past version has no form to switch to. + expect(q(".view-switch")).toBeNull(); + }); + }); + describe("version display bar", () => { /** Puts the menu into the "viewing an older version" state and renders it. */ function showVersion(versionId: number | null = 7): void { diff --git a/frontend/src/app/workspace/component/menu/menu.component.ts b/frontend/src/app/workspace/component/menu/menu.component.ts index c3ad4aa979..3a46e987f3 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.ts +++ b/frontend/src/app/workspace/component/menu/menu.component.ts @@ -22,7 +22,7 @@ import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from "@ang import { Router, RouterLink } from "@angular/router"; import { UserService } from "../../../common/service/user/user.service"; import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; -import { Workflow, WorkflowContent } from "../../../common/type/workflow"; +import { exportedWorkflow, Workflow, WorkflowContent } from "../../../common/type/workflow"; import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-workflow.service"; import { UndoRedoService } from "../../service/undo-redo/undo-redo.service"; import { ValidationWorkflowService } from "../../service/validation/validation-workflow.service"; @@ -45,7 +45,7 @@ import { ResultExportationComponent } from "../result-exportation/result-exporta import { ReportGenerationService } from "../../service/report-generation/report-generation.service"; import { ShareAccessComponent } from "src/app/dashboard/component/user/share-access/share-access.component"; import { PanelService } from "../../service/panel/panel.service"; -import { USER_WORKFLOW } from "../../../app-routing.constant"; +import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant"; import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface"; import { ComputingUnitSelectionComponent } from "../power-button/computing-unit-selection.component"; @@ -130,6 +130,10 @@ export class MenuComponent implements OnInit, OnDestroy { public isWorkflowValid: boolean = true; // this will check whether the workflow error or not public isWorkflowEmpty: boolean = false; public isSaving: boolean = false; + /** A Form View hand-over is in progress (saving, then a full-page load); a second click is a no-op. */ + private handingOverToFormView = false; + /** An edit has been reported since the hand-over's last save snapshot (see onClickOpenFormView). */ + private editedSinceSwitchSnapshot = false; public isWorkflowModifiable: boolean = false; public workflowId?: number; public isExportDeactivate: boolean = false; @@ -219,6 +223,13 @@ export class MenuComponent implements OnInit, OnDestroy { } public ngOnInit(): void { + // Marks an edit for the Form View hand-over (see onClickOpenFormView): set the moment an edit is + // reported, before the autosave debounce, cleared when the switch's save snapshots the workflow. + this.workflowActionService + .workflowChanged() + .pipe(untilDestroyed(this)) + .subscribe(() => (this.editedSinceSwitchSnapshot = true)); + this.executeWorkflowService .getExecutionStateStream() .pipe(untilDestroyed(this)) @@ -617,8 +628,13 @@ export class MenuComponent implements OnInit, OnDestroy { } public onClickExportWorkflow(): void { - const workflowContent: WorkflowContent = this.workflowActionService.getWorkflowContent(); - const workflowContentJson = JSON.stringify(workflowContent, null, 2); + // The same shape the dashboard download produces (see exportedWorkflow): the content plus the + // landing view as a sibling key, so a file exported here uploads as a form-default workflow too. + const exported = exportedWorkflow( + this.workflowActionService.getWorkflowContent(), + this.workflowActionService.getWorkflowMetadata().defaultView + ); + const workflowContentJson = JSON.stringify(exported, null, 2); const fileName = this.currentWorkflowName + ".json"; // Through the injectable wrapper (as the dashboard downloads already do), so a spec stubs it // with TestBed instead of module-mocking the CommonJS file-saver package, which the unit-test @@ -626,6 +642,83 @@ export class MenuComponent implements OnInit, OnDestroy { this.fileSaverService.saveAs(new Blob([workflowContentJson], { type: "text/plain;charset=utf-8" }), fileName); } + /** + * Open the Form View -- a full page load, not a route: the two views share root-level + * singletons (graph, Yjs shared model), and routing left the old collaboration client + * alive (you appeared as your own coeditor). A fresh document is the clean handover. + */ + public onClickOpenFormView(): void { + const wid = this.workflowActionService.getWorkflowMetadata().wid; + if (wid === undefined || this.handingOverToFormView) { + return; + } + // A reader has nothing to save, and every save of theirs is a guaranteed 403 that would keep + // them here with an error: straight over, as the form's own switch does for a reader. + if (!this.writeAccess) { + this.openFormViewPage(wid); + return; + } + // Save first, and hand over only once the save has completed. The full-page load that + // follows unloads this document, and a request still in flight at that moment is aborted, so + // navigating right after firing the save could lose the very edit the switch is meant to carry + // across; the workspace's beforeunload save runs into the same unload and is no safety net. A + // save that fails keeps the user here with the error shown, rather than leaving with changes + // that were never stored. The form's own switch (openRegularCanvas) does the same. + // + // Two more things the hand-over must not lose. An autosave already in flight when the switch + // is clicked: WorkflowPersistService sends saves one at a time and in order, so ours lands after + // it and completes after it. And an edit made while our save is out (the page stays editable + // until the load): workflowChanged marks it, and the drain below saves once more before handing + // over rather than letting the full-page load abort that edit's own debounced autosave. + this.handingOverToFormView = true; + this.isSaving = true; + this.saveThenOpenFormView(wid); + } + + private saveThenOpenFormView(wid: number): void { + // The snapshot below carries everything reported up to now. + this.editedSinceSwitchSnapshot = false; + // A workflow the canvas holds but has never saved carries the default id (0); the save creates + // it and answers with the id it was given, which is the one to open -- as the autosave, which + // moves the URL to the answered id, already does. + let target = wid; + this.workflowPersistService + .persistWorkflow(this.workflowActionService.getWorkflow()) + .pipe(untilDestroyed(this)) + .subscribe({ + next: (updatedWorkflow: Workflow) => { + target = updatedWorkflow.wid ?? wid; + this.workflowActionService.setWorkflowMetadata(updatedWorkflow); + }, + error: () => { + this.isSaving = false; + this.handingOverToFormView = false; + // The same wording as the form's own save, so the two switches read alike. + this.notificationService.error("Could not save. Your latest changes are not stored yet."); + }, + complete: () => { + if (this.editedSinceSwitchSnapshot) { + // An edit landed while the save was out; the full-page load would kill its autosave. + this.saveThenOpenFormView(target); + return; + } + this.isSaving = false; + this.openFormViewPage(target); + }, + }); + } + + /** + * The full-page handover to the Form View, apart from the save so the order is testable. + * Excluded from coverage as a whole: jsdom cannot navigate, so the specs stub this method and + * assert when it is called rather than what it does. + */ + /* v8 ignore start */ + private openFormViewPage(wid: number): void { + window.location.href = `${USER_WORKSPACE}/${wid}/form`; + } + /* v8 ignore stop */ + /** * Calls Markdown Description Component */ diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html index 6a9a7d9061..066e39c836 100644 --- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html +++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html @@ -113,7 +113,7 @@ 'unit-connecting': unit.status === 'Pending', }" [nz-tooltip]="cannotSelectUnit(unit) ? getUnitStatusTooltip(unit) + '. Cannot select.' : ''" - (click)="selectedComputingUnit = unit; selectComputingUnit(this.workflowId, unit?.computingUnit?.cuid)"> + (click)="onPickComputingUnit(unit)"> <div class="computing-unit-row"> <texera-user-avatar [avatar]="unit.ownerAvatar" diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts index 3f2b2a06f4..2fca86830e 100644 --- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts +++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts @@ -153,6 +153,12 @@ describe("PowerButtonComponent", () => { ], }).compileComponents(); + // Selecting a unit now remembers it per workflow in localStorage, which survives + // between tests and would let one spec's selection steer another's auto-select. + Object.keys(localStorage) + .filter(key => key.startsWith("computing-unit-of-workflow-")) + .forEach(key => localStorage.removeItem(key)); + fixture = TestBed.createComponent(ComputingUnitSelectionComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -1357,6 +1363,191 @@ describe("PowerButtonComponent", () => { expect(selectSpy).not.toHaveBeenCalled(); }); + + it("drops a latest-execution answer that arrives after the workflow changed underneath it", () => { + const execService = TestBed.inject(WorkflowExecutionsService); + const late$ = new Subject<WorkflowExecutionsEntry>(); + vi.spyOn(execService, "retrieveLatestWorkflowExecution").mockImplementation((wid: number) => + wid === 100 ? late$ : of({ cuId: 9 } as unknown as WorkflowExecutionsEntry) + ); + const { comp, emit } = bootWithMetaStream(); + const selectSpy = vi.spyOn(comp, "selectComputingUnit").mockImplementation(() => {}); + + emit(100); + emit(101); // decides at once from its own latest execution + late$.next({ cuId: 55 } as unknown as WorkflowExecutionsEntry); + + expect(selectSpy).toHaveBeenCalledWith(101, 9); + expect(selectSpy).not.toHaveBeenCalledWith(100, 55); + }); + + it("drops the running-unit fallback when the failed lookup was for a workflow no longer shown", () => { + const execService = TestBed.inject(WorkflowExecutionsService); + const late$ = new Subject<WorkflowExecutionsEntry>(); + vi.spyOn(execService, "retrieveLatestWorkflowExecution").mockImplementation((wid: number) => + wid === 100 ? late$ : of({ cuId: 9 } as unknown as WorkflowExecutionsEntry) + ); + const { comp, emit } = bootWithMetaStream(); + comp.allComputingUnits = [makeComputingUnit({ cuid: 2, status: "Running" })]; + const selectSpy = vi.spyOn(comp, "selectComputingUnit").mockImplementation(() => {}); + + emit(100); + emit(101); + late$.error(new Error("no execution")); + + expect(selectSpy).not.toHaveBeenCalledWith(100, 2); + expect(selectSpy).toHaveBeenCalledTimes(1); // 101's own decision only + }); + + it("prefers the remembered unit for this workflow over the latest execution", () => { + localStorage.setItem("computing-unit-of-workflow-100", "77"); + const execService = TestBed.inject(WorkflowExecutionsService); + const latestSpy = vi + .spyOn(execService, "retrieveLatestWorkflowExecution") + .mockReturnValue(of({ cuId: 55 } as unknown as WorkflowExecutionsEntry)); + const { comp, emit } = bootWithMetaStream(); + vi.spyOn(TestBed.inject(ComputingUnitStatusService), "getAllComputingUnits").mockReturnValue( + of([makeComputingUnit({ cuid: 77, status: "Running" })]) + ); + const selectSpy = vi.spyOn(comp, "selectComputingUnit").mockImplementation(() => {}); + + emit(100); + + expect(selectSpy).toHaveBeenCalledWith(100, 77); + expect(latestSpy).not.toHaveBeenCalled(); + }); + + // A remembered unit that has since been terminated must not be chased: the status service + // would wait for it to appear forever and the fallbacks would never run. + it("forgets a remembered unit that no longer exists and uses the latest execution", () => { + localStorage.setItem("computing-unit-of-workflow-100", "77"); + const execService = TestBed.inject(WorkflowExecutionsService); + vi.spyOn(execService, "retrieveLatestWorkflowExecution").mockReturnValue( + of({ cuId: 55 } as unknown as WorkflowExecutionsEntry) + ); + const { comp, emit } = bootWithMetaStream(); + vi.spyOn(TestBed.inject(ComputingUnitStatusService), "getAllComputingUnits").mockReturnValue( + of([makeComputingUnit({ cuid: 55, status: "Running" })]) + ); + const selectSpy = vi.spyOn(comp, "selectComputingUnit").mockImplementation(() => {}); + + emit(100); + + expect(selectSpy).toHaveBeenCalledWith(100, 55); + expect(localStorage.getItem("computing-unit-of-workflow-100")).toBeNull(); + }); + + it("still falls back when forgetting the stale unit throws", () => { + localStorage.setItem("computing-unit-of-workflow-100", "77"); + const execService = TestBed.inject(WorkflowExecutionsService); + vi.spyOn(execService, "retrieveLatestWorkflowExecution").mockReturnValue( + of({ cuId: 55 } as unknown as WorkflowExecutionsEntry) + ); + const { comp, emit } = bootWithMetaStream(); + vi.spyOn(TestBed.inject(ComputingUnitStatusService), "getAllComputingUnits").mockReturnValue( + of([makeComputingUnit({ cuid: 55, status: "Running" })]) + ); + // Only the component's one removeItem call throws; the storage keeps working for the + // afterEach clean-up and the tests that follow. + const removeSpy = vi.spyOn(Storage.prototype, "removeItem").mockImplementationOnce(() => { + throw new Error("storage unavailable"); + }); + const selectSpy = vi.spyOn(comp, "selectComputingUnit").mockImplementation(() => {}); + + expect(() => emit(100)).not.toThrow(); + + expect(removeSpy).toHaveBeenCalledWith("computing-unit-of-workflow-100"); + expect(selectSpy).toHaveBeenCalledWith(100, 55); + removeSpy.mockRestore(); + }); + + // Deciding on an empty list would throw the choice away before the list has loaded, so the + // decision waits for the first non-empty list. + it("waits for the unit list before honouring a remembered unit", () => { + localStorage.setItem("computing-unit-of-workflow-100", "77"); + const execService = TestBed.inject(WorkflowExecutionsService); + const latestSpy = vi.spyOn(execService, "retrieveLatestWorkflowExecution"); + const { comp, emit } = bootWithMetaStream(); + const units$ = new Subject<DashboardWorkflowComputingUnit[]>(); + vi.spyOn(TestBed.inject(ComputingUnitStatusService), "getAllComputingUnits").mockReturnValue(units$); + const selectSpy = vi.spyOn(comp, "selectComputingUnit").mockImplementation(() => {}); + + emit(100); + units$.next([]); + + expect(selectSpy).not.toHaveBeenCalled(); + expect(latestSpy).not.toHaveBeenCalled(); + + units$.next([makeComputingUnit({ cuid: 77, status: "Running" })]); + + expect(selectSpy).toHaveBeenCalledWith(100, 77); + }); + + it("drops a pending remembered decision once the workflow has changed underneath it", () => { + localStorage.setItem("computing-unit-of-workflow-100", "77"); + const execService = TestBed.inject(WorkflowExecutionsService); + vi.spyOn(execService, "retrieveLatestWorkflowExecution").mockReturnValue( + of({ cuId: 55 } as unknown as WorkflowExecutionsEntry) + ); + const { comp, emit } = bootWithMetaStream(); + const units$ = new Subject<DashboardWorkflowComputingUnit[]>(); + vi.spyOn(TestBed.inject(ComputingUnitStatusService), "getAllComputingUnits").mockReturnValue(units$); + const selectSpy = vi.spyOn(comp, "selectComputingUnit").mockImplementation(() => {}); + + emit(100); + // Workflow 101 has nothing remembered, so it decides at once from its latest execution. + emit(101); + units$.next([makeComputingUnit({ cuid: 77, status: "Running" })]); + + expect(selectSpy).toHaveBeenCalledWith(101, 55); + expect(selectSpy).not.toHaveBeenCalledWith(100, 77); + }); + + it("does not carry a remembered unit across workflows", () => { + localStorage.setItem("computing-unit-of-workflow-100", "77"); + const execService = TestBed.inject(WorkflowExecutionsService); + vi.spyOn(execService, "retrieveLatestWorkflowExecution").mockReturnValue( + of({ cuId: 55 } as unknown as WorkflowExecutionsEntry) + ); + const { comp, emit } = bootWithMetaStream(); + comp.allComputingUnits = [makeComputingUnit({ cuid: 77, status: "Running" })]; + const selectSpy = vi.spyOn(comp, "selectComputingUnit").mockImplementation(() => {}); + + emit(101); + + expect(selectSpy).toHaveBeenCalledWith(101, 55); + }); + + // Number() is lenient enough to turn several kinds of junk into a "valid" cuid. + ["0", "-3", "1.5", "", " "].forEach(stored => { + it(`ignores a remembered value of ${JSON.stringify(stored)}`, () => { + localStorage.setItem("computing-unit-of-workflow-100", stored); + const execService = TestBed.inject(WorkflowExecutionsService); + vi.spyOn(execService, "retrieveLatestWorkflowExecution").mockReturnValue( + of({ cuId: 55 } as unknown as WorkflowExecutionsEntry) + ); + const { comp, emit } = bootWithMetaStream(); + const selectSpy = vi.spyOn(comp, "selectComputingUnit").mockImplementation(() => {}); + + emit(100); + + expect(selectSpy).toHaveBeenCalledWith(100, 55); + }); + }); + + it("ignores a corrupt remembered value", () => { + localStorage.setItem("computing-unit-of-workflow-100", "not-a-number"); + const execService = TestBed.inject(WorkflowExecutionsService); + vi.spyOn(execService, "retrieveLatestWorkflowExecution").mockReturnValue( + of({ cuId: 55 } as unknown as WorkflowExecutionsEntry) + ); + const { comp, emit } = bootWithMetaStream(); + const selectSpy = vi.spyOn(comp, "selectComputingUnit").mockImplementation(() => {}); + + emit(100); + + expect(selectSpy).toHaveBeenCalledWith(100, 55); + }); }); describe("selectComputingUnit guards", () => { @@ -2200,4 +2391,50 @@ describe("PowerButtonComponent", () => { expect(component.pveModalVisible).toBe(false); }); }); + + describe("remembering the selected unit per workflow", () => { + const unit77 = { computingUnit: { cuid: 77 } } as unknown as DashboardWorkflowComputingUnit; + + it("writes the user's own pick so the other view of the same workflow restores it", () => { + component.workflowId = 100; + const select = vi.spyOn(component, "selectComputingUnit"); + + component.onPickComputingUnit(unit77); + + expect(select).toHaveBeenCalledWith(100, 77); + expect(component.selectedComputingUnit).toBe(unit77); + expect(localStorage.getItem("computing-unit-of-workflow-100")).toBe("77"); + }); + + it("does not remember a unit selected on load, which is derived rather than chosen", () => { + // The remembered unit, the last execution's or a running one are picked FOR the user; storing + // them would let a derived unit outrank a fresher last execution on the next load. + component.selectComputingUnit(100, 77); + expect(localStorage.getItem("computing-unit-of-workflow-100")).toBeNull(); + }); + + it("does not record a pick the component refused to make", () => { + component.workflowId = DEFAULT_WORKFLOW.wid; + component.onPickComputingUnit(unit77); + expect(localStorage.getItem(`computing-unit-of-workflow-${DEFAULT_WORKFLOW.wid}`)).toBeNull(); + component.workflowId = 100; + component.onPickComputingUnit({ computingUnit: {} } as unknown as DashboardWorkflowComputingUnit); + expect(localStorage.getItem("computing-unit-of-workflow-100")).toBeNull(); + }); + + it("skips remembering when there is no workflow to remember it for", () => { + const setItem = vi.spyOn(Storage.prototype, "setItem"); + (component as any).rememberComputingUnit(undefined, 5); + expect(setItem).not.toHaveBeenCalled(); + setItem.mockRestore(); + }); + + it("recalls nothing when storage cannot be read", () => { + const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("storage blocked"); + }); + expect((component as any).recallComputingUnit(100)).toBeUndefined(); + getItem.mockRestore(); + }); + }); }); diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts index 06da3e69ab..eb899448f3 100644 --- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts +++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts @@ -18,7 +18,7 @@ */ import { ChangeDetectorRef, Component, OnInit, NgZone, ViewChild } from "@angular/core"; -import { take } from "rxjs/operators"; +import { filter, take } from "rxjs/operators"; import { WorkflowComputingUnitManagingService } from "../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service"; import { DashboardWorkflowComputingUnit } from "../../../common/type/workflow-computing-unit"; import { NotificationService } from "../../../common/service/notification/notification.service"; @@ -269,25 +269,71 @@ export class ComputingUnitSelectionComponent implements OnInit { if (wid !== this.workflowId) { this.workflowId = wid; if (isDefined(this.workflowId) && this.workflowId !== DEFAULT_WORKFLOW.wid) { - this.workflowExecutionsService - .retrieveLatestWorkflowExecution(this.workflowId) - .pipe(untilDestroyed(this)) - .subscribe({ - next: (latestWorkflowExecution: WorkflowExecutionsEntry) => { - this.selectComputingUnit(this.workflowId, latestWorkflowExecution.cuId); - }, - error: (err: unknown) => { - const runningUnit = this.allComputingUnits.find(unit => unit.status === "Running"); - if (runningUnit) { - this.selectComputingUnit(this.workflowId, runningUnit.computingUnit.cuid); - } - }, - }); + this.selectInitialUnit(this.workflowId); } } }); } + /** + * Pick the unit for a workflow that has just come into view. An explicit choice remembered for + * it is newer than its last run, so it wins -- but only once the unit list has arrived and still + * holds that unit. Deciding on an empty list would either chase a unit that has since been + * terminated (the status service waits for it to appear, forever, and the fallbacks below never + * run) or throw the choice away before the list has loaded. A remembered unit that is gone is + * forgotten, and the fallbacks take over: the last execution's unit, else any running unit. + */ + private selectInitialUnit(wid: number): void { + const remembered = this.recallComputingUnit(wid); + if (!isDefined(remembered)) { + this.selectFromLastExecution(wid); + return; + } + this.computingUnitStatusService + .getAllComputingUnits() + .pipe( + filter(units => units.length > 0), + take(1), + untilDestroyed(this) + ) + .subscribe(units => { + // The workflow can change while the list is still loading; that later change made its own + // decision, so this one is stale. + if (wid !== this.workflowId) { + return; + } + if (units.some(unit => unit.computingUnit.cuid === remembered)) { + this.selectComputingUnit(wid, remembered); + } else { + this.forgetComputingUnit(wid); + this.selectFromLastExecution(wid); + } + }); + } + + /** The unit the workflow last ran on, else any unit that is running. */ + private selectFromLastExecution(wid: number): void { + // The workflow can change while the lookup is out; that later change decided for itself, so an + // answer (or a failure) that arrives for the earlier one is stale. + const stillShown = () => wid === this.workflowId; + this.workflowExecutionsService + .retrieveLatestWorkflowExecution(wid) + .pipe(untilDestroyed(this)) + .subscribe({ + next: (latestWorkflowExecution: WorkflowExecutionsEntry) => { + if (stillShown()) { + this.selectComputingUnit(wid, latestWorkflowExecution.cuId); + } + }, + error: () => { + const runningUnit = this.allComputingUnits.find(unit => unit.status === "Running"); + if (stillShown() && runningUnit) { + this.selectComputingUnit(wid, runningUnit.computingUnit.cuid); + } + }, + }); + } + /** * Called whenever the selected computing unit changes. */ @@ -297,6 +343,72 @@ export class ComputingUnitSelectionComponent implements OnInit { } } + /** + * The user's own pick from the list: select it and remember it for this workflow. Only an explicit + * choice is remembered -- the units selected on load (the remembered one, the last execution's, + * a running one) are derived and must not be stored as if chosen, or a derived unit would later + * outrank a fresher last execution. + */ + public onPickComputingUnit(unit: DashboardWorkflowComputingUnit): void { + this.selectedComputingUnit = unit; + const cuid = unit?.computingUnit?.cuid; + // The same rule as selectComputingUnit: nothing is selected, or remembered, for a workflow that + // has not been saved yet or for a unit without an id. + if (!isDefined(cuid) || this.workflowId === DEFAULT_WORKFLOW.wid) { + return; + } + this.selectComputingUnit(this.workflowId, cuid); + this.rememberComputingUnit(this.workflowId, cuid); + } + + /** + * The live selection lives only in ComputingUnitStatusService, re-derived on load from the + * last execution -- but that only exists once the workflow has run (pick a unit, reload + * before running, and it is gone). Canvas<->Form View switches reload, so we remember the + * last explicit choice per workflow to keep the two views agreeing. One unit per workflow. + */ + private static computingUnitStorageKey(wid: number): string { + return `computing-unit-of-workflow-${wid}`; + } + + private rememberComputingUnit(wid: number | undefined, cuid: number): void { + if (!isDefined(wid)) { + return; + } + try { + localStorage.setItem(ComputingUnitSelectionComponent.computingUnitStorageKey(wid), String(cuid)); + } catch { + // Private browsing or a full quota; remembering is an optimisation, not a + // requirement -- the last-execution lookup still applies on the next load. + } + } + + private recallComputingUnit(wid: number): number | undefined { + let stored: string | null = null; + try { + stored = localStorage.getItem(ComputingUnitSelectionComponent.computingUnitStorageKey(wid)); + } catch { + return undefined; + } + // A cuid is a positive integer. Number() would also accept "0" and "1.5", and handing + // either on would mean chasing a unit that cannot exist. Whether the unit still exists is + // not decided here but against the loaded unit list (selectInitialUnit). + const cuid = Number(stored); + if (!stored || !Number.isInteger(cuid) || cuid <= 0) { + return undefined; + } + return cuid; + } + + /** Drop a remembered unit that no longer exists, so the next load goes straight to the fallbacks. */ + private forgetComputingUnit(wid: number): void { + try { + localStorage.removeItem(ComputingUnitSelectionComponent.computingUnitStorageKey(wid)); + } catch { + // Best effort, like remembering: a stale entry only costs the list check on the next load. + } + } + isComputingUnitRunning(): boolean { return this.selectedComputingUnit != null && this.selectedComputingUnit.status === "Running"; } @@ -348,7 +460,8 @@ export class ComputingUnitSelectionComponent implements OnInit { } onComputingUnitCreated(unit: DashboardWorkflowComputingUnit): void { - this.selectComputingUnit(this.workflowId, unit.computingUnit.cuid); + // Creating a unit from here is as explicit a choice as picking one. + this.onPickComputingUnit(unit); } openComputingUnitMetadataModal(unit: DashboardWorkflowComputingUnit) {
