From 38b74af3f13719afdceedbe250223626a9eb8c3a Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Tue, 29 Sep 2026 21:59:36 -0700 Subject: [PATCH] fix(studio): fall back for framework icons without an asset (#51065) I made the connected-project framework icons fall back when no shipped SVG exists for a framework. The three icon sites built `/img/icons/frameworks/.svg` straight from the integration's framework preset, which is an open-ended string. They only fell back when the value was empty, so any preset without an asset (`express`, `hono`, `fastapi`, `tanstack-start` and others) showed a broken image and logged a 404. **Changed:** - **Broken framework icons**: `getFrameworkIconUrl` returns the asset URL only for slugs in a set that mirrors `public/img/icons/frameworks/`. The integration connection row, the org project linker and the marketplace project picker now show their existing fallback icon for any other slug. A test keeps the set equal to the directory listing. - **Framework type**: I deleted the hand-kept `VercelFramework` union. It listed exactly the shipped icon slugs, while the API types the field as `string | null`, and that mismatch is what made the old empty-only check look safe. **Note:** I rejected an `onError` fallback because the browser still sends the 404 request. Adding logos for common presets is left for design. ## To test Tested on Vercel preview (staging): no real connection there uses these presets, so I rewrote the org integrations response in the browser to give one integration four connections. - [x] Open an org's Integrations page with connections whose framework has no shipped icon (`express`, `eve`, `tanstack-start-lovable`). Expect the fallback badge and no request under `/dashboard/img/icons/frameworks/` for those slugs. Observed: all three rows showed the badge and the network log had no request for their SVGs. - [x] Same page with a `nextjs` connection. Expect its framework logo. Observed: `nextjs.svg` loaded with a 200. - [x] Same page with the real, unmodified response (one connection with `framework: null`). Expect the badge, no frameworks requests, and no new console errors. Observed: as expected. ## Linear - fixes GROWTH-1309 ## Summary by CodeRabbit * **Bug Fixes** * Vercel integration and project views now display framework icons when available and fall back to the Vercel icon when no matching icon exists. * Framework metadata now supports values beyond a fixed list, while unsupported frameworks continue to use the fallback icon. * **Tests** * Added coverage for supported and unsupported framework icons, including base-path handling. --- .../VercelGithub/IntegrationPanels.test.tsx | 39 ++++++++++++++ .../VercelGithub/IntegrationPanels.tsx | 12 ++--- .../SidePanelVercelProjectLinker.tsx | 12 ++--- .../data/integrations/integrations.types.ts | 49 +----------------- apps/studio/lib/integration-utils.test.ts | 24 +++++++++ apps/studio/lib/integration-utils.ts | 51 +++++++++++++++++++ .../[slug]/marketplace/choose-project.tsx | 12 ++--- 7 files changed, 127 insertions(+), 72 deletions(-) create mode 100644 apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.test.tsx diff --git a/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.test.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.test.tsx new file mode 100644 index 00000000000..e81bc75df96 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' + +import { IntegrationConnection } from './IntegrationPanels' +import type { IntegrationProjectConnection } from '@/data/integrations/integrations.types' +import { render } from '@/tests/helpers' + +vi.mock('@/data/projects/project-detail-query', () => ({ + useProjectDetailQuery: () => ({ data: undefined, isPending: false }), +})) + +const connectionWithFramework = (framework: string) => + ({ + id: 'connection-1', + supabase_project_ref: 'project-ref', + foreign_project_id: 'vercel-project-1', + metadata: { id: 'vercel-project-1', name: 'my-app', framework }, + }) as IntegrationProjectConnection + +const frameworkIconSelector = 'img[src*="/img/icons/frameworks/"]' + +describe('IntegrationConnection', () => { + it('renders the fallback badge for a framework without a shipped icon', () => { + const { container } = render( + + ) + + expect(container.querySelector(frameworkIconSelector)).toBeNull() + }) + + it('renders the framework icon for a framework with a shipped icon', () => { + const { container } = render( + + ) + + expect(container.querySelector(frameworkIconSelector)?.getAttribute('src')).toMatch( + /\/img\/icons\/frameworks\/nextjs\.svg$/ + ) + }) +}) diff --git a/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx index 25c218a2134..1731a89d5d6 100644 --- a/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx @@ -13,7 +13,7 @@ import type { } from '@/data/integrations/integrations.types' import { useProjectDetailQuery } from '@/data/projects/project-detail-query' import { BASE_PATH } from '@/lib/constants' -import { getIntegrationConfigurationUrl } from '@/lib/integration-utils' +import { getFrameworkIconUrl, getIntegrationConfigurationUrl } from '@/lib/integration-utils' const ICON_STROKE_WIDTH = 2 const ICON_SIZE = 14 @@ -150,6 +150,7 @@ export const IntegrationConnection = forwardRef { const { data: project } = useProjectDetailQuery({ ref: connection.supabase_project_ref }) + const frameworkIconUrl = getFrameworkIconUrl(connection?.metadata?.framework) return (
  • - {!connection?.metadata?.framework ? ( + {!frameworkIconUrl ? (
    ) : ( - {`icon`} + {`icon`} )} {type === 'GitHub' ? ( { const getForeignProjectIcon = useCallback( (_project: ForeignProject) => { const project = vercelProjectsById[_project.id] + const frameworkIconUrl = getFrameworkIconUrl(project?.framework) - return !project?.framework ? ( + return !frameworkIconUrl ? ( vercelIcon ) : ( - {`icon`} + {`icon`} ) }, [vercelProjectsById] diff --git a/apps/studio/data/integrations/integrations.types.ts b/apps/studio/data/integrations/integrations.types.ts index 4e2d69fe60c..cc3344b1a46 100644 --- a/apps/studio/data/integrations/integrations.types.ts +++ b/apps/studio/data/integrations/integrations.types.ts @@ -1,50 +1,3 @@ -export type VercelFramework = - | ( - | 'blitzjs' - | 'nextjs' - | 'gatsby' - | 'remix' - | 'astro' - | 'hexo' - | 'eleventy' - | 'docusaurus-2' - | 'docusaurus' - | 'preact' - | 'solidstart' - | 'dojo' - | 'ember' - | 'vue' - | 'scully' - | 'ionic-angular' - | 'angular' - | 'polymer' - | 'svelte' - | 'sveltekit' - | 'sveltekit-1' - | 'ionic-react' - | 'create-react-app' - | 'gridsome' - | 'umijs' - | 'sapper' - | 'saber' - | 'stencil' - | 'nuxtjs' - | 'redwoodjs' - | 'hugo' - | 'jekyll' - | 'brunch' - | 'middleman' - | 'zola' - | 'hydrogen' - | 'vite' - | 'vitepress' - | 'vuepress' - | 'parcel' - | 'sanity' - | 'storybook' - ) - | null - export type VercelGitLink = | { /** @@ -142,7 +95,7 @@ export type Imetadata = { } link?: VercelGitLink name: string - framework: VercelFramework + framework: string | null } export type IntegrationProjectConnection = { diff --git a/apps/studio/lib/integration-utils.test.ts b/apps/studio/lib/integration-utils.test.ts index 070ab28e471..68767eba83a 100644 --- a/apps/studio/lib/integration-utils.test.ts +++ b/apps/studio/lib/integration-utils.test.ts @@ -1,6 +1,10 @@ +import { readdirSync } from 'node:fs' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { + FRAMEWORK_ICON_SLUGS, + getFrameworkIconUrl, getInitialMigrationSQLFromGitHubRepo, getIntegrationConfigurationUrl, } from './integration-utils' @@ -10,6 +14,7 @@ import type { VercelAccount, VercelTeamAccount, } from '@/data/integrations/integrations.types' +import { BASE_PATH } from '@/lib/constants' vi.mock('@/data/fetchers', () => ({ fetchHandler: vi.fn(), @@ -255,4 +260,23 @@ describe('integration-utils', () => { expect(result).toBe('') }) }) + + describe('getFrameworkIconUrl', () => { + it('returns undefined for missing or unshipped frameworks and a URL for shipped ones', () => { + expect(getFrameworkIconUrl('not-a-framework')).toBeUndefined() + expect(getFrameworkIconUrl(null)).toBeUndefined() + expect(getFrameworkIconUrl(undefined)).toBeUndefined() + expect(getFrameworkIconUrl('')).toBeUndefined() + expect(getFrameworkIconUrl('nextjs')).toBe(`${BASE_PATH}/img/icons/frameworks/nextjs.svg`) + }) + + it('stays in sync with public/img/icons/frameworks/*.svg', () => { + const slugsOnDisk = readdirSync(join(__dirname, '../public/img/icons/frameworks')) + .filter((file) => file.endsWith('.svg')) + .map((file) => file.replace(/\.svg$/, '')) + .sort() + + expect([...FRAMEWORK_ICON_SLUGS].sort()).toEqual(slugsOnDisk) + }) + }) }) diff --git a/apps/studio/lib/integration-utils.ts b/apps/studio/lib/integration-utils.ts index 3a17c746580..80e42858fee 100644 --- a/apps/studio/lib/integration-utils.ts +++ b/apps/studio/lib/integration-utils.ts @@ -3,6 +3,7 @@ import { getCreateMigrationsTableSQL, getInsertMigrationSQL } from '@supabase/pg import { isResponseOk } from './api/apiWrapper' import { fetchHandler } from '@/data/fetchers' import type { Integration } from '@/data/integrations/integrations.types' +import { BASE_PATH } from '@/lib/constants' import { ResponseError, type SupaResponse } from '@/types' async function fetchGitHub(url: string, responseJson = true): Promise> { @@ -132,3 +133,53 @@ function getGitHubConfigurationUrl(integration: GitHubIntegration) { : '' }settings/installations/${integration.metadata?.installation_id}` } + +export const FRAMEWORK_ICON_SLUGS: ReadonlySet = new Set([ + 'angular', + 'astro', + 'blitzjs', + 'brunch', + 'create-react-app', + 'docusaurus', + 'docusaurus-2', + 'dojo', + 'eleventy', + 'ember', + 'gatsby', + 'gridsome', + 'hexo', + 'hugo', + 'hydrogen', + 'ionic-angular', + 'ionic-react', + 'jekyll', + 'middleman', + 'nextjs', + 'nuxtjs', + 'parcel', + 'polymer', + 'preact', + 'redwoodjs', + 'remix', + 'saber', + 'sanity', + 'sapper', + 'scully', + 'solidstart', + 'stencil', + 'storybook', + 'svelte', + 'sveltekit', + 'sveltekit-1', + 'umijs', + 'vite', + 'vitepress', + 'vue', + 'vuepress', + 'zola', +]) + +export function getFrameworkIconUrl(framework?: string | null): string | undefined { + if (!framework || !FRAMEWORK_ICON_SLUGS.has(framework)) return undefined + return `${BASE_PATH}/img/icons/frameworks/${framework}.svg` +} diff --git a/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx index ccd04468424..08c9592f422 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx @@ -25,8 +25,8 @@ import { useIntegrationVercelConnectionsCreateMutation } from '@/data/integratio import { useVercelProjectsQuery } from '@/data/integrations/integrations-vercel-projects-query' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { withAuth } from '@/hooks/misc/withAuth' -import { BASE_PATH } from '@/lib/constants' import { getErrorMessage } from '@/lib/get-error-message' +import { getFrameworkIconUrl } from '@/lib/integration-utils' import { hasVercelDeployButtonSignals } from '@/lib/integrations/vercel-install.utils' import { buildStudioPageTitle } from '@/lib/page-title' import { useProfileNameAndPicture } from '@/lib/profile' @@ -83,16 +83,12 @@ const VercelChooseProjectPage: NextPageWithLayout = () => { const getForeignProjectIcon = useCallback( (_project: ForeignProject) => { const project = vercelProjectsById[_project.id] + const frameworkIconUrl = getFrameworkIconUrl(project?.framework) - return !project?.framework ? ( + return !frameworkIconUrl ? ( vercelIcon ) : ( - Framework icon + Framework icon ) }, [vercelProjectsById]