FN-6088: open workflow editor on selected workflow
Open workflow-mode edits on the workflow the user already selected. - pass the workflow editor's initial workflow id through AppModals - preselect the matching workflow in WorkflowNodeEditor before defaulting to the first workflow - skip the mobile workflow list stage when a valid initial workflow is already selected - add dashboard tests for desktop selection, missing-id fallback, and mobile deep-link behavior - add a patch changeset for @runfusion/fusion Files changed: .../fn-6088-workflow-editor-selected-workflow.md | 5 +++ packages/dashboard/app/components/AppModals.tsx | 1 + .../app/components/WorkflowNodeEditor.tsx | 37 ++++++++++++++++++++-- .../__tests__/WorkflowNodeEditor.test.tsx | 34 ++++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-6088 Fusion-Task-Lineage: 88a0cd03-2112-4706-b6b5-4cbebdcae234
This commit is contained in:
5
.changeset/fn-6088-workflow-editor-selected-workflow.md
Normal file
5
.changeset/fn-6088-workflow-editor-selected-workflow.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Open the workflow editor on the selected board workflow when using the workflow-mode edit action.
|
||||||
@@ -387,6 +387,7 @@ export function AppModals({
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
initialPanel={modalManager.workflowEditorInitialPanel}
|
initialPanel={modalManager.workflowEditorInitialPanel}
|
||||||
initialAction={modalManager.workflowEditorInitialAction}
|
initialAction={modalManager.workflowEditorInitialAction}
|
||||||
|
initialWorkflowId={modalManager.workflowEditorInitialWorkflowId}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ModalErrorBoundary>
|
</ModalErrorBoundary>
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ interface WorkflowNodeEditorProps {
|
|||||||
initialPanel?: "settings";
|
initialPanel?: "settings";
|
||||||
/** When "create" the editor opens with the new-workflow dialog active. */
|
/** When "create" the editor opens with the new-workflow dialog active. */
|
||||||
initialAction?: "create";
|
initialAction?: "create";
|
||||||
|
/** Workflow id to preselect when the editor opens from workflow-aware surfaces. */
|
||||||
|
initialWorkflowId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nodeSeq = 0;
|
let nodeSeq = 0;
|
||||||
@@ -671,6 +673,7 @@ function InnerEditor({
|
|||||||
projectId,
|
projectId,
|
||||||
initialPanel,
|
initialPanel,
|
||||||
initialAction,
|
initialAction,
|
||||||
|
initialWorkflowId,
|
||||||
modalRef,
|
modalRef,
|
||||||
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
|
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
|
||||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||||
@@ -704,6 +707,7 @@ function InnerEditor({
|
|||||||
// "New workflow" button (NewTaskModal focus pattern).
|
// "New workflow" button (NewTaskModal focus pattern).
|
||||||
const [createOpen, setCreateOpen] = useState(initialAction === "create");
|
const [createOpen, setCreateOpen] = useState(initialAction === "create");
|
||||||
const newWorkflowBtnRef = useRef<HTMLButtonElement>(null);
|
const newWorkflowBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const mobileInitialWorkflowDismissedRef = useRef<string | null>(null);
|
||||||
// Inline-editable name/description (KTD-10). `name`/`description` mirror the
|
// Inline-editable name/description (KTD-10). `name`/`description` mirror the
|
||||||
// active workflow and are persisted through handleSave; `editingName`/
|
// active workflow and are persisted through handleSave; `editingName`/
|
||||||
// `editingDescription` flag the active inline input.
|
// `editingDescription` flag the active inline input.
|
||||||
@@ -1006,6 +1010,9 @@ function InnerEditor({
|
|||||||
setWorkflows(data);
|
setWorkflows(data);
|
||||||
setActiveId((prev) => {
|
setActiveId((prev) => {
|
||||||
if (prev && data.some((workflow) => workflow.id === prev)) return prev;
|
if (prev && data.some((workflow) => workflow.id === prev)) return prev;
|
||||||
|
if (initialAction !== "create" && initialWorkflowId && data.some((workflow) => workflow.id === initialWorkflowId)) {
|
||||||
|
return initialWorkflowId;
|
||||||
|
}
|
||||||
return isMobileViewport ? null : data[0]?.id ?? null;
|
return isMobileViewport ? null : data[0]?.id ?? null;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1013,12 +1020,20 @@ function InnerEditor({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [projectId, addToast, isMobileViewport]);
|
}, [projectId, addToast, isMobileViewport, initialAction, initialWorkflowId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadWorkflows();
|
void loadWorkflows();
|
||||||
}, [loadWorkflows]);
|
}, [loadWorkflows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialWorkflowId || !isMobileViewport || !workflowListStageOpen) return;
|
||||||
|
if (activeId !== initialWorkflowId) return;
|
||||||
|
if (mobileInitialWorkflowDismissedRef.current === initialWorkflowId) return;
|
||||||
|
mobileInitialWorkflowDismissedRef.current = initialWorkflowId;
|
||||||
|
setWorkflowListStageOpen(false);
|
||||||
|
}, [activeId, initialWorkflowId, isMobileViewport, workflowListStageOpen]);
|
||||||
|
|
||||||
// U2/R5: fire the lazy legacy-step migration once on editor open, then reload
|
// U2/R5: fire the lazy legacy-step migration once on editor open, then reload
|
||||||
// the workflow list so any newly created fragments / "Migrated steps" workflow
|
// the workflow list so any newly created fragments / "Migrated steps" workflow
|
||||||
// appear. Non-fatal on ANY error (incl. 404 if the route ships in a later
|
// appear. Non-fatal on ANY error (incl. 404 if the route ships in a later
|
||||||
@@ -4126,13 +4141,29 @@ function InnerEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkflowNodeEditor({ isOpen, ...rest }: WorkflowNodeEditorProps) {
|
export function WorkflowNodeEditor({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
addToast,
|
||||||
|
projectId,
|
||||||
|
initialPanel,
|
||||||
|
initialAction,
|
||||||
|
initialWorkflowId,
|
||||||
|
}: WorkflowNodeEditorProps) {
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
useModalResizePersist(modalRef, isOpen, "fusion:workflow-node-editor-size");
|
useModalResizePersist(modalRef, isOpen, "fusion:workflow-node-editor-size");
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
return (
|
return (
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<InnerEditor {...rest} modalRef={modalRef} />
|
<InnerEditor
|
||||||
|
onClose={onClose}
|
||||||
|
addToast={addToast}
|
||||||
|
projectId={projectId}
|
||||||
|
initialPanel={initialPanel}
|
||||||
|
initialAction={initialAction}
|
||||||
|
initialWorkflowId={initialWorkflowId}
|
||||||
|
modalRef={modalRef}
|
||||||
|
/>
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -373,6 +373,40 @@ describe("WorkflowNodeEditor", () => {
|
|||||||
expect(screen.getByTestId("wf-mobile-tab-actions")).toBeInTheDocument();
|
expect(screen.getByTestId("wf-mobile-tab-actions")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preselects the matching initial workflow id on desktop", async () => {
|
||||||
|
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
|
||||||
|
|
||||||
|
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialWorkflowId="WF-002" />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Custom");
|
||||||
|
expect(screen.queryByTestId("wf-mobile-select-note")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "QA" })).not.toHaveClass("active");
|
||||||
|
expect(screen.getAllByRole("button", { name: "Custom" })[0]).toHaveClass("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first workflow on desktop when the initial workflow id is missing", async () => {
|
||||||
|
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
|
||||||
|
|
||||||
|
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialWorkflowId="WF-missing" />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
|
||||||
|
expect(screen.queryByTestId("wf-mobile-select-note")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole("button", { name: "QA" })[0]).toHaveClass("active");
|
||||||
|
expect(screen.getByRole("button", { name: "Custom" })).not.toHaveClass("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the mobile workflow list stage when the initial workflow id is valid", async () => {
|
||||||
|
mockWorkflowEditorViewport("mobile");
|
||||||
|
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
|
||||||
|
|
||||||
|
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialWorkflowId="WF-002" />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Custom");
|
||||||
|
expect(screen.queryByTestId("wf-mobile-select-note")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "QA" })).not.toHaveClass("active");
|
||||||
|
expect(screen.getAllByRole("button", { name: "Custom" })[0]).toHaveClass("active");
|
||||||
|
});
|
||||||
|
|
||||||
it("opens populated mobile workflows on the list with no preselected workflow", async () => {
|
it("opens populated mobile workflows on the list with no preselected workflow", async () => {
|
||||||
mockWorkflowEditorViewport("mobile");
|
mockWorkflowEditorViewport("mobile");
|
||||||
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
|
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
|
||||||
|
|||||||
Reference in New Issue
Block a user