feat(FN-3080): document graph navigation canonicalization in plugin authori
Adds documentation for graph navigation canonicalization (FN-3080) covering plugin authoring and dashboard usage, along with a changeset for the `@runfusion/fusion` package release. Fusion-Task-Id: FN-3080
This commit is contained in:
5
.changeset/fn-3080-graph-nav-canonicalization.md
Normal file
5
.changeset/fn-3080-graph-nav-canonicalization.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Normalize dependency graph dashboard navigation so Graph resolves through a canonical `graph` task view destination and appears only in secondary navigation surfaces (desktop Header overflow and mobile More sheet). Also add TaskCard embedding support via `disableDrag` for plugin-hosted graph nodes.
|
||||||
@@ -612,6 +612,11 @@ Placement guidance:
|
|||||||
- `overflow`: desktop header overflow menu
|
- `overflow`: desktop header overflow menu
|
||||||
- `more`: mobile More sheet / secondary nav surfaces
|
- `more`: mobile More sheet / secondary nav surfaces
|
||||||
|
|
||||||
|
Project-scoped UI state guidance:
|
||||||
|
- Persist plugin view layout/state in browser storage using a plugin-owned base key and the shared project-scoped pattern (`kb:${projectId}:${baseKey}`).
|
||||||
|
- For dependency graph layout, the canonical base key is `fusion-plugin-dependency-graph:positions`.
|
||||||
|
- Do not persist plugin UI state in task metadata or server-side task records.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Registering Agent Runtimes
|
## 9. Registering Agent Runtimes
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ Features:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## Graph View
|
||||||
|
|
||||||
|
Graph view visualizes task dependencies as an interactive node/edge map.
|
||||||
|
|
||||||
|
Navigation:
|
||||||
|
- Desktop: **Header → More views → Graph**
|
||||||
|
- Mobile: **MobileNavBar → More → Graph**
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Shows only tasks in `triage`, `todo`, `in-progress`, and `in-review`
|
||||||
|
- Excludes `done` and `archived`
|
||||||
|
- Supports pan/zoom, fit-to-graph, dependency-chain highlight, and drag-to-position
|
||||||
|
- Persists node layout per project using plugin-scoped storage key `fusion-plugin-dependency-graph:positions`
|
||||||
|
|
||||||
## Chat View
|
## Chat View
|
||||||
|
|
||||||
Chat view provides project-scoped conversations with agents.
|
Chat view provides project-scoped conversations with agents.
|
||||||
|
|||||||
@@ -256,6 +256,12 @@ function AppInner() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { views: pluginDashboardViews } = usePluginDashboardViews(currentProject?.id);
|
const { views: pluginDashboardViews } = usePluginDashboardViews(currentProject?.id);
|
||||||
|
const graphPluginTaskView = useMemo(() => {
|
||||||
|
const graphView = pluginDashboardViews.find(
|
||||||
|
(entry) => entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph",
|
||||||
|
);
|
||||||
|
return graphView ? (`plugin:${graphView.pluginId}:${graphView.view.viewId}` as const) : null;
|
||||||
|
}, [pluginDashboardViews]);
|
||||||
|
|
||||||
// History-aware view change handler — pushes nav entry on back-navigation stack.
|
// History-aware view change handler — pushes nav entry on back-navigation stack.
|
||||||
const handleTaskViewChange = useCallback((newView: TaskView) => {
|
const handleTaskViewChange = useCallback((newView: TaskView) => {
|
||||||
@@ -536,6 +542,10 @@ function AppInner() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settingsLoaded) return;
|
if (!settingsLoaded) return;
|
||||||
if (isPluginViewId(taskView)) return;
|
if (isPluginViewId(taskView)) return;
|
||||||
|
if (taskView === "graph" && !graphPluginTaskView) {
|
||||||
|
handleChangeTaskView("board");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (taskView === "skills" && !skillsEnabled) {
|
if (taskView === "skills" && !skillsEnabled) {
|
||||||
handleChangeTaskView("board");
|
handleChangeTaskView("board");
|
||||||
}
|
}
|
||||||
@@ -557,7 +567,7 @@ function AppInner() {
|
|||||||
if (taskView === "research" && !researchEnabled) {
|
if (taskView === "research" && !researchEnabled) {
|
||||||
handleChangeTaskView("board");
|
handleChangeTaskView("board");
|
||||||
}
|
}
|
||||||
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled]);
|
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, graphPluginTaskView]);
|
||||||
|
|
||||||
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
|
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -975,12 +985,14 @@ function AppInner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedPluginTaskView = taskView === "graph" ? graphPluginTaskView : (isPluginViewId(taskView) ? taskView : null);
|
||||||
|
|
||||||
// Project view
|
// Project view
|
||||||
if (isPluginViewId(taskView)) {
|
if (resolvedPluginTaskView) {
|
||||||
return (
|
return (
|
||||||
<PageErrorBoundary>
|
<PageErrorBoundary>
|
||||||
<PluginDashboardViewHost
|
<PluginDashboardViewHost
|
||||||
taskView={taskView as `plugin:${string}:${string}`}
|
taskView={resolvedPluginTaskView as `plugin:${string}:${string}`}
|
||||||
context={{
|
context={{
|
||||||
projectId: currentProject?.id,
|
projectId: currentProject?.id,
|
||||||
tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks,
|
tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks,
|
||||||
@@ -993,6 +1005,7 @@ function AppInner() {
|
|||||||
onOpenDetail={(value: Task | TaskDetail) => openDetailTask(value)}
|
onOpenDetail={(value: Task | TaskDetail) => openDetailTask(value)}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
workflowStepNameLookup={workflowStepNameLookup}
|
workflowStepNameLookup={workflowStepNameLookup}
|
||||||
|
disableDrag={true}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
@@ -1434,7 +1447,7 @@ function AppInner() {
|
|||||||
}}
|
}}
|
||||||
pluginDashboardViews={pluginDashboardViews}
|
pluginDashboardViews={pluginDashboardViews}
|
||||||
/>
|
/>
|
||||||
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !isPluginViewId(taskView) && (
|
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && taskView !== "graph" && !isPluginViewId(taskView) && (
|
||||||
<QuickChatFAB
|
<QuickChatFAB
|
||||||
projectId={currentProject.id}
|
projectId={currentProject.id}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
|
|||||||
@@ -1159,8 +1159,8 @@ export function Header({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${entry.pluginId}:${entry.view.viewId}`}
|
key={`${entry.pluginId}:${entry.view.viewId}`}
|
||||||
className={`view-toggle-btn${view === pluginTaskView ? " active" : ""}`}
|
className={`view-toggle-btn${view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph") ? " active" : ""}`}
|
||||||
onClick={() => onChangeView(pluginTaskView)}
|
onClick={() => onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView)}
|
||||||
title={`${entry.view.label} view`}
|
title={`${entry.view.label} view`}
|
||||||
aria-label={`${entry.view.label} view`}
|
aria-label={`${entry.view.label} view`}
|
||||||
aria-pressed={view === pluginTaskView}
|
aria-pressed={view === pluginTaskView}
|
||||||
@@ -1174,7 +1174,7 @@ export function Header({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
ref={viewOverflowTriggerRef}
|
ref={viewOverflowTriggerRef}
|
||||||
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
|
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver", "graph"].includes(view) || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
|
||||||
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||||
title="More views"
|
title="More views"
|
||||||
aria-label="More views"
|
aria-label="More views"
|
||||||
@@ -1298,9 +1298,9 @@ export function Header({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${entry.pluginId}:${entry.view.viewId}`}
|
key={`${entry.pluginId}:${entry.view.viewId}`}
|
||||||
className={`view-toggle-overflow-item${view === pluginTaskView ? " active" : ""}`}
|
className={`view-toggle-overflow-item${view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph") ? " active" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChangeView(pluginTaskView);
|
onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView);
|
||||||
setIsViewOverflowOpen(false);
|
setIsViewOverflowOpen(false);
|
||||||
}}
|
}}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -212,22 +212,11 @@ export function MobileNavBar({
|
|||||||
const showRoadmapsTopLevel = roadmapEnabled && (!skillsEnabled || view === "roadmaps");
|
const showRoadmapsTopLevel = roadmapEnabled && (!skillsEnabled || view === "roadmaps");
|
||||||
const showSkillsTopLevel = skillsEnabled && (!roadmapEnabled || view !== "roadmaps");
|
const showSkillsTopLevel = skillsEnabled && (!roadmapEnabled || view !== "roadmaps");
|
||||||
const showSkillsInMore = skillsEnabled && !showSkillsTopLevel;
|
const showSkillsInMore = skillsEnabled && !showSkillsTopLevel;
|
||||||
const isDependencyGraphView = (entry: PluginDashboardViewEntry): boolean => (
|
|
||||||
entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph"
|
|
||||||
);
|
|
||||||
const sortedPrimaryPluginViews = pluginDashboardViews
|
const sortedPrimaryPluginViews = pluginDashboardViews
|
||||||
.filter((entry) => entry.view.placement === "primary")
|
.filter((entry) => entry.view.placement === "primary")
|
||||||
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER));
|
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER));
|
||||||
const dependencyGraphPluginView = pluginDashboardViews.find(isDependencyGraphView) ?? null;
|
|
||||||
// Keep plugin-provided top-level tabs constrained on mobile so fixed tabs retain
|
|
||||||
// reasonable touch-target width. Additional primary plugin destinations overflow into More.
|
|
||||||
// FN-3235: Always surface the dependency graph destination as the first plugin top-level tab
|
|
||||||
// on mobile so task graph navigation has a clear entry point.
|
|
||||||
const prioritizedPrimaryPluginViews = dependencyGraphPluginView
|
|
||||||
? [dependencyGraphPluginView, ...sortedPrimaryPluginViews.filter((entry) => !isDependencyGraphView(entry))]
|
|
||||||
: sortedPrimaryPluginViews;
|
|
||||||
const MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS = 1;
|
const MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS = 1;
|
||||||
const topLevelPrimaryPluginViews = prioritizedPrimaryPluginViews.slice(0, MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS);
|
const topLevelPrimaryPluginViews = sortedPrimaryPluginViews.slice(0, MAX_PRIMARY_PLUGIN_TOP_LEVEL_TABS);
|
||||||
const topLevelPluginViewKeys = new Set(
|
const topLevelPluginViewKeys = new Set(
|
||||||
topLevelPrimaryPluginViews.map((entry) => `${entry.pluginId}:${entry.view.viewId}`),
|
topLevelPrimaryPluginViews.map((entry) => `${entry.pluginId}:${entry.view.viewId}`),
|
||||||
);
|
);
|
||||||
@@ -245,6 +234,7 @@ export function MobileNavBar({
|
|||||||
|| (todosOpen && todoViewEnabled)
|
|| (todosOpen && todoViewEnabled)
|
||||||
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
||||||
|| (view === "skills" && !showSkillsTopLevel)
|
|| (view === "skills" && !showSkillsTopLevel)
|
||||||
|
|| view === "graph"
|
||||||
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -365,11 +355,11 @@ export function MobileNavBar({
|
|||||||
<button
|
<button
|
||||||
key={`${entry.pluginId}:${entry.view.viewId}`}
|
key={`${entry.pluginId}:${entry.view.viewId}`}
|
||||||
type="button"
|
type="button"
|
||||||
className={`mobile-nav-tab${view === pluginTaskView ? " mobile-nav-tab--active" : ""}`}
|
className={`mobile-nav-tab${view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph") ? " mobile-nav-tab--active" : ""}`}
|
||||||
data-testid={`mobile-nav-tab-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
data-testid={`mobile-nav-tab-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={view === pluginTaskView}
|
aria-selected={view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph")}
|
||||||
onClick={() => onChangeView(pluginTaskView)}
|
onClick={() => onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView)}
|
||||||
>
|
>
|
||||||
<PluginIcon />
|
<PluginIcon />
|
||||||
<span className="mobile-nav-tab-label">{entry.view.label}</span>
|
<span className="mobile-nav-tab-label">{entry.view.label}</span>
|
||||||
@@ -719,7 +709,7 @@ export function MobileNavBar({
|
|||||||
type="button"
|
type="button"
|
||||||
className="mobile-more-item"
|
className="mobile-more-item"
|
||||||
data-testid={`mobile-more-item-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
data-testid={`mobile-more-item-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
||||||
onClick={() => handleMoreAction(() => onChangeView(pluginTaskView))}
|
onClick={() => handleMoreAction(() => onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView))}
|
||||||
>
|
>
|
||||||
<PluginIcon />
|
<PluginIcon />
|
||||||
<span>{entry.view.label}</span>
|
<span>{entry.view.label}</span>
|
||||||
|
|||||||
@@ -274,6 +274,8 @@ interface TaskCardProps {
|
|||||||
lastFetchTimeMs?: number;
|
lastFetchTimeMs?: number;
|
||||||
/** Lookup of workflow step IDs to display names, fetched once at board level. */
|
/** Lookup of workflow step IDs to display names, fetched once at board level. */
|
||||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||||
|
/** Disable card drag semantics when embedding in custom draggable containers (e.g. dependency graph). */
|
||||||
|
disableDrag?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function areTaskBadgeInfosEqual(
|
function areTaskBadgeInfosEqual(
|
||||||
@@ -415,6 +417,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
|||||||
previous.onOpenMission === next.onOpenMission &&
|
previous.onOpenMission === next.onOpenMission &&
|
||||||
previous.onMoveTask === next.onMoveTask &&
|
previous.onMoveTask === next.onMoveTask &&
|
||||||
previous.workflowStepNameLookup === next.workflowStepNameLookup &&
|
previous.workflowStepNameLookup === next.workflowStepNameLookup &&
|
||||||
|
previous.disableDrag === next.disableDrag &&
|
||||||
previousTask.id === nextTask.id &&
|
previousTask.id === nextTask.id &&
|
||||||
previousTask.title === nextTask.title &&
|
previousTask.title === nextTask.title &&
|
||||||
previousTask.description === nextTask.description &&
|
previousTask.description === nextTask.description &&
|
||||||
@@ -478,6 +481,7 @@ function TaskCardComponent({
|
|||||||
onMoveTask,
|
onMoveTask,
|
||||||
lastFetchTimeMs,
|
lastFetchTimeMs,
|
||||||
workflowStepNameLookup,
|
workflowStepNameLookup,
|
||||||
|
disableDrag,
|
||||||
}: TaskCardProps) {
|
}: TaskCardProps) {
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [fileDragOver, setFileDragOver] = useState(false);
|
const [fileDragOver, setFileDragOver] = useState(false);
|
||||||
@@ -728,7 +732,7 @@ function TaskCardComponent({
|
|||||||
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
|
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
|
||||||
const isArchived = task.column === "archived";
|
const isArchived = task.column === "archived";
|
||||||
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||||
const isDraggable = !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit or if archived
|
const isDraggable = !disableDrag && !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit/archived or host embedding
|
||||||
|
|
||||||
// Check if this card can be edited inline
|
// Check if this card can be edited inline
|
||||||
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
|
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
|
||||||
|
|||||||
@@ -167,14 +167,14 @@ describe("Header", () => {
|
|||||||
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
|
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders plugin dashboard views by placement and uses manifest icon metadata", () => {
|
it("renders dependency graph in overflow and uses canonical graph task view", () => {
|
||||||
const onChangeView = vi.fn();
|
const onChangeView = vi.fn();
|
||||||
renderHeader({
|
renderHeader({
|
||||||
onChangeView,
|
onChangeView,
|
||||||
pluginDashboardViews: [
|
pluginDashboardViews: [
|
||||||
{
|
{
|
||||||
pluginId: "fusion-plugin-dependency-graph",
|
pluginId: "fusion-plugin-dependency-graph",
|
||||||
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", icon: "Map", placement: "primary" },
|
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", icon: "Map", placement: "more" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pluginId: "fusion-plugin-dependency-graph",
|
pluginId: "fusion-plugin-dependency-graph",
|
||||||
@@ -183,10 +183,13 @@ describe("Header", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const graphPrimary = screen.getByTestId("view-toggle-plugin-fusion-plugin-dependency-graph-graph");
|
expect(screen.queryByTestId("view-toggle-plugin-fusion-plugin-dependency-graph-graph")).toBeNull();
|
||||||
expect(graphPrimary.querySelector(".lucide-map")).toBeTruthy();
|
|
||||||
fireEvent.click(graphPrimary);
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||||
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
|
const graphItem = screen.getByTestId("view-overflow-plugin-fusion-plugin-dependency-graph-graph");
|
||||||
|
expect(graphItem.querySelector(".lucide-map")).toBeTruthy();
|
||||||
|
fireEvent.click(graphItem);
|
||||||
|
expect(onChangeView).toHaveBeenCalledWith("graph");
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||||
const queueItem = screen.getByTestId("view-overflow-plugin-fusion-plugin-dependency-graph-queue");
|
const queueItem = screen.getByTestId("view-overflow-plugin-fusion-plugin-dependency-graph-queue");
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ describe("MobileNavBar", () => {
|
|||||||
expect(onOpenTodos).toHaveBeenCalled();
|
expect(onOpenTodos).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders dependency graph as a top-level tab and keeps additional plugin views in More", () => {
|
it("keeps dependency graph in More and routes to canonical graph task view", () => {
|
||||||
const props = createDefaultProps();
|
const props = createDefaultProps();
|
||||||
render(
|
render(
|
||||||
<MobileNavBar
|
<MobileNavBar
|
||||||
@@ -139,14 +139,14 @@ describe("MobileNavBar", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const primaryTab = screen.getByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph");
|
expect(screen.queryByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph")).toBeNull();
|
||||||
expect(primaryTab.querySelector(".lucide-map")).toBeTruthy();
|
|
||||||
fireEvent.click(primaryTab);
|
|
||||||
expect(props.onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||||
expect(screen.queryByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-graph")).toBeNull();
|
const graphItem = screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-graph");
|
||||||
|
fireEvent.click(graphItem);
|
||||||
|
expect(props.onChangeView).toHaveBeenCalledWith("graph");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||||
const overflowItem = screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-queue");
|
const overflowItem = screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-queue");
|
||||||
expect(overflowItem.querySelector(".lucide-workflow")).toBeTruthy();
|
expect(overflowItem.querySelector(".lucide-workflow")).toBeTruthy();
|
||||||
fireEvent.click(overflowItem);
|
fireEvent.click(overflowItem);
|
||||||
@@ -177,11 +177,11 @@ describe("MobileNavBar", () => {
|
|||||||
expect(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-queue")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-queue")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks dependency graph plugin tab active when viewing its canonical plugin task-view", () => {
|
it("marks More active when current view is graph", () => {
|
||||||
render(
|
render(
|
||||||
<MobileNavBar
|
<MobileNavBar
|
||||||
{...createDefaultProps()}
|
{...createDefaultProps()}
|
||||||
view="plugin:fusion-plugin-dependency-graph:graph"
|
view="graph"
|
||||||
pluginDashboardViews={[
|
pluginDashboardViews={[
|
||||||
{
|
{
|
||||||
pluginId: "fusion-plugin-dependency-graph",
|
pluginId: "fusion-plugin-dependency-graph",
|
||||||
@@ -195,8 +195,7 @@ describe("MobileNavBar", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph").className).toContain("mobile-nav-tab--active");
|
expect(screen.getByTestId("mobile-nav-tab-more").className).toContain("mobile-nav-tab--active");
|
||||||
expect(screen.getByTestId("mobile-nav-tab-more").className).not.toContain("mobile-nav-tab--active");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks More active when current plugin view is overflow-only", () => {
|
it("marks More active when current plugin view is overflow-only", () => {
|
||||||
@@ -218,7 +217,7 @@ describe("MobileNavBar", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("mobile-nav-tab-more").className).toContain("mobile-nav-tab--active");
|
expect(screen.getByTestId("mobile-nav-tab-more").className).toContain("mobile-nav-tab--active");
|
||||||
expect(screen.getByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph").className).not.toContain("mobile-nav-tab--active");
|
expect(screen.queryByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("active tab is highlighted for mailbox", () => {
|
it("active tab is highlighted for mailbox", () => {
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ describe("TaskCard", () => {
|
|||||||
expect(screen.getByText("FN-001")).toBeDefined();
|
expect(screen.getByText("FN-001")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("disables native card dragging when disableDrag is true", () => {
|
||||||
|
const { container } = render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} disableDrag={true} />);
|
||||||
|
const card = container.querySelector(".card") as HTMLElement;
|
||||||
|
expect(card.getAttribute("draggable")).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
it("clicking PR badge link does not open the task detail modal", () => {
|
it("clicking PR badge link does not open the task detail modal", () => {
|
||||||
const onOpenDetail = vi.fn();
|
const onOpenDetail = vi.fn();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -307,6 +307,28 @@ describe("useViewState", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores and persists graph taskView using scoped storage", async () => {
|
||||||
|
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "graph");
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useViewState(
|
||||||
|
createOptions({
|
||||||
|
currentProject: PROJECT,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.taskView).toBe("graph");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.setTaskView("graph");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("graph");
|
||||||
|
});
|
||||||
|
|
||||||
it("restores and persists plugin task views using the canonical composite key", async () => {
|
it("restores and persists plugin task views using the canonical composite key", async () => {
|
||||||
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "plugin:fusion-plugin-dependency-graph:graph");
|
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "plugin:fusion-plugin-dependency-graph:graph");
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
|||||||
import { isPluginViewId } from "../plugins/pluginViewRegistry";
|
import { isPluginViewId } from "../plugins/pluginViewRegistry";
|
||||||
|
|
||||||
export type ViewMode = "overview" | "project";
|
export type ViewMode = "overview" | "project";
|
||||||
export type BuiltInTaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
|
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
|
||||||
export type PluginTaskView = `plugin:${string}:${string}`;
|
export type PluginTaskView = `plugin:${string}:${string}`;
|
||||||
export type TaskView = BuiltInTaskView | PluginTaskView;
|
export type TaskView = BuiltInTaskView | PluginTaskView;
|
||||||
|
|
||||||
const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
|
const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
|
||||||
"board",
|
"board",
|
||||||
"list",
|
"list",
|
||||||
|
"graph",
|
||||||
"agents",
|
"agents",
|
||||||
"missions",
|
"missions",
|
||||||
"chat",
|
"chat",
|
||||||
|
|||||||
Reference in New Issue
Block a user