Files
supabase/apps/studio/lib/integration-utils.ts
Pamela Chia 38b74af3f1 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/<framework>.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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-30 12:59:36 +08:00

186 lines
5.5 KiB
TypeScript

import { getCreateMigrationsTableSQL, getInsertMigrationSQL } from '@supabase/pg-meta'
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<T = any>(url: string, responseJson = true): Promise<SupaResponse<T>> {
const response = await fetchHandler(url)
if (!response.ok) {
return {
error: new ResponseError(response.statusText, response.status),
}
}
try {
return (responseJson ? await response.json() : await response.text()) as T
} catch (error: any) {
return {
error: new ResponseError(error.message, 500),
}
}
}
export type File = {
name: string
download_url: string
}
/**
* Returns the initial migration SQL from a GitHub repo.
* @param externalId An external GitHub URL for example: https://github.com/vercel/next.js/tree/canary/examples/with-supabase
*/
export async function getInitialMigrationSQLFromGitHubRepo(
externalId?: string
): Promise<string | null> {
if (!externalId) return null
const [, , , owner, repo, , branch, ...pathSegments] = externalId?.split('/') ?? []
const path = pathSegments.join('/')
const baseGitHubUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`
const supabaseFolderUrl = `${baseGitHubUrl}/supabase?ref=${branch}`
const supabaseMigrationsPath = `supabase/migrations` // TODO: read this from the `supabase/config.toml` file
const migrationsFolderUrl = `${baseGitHubUrl}/${supabaseMigrationsPath}${
branch ? `?ref=${branch}` : ``
}`
const [supabaseFilesResponse, migrationFilesResponse] = await Promise.all([
fetchGitHub<File[]>(supabaseFolderUrl),
fetchGitHub<File[]>(migrationsFolderUrl),
])
if (!isResponseOk(supabaseFilesResponse)) {
console.warn(`Failed to fetch supabase files from GitHub: ${supabaseFilesResponse.error}`)
return null
}
if (!isResponseOk(migrationFilesResponse)) {
console.warn(`Failed to fetch migration files from GitHub: ${migrationFilesResponse.error}`)
return null
}
const seedFileUrl = supabaseFilesResponse.find((file) => file.name === 'seed.sql')?.download_url
const sortedFiles = migrationFilesResponse.sort((a, b) => {
// sort by name ascending
if (a.name < b.name) return -1
if (a.name > b.name) return 1
return 0
})
const migrationFileDownloadUrlPromises = sortedFiles.map((file) =>
fetchGitHub<string>(file.download_url, false)
)
const [seedFileResponse, ...migrationFileResponses] = await Promise.all([
seedFileUrl ? fetchGitHub<string>(seedFileUrl, false) : Promise.resolve<string>(''),
...migrationFileDownloadUrlPromises,
])
const migrations = migrationFileResponses.filter((response) => isResponseOk(response)).join(';')
const seed = isResponseOk(seedFileResponse) ? seedFileResponse : ''
const createMigrationsTableSql = getCreateMigrationsTableSQL()
const migrationsTableSql = `
${createMigrationsTableSql}
${sortedFiles
.map((file, i) => {
const migration = migrationFileResponses[i]
if (!isResponseOk(migration)) return ''
const version = file.name.split('_')[0]
const statements = JSON.stringify(
migration
.split(';')
.map((statement) => statement.trim())
.filter(Boolean)
)
return getInsertMigrationSQL({ name: file.name, version, statements })
})
.join('')}
`
return `${migrations};${migrationsTableSql};${seed}`
}
type VercelIntegration = Extract<Integration, { integration: { name: 'Vercel' } }>
type GitHubIntegration = Extract<Integration, { integration: { name: 'GitHub' } }>
export function getIntegrationConfigurationUrl(integration: Integration) {
if (integration.integration.name === 'Vercel') {
return getVercelConfigurationUrl(integration as VercelIntegration)
}
if (integration.integration.name === 'GitHub') {
return getGitHubConfigurationUrl(integration as GitHubIntegration)
}
return ''
}
function getVercelConfigurationUrl(integration: VercelIntegration) {
return `https://vercel.com/dashboard/${
integration.metadata?.account.type === 'Team'
? `${integration.metadata?.account.team_slug}/`
: ''
}integrations/${integration.metadata?.configuration_id}`
}
function getGitHubConfigurationUrl(integration: GitHubIntegration) {
return `https://github.com/${
integration.metadata?.account.type === 'Organization'
? `organizations/${integration.metadata?.account.name}/`
: ''
}settings/installations/${integration.metadata?.installation_id}`
}
export const FRAMEWORK_ICON_SLUGS: ReadonlySet<string> = 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`
}