diff --git a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/OverviewTab.connection.test.tsx b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/OverviewTab.connection.test.tsx new file mode 100644 index 000000000..b836322c4 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/OverviewTab.connection.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/components/i18n-provider"; +import type { AppConnectionOutput } from "@/lib/api/apps"; +import { OverviewTab } from "./OverviewTab"; + +const h = vi.hoisted(() => ({ + connection: vi.fn(), + project: {} as Record, + services: [] as Array>, +})); +vi.mock("@/context/ProjectSettingsContext", () => ({ + useProjectSettings: () => ({ + projectData: h.project, + buildData: {}, + id: "project-one", + setActiveTab: vi.fn(), + servicesData: { isLoading: false, services: h.services }, + selectedDomain: "", + domain: "", + domainsData: { domains: [] }, + }), +})); +vi.mock("@/hooks/useProjectEndpoints", () => ({ + useProjectInfo: () => ({ isLoading: false }), + useAnalyticsData: () => ({ data: null, isLoadingSummary: false, isLoadingPeriods: false }), +})); +vi.mock("@/lib/api/apps", () => ({ appsApi: { getConnection: h.connection } })); +vi.mock("@/hooks/useLocalhostForward", () => ({ + useLocalhostForward: () => ({ canForward: false }), +})); +vi.mock("./ConnectedServicesCard", () => ({ ConnectedServicesCard: () => null })); +vi.mock("./UsedByCard", () => ({ UsedByCard: () => null })); +vi.mock("./UseInProjectModal", () => ({ + UseInProjectModal: ({ + sourceProjectId, + outputs, + }: { + sourceProjectId: string; + outputs: AppConnectionOutput[]; + }) => ( +
+ {sourceProjectId}: {outputs.map((o) => o.value).join(", ")} +
+ ), +})); + +let root: Root; +let container: HTMLDivElement; +const output: AppConnectionOutput = { + id: "internal", + label: "Internal address", + value: "http://api:3000", + secret: false, + service: null, + internal: true, +}; +async function render() { + await act(async () => + root.render( + + + , + ), + ); +} +function useButton() { + return [...container.querySelectorAll("button")].find((b) => + b.textContent?.includes("Use in a project"), + ); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + localStorage.clear(); + h.project = { + id: "project-one", + name: "API", + slug: "api", + isApp: false, + hasServer: true, + workloadType: "web", + productionMode: "server", + port: 3000, + deployTarget: "server", + serverId: "server-one", + }; + h.services = []; + h.connection.mockReset().mockResolvedValue({ data: { outputs: [output] } }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe("project connection discovery (#504)", () => { + it("lets a plain single app open the existing connection flow with its resolved outputs", async () => { + await render(); + expect(h.connection).toHaveBeenCalledWith("project-one"); + expect(useButton()).toBeDefined(); + await act(async () => useButton()!.click()); + expect(container.querySelector('[role="dialog"]')?.textContent).toContain( + "project-one: http://api:3000", + ); + }); + + it("keeps catalog connection details available", async () => { + h.project.isApp = true; + h.project.appTemplateId = "database"; + await render(); + expect(useButton()).toBeDefined(); + }); + + it("does not offer a static or worker project with no connection outputs", async () => { + h.project.workloadType = "static"; + h.project.hasServer = false; + h.connection.mockResolvedValue({ data: { outputs: [] } }); + await render(); + expect(h.connection).toHaveBeenCalledOnce(); + expect(useButton()).toBeUndefined(); + }); + + it("shows reachable service outputs even when the parent project is static", async () => { + h.project.workloadType = "static"; + h.project.hasServer = false; + h.project.productionMode = "static"; + h.services = [{ id: "redis", name: "redis", ports: ["6379"], enabled: true }]; + h.connection.mockResolvedValue({ + data: { outputs: [{ ...output, value: "redis://redis:6379" }] }, + }); + await render(); + expect(useButton()).toBeDefined(); + }); + + it("does not offer a synthesized Docker-network address for a cloud project", async () => { + h.project.deployTarget = "cloud"; + await render(); + expect(h.connection).not.toHaveBeenCalled(); + expect(useButton()).toBeUndefined(); + }); +}); diff --git a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/OverviewTab.tsx b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/OverviewTab.tsx index c0daef4e2..39195c82f 100644 --- a/apps/dashboard/src/app/(dashboard)/projects/[id]/components/OverviewTab.tsx +++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/components/OverviewTab.tsx @@ -179,12 +179,10 @@ export const OverviewTab = () => { return (
- {/* Only a catalog app's curated connection (URLs + generated keys) belongs on - the overview. A plain project's synthesized internal address is edited in - Settings → Advanced (single-app alias) and shown per service in the service - detail panel — surfacing it here too just clutters a plain project. Card - self-hides when the app declares no connection outputs. */} - {projectData.isApp && ( + {/* The API resolves reachable outputs, including services attached to a + static project. The card hides itself when none exist. Synthesized + internal addresses are only useful on self-hosted targets. */} + {projectData.id && (projectData.isApp || deployTarget !== "cloud") && (