diff --git a/src/pages/orchestration/[slug].astro b/src/pages/orchestration/[slug].astro index 7cca1d466c..6404efe592 100644 --- a/src/pages/orchestration/[slug].astro +++ b/src/pages/orchestration/[slug].astro @@ -34,6 +34,7 @@ import { fetchTotalPluginsCount, replaceTotalPluginsPlaceholder, } from "~/utils/plugins/pluginCount" +import { resolveBlueprintRenders, fetchToolBlueprintCount } from "~/utils/blueprints/orchestrationBlueprints" import GorgiasLogo from "~/contents/customer-stories/gorgias-using-declarative-data-engineering-orchestration-with-kestra/logo.svg?raw" import GraviteeLogo from "~/contents/customer-stories/when-your-api-writes-its-own-docs-with-kestra/logo.svg?raw" @@ -49,7 +50,6 @@ import SophtLogo from "~/components/orchestrate/assets/sopht.svg?raw" import BsiLogo from "~/components/orchestrate/assets/bsi.svg?raw" import CenshareLogo from "~/components/orchestrate/assets/censhare.svg?raw" import WestBendLogo from "~/components/orchestrate/assets/west-bend.svg?raw" -import Fortune500Logo from "~/components/enterprise/assets/fortune500.svg?raw" const versusLogoModules = import.meta.glob( "../../components/versus/assets/*.svg", @@ -87,7 +87,6 @@ const LOGO_MAP: Record = { bsi: optimizeSvgIcon(BsiLogo, "bsi-orch-logo"), censhare: optimizeSvgIcon(CenshareLogo, "censhare-orch-logo"), "west-bend": optimizeSvgIcon(WestBendLogo, "west-bend-orch-logo"), - fortune500: optimizeSvgIcon(Fortune500Logo, "fortune500-orch-logo"), } export async function getStaticPaths() { @@ -166,38 +165,9 @@ const { resources, } = replaceTotalPluginsPlaceholder(entry.data, totalPlugins) -const blueprintItems = await Promise.all( - blueprints.items.map(async (item) => { - if (!item.blueprintId) return item - try { - const [bp, graph] = await Promise.all([ - $fetchApiCached<{ flow: string }>( - `/blueprints/${item.blueprintId}/versions/latest`, - ), - $fetchApiCached( - `/blueprints/${item.blueprintId}/versions/latest/graph`, - ), - ]) - return { ...item, render: { source: bp.flow, graph } } - } catch (e) { - console.warn( - `Could not fetch blueprint "${item.blueprintId}" for ${entry.id}`, - e, - ) - return item - } - }), -) +const blueprintItems = await resolveBlueprintRenders(blueprints.items) -let allBlueprintsCount = 0 -try { - const search = await $fetchApiCached<{ total: number }>( - `/blueprints/versions/latest?q=${encodeURIComponent(tool.name)}&size=1`, - ) - allBlueprintsCount = search?.total ?? 0 -} catch (e) { - console.warn(`Could not fetch blueprint count for ${tool.name}`, e) -} +const allBlueprintsCount = await fetchToolBlueprintCount(tool.name) let pluginIcon = "" try { diff --git a/src/utils/blueprints/orchestrationBlueprints.test.ts b/src/utils/blueprints/orchestrationBlueprints.test.ts new file mode 100644 index 0000000000..9e430c661d --- /dev/null +++ b/src/utils/blueprints/orchestrationBlueprints.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + resolveBlueprintRenders, + fetchToolBlueprintCount, + buildBlueprintFlowUrl, + buildBlueprintGraphUrl, + buildToolBlueprintCountUrl, +} from "./orchestrationBlueprints"; + +vi.mock("~/utils/fetch", () => ({ + $fetchApiCachedOptional: vi.fn(), +})); + +import { $fetchApiCachedOptional } from "~/utils/fetch"; + +describe("orchestrationBlueprints", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("buildBlueprintFlowUrl", () => { + it("builds correct flow URL", () => { + expect(buildBlueprintFlowUrl("my-blueprint")).toBe("/blueprints/my-blueprint/versions/latest"); + }); + }); + + describe("buildBlueprintGraphUrl", () => { + it("builds correct graph URL", () => { + expect(buildBlueprintGraphUrl("my-blueprint")).toBe("/blueprints/my-blueprint/versions/latest/graph"); + }); + }); + + describe("buildToolBlueprintCountUrl", () => { + it("builds correct count URL with encoding", () => { + expect(buildToolBlueprintCountUrl("my tool")).toBe("/blueprints/versions/latest?q=my%20tool&size=1"); + }); + + it("handles special characters", () => { + expect(buildToolBlueprintCountUrl("tool&name")).toBe("/blueprints/versions/latest?q=tool%26name&size=1"); + }); + }); + + describe("resolveBlueprintRenders", () => { + it("returns items with render when both flow and graph succeed", async () => { + vi.mocked($fetchApiCachedOptional) + .mockResolvedValueOnce({ flow: "flow source 1" }) + .mockResolvedValueOnce({ flow: "flow source 2" }) + .mockResolvedValueOnce({ nodes: [], edges: [] }) + .mockResolvedValueOnce({ nodes: [1], edges: [2] }); + + const items = [ + { blueprintId: "bp1", name: "Item 1" }, + { blueprintId: "bp2", name: "Item 2" }, + ]; + + const result = await resolveBlueprintRenders(items); + + expect(result).toEqual([ + { blueprintId: "bp1", name: "Item 1", render: { source: "flow source 1", graph: { nodes: [], edges: [] } } }, + { blueprintId: "bp2", name: "Item 2", render: { source: "flow source 2", graph: { nodes: [1], edges: [2] } } }, + ]); + }); + + it("returns item without render when flow fetch fails", async () => { + vi.mocked($fetchApiCachedOptional).mockResolvedValueOnce(null); + + const items = [{ blueprintId: "bp1", name: "Item 1" }]; + + const result = await resolveBlueprintRenders(items); + + expect(result).toEqual([{ blueprintId: "bp1", name: "Item 1" }]); + }); + + it("returns item with source only when graph fetch fails but flow succeeds", async () => { + vi.mocked($fetchApiCachedOptional) + .mockResolvedValueOnce({ flow: "flow source" }) + .mockResolvedValueOnce(null); + + const items = [{ blueprintId: "bp1", name: "Item 1" }]; + + const result = await resolveBlueprintRenders(items); + + expect(result).toEqual([ + { blueprintId: "bp1", name: "Item 1", render: { source: "flow source", graph: null } }, + ]); + }); + + it("passes through items without blueprintId unchanged", async () => { + const items = [{ name: "No blueprint" }, { blueprintId: "bp1", name: "Has blueprint" }]; + + vi.mocked($fetchApiCachedOptional) + .mockResolvedValueOnce({ flow: "flow" }) + .mockResolvedValueOnce({ nodes: [] }); + + const result = await resolveBlueprintRenders(items); + + expect(result[0]).toEqual({ name: "No blueprint" }); + expect(result[1]).toHaveProperty("render"); + }); + + it("handles empty array", async () => { + const result = await resolveBlueprintRenders([]); + expect(result).toEqual([]); + }); + + it("handles fetch returning null gracefully", async () => { + vi.mocked($fetchApiCachedOptional).mockResolvedValueOnce(null); + + const items = [{ blueprintId: "bp1", name: "Item 1" }]; + + const result = await resolveBlueprintRenders(items); + + expect(result).toEqual([{ blueprintId: "bp1", name: "Item 1" }]); + }); + }); + + describe("fetchToolBlueprintCount", () => { + it("returns total on success", async () => { + vi.mocked($fetchApiCachedOptional).mockResolvedValueOnce({ total: 42 }); + + const result = await fetchToolBlueprintCount("terraform"); + + expect(result).toBe(42); + expect($fetchApiCachedOptional).toHaveBeenCalledWith("/blueprints/versions/latest?q=terraform&size=1"); + }); + + it("returns 0 when total is missing", async () => { + vi.mocked($fetchApiCachedOptional).mockResolvedValueOnce({}); + + const result = await fetchToolBlueprintCount("terraform"); + + expect(result).toBe(0); + }); + + it("returns 0 on 404", async () => { + vi.mocked($fetchApiCachedOptional).mockResolvedValueOnce(null); + + const result = await fetchToolBlueprintCount("terraform"); + + expect(result).toBe(0); + }); + + it("returns 0 when fetch returns null", async () => { + vi.mocked($fetchApiCachedOptional).mockResolvedValueOnce(null); + + const result = await fetchToolBlueprintCount("terraform"); + + expect(result).toBe(0); + }); + + it("encodes tool name in URL", async () => { + vi.mocked($fetchApiCachedOptional).mockResolvedValueOnce({ total: 5 }); + + await fetchToolBlueprintCount("my tool"); + + expect($fetchApiCachedOptional).toHaveBeenCalledWith("/blueprints/versions/latest?q=my%20tool&size=1"); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/blueprints/orchestrationBlueprints.ts b/src/utils/blueprints/orchestrationBlueprints.ts new file mode 100644 index 0000000000..846b379002 --- /dev/null +++ b/src/utils/blueprints/orchestrationBlueprints.ts @@ -0,0 +1,56 @@ +import { $fetchApiCachedOptional } from "~/utils/fetch"; + +export interface BlueprintRender { + source: string; + graph: unknown | null; +} + +export async function resolveBlueprintRenders( + items: T[] +): Promise<(T & { render?: BlueprintRender })[]> { + return Promise.all( + items.map(async (item) => { + if (!item.blueprintId) { + return item; + } + + const flow = await $fetchApiCachedOptional<{ flow: string }>( + buildBlueprintFlowUrl(item.blueprintId) + ); + + if (!flow) { + return item; + } + + const graph = await $fetchApiCachedOptional( + buildBlueprintGraphUrl(item.blueprintId) + ); + + return { + ...item, + render: { + source: flow.flow, + graph: graph ?? null, + }, + }; + }) + ); +} + +export function buildBlueprintFlowUrl(blueprintId: string): string { + return `/blueprints/${blueprintId}/versions/latest`; +} + +export function buildBlueprintGraphUrl(blueprintId: string): string { + return `/blueprints/${blueprintId}/versions/latest/graph`; +} + +export function buildToolBlueprintCountUrl(toolName: string): string { + return `/blueprints/versions/latest?q=${encodeURIComponent(toolName)}&size=1`; +} + +export async function fetchToolBlueprintCount(toolName: string): Promise { + const url = buildToolBlueprintCountUrl(toolName); + const result = await $fetchApiCachedOptional<{ total: number }>(url); + return result?.total ?? 0; +}