Enable native previews and hosted navigation for roadmap items. - Extend native structure references and API resolution through the roadmap adapter. - Register the bundled Roadmaps view across desktop and mobile navigation. - Cover roadmap preview availability, navigation, and route behavior. Files changed: ...n-8358-roadmap-item-native-structure-preview.md | 7 ++++ docs/dashboard-guide.md | 6 ++-- packages/core/src/types.ts | 24 ++++++++----- packages/dashboard/app/App.tsx | 24 ++++++++----- .../dashboard/app/components/LeftSidebarNav.tsx | 12 +++---- .../app/components/NativeStructurePreview.tsx | 10 ++++-- .../app/components/__tests__/App.test.tsx | 13 ++++--- .../app/components/__tests__/Header.test.tsx | 5 ++- .../components/__tests__/LeftSidebarNav.test.tsx | 5 ++- .../app/components/__tests__/MobileNavBar.test.tsx | 4 +-- .../__tests__/NativeStructurePreview.test.tsx | 40 ++++++++++++++++------ .../__tests__/registerBundledPluginViews.test.tsx | 24 ++++++++++--- .../app/plugins/registerBundledPluginViews.ts | 23 +++++++++++++ packages/dashboard/src/native-structure-preview.ts | 25 +++++++++++++- .../native-structure-preview-routes.test.ts | 31 +++++++++++++++-- .../src/routes/register-task-workflow-routes.ts | 4 +-- packages/dashboard/vite.config.ts | 4 +++ packages/dashboard/vitest.config.ts | 4 +++ plugins/fusion-plugin-roadmap/package.json | 5 +++ .../fusion-plugin-roadmap/src/dashboard-view.tsx | 5 +++ plugins/fusion-plugin-roadmap/src/index.ts | 14 ++++++-- 21 files changed, 223 insertions(+), 66 deletions(-) Fusion-Task-Id: FN-8358 Fusion-Task-Lineage: c369ce87-eef0-4d06-bf23-bced9c4363f9 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
122 lines
5.6 KiB
TypeScript
122 lines
5.6 KiB
TypeScript
import { memo, useEffect, useState } from "react";
|
|
import { BarChart3, CircleAlert, Flag, Lightbulb, Map, Target } from "lucide-react";
|
|
import type { NativeStructurePreviewResult, NativeStructureRef } from "@fusion/core";
|
|
import { fetchNativeStructurePreview } from "../api";
|
|
import "./NativeStructurePreview.css";
|
|
|
|
export interface NativeStructurePreviewProps {
|
|
ref: NativeStructureRef;
|
|
payload?: NativeStructurePreviewResult;
|
|
/** Attach-time label used only when a persisted target is no longer available. */
|
|
capturedLabel?: string;
|
|
onOpen: (ref: NativeStructureRef, payload: NativeStructurePreviewResult) => void;
|
|
}
|
|
|
|
const icons = {
|
|
mission: Map,
|
|
milestone: Flag,
|
|
"research-finding": Lightbulb,
|
|
"eval-result": BarChart3,
|
|
goal: Target,
|
|
"roadmap-item": Map,
|
|
} satisfies Record<NativeStructureRef["kind"], typeof Map>;
|
|
|
|
function isSupportedKind(kind: string): kind is NativeStructureRef["kind"] {
|
|
return Object.prototype.hasOwnProperty.call(icons, kind);
|
|
}
|
|
|
|
function unavailableLabel(kind: string): string {
|
|
return kind.replace(/-/g, " ");
|
|
}
|
|
|
|
/**
|
|
* FNXC:NativeStructureEmbed 2026-07-16-12:00:
|
|
* Chat and mail use this one memoized renderer for compact structure cards. Navigation remains
|
|
* owned by each consumer through `onOpen` because dashboard views use callback/view state rather
|
|
* than URL routes; rendering an anchor here would create dead destinations.
|
|
*
|
|
* FNXC:NativeStructureEmbed 2026-07-19-12:45:
|
|
* Roadmap items join this shared renderer with the roadmap icon and callback-only open action.
|
|
*/
|
|
export const NativeStructurePreview = memo(function NativeStructurePreview({ ref, payload, capturedLabel, onOpen }: NativeStructurePreviewProps) {
|
|
const supportedKind = isSupportedKind(ref.kind);
|
|
const refKey = `${ref.kind}\u0000${ref.id}\u0000${ref.projectId ?? ""}`;
|
|
const [fetchedPayload, setFetchedPayload] = useState<{ refKey: string; result: NativeStructurePreviewResult } | undefined>();
|
|
const [error, setError] = useState(false);
|
|
// FNXC:NativeStructureEmbed 2026-07-16-12:00: A ref update must not briefly render a prior fetch result; consumers can replace cards while messages or drafts rehydrate.
|
|
const result = payload ?? (fetchedPayload?.refKey === refKey ? fetchedPayload.result : undefined);
|
|
const Icon = supportedKind ? icons[ref.kind] : CircleAlert;
|
|
|
|
useEffect(() => {
|
|
/*
|
|
FNXC:NativeStructureEmbed 2026-07-19-18:00:
|
|
Refs can arrive from persisted chat/mail content, so reject a malformed kind before fetching.
|
|
The six-kind route owns resolution; an invalid icon lookup must not trigger a plugin read or
|
|
turn into a render crash.
|
|
*/
|
|
if (payload || !supportedKind) return;
|
|
let active = true;
|
|
setFetchedPayload(undefined);
|
|
setError(false);
|
|
void fetchNativeStructurePreview(ref)
|
|
.then((nextPayload) => {
|
|
if (active) setFetchedPayload({ refKey, result: nextPayload });
|
|
})
|
|
.catch(() => {
|
|
if (active) setError(true);
|
|
});
|
|
return () => { active = false; };
|
|
}, [payload, ref.kind, ref.id, ref.projectId, refKey]);
|
|
|
|
if (!supportedKind) {
|
|
return (
|
|
<section className="native-structure-preview native-structure-preview--unavailable" data-testid="native-structure-preview-unavailable" data-reason="missing">
|
|
<Icon aria-hidden="true" />
|
|
<div className="native-structure-preview__content"><span className="native-structure-preview__label">Preview unavailable</span><p>This structure is unavailable.</p></div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// FNXC:NativeStructureEmbed 2026-07-16-14:05: A caller-supplied projection is authoritative after a transient fetch failure, so it must replace the error placeholder for the same ref.
|
|
if (error && !payload) {
|
|
return (
|
|
<section className="native-structure-preview native-structure-preview--unavailable" data-testid="native-structure-preview-error">
|
|
<Icon aria-hidden="true" />
|
|
<div className="native-structure-preview__content"><span className="native-structure-preview__label">Preview unavailable</span><p>Could not load this {unavailableLabel(ref.kind)}.</p></div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (!result) {
|
|
return (
|
|
<section className="native-structure-preview" data-testid="native-structure-preview-loading" aria-busy="true">
|
|
<Icon aria-hidden="true" />
|
|
<div className="native-structure-preview__content"><span className="native-structure-preview__label">Loading {unavailableLabel(ref.kind)}</span></div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (!result.available) {
|
|
return (
|
|
<section className="native-structure-preview native-structure-preview--unavailable" data-testid="native-structure-preview-unavailable" data-reason={result.reason}>
|
|
<Icon aria-hidden="true" />
|
|
<div className="native-structure-preview__content"><span className="native-structure-preview__label">{unavailableLabel(result.kind)}</span><strong className="native-structure-preview__title">{capturedLabel?.trim() || "Preview unavailable"}</strong><p>This structure is unavailable.</p></div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="native-structure-preview" data-testid="native-structure-preview" data-kind={result.kind}>
|
|
<Icon aria-hidden="true" />
|
|
<div className="native-structure-preview__content">
|
|
<span className="native-structure-preview__label">{result.kindLabel}</span>
|
|
<strong className="native-structure-preview__title">{result.title}</strong>
|
|
<p className="native-structure-preview__excerpt">{result.excerpt}</p>
|
|
</div>
|
|
<button className="btn native-structure-preview__open" type="button" onClick={() => onOpen(ref, result)} aria-label={`Open ${result.kindLabel}: ${result.title}`}>
|
|
Open
|
|
</button>
|
|
</section>
|
|
);
|
|
});
|