fix(web): render workflow description in settings (#2418)
This commit is contained in:
@@ -762,6 +762,7 @@ func (s *Service) publishWorkflowEvent(ctx context.Context, eventType string, wo
|
||||
"workspace_id": workflow.WorkspaceID,
|
||||
"name": workflow.Name,
|
||||
"description": workflow.Description,
|
||||
"prompt": workflow.Prompt,
|
||||
"agent_profile_id": workflow.AgentProfileID,
|
||||
"hidden": workflow.Hidden,
|
||||
"source": workflow.Source,
|
||||
|
||||
@@ -2824,6 +2824,37 @@ func TestService_UpdateWorkflow(t *testing.T) {
|
||||
t.Errorf("expected name 'Updated', got %s", updated.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateWorkflow_PublishesPromptOnWorkflowEvent(t *testing.T) {
|
||||
// Cross-tab WS sync (use-workflow-settings.ts) relies on workflow.updated
|
||||
// carrying the current prompt, the same way it already carries
|
||||
// description and agent_profile_id.
|
||||
svc, eventBus, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
workflow := &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Original"}
|
||||
_ = repo.CreateWorkflow(ctx, workflow)
|
||||
|
||||
newPrompt := "Updated prompt"
|
||||
req := &UpdateWorkflowRequest{Prompt: &newPrompt}
|
||||
|
||||
if _, err := svc.UpdateWorkflow(ctx, "wf-123", req); err != nil {
|
||||
t.Fatalf("failed to update workflow: %v", err)
|
||||
}
|
||||
|
||||
if len(eventBus.publishedEvents) == 0 {
|
||||
t.Fatal("expected a workflow.updated event to be published")
|
||||
}
|
||||
last := eventBus.publishedEvents[len(eventBus.publishedEvents)-1]
|
||||
data, ok := last.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected event data to be a map, got %T", last.Data)
|
||||
}
|
||||
if data["prompt"] != newPrompt {
|
||||
t.Errorf("expected published prompt %q, got %v", newPrompt, data["prompt"])
|
||||
}
|
||||
}
|
||||
func TestService_DeleteWorkflow(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -161,12 +161,12 @@ function useWorkflowImportExport(
|
||||
};
|
||||
}
|
||||
|
||||
function hasNewerWorkflowMetadata(current: Workflow, savedFrom: Workflow) {
|
||||
export function hasNewerWorkflowMetadata(current: Workflow, savedFrom: Workflow) {
|
||||
return (
|
||||
current.name !== savedFrom.name ||
|
||||
current.description !== savedFrom.description ||
|
||||
(current.description ?? "") !== (savedFrom.description ?? "") ||
|
||||
(current.prompt ?? "") !== (savedFrom.prompt ?? "") ||
|
||||
current.agent_profile_id !== savedFrom.agent_profile_id
|
||||
(current.agent_profile_id ?? "") !== (savedFrom.agent_profile_id ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { workflowId as toWorkflowId, type Workflow } from "@/lib/types/http";
|
||||
import {
|
||||
agentProfileId as toAgentProfileId,
|
||||
workflowId as toWorkflowId,
|
||||
type Workflow,
|
||||
} from "@/lib/types/http";
|
||||
import {
|
||||
alignSavedWorkflowsToDraftOrder,
|
||||
getWorkflowOrderDirtyIds,
|
||||
hasNewerWorkflowMetadata,
|
||||
} from "./workspace-workflows-client";
|
||||
|
||||
function workflow(id: string, name = id): Workflow {
|
||||
@@ -68,3 +73,46 @@ describe("getWorkflowOrderDirtyIds", () => {
|
||||
expect([...getWorkflowOrderDirtyIds([first, draft], [first])]).toEqual([draft.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasNewerWorkflowMetadata", () => {
|
||||
it("treats an empty description as unchanged from an absent saved description", () => {
|
||||
// Backend omits `description` when empty (omitempty), so a save response's
|
||||
// description is undefined, not "". A draft that was never edited beyond
|
||||
// that (or was edited then cleared) must not be treated as "newer" than
|
||||
// what was just submitted, or a save reconciliation discards the response.
|
||||
const saved: Workflow = { ...workflow("wf-1"), description: undefined };
|
||||
const current: Workflow = { ...workflow("wf-1"), description: "" };
|
||||
|
||||
expect(hasNewerWorkflowMetadata(current, saved)).toBe(false);
|
||||
});
|
||||
|
||||
it("still detects a real description change", () => {
|
||||
const saved: Workflow = { ...workflow("wf-1"), description: "old" };
|
||||
const current: Workflow = { ...workflow("wf-1"), description: "new" };
|
||||
|
||||
expect(hasNewerWorkflowMetadata(current, saved)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an empty agent_profile_id as unchanged from an absent saved agent_profile_id", () => {
|
||||
// Same omitempty class of bug as description: clearing a profile override
|
||||
// produces agent_profile_id === "" locally, but the save response omits
|
||||
// the empty field, so saved.agent_profile_id is undefined.
|
||||
const saved: Workflow = { ...workflow("wf-1"), agent_profile_id: undefined };
|
||||
const current: Workflow = { ...workflow("wf-1"), agent_profile_id: toAgentProfileId("") };
|
||||
|
||||
expect(hasNewerWorkflowMetadata(current, saved)).toBe(false);
|
||||
});
|
||||
|
||||
it("still detects a real agent_profile_id change", () => {
|
||||
const saved: Workflow = {
|
||||
...workflow("wf-1"),
|
||||
agent_profile_id: toAgentProfileId("profile-a"),
|
||||
};
|
||||
const current: Workflow = {
|
||||
...workflow("wf-1"),
|
||||
agent_profile_id: toAgentProfileId("profile-b"),
|
||||
};
|
||||
|
||||
expect(hasNewerWorkflowMetadata(current, saved)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useWorkflowMutationGuard } from "./workflow-mutation-guard";
|
||||
import { WorkflowCycleGuardDialog } from "./workflow-cycle-diagnostic";
|
||||
import { useWorkflowDraftContributor } from "./use-workflow-draft-contributor";
|
||||
import { WorkflowPromptSection } from "./workflow-prompt-section";
|
||||
import { WorkflowDescriptionField } from "./workflow-description-field";
|
||||
|
||||
const TEMP_WORKFLOW_PREFIX = "temp-workflow-";
|
||||
|
||||
@@ -342,6 +343,12 @@ function WorkflowCardBody({
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowDescriptionField
|
||||
workflow={workflow}
|
||||
savedWorkflow={savedWorkflow}
|
||||
readOnly={readOnly}
|
||||
onUpdate={(description) => onUpdateWorkflow({ description })}
|
||||
/>
|
||||
<WorkflowPromptSection
|
||||
workflow={workflow}
|
||||
savedWorkflow={savedWorkflow}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import type { Workflow } from "@/lib/types/http";
|
||||
import { isWorkflowFieldDirty } from "./workflow-dirty-state";
|
||||
|
||||
type WorkflowDescriptionFieldProps = {
|
||||
workflow: Workflow;
|
||||
savedWorkflow?: Workflow;
|
||||
readOnly?: boolean;
|
||||
onUpdate: (description: string) => void;
|
||||
};
|
||||
|
||||
// Always visible, unlike WorkflowPromptSection's collapsible — every synced workflow carries one.
|
||||
export function WorkflowDescriptionField({
|
||||
workflow,
|
||||
savedWorkflow,
|
||||
readOnly,
|
||||
onUpdate,
|
||||
}: WorkflowDescriptionFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const inputId = `workflow-description-input-${workflow.id}`;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={inputId}>{t("workflows:workflowDescription")}</Label>
|
||||
<Textarea
|
||||
id={inputId}
|
||||
value={workflow.description ?? ""}
|
||||
onChange={(e) => onUpdate(e.target.value)}
|
||||
disabled={readOnly}
|
||||
placeholder={t("workflows:workflowDescriptionPlaceholder")}
|
||||
rows={2}
|
||||
className="min-h-16 text-sm"
|
||||
data-testid="workflow-description-input"
|
||||
data-settings-dirty={isWorkflowFieldDirty(workflow, savedWorkflow, "description")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -214,7 +214,6 @@ test.describe("GitLab MR badge on the Kanban card", () => {
|
||||
await kanban.goto();
|
||||
const card = kanban.taskCard(task.id);
|
||||
await expect(card).toBeVisible({ timeout: 45_000 });
|
||||
|
||||
const prIcon = card.getByTestId(`pr-task-icon-${task.id}`);
|
||||
const mrIcon = card.getByTestId(`mr-task-icon-${task.id}`);
|
||||
await expect(prIcon).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
@@ -479,6 +479,91 @@ test.describe("Workflow settings", () => {
|
||||
await page.goto(seedData.workspaceId);
|
||||
await expect(await page.findWorkflowCard("Manually Saved Workflow Name")).toBeVisible();
|
||||
});
|
||||
|
||||
test("edits and persists a workflow description", async ({ testPage, apiClient, seedData }) => {
|
||||
const workflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Workflow Description Save",
|
||||
);
|
||||
const page = new WorkflowSettingsPage(testPage);
|
||||
await page.goto(seedData.workspaceId);
|
||||
|
||||
const card = await page.findWorkflowCard(workflow.name);
|
||||
const descriptionInput = card.getByLabel("Description", { exact: true });
|
||||
await expect(descriptionInput).toBeVisible();
|
||||
await expect(descriptionInput).toBeEnabled();
|
||||
await descriptionInput.fill("rev 1 (2026-08-08) — test description");
|
||||
|
||||
await expect(descriptionInput).toHaveAttribute("data-settings-dirty", "true");
|
||||
|
||||
expect(
|
||||
(await apiClient.listWorkflows(seedData.workspaceId)).workflows.find(
|
||||
(candidate) => candidate.id === workflow.id,
|
||||
)?.description,
|
||||
).toBeFalsy();
|
||||
await page.saveChanges();
|
||||
|
||||
await expect(descriptionInput).toHaveAttribute("data-settings-dirty", "false");
|
||||
|
||||
await page.goto(seedData.workspaceId);
|
||||
const reloadedCard = await page.findWorkflowCard(workflow.name);
|
||||
await expect(reloadedCard.getByTestId("workflow-description-input")).toHaveValue(
|
||||
"rev 1 (2026-08-08) — test description",
|
||||
);
|
||||
});
|
||||
|
||||
test("syncs a cross-tab description/prompt update live without clobbering an unsaved edit", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
// Regression coverage for the WS store-sync effect in use-workflow-settings.ts:
|
||||
// it used to only propagate `name` from workflow.updated events, leaving
|
||||
// description/prompt/agent_profile_id stale on an open settings page until a
|
||||
// full reload. Simulates "another tab" by mutating via the API (which fires the
|
||||
// real workflow.updated WS event) while this page stays open — no page.goto.
|
||||
const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Cross-Tab Sync Test");
|
||||
await apiClient.updateWorkflow(workflow.id, { description: "Old description" });
|
||||
|
||||
const page = new WorkflowSettingsPage(testPage);
|
||||
await page.goto(seedData.workspaceId);
|
||||
const card = await page.findWorkflowCard(workflow.name);
|
||||
const descriptionInput = card.getByLabel("Description", { exact: true });
|
||||
await expect(descriptionInput).toHaveValue("Old description");
|
||||
|
||||
// Local, unsaved edit to a different field (name) — must survive the cross-tab sync below.
|
||||
const nameInput = card.locator("input").first();
|
||||
await nameInput.fill("Unsaved local name");
|
||||
await expect(nameInput).toHaveAttribute("data-settings-dirty", "true");
|
||||
|
||||
// "Another tab" edits description and prompt via the API.
|
||||
await apiClient.updateWorkflow(workflow.id, {
|
||||
description: "New description from another tab",
|
||||
prompt: "New prompt from another tab",
|
||||
});
|
||||
|
||||
// Description and prompt update live via WS, with no local draft on either field.
|
||||
await expect(descriptionInput).toHaveValue("New description from another tab");
|
||||
await expect(descriptionInput).toHaveAttribute("data-settings-dirty", "false");
|
||||
const promptInput = card.getByTestId("workflow-prompt-input");
|
||||
await expect(promptInput).toBeVisible();
|
||||
await expect(promptInput).toHaveValue("New prompt from another tab");
|
||||
await expect(promptInput).toHaveAttribute("data-settings-dirty", "false");
|
||||
|
||||
// The unsaved local name edit was not clobbered by the cross-tab sync.
|
||||
await expect(nameInput).toHaveValue("Unsaved local name");
|
||||
await expect(nameInput).toHaveAttribute("data-settings-dirty", "true");
|
||||
|
||||
// The saved-baseline is what future dirty checks compare against, so saving now
|
||||
// must persist the local name edit alongside the already-synced description/prompt.
|
||||
await page.saveChanges();
|
||||
const persisted = (await apiClient.listWorkflows(seedData.workspaceId)).workflows.find(
|
||||
(candidate) => candidate.id === workflow.id,
|
||||
);
|
||||
expect(persisted?.name).toBe("Unsaved local name");
|
||||
expect(persisted?.description).toBe("New description from another tab");
|
||||
expect(persisted?.prompt).toBe("New prompt from another tab");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Seed protection", () => {
|
||||
|
||||
@@ -3,10 +3,13 @@ import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
|
||||
|
||||
const GITLAB_PROJECT = "platform/kandev";
|
||||
|
||||
const SYNCED_WORKFLOW_DESCRIPTION = "rev 1 (2026-08-08) — synced from GitLab";
|
||||
|
||||
const VALID_WORKFLOW_YAML = `version: 1
|
||||
type: kandev_workflow
|
||||
workflows:
|
||||
- name: GitLab Synced Workflow
|
||||
description: "${SYNCED_WORKFLOW_DESCRIPTION}"
|
||||
steps:
|
||||
- name: Open
|
||||
position: 0
|
||||
@@ -114,6 +117,13 @@ test.describe("GitLab workflow sync", () => {
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByText("Open")).toBeVisible();
|
||||
await expect(card.getByText("Closed")).toBeVisible();
|
||||
|
||||
// Synced workflows are read-only: the description renders the synced
|
||||
// value but must not be editable, or a later reconcile would just
|
||||
// revert a user's in-UI edit.
|
||||
const descriptionInput = card.getByLabel("Description", { exact: true });
|
||||
await expect(descriptionInput).toHaveValue(SYNCED_WORKFLOW_DESCRIPTION);
|
||||
await expect(descriptionInput).toBeDisabled();
|
||||
});
|
||||
|
||||
test("requires a project path before Save is enabled", async ({ testPage, seedData }) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import {
|
||||
agentProfileId,
|
||||
workflowId as toWorkflowId,
|
||||
workspaceId as toWorkspaceId,
|
||||
type Workflow,
|
||||
@@ -11,6 +12,8 @@ type StoreWorkflow = {
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
prompt?: string;
|
||||
agent_profile_id?: string;
|
||||
hidden?: boolean;
|
||||
style?: "kanban" | "office" | "custom";
|
||||
};
|
||||
@@ -43,6 +46,24 @@ const NAME_B1 = "Workflow B1";
|
||||
const STORE_A1: StoreWorkflow = { id: "wf-a1", workspaceId: "ws-a", name: NAME_A1 };
|
||||
const STORE_B1: StoreWorkflow = { id: "wf-b1", workspaceId: "ws-b", name: NAME_B1 };
|
||||
|
||||
const OLD_DESCRIPTION = "Old description";
|
||||
const NEW_DESCRIPTION = "New description";
|
||||
const OLD_PROMPT = "Old prompt";
|
||||
const NEW_PROMPT = "New prompt";
|
||||
const UNSAVED_LOCAL_DRAFT = "Unsaved local draft";
|
||||
|
||||
// Shared by the cross-tab field-sync tests below: renders the hook with a
|
||||
// rerender-able store snapshot so a test can simulate a WS-driven store update.
|
||||
function renderWithStoreItem(initial: Workflow[], store: StoreWorkflow) {
|
||||
return renderHook(
|
||||
({ storeItems }: { storeItems: StoreWorkflow[] }) => {
|
||||
setStore(storeItems);
|
||||
return useWorkflowSettings(initial, "ws-b");
|
||||
},
|
||||
{ initialProps: { storeItems: [store] } },
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setStore([]);
|
||||
});
|
||||
@@ -171,6 +192,237 @@ describe("useWorkflowSettings", () => {
|
||||
expect(result.current.savedWorkflowItems[0].name).toBe("Remote name");
|
||||
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("does not report a workflow dirty after its description is edited then cleared back to an absent saved description", () => {
|
||||
// Backend omits `description` entirely when empty (omitempty), so a
|
||||
// workflow with no saved description has `saved.description === undefined`,
|
||||
// not "". Typing into the description field and then clearing it sets the
|
||||
// draft to "" — that must still compare equal to the absent saved value.
|
||||
const initial: Workflow[] = [{ ...wf("wf-b1", "ws-b", NAME_B1), description: undefined }];
|
||||
const { result } = renderHook(() => useWorkflowSettings(initial, "ws-b"));
|
||||
|
||||
act(() => {
|
||||
result.current.setWorkflowItems((items) =>
|
||||
items.map((item) => ({ ...item, description: "test description" })),
|
||||
);
|
||||
});
|
||||
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.setWorkflowItems((items) =>
|
||||
items.map((item) => ({ ...item, description: "" })),
|
||||
);
|
||||
});
|
||||
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkflowSettings — cross-tab field sync", () => {
|
||||
it("syncs a description update from the store within the current workspace", () => {
|
||||
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), description: OLD_DESCRIPTION }];
|
||||
const store: StoreWorkflow = { ...STORE_B1, description: OLD_DESCRIPTION };
|
||||
const { result, rerender } = renderWithStoreItem(initial, store);
|
||||
|
||||
act(() => {
|
||||
rerender({ storeItems: [{ ...store, description: NEW_DESCRIPTION }] });
|
||||
});
|
||||
|
||||
expect(result.current.workflowItems[0].description).toBe(NEW_DESCRIPTION);
|
||||
expect(result.current.savedWorkflowItems[0].description).toBe(NEW_DESCRIPTION);
|
||||
});
|
||||
|
||||
it("does not overwrite a dirty description when the store refreshes, but still moves the dirty-flag baseline", () => {
|
||||
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), description: OLD_DESCRIPTION }];
|
||||
const store: StoreWorkflow = { ...STORE_B1, description: OLD_DESCRIPTION };
|
||||
const { result, rerender } = renderWithStoreItem(initial, store);
|
||||
|
||||
act(() => {
|
||||
result.current.setWorkflowItems((items) =>
|
||||
items.map((item) => ({ ...item, description: UNSAVED_LOCAL_DRAFT })),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
rerender({ storeItems: [{ ...store, description: NEW_DESCRIPTION }] });
|
||||
});
|
||||
|
||||
expect(result.current.workflowItems[0].description).toBe(UNSAVED_LOCAL_DRAFT);
|
||||
expect(result.current.savedWorkflowItems[0].description).toBe(NEW_DESCRIPTION);
|
||||
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("syncs a prompt update from the store within the current workspace", () => {
|
||||
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), prompt: OLD_PROMPT }];
|
||||
const store: StoreWorkflow = { ...STORE_B1, prompt: OLD_PROMPT };
|
||||
const { result, rerender } = renderWithStoreItem(initial, store);
|
||||
|
||||
act(() => {
|
||||
rerender({ storeItems: [{ ...store, prompt: NEW_PROMPT }] });
|
||||
});
|
||||
|
||||
expect(result.current.workflowItems[0].prompt).toBe(NEW_PROMPT);
|
||||
expect(result.current.savedWorkflowItems[0].prompt).toBe(NEW_PROMPT);
|
||||
});
|
||||
|
||||
it("does not overwrite a dirty prompt when the store refreshes, but still moves the dirty-flag baseline", () => {
|
||||
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), prompt: OLD_PROMPT }];
|
||||
const store: StoreWorkflow = { ...STORE_B1, prompt: OLD_PROMPT };
|
||||
const { result, rerender } = renderWithStoreItem(initial, store);
|
||||
|
||||
act(() => {
|
||||
result.current.setWorkflowItems((items) =>
|
||||
items.map((item) => ({ ...item, prompt: UNSAVED_LOCAL_DRAFT })),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
rerender({ storeItems: [{ ...store, prompt: NEW_PROMPT }] });
|
||||
});
|
||||
|
||||
expect(result.current.workflowItems[0].prompt).toBe(UNSAVED_LOCAL_DRAFT);
|
||||
expect(result.current.savedWorkflowItems[0].prompt).toBe(NEW_PROMPT);
|
||||
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("syncs an agent_profile_id update from the store within the current workspace", () => {
|
||||
const initial = [
|
||||
{ ...wf("wf-b1", "ws-b", NAME_B1), agent_profile_id: agentProfileId("agent-old") },
|
||||
];
|
||||
const store: StoreWorkflow = { ...STORE_B1, agent_profile_id: "agent-old" };
|
||||
const { result, rerender } = renderWithStoreItem(initial, store);
|
||||
|
||||
act(() => {
|
||||
rerender({ storeItems: [{ ...store, agent_profile_id: "agent-new" }] });
|
||||
});
|
||||
|
||||
expect(result.current.workflowItems[0].agent_profile_id).toBe(agentProfileId("agent-new"));
|
||||
expect(result.current.savedWorkflowItems[0].agent_profile_id).toBe(agentProfileId("agent-new"));
|
||||
});
|
||||
|
||||
it("does not overwrite a dirty agent_profile_id when the store refreshes, but still moves the dirty-flag baseline", () => {
|
||||
const initial = [
|
||||
{ ...wf("wf-b1", "ws-b", NAME_B1), agent_profile_id: agentProfileId("agent-old") },
|
||||
];
|
||||
const store: StoreWorkflow = { ...STORE_B1, agent_profile_id: "agent-old" };
|
||||
const { result, rerender } = renderWithStoreItem(initial, store);
|
||||
|
||||
act(() => {
|
||||
result.current.setWorkflowItems((items) =>
|
||||
items.map((item) => ({ ...item, agent_profile_id: agentProfileId("agent-draft") })),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
rerender({ storeItems: [{ ...store, agent_profile_id: "agent-new" }] });
|
||||
});
|
||||
|
||||
expect(result.current.workflowItems[0].agent_profile_id).toBe(agentProfileId("agent-draft"));
|
||||
expect(result.current.savedWorkflowItems[0].agent_profile_id).toBe(agentProfileId("agent-new"));
|
||||
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression coverage: a field that starts absent (undefined), gets typed into and
|
||||
// cleared back to "" is not dirty per `isWorkflowDirty` (which normalizes via `?? ""`),
|
||||
// so a remote update landing afterward must still sync — not get stuck behind a
|
||||
// false-positive "local draft" that also silently drags the dirty-flag baseline out
|
||||
// from under the stuck display, priming the next Save to clobber the remote value.
|
||||
describe("useWorkflowSettings — cross-tab field sync (absent-vs-empty normalization)", () => {
|
||||
it("syncs a description update after a local edit is cleared back to the absent saved value", () => {
|
||||
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), description: undefined }];
|
||||
const store: StoreWorkflow = { ...STORE_B1 };
|
||||
const { result, rerender } = renderWithStoreItem(initial, store);
|
||||
|
||||
act(() => {
|
||||
result.current.setWorkflowItems((items) =>
|
||||
items.map((item) => ({ ...item, description: "typed then cleared" })),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
result.current.setWorkflowItems((items) =>
|
||||
items.map((item) => ({ ...item, description: "" })),
|
||||
);
|
||||
});
|
||||
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
|
||||
|
||||
act(() => {
|
||||
rerender({ storeItems: [{ ...store, description: NEW_DESCRIPTION }] });
|
||||
});
|
||||
|
||||
expect(result.current.workflowItems[0].description).toBe(NEW_DESCRIPTION);
|
||||
expect(result.current.savedWorkflowItems[0].description).toBe(NEW_DESCRIPTION);
|
||||
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
|
||||
});
|
||||
|
||||
it("syncs a prompt update after a local edit is cleared back to the absent saved value", () => {
|
||||
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), prompt: undefined }];
|
||||
const store: StoreWorkflow = { ...STORE_B1 };
|
||||
const { result, rerender } = renderWithStoreItem(initial, store);
|
||||
|
||||
act(() => {
|
||||
result.current.setWorkflowItems((items) =>
|
||||
items.map((item) => ({ ...item, prompt: "typed then cleared" })),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
result.current.setWorkflowItems((items) => items.map((item) => ({ ...item, prompt: "" })));
|
||||
});
|
||||
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
|
||||
|
||||
act(() => {
|
||||
rerender({ storeItems: [{ ...store, prompt: NEW_PROMPT }] });
|
||||
});
|
||||
|
||||
expect(result.current.workflowItems[0].prompt).toBe(NEW_PROMPT);
|
||||
expect(result.current.savedWorkflowItems[0].prompt).toBe(NEW_PROMPT);
|
||||
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
|
||||
});
|
||||
|
||||
it("syncs an agent_profile_id update after a local edit is cleared back to the absent saved value", () => {
|
||||
// Mirrors the UI: selecting "None" in the agent profile picker sets
|
||||
// agent_profile_id to "" (workflow-card.tsx), not undefined.
|
||||
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), agent_profile_id: undefined }];
|
||||
const store: StoreWorkflow = { ...STORE_B1 };
|
||||
const { result, rerender } = renderWithStoreItem(initial, store);
|
||||
|
||||
act(() => {
|
||||
result.current.setWorkflowItems((items) =>
|
||||
items.map((item) => ({ ...item, agent_profile_id: agentProfileId("agent-draft") })),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
result.current.setWorkflowItems((items) =>
|
||||
items.map((item) => ({ ...item, agent_profile_id: agentProfileId("") })),
|
||||
);
|
||||
});
|
||||
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
|
||||
|
||||
act(() => {
|
||||
rerender({ storeItems: [{ ...store, agent_profile_id: "agent-new" }] });
|
||||
});
|
||||
|
||||
expect(result.current.workflowItems[0].agent_profile_id).toBe(agentProfileId("agent-new"));
|
||||
expect(result.current.savedWorkflowItems[0].agent_profile_id).toBe(agentProfileId("agent-new"));
|
||||
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(false);
|
||||
});
|
||||
|
||||
it("does not sync a description update while a different field (name) has a genuine local draft", () => {
|
||||
const initial = [{ ...wf("wf-b1", "ws-b", NAME_B1), description: OLD_DESCRIPTION }];
|
||||
const store: StoreWorkflow = { ...STORE_B1, description: OLD_DESCRIPTION };
|
||||
const { result, rerender } = renderWithStoreItem(initial, store);
|
||||
|
||||
act(() => {
|
||||
result.current.setWorkflowItems((items) =>
|
||||
items.map((item) => ({ ...item, name: UNSAVED_LOCAL_DRAFT })),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
rerender({ storeItems: [{ ...store, description: NEW_DESCRIPTION }] });
|
||||
});
|
||||
|
||||
// The name draft is preserved, and the unrelated description field still syncs live.
|
||||
expect(result.current.workflowItems[0].name).toBe(UNSAVED_LOCAL_DRAFT);
|
||||
expect(result.current.workflowItems[0].description).toBe(NEW_DESCRIPTION);
|
||||
expect(result.current.savedWorkflowItems[0].description).toBe(NEW_DESCRIPTION);
|
||||
expect(result.current.isWorkflowDirty(result.current.workflowItems[0])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkflowSettings visibility", () => {
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { workflowId, workspaceId as toWorkspaceId, type Workflow } from "@/lib/types/http";
|
||||
import {
|
||||
agentProfileId,
|
||||
workflowId,
|
||||
workspaceId as toWorkspaceId,
|
||||
type Workflow,
|
||||
} from "@/lib/types/http";
|
||||
|
||||
/**
|
||||
* Manages workflow list state for the settings page, synced with WS events
|
||||
@@ -84,10 +89,9 @@ export function useWorkflowSettings(initialWorkflows: Workflow[], workspaceId?:
|
||||
const updated = filtered.map((w) => {
|
||||
if (w.id.startsWith("temp-")) return w;
|
||||
const sw = scopedStoreWorkflows.find((s) => s.id === w.id);
|
||||
if (!sw) return w;
|
||||
const saved = savedWorkflowItemsRef.current.find((item) => item.id === w.id);
|
||||
const hasLocalNameDraft = saved && w.name !== saved.name;
|
||||
if (sw && sw.name !== w.name && !hasLocalNameDraft) return { ...w, name: sw.name };
|
||||
return w;
|
||||
return mergeDisplayedWorkflowFromStore(w, saved, sw);
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -105,9 +109,7 @@ export function useWorkflowSettings(initialWorkflows: Workflow[], workspaceId?:
|
||||
const filtered = prev.filter((w) => !deletedIds.has(w.id));
|
||||
const updated = filtered.map((workflow) => {
|
||||
const server = scopedStoreWorkflows.find((item) => item.id === workflow.id);
|
||||
return server && server.name !== workflow.name
|
||||
? { ...workflow, name: server.name }
|
||||
: workflow;
|
||||
return server ? mergeSavedWorkflowFromServer(workflow, server) : workflow;
|
||||
});
|
||||
if (
|
||||
toAdd.length === 0 &&
|
||||
@@ -140,18 +142,107 @@ function workflowIsDirty(workflow: Workflow, savedWorkflows: Map<string, Workflo
|
||||
if (!saved) return true;
|
||||
return (
|
||||
workflow.name !== saved.name ||
|
||||
workflow.description !== saved.description ||
|
||||
(workflow.description ?? "") !== (saved.description ?? "") ||
|
||||
(workflow.prompt ?? "") !== (saved.prompt ?? "") ||
|
||||
(workflow.agent_profile_id ?? "") !== (saved.agent_profile_id ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
type EditableWorkflowField = "name" | "description" | "prompt" | "agent_profile_id";
|
||||
|
||||
// Whether the displayed value for `field` has already diverged from the last-known-saved
|
||||
// baseline — i.e. the user has an uncommitted local edit that a store refresh must not clobber.
|
||||
// No baseline (a workflow not yet tracked in savedWorkflowItems) is treated as "no draft" so a
|
||||
// fresh store sync can still populate it, mirroring the pre-existing name-only behavior.
|
||||
// Normalizes absent values via `?? ""` the same way `workflowIsDirty` does — otherwise a field
|
||||
// that starts undefined, gets typed into, and is cleared back to "" reads as a phantom draft:
|
||||
// it blocks a legitimate store sync while the saved baseline still advances underneath it,
|
||||
// leaving the field falsely dirty and primed to clobber the remote value on the next Save.
|
||||
function hasLocalDraft(
|
||||
displayed: Workflow,
|
||||
saved: Workflow | undefined,
|
||||
field: EditableWorkflowField,
|
||||
): boolean {
|
||||
return saved != null && (displayed[field] ?? "") !== (saved[field] ?? "");
|
||||
}
|
||||
|
||||
type StoreWorkflowItem = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
prompt?: string;
|
||||
agent_profile_id?: string;
|
||||
};
|
||||
|
||||
// Pulls fresh values from the store into the displayed/editable draft, field by field, skipping
|
||||
// any field the user is mid-edit on so a cross-tab update never clobbers an uncommitted change.
|
||||
function mergeDisplayedWorkflowFromStore(
|
||||
displayed: Workflow,
|
||||
saved: Workflow | undefined,
|
||||
server: StoreWorkflowItem,
|
||||
): Workflow {
|
||||
const next = { ...displayed };
|
||||
let changed = false;
|
||||
|
||||
if (!hasLocalDraft(displayed, saved, "name") && server.name !== displayed.name) {
|
||||
next.name = server.name;
|
||||
changed = true;
|
||||
}
|
||||
if (
|
||||
!hasLocalDraft(displayed, saved, "description") &&
|
||||
server.description !== displayed.description
|
||||
) {
|
||||
next.description = server.description;
|
||||
changed = true;
|
||||
}
|
||||
if (!hasLocalDraft(displayed, saved, "prompt") && server.prompt !== displayed.prompt) {
|
||||
next.prompt = server.prompt;
|
||||
changed = true;
|
||||
}
|
||||
const serverAgentProfileId =
|
||||
server.agent_profile_id !== undefined ? agentProfileId(server.agent_profile_id) : undefined;
|
||||
if (
|
||||
!hasLocalDraft(displayed, saved, "agent_profile_id") &&
|
||||
serverAgentProfileId !== displayed.agent_profile_id
|
||||
) {
|
||||
next.agent_profile_id = serverAgentProfileId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? next : displayed;
|
||||
}
|
||||
|
||||
// The dirty-flag baseline always tracks the server's current value for every editable field —
|
||||
// unlike the displayed draft, it is never guarded by a local-draft check.
|
||||
function mergeSavedWorkflowFromServer(saved: Workflow, server: StoreWorkflowItem): Workflow {
|
||||
const serverAgentProfileId =
|
||||
server.agent_profile_id !== undefined ? agentProfileId(server.agent_profile_id) : undefined;
|
||||
if (
|
||||
server.name === saved.name &&
|
||||
server.description === saved.description &&
|
||||
server.prompt === saved.prompt &&
|
||||
serverAgentProfileId === saved.agent_profile_id
|
||||
) {
|
||||
return saved;
|
||||
}
|
||||
return {
|
||||
...saved,
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
prompt: server.prompt,
|
||||
agent_profile_id: serverAgentProfileId,
|
||||
};
|
||||
}
|
||||
|
||||
function useScopedStoreWorkflows<
|
||||
T extends {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
prompt?: string;
|
||||
agent_profile_id?: string;
|
||||
hidden?: boolean;
|
||||
style?: string;
|
||||
},
|
||||
@@ -166,17 +257,15 @@ function useScopedStoreWorkflows<
|
||||
}, [storeWorkflows, workspaceId]);
|
||||
}
|
||||
|
||||
function storeItemToWorkflow(sw: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}): Workflow {
|
||||
function storeItemToWorkflow(sw: StoreWorkflowItem): Workflow {
|
||||
return {
|
||||
id: workflowId(sw.id),
|
||||
workspace_id: toWorkspaceId(sw.workspaceId),
|
||||
name: sw.name,
|
||||
description: sw.description,
|
||||
prompt: sw.prompt,
|
||||
agent_profile_id:
|
||||
sw.agent_profile_id !== undefined ? agentProfileId(sw.agent_profile_id) : undefined,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ function makeWorkflow(id: string, workspaceId: string) {
|
||||
workspace_id: workspaceId,
|
||||
name: id,
|
||||
description: null,
|
||||
prompt: undefined as string | undefined,
|
||||
sort_order: 0,
|
||||
agent_profile_id: null,
|
||||
hidden: false,
|
||||
@@ -190,6 +191,20 @@ describe("useWorkflows — explicit workspace selection", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps the workflow's prompt into the store item", async () => {
|
||||
mockListWorkflows.mockResolvedValueOnce({
|
||||
workflows: [{ ...makeWorkflow("wf-B", "ws-B"), prompt: "Do the thing" }],
|
||||
});
|
||||
|
||||
renderHook(() => useWorkflows("ws-B", true));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockSetWorkflows).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ id: "wf-B", prompt: "Do the thing" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useEnsureWorkspaceWorkflows", () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ function useWorkflowsFetchEffect(
|
||||
workspaceId: workflow.workspace_id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
prompt: workflow.prompt,
|
||||
sortOrder: workflow.sort_order ?? 0,
|
||||
agent_profile_id: workflow.agent_profile_id,
|
||||
hidden: workflow.hidden,
|
||||
|
||||
@@ -137,6 +137,7 @@ export type WorkflowsState = {
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
prompt?: string;
|
||||
sortOrder?: number;
|
||||
agent_profile_id?: string;
|
||||
hidden?: boolean;
|
||||
|
||||
@@ -152,6 +152,28 @@ describe("workflow.updated handler — hidden flag reconciles activeId", () => {
|
||||
expect(store.getState().workflows.activeId).toBe("wf-1");
|
||||
expect(store.getState().workflows.items[0]?.name).toBe("New Name");
|
||||
});
|
||||
|
||||
it("propagates description and prompt from the payload", () => {
|
||||
const store = makeStore(
|
||||
[{ id: "wf-1", workspaceId: "ws-1", name: "Workflow", hidden: false }],
|
||||
"wf-1",
|
||||
);
|
||||
const handlers = registerWorkflowsHandlers(store);
|
||||
|
||||
handlers["workflow.updated"]?.(
|
||||
updatedMessage({
|
||||
id: "wf-1",
|
||||
workspace_id: "ws-1",
|
||||
name: "Workflow",
|
||||
description: "Updated from another tab",
|
||||
prompt: "Updated prompt",
|
||||
}),
|
||||
);
|
||||
|
||||
const item = store.getState().workflows.items[0];
|
||||
expect(item?.description).toBe("Updated from another tab");
|
||||
expect(item?.prompt).toBe("Updated prompt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow step handlers", () => {
|
||||
|
||||
@@ -51,6 +51,8 @@ function applyWorkflowUpdated(state: AppState, payload: WorkflowPayload): AppSta
|
||||
? {
|
||||
...item,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
prompt: payload.prompt,
|
||||
agent_profile_id: payload.agent_profile_id,
|
||||
hidden: payload.hidden !== undefined ? Boolean(payload.hidden) : item.hidden,
|
||||
style: payload.style ?? item.style,
|
||||
|
||||
@@ -245,6 +245,8 @@
|
||||
"workflowCycleBlockingBody": "This path re-enters \"{{stepName}}\" and can start the agent again without another user action.",
|
||||
"workflowCycleReplayPathFor": "Replay path for {{stepName}}",
|
||||
"workflowCycleWarningBody": "This path can re-enter \"{{stepName}}\" and start the agent again after a user action.",
|
||||
"workflowDescription": "Description",
|
||||
"workflowDescriptionPlaceholder": "Optional description",
|
||||
"workflowDetails": "Workflow details",
|
||||
"workflowName": "Workflow Name",
|
||||
"workflowsReadOnlyImprove": "Workflows in this workspace are managed by Improve Kandev and cannot be changed.",
|
||||
|
||||
@@ -245,6 +245,8 @@
|
||||
"workflowCycleBlockingBody": "Ţĥĩś ƥàţĥ ŕē-ēńţēŕś \"{{stepName}}\" àńď ćàń śţàŕţ ţĥē àĝēńţ àĝàĩń ŵĩţĥōũţ àńōţĥēŕ ũśēŕ àćţĩōń.",
|
||||
"workflowCycleReplayPathFor": "Ŕēƥĺàŷ ƥàţĥ ƒōŕ {{stepName}}",
|
||||
"workflowCycleWarningBody": "Ţĥĩś ƥàţĥ ćàń ŕē-ēńţēŕ \"{{stepName}}\" àńď śţàŕţ ţĥē àĝēńţ àĝàĩń àƒţēŕ à ũśēŕ àćţĩōń.",
|
||||
"workflowDescription": "Ďēśćŕĩƥţĩōń",
|
||||
"workflowDescriptionPlaceholder": "Ōƥţĩōńàĺ ďēśćŕĩƥţĩōń",
|
||||
"workflowDetails": "Ŵōŕķƒĺōŵ ďēţàĩĺś",
|
||||
"workflowName": "Ŵōŕķƒĺōŵ Ńàḿē",
|
||||
"workflowsReadOnlyImprove": "Ŵōŕķƒĺōŵś ĩń ţĥĩś ŵōŕķśƥàćē àŕē ḿàńàĝēď ƀŷ Ĩḿƥŕōvē Ķàńďēv àńď ćàńńōţ ƀē ćĥàńĝēď.",
|
||||
|
||||
Reference in New Issue
Block a user