From beb5d10b61dbd6d3fef218061ff8d124eebd5c7d Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Fri, 23 Jun 2023 16:07:48 +0800 Subject: [PATCH] deploy button flow started --- packages/common/hooks/useParams.ts | 33 +- .../Database/Backups/PITR/PITRSelection.tsx | 1 - .../Integrations/IntegrationPanels.tsx | 5 +- .../interfaces/Integrations/ProjectLinker.tsx | 56 +-- .../IntegrationSettings/Integration.tsx | 2 +- .../IntegrationSettings/IntegrationGitHub.tsx | 2 +- .../IntegrationSettings.tsx | 4 +- .../SidePanelGitHubRepoSelection.tsx | 2 +- .../SidePanelVercelProjectLinker.tsx | 8 +- studio/components/ui/OrganizationPicker.tsx | 73 +++- ...integration-connections-create-mutation.ts | 11 +- .../integrations-query-org-only.ts | 35 ++ .../data/integrations/integrations-query.ts | 196 +--------- .../integrations-vercel-projects-query.ts | 31 +- .../data/integrations/integrations.types.ts | 178 ++++++++++ studio/data/integrations/keys.ts | 4 +- .../vercel-integration-create-mutation.ts | 10 +- studio/pages/integrations/vercel.tsx | 33 +- .../vercel/[slug]/new-project.tsx | 334 ++++++++++++++++++ studio/pages/integrations/vercel/install.tsx | 248 +++++++++++++ .../state/vercel-integration-installation.ts | 15 + 21 files changed, 1003 insertions(+), 278 deletions(-) create mode 100644 studio/data/integrations/integrations-query-org-only.ts create mode 100644 studio/data/integrations/integrations.types.ts create mode 100644 studio/pages/integrations/vercel/[slug]/new-project.tsx create mode 100644 studio/pages/integrations/vercel/install.tsx create mode 100644 studio/state/vercel-integration-installation.ts diff --git a/packages/common/hooks/useParams.ts b/packages/common/hooks/useParams.ts index cac30143bd9..879d3f4e50e 100644 --- a/packages/common/hooks/useParams.ts +++ b/packages/common/hooks/useParams.ts @@ -1,15 +1,46 @@ import { useRouter } from 'next/router' import { useMemo } from 'react' +/** + * Helper to convert kebab case to camel case + */ +function convertToCamelCase(key: string): string { + if (!key.includes('-')) { + return key + } + + const parts = key.split('-') + const capitalizedParts = parts.map((part, index) => { + if (index === 0) { + return part + } + return part.charAt(0).toUpperCase() + part.slice(1) + }) + return capitalizedParts.join('') +} + export function useParams(): { [k: string]: string | undefined } { const { query } = useRouter() + const modifiedQuery = { + ...query, + } + + // Convert kebab case keys to camel case + Object.keys(modifiedQuery).forEach((key) => { + const modifiedKey = convertToCamelCase(key) + if (modifiedKey !== key) { + modifiedQuery[modifiedKey] = modifiedQuery[key] + delete modifiedQuery[key] + } + }) + return useMemo( () => Object.fromEntries( - Object.entries(query).map(([key, value]) => { + Object.entries(modifiedQuery).map(([key, value]) => { if (Array.isArray(value)) { return [key, value[0]] } else { diff --git a/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx b/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx index eb1ecceb2f0..1517f52ff3d 100644 --- a/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx +++ b/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx @@ -147,7 +147,6 @@ const PITRSelection = ({}) => { - {/*
-

- The following enviroment variables will be added: -

-

- {ENV_VAR_RAW_KEYS.map((x, idx) => { - return ( - - {x},{' '} - - ) - })} -

-
*/} { const { ui } = useStore() - const { data } = useIntegrationsQuery({ orgSlug: ui.selectedOrganization?.slug }) + const { data } = useOrgIntegrationsQuery({ orgSlug: ui.selectedOrganization?.slug }) const org = ui.selectedOrganization diff --git a/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx index 0b4494a00e1..9331baf205d 100644 --- a/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx +++ b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx @@ -1,6 +1,6 @@ import { useParams } from 'common' import { Markdown } from 'components/interfaces/Markdown' -import { useIntegrationsQuery } from 'data/integrations/integrations-query' +import { useIntegrationsQuery } from 'data/integrations/integrations-query-org-only' import { useStore } from 'hooks' import { observer } from 'mobx-react-lite' import { useGithubConnectionConfigPanelSnapshot } from 'state/github-connection-config-panel' diff --git a/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx index 58ee9775500..9e86712d86c 100644 --- a/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx +++ b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx @@ -1,6 +1,7 @@ +import { useParams } from 'common' import ProjectLinker from 'components/interfaces/Integrations/ProjectLinker' import { Markdown } from 'components/interfaces/Markdown' -import { useIntegrationsQuery } from 'data/integrations/integrations-query' +import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' import { useVercelProjectConnectionsQuery } from 'data/integrations/integrations-vercel-installed-connections-query' import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' import { useProjectsQuery } from 'data/projects/projects-query' @@ -13,10 +14,11 @@ import { SidePanel } from 'ui' const SidePanelVercelProjectLinker = () => { const { ui } = useStore() + const selectedOrg = ui.selectedOrganization - const { data: integrationData } = useIntegrationsQuery({ - orgId: ui.selectedOrganization?.id, + const { data: integrationData } = useOrgIntegrationsQuery({ + orgSlug: ui.selectedOrganization?.slug, }) const vercelIntegrations = integrationData?.filter( (integration) => integration.integration.name === 'Vercel' diff --git a/studio/components/ui/OrganizationPicker.tsx b/studio/components/ui/OrganizationPicker.tsx index 808a5166f68..4b89efa3b3e 100644 --- a/studio/components/ui/OrganizationPicker.tsx +++ b/studio/components/ui/OrganizationPicker.tsx @@ -1,19 +1,41 @@ -import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useIntegrationsQuery } from 'data/integrations/integrations-query' +import { IntegrationName } from 'data/integrations/integrations.types' +import { + OrganizationsResponse, + useOrganizationsQuery, +} from 'data/organizations/organizations-query' +import { useStore } from 'hooks' import { useState } from 'react' import { Organization } from 'types' -import { Listbox } from 'ui' +import { Badge, IconHexagon, Listbox } from 'ui' export interface OrganizationPickerProps { label?: string onSelectedOrgChange?: (org: Organization) => void + integrationName: IntegrationName } const OrganizationPicker = ({ label = 'Choose an organization', onSelectedOrgChange, + integrationName, }: OrganizationPickerProps) => { + const { ui } = useStore() const [selectedOrg, setSelectedOrg] = useState(null) + /** + * array of integrations installed on all + */ + const { data: integrationData, isLoading: integrationDataLoading } = useIntegrationsQuery({}) + console.log('integrationData', integrationData) + + /** + * filter integrations to match integrationName + */ + const vercelIntegrationsInstalled = integrationData?.filter( + (integration) => integration.integration.name === 'Vercel' + ) + const { data, isLoading } = useOrganizationsQuery({ onSuccess(organizations) { const firstOrg = organizations?.[0] @@ -33,7 +55,7 @@ const OrganizationPicker = ({ } } - if (isLoading) { + if (isLoading || integrationDataLoading) { return ( @@ -43,12 +65,51 @@ const OrganizationPicker = ({ ) } + /** + * Organization type with `installationInstalled` added + */ + interface OrganizationsResponseWithInstalledData extends Organization { + installationInstalled?: boolean + } + + /** + * A flat array of org slugs that have integration installed + */ + const flatInstalledConnectionsIds = + integrationData && integrationData.length > 0 + ? integrationData?.map((x) => x.organization.slug) + : [] + + /** + * Organizations with extra `installationInstalled` attribute + * Used to show label/badge and allow/disallow installing + */ + const organizationsWithInstalledData: OrganizationsResponseWithInstalledData[] = data + ? data.map((org) => { + return { + ...org, + installationInstalled: !flatInstalledConnectionsIds.includes(org.slug) ? true : false, + } + }) + : [] + return ( - {data?.map((org) => { - return ( - + {organizationsWithInstalledData?.map((org) => { + const label = ( +
{org.name} + {org.installationInstalled && Integration Installed} +
+ ) + return ( + } + > + {label} ) })} diff --git a/studio/data/integrations/integration-connections-create-mutation.ts b/studio/data/integrations/integration-connections-create-mutation.ts index b6605601fd6..491e606ea9b 100644 --- a/studio/data/integrations/integration-connections-create-mutation.ts +++ b/studio/data/integrations/integration-connections-create-mutation.ts @@ -8,10 +8,9 @@ export type IntegrationConnectionsCreateVariables = { connection: { foreign_project_id: string supabase_project_ref: string - integration_id: number metadata: any } - orgId: number | undefined + orgSlug: string | undefined } export async function createIntegrationConnections({ @@ -49,7 +48,13 @@ export const useIntegrationConnectionsCreateMutation = ({ IntegrationConnectionsCreateVariables >((vars) => createIntegrationConnections(vars), { async onSuccess(data, variables, context) { - await Promise.all([queryClient.invalidateQueries(integrationKeys.list(variables.orgId))]) + await Promise.all([ + queryClient.invalidateQueries(integrationKeys.integrationsList()), + queryClient.invalidateQueries(integrationKeys.integrationsListWithOrg(variables.orgSlug)), + queryClient.invalidateQueries( + integrationKeys.vercelProjectList(variables.organizationIntegrationId) + ), + ]) await onSuccess?.(data, variables, context) }, ...options, diff --git a/studio/data/integrations/integrations-query-org-only.ts b/studio/data/integrations/integrations-query-org-only.ts new file mode 100644 index 00000000000..664f4aced96 --- /dev/null +++ b/studio/data/integrations/integrations-query-org-only.ts @@ -0,0 +1,35 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { integrationKeys } from './keys' +import { IntegrationsVariables, Integration } from './integrations.types' + +export type IntegrationsResponse = Integration[] + +export async function getIntegrations({ orgSlug }: IntegrationsVariables, signal?: AbortSignal) { + if (!orgSlug) { + throw new Error('orgSlug is required') + } + const response = await get(`${API_URL}/integrations/${orgSlug}?expand=true`, { + signal, + }) + if (response.error) { + throw response.error + } + + return response as IntegrationsResponse +} + +export type IntegrationsData = Awaited> +export type ProjectIntegrationConnectionsData = Awaited> +export type IntegrationsError = unknown + +export const useOrgIntegrationsQuery = ( + { orgSlug }: IntegrationsVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + integrationKeys.integrationsListWithOrg(orgSlug), + ({ signal }) => getIntegrations({ orgSlug }, signal), + { enabled: enabled && typeof orgSlug !== 'undefined', ...options } + ) diff --git a/studio/data/integrations/integrations-query.ts b/studio/data/integrations/integrations-query.ts index d0c01593a57..c43435ff32b 100644 --- a/studio/data/integrations/integrations-query.ts +++ b/studio/data/integrations/integrations-query.ts @@ -2,190 +2,12 @@ import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { integrationKeys } from './keys' - -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 = - | { - /** - * GitHub link - */ - org?: string - repo?: string - repoId?: number - type?: 'github' - createdAt?: number - deployHooks: { - createdAt?: number - id: string - name: string - ref: string - url: string - }[] - gitCredentialId?: string - updatedAt?: number - sourceless?: boolean - productionBranch?: string - } - | { - /** - * GitLab link - */ - projectId?: string - projectName?: string - projectNameWithNamespace?: string - projectNamespace?: string - projectUrl?: string - type?: 'gitlab' - createdAt?: number - deployHooks: { - createdAt?: number - id: string - name: string - ref: string - url: string - }[] - gitCredentialId?: string - updatedAt?: number - sourceless?: boolean - productionBranch?: string - } - | { - /** - * Bitbucket link - */ - name?: string - slug?: string - owner?: string - type?: 'bitbucket' - uuid?: string - workspaceUuid?: string - createdAt?: number - deployHooks: { - createdAt?: number - id: string - name: string - ref: string - url: string - }[] - gitCredentialId?: string - updatedAt?: number - sourceless?: boolean - productionBranch?: string - } - -type Imetadata = { - metadata: { - id: string - supabaseConfig: { - projectEnvVars: { - write: boolean - } - } - link?: VercelGitLink - name: string - framework: VercelFramework - } -} -export type IntegrationProjectConnection = { - id: string - created_at: string - updated_at: string - added_by: addedBy - supabase_project_id: string - foreign_project_id: string - organization_integration_id: string - metadata: Imetadata -} - -export type IntegrationsVariables = { - orgSlug?: string -} - -export type IntegrationProjectConnectionPayload = { - foreignProjectId: string - supabaseProjectId: string - integrationId: string - metadata: Imetadata -} - -type userDetails = { - username: string - id: string - primary_email: string -} -type addedBy = userDetails -type updatedBy = userDetails - -export type Integration = { - id: string - integration: { - id: string - name: 'Vercel' | 'Netlify' | 'GitHub' - } - added_by: addedBy - created_at: string - updated_at: string - connections: IntegrationProjectConnection[] | [] - metadata?: { - vercelTeam?: string - gitHubConnectionOwner?: string - } -} +import { IntegrationsVariables, Integration } from './integrations.types' export type IntegrationsResponse = Integration[] -export async function getIntegrations({ orgSlug }: IntegrationsVariables, signal?: AbortSignal) { - if (!orgSlug) { - throw new Error('orgSlug is required') - } - - const response = await get(`${API_URL}/integrations/${orgSlug}`, { +export async function getIntegrations({}: {}, signal?: AbortSignal) { + const response = await get(`${API_URL}/integrations`, { signal, }) if (response.error) { @@ -196,17 +18,15 @@ export async function getIntegrations({ orgSlug }: IntegrationsVariables, signal } export type IntegrationsData = Awaited> -export type ProjectIntegrationConnectionsData = Awaited< - ReturnType -> +export type ProjectIntegrationConnectionsData = Awaited> export type IntegrationsError = unknown export const useIntegrationsQuery = ( - { orgSlug }: IntegrationsVariables, + {}: {}, { enabled = true, ...options }: UseQueryOptions = {} ) => useQuery( - integrationKeys.list(orgSlug), - ({ signal }) => getIntegrations({ orgSlug }, signal), - { enabled: enabled && typeof orgSlug !== 'undefined', ...options } + integrationKeys.integrationsList(), + ({ signal }) => getIntegrations({}, signal), + { enabled: enabled, ...options } ) diff --git a/studio/data/integrations/integrations-vercel-projects-query.ts b/studio/data/integrations/integrations-vercel-projects-query.ts index 1e63ee1f6a3..5c6556c283f 100644 --- a/studio/data/integrations/integrations-vercel-projects-query.ts +++ b/studio/data/integrations/integrations-vercel-projects-query.ts @@ -3,12 +3,10 @@ import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { useCallback } from 'react' import { integrationKeys } from './keys' -import { VercelFramework } from './integrations-query' +import { VercelFramework } from './integrations.types' export type VercelProjectsVariables = { - orgId?: number - orgSlug?: string - organization_integration_id?: string + organization_integration_id: string | undefined } export type VercelProjectsResponse = { @@ -24,11 +22,11 @@ export type VercelProjectsResponse = { } export async function getVercelProjects( - { orgId, organization_integration_id }: VercelProjectsVariables, + { organization_integration_id }: VercelProjectsVariables, signal?: AbortSignal ) { if (!organization_integration_id) { - throw new Error('orgId is required') + throw new Error('organization_integration_id is required') } const response = await get( @@ -48,17 +46,17 @@ export type VercelProjectsData = Awaited> export type VercelProjectsError = unknown export const useVercelProjectsQuery = ( - { orgSlug, orgId, organization_integration_id }: VercelProjectsVariables, + { organization_integration_id }: VercelProjectsVariables, { enabled = true, ...options }: UseQueryOptions = {} ) => useQuery( - integrationKeys.vercelProjectList(orgSlug), - ({ signal }) => getVercelProjects({ orgSlug, orgId, organization_integration_id }, signal), + integrationKeys.vercelProjectList(organization_integration_id), + ({ signal }) => getVercelProjects({ organization_integration_id }, signal), { - enabled: enabled && typeof orgSlug !== 'undefined' && typeof orgId !== 'undefined', + enabled: enabled && typeof organization_integration_id !== 'undefined', ...options, } ) @@ -76,17 +74,18 @@ export const useVercelProjectsQuery = ( * ) */ export const useVercelProjectsPrefetch = ({ - orgSlug, - orgId, + // orgSlug, + // orgId, organization_integration_id, }: VercelProjectsVariables) => { const client = useQueryClient() return useCallback(() => { - if (orgSlug) { - client.prefetchQuery(integrationKeys.vercelProjectList(orgSlug), ({ signal }) => - getVercelProjects({ orgSlug, orgId, organization_integration_id }, signal) + if (organization_integration_id) { + client.prefetchQuery( + integrationKeys.vercelProjectList(organization_integration_id), + ({ signal }) => getVercelProjects({ organization_integration_id }, signal) ) } - }, [orgSlug]) + }, [organization_integration_id]) } diff --git a/studio/data/integrations/integrations.types.ts b/studio/data/integrations/integrations.types.ts new file mode 100644 index 00000000000..c109395510d --- /dev/null +++ b/studio/data/integrations/integrations.types.ts @@ -0,0 +1,178 @@ +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 = + | { + /** + * GitHub link + */ + org?: string + repo?: string + repoId?: number + type?: 'github' + createdAt?: number + deployHooks: { + createdAt?: number + id: string + name: string + ref: string + url: string + }[] + gitCredentialId?: string + updatedAt?: number + sourceless?: boolean + productionBranch?: string + } + | { + /** + * GitLab link + */ + projectId?: string + projectName?: string + projectNameWithNamespace?: string + projectNamespace?: string + projectUrl?: string + type?: 'gitlab' + createdAt?: number + deployHooks: { + createdAt?: number + id: string + name: string + ref: string + url: string + }[] + gitCredentialId?: string + updatedAt?: number + sourceless?: boolean + productionBranch?: string + } + | { + /** + * Bitbucket link + */ + name?: string + slug?: string + owner?: string + type?: 'bitbucket' + uuid?: string + workspaceUuid?: string + createdAt?: number + deployHooks: { + createdAt?: number + id: string + name: string + ref: string + url: string + }[] + gitCredentialId?: string + updatedAt?: number + sourceless?: boolean + productionBranch?: string + } + +export type Imetadata = { + metadata: { + id: string + supabaseConfig: { + projectEnvVars: { + write: boolean + } + } + link?: VercelGitLink + name: string + framework: VercelFramework + } +} +export type IntegrationProjectConnection = { + id: string + created_at: string + updated_at: string + added_by: addedBy + supabase_project_id: string + foreign_project_id: string + organization_integration_id: string + metadata: Imetadata +} + +export type IntegrationsVariables = { + orgSlug?: string +} + +export type IntegrationProjectConnectionPayload = { + foreignProjectId: string + supabaseProjectId: string + integrationId: string + metadata: Imetadata +} + +export type userDetails = { + username: string + id: string + primary_email: string +} +type addedBy = userDetails +type updatedBy = userDetails + +export type IntegrationName = 'Vercel' | 'Netlify' | 'GitHub' +export type Integration = { + id: string + integration: { + id: string + name: 'Vercel' | 'Netlify' | 'GitHub' + } + added_by: addedBy + created_at: string + updated_at: string + connections: IntegrationProjectConnection[] | [] + organization: { + slug: string + } + metadata?: { + vercelTeam?: string + gitHubConnectionOwner?: string + } +} diff --git a/studio/data/integrations/keys.ts b/studio/data/integrations/keys.ts index 7164ad8068f..c099d9d7783 100644 --- a/studio/data/integrations/keys.ts +++ b/studio/data/integrations/keys.ts @@ -1,5 +1,7 @@ export const integrationKeys = { - list: (orgSlug: string | undefined) => ['organizations', orgSlug, 'integrations'] as const, + integrationsListWithOrg: (orgSlug: string | undefined) => + ['organizations', orgSlug, 'integrations'] as const, + integrationsList: () => ['organizations', 'integrations'] as const, vercelProjectList: (organization_integration_id: string | undefined) => ['organizations', organization_integration_id, 'vercel-projects'] as const, vercelConnectionsList: (organization_integration_id: string | undefined) => diff --git a/studio/data/integrations/vercel-integration-create-mutation.ts b/studio/data/integrations/vercel-integration-create-mutation.ts index 9165c33df2d..685ee4a9545 100644 --- a/studio/data/integrations/vercel-integration-create-mutation.ts +++ b/studio/data/integrations/vercel-integration-create-mutation.ts @@ -1,6 +1,7 @@ -import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' +import { integrationKeys } from './keys' export type VercelIntegrationCreateVariables = { code: string @@ -46,11 +47,18 @@ export const useVercelIntegrationCreateMutation = ({ UseMutationOptions, 'mutationFn' > = {}) => { + const queryClient = useQueryClient() console.log('mutating') return useMutation( (vars) => createVercelIntegration(vars), { async onSuccess(data, variables, context) { + console.log('data', data, variables) + await Promise.all([ + queryClient.invalidateQueries(integrationKeys.integrationsList()), + queryClient.invalidateQueries(integrationKeys.integrationsListWithOrg(variables.orgSlug)), + queryClient.invalidateQueries(integrationKeys.vercelProjectList(data.id)), + ]) await onSuccess?.(data, variables, context) }, ...options, diff --git a/studio/pages/integrations/vercel.tsx b/studio/pages/integrations/vercel.tsx index da33fc04467..0d6bac721bb 100644 --- a/studio/pages/integrations/vercel.tsx +++ b/studio/pages/integrations/vercel.tsx @@ -1,25 +1,36 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useParams } from 'common' import ProjectLinker from 'components/interfaces/Integrations/ProjectLinker' +import { Markdown } from 'components/interfaces/Markdown' +import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' import OrganizationPicker from 'components/ui/OrganizationPicker' -import { useVercelIntegrationCreateMutation } from 'data/integrations/vercel-integration-create-mutation' +import { useIntegrationsQuery } from 'data/integrations/integrations-query' import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' +import { useVercelIntegrationCreateMutation } from 'data/integrations/vercel-integration-create-mutation' import { useProjectsQuery } from 'data/projects/projects-query' import { useStore } from 'hooks' import { EMPTY_ARR } from 'lib/void' import { NextPageWithLayout, Organization } from 'types' -import { Alert, Button, IconBook, IconLifeBuoy, Input, LoadingLine, cn } from 'ui' -import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' -import { ScaffoldContainer, ScaffoldDivider, ScaffoldSection } from 'components/layouts/Scaffold' -import { Markdown } from 'components/interfaces/Markdown' -import { BASE_PATH } from 'lib/constants' -import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Integrations-Vercel.constants' +import { Alert, Button, IconBook, IconLifeBuoy, LoadingLine } from 'ui' +import { useRouter } from 'next/router' const VercelIntegration: NextPageWithLayout = () => { + const router = useRouter() + + useEffect(() => { + router.push({ pathname: '/integrations/vercel/install', query: router.query }) + }, [router]) + + /** + * + * remove everything here + */ const { ui } = useStore() - const { code, configurationId, next, teamId, source } = useParams() + const { code, configurationId, next, teamId, source, externalId } = useParams() + const params = useParams() const [selectedOrg, setSelectedOrg] = useState(null) const [organizationIntegrationId, setOrganizationIntegrationId] = useState(null) @@ -29,6 +40,9 @@ const VercelIntegration: NextPageWithLayout = () => { }, }) + console.log('params', params) + console.log('externalId', externalId) + function onInstall() { const orgSlug = selectedOrg?.slug if (!orgSlug) { @@ -65,6 +79,7 @@ const VercelIntegration: NextPageWithLayout = () => { const { data: supabaseProjectsData } = useProjectsQuery({ enabled: organizationIntegrationId !== null, }) + const supabaseProjects = useMemo( () => supabaseProjectsData diff --git a/studio/pages/integrations/vercel/[slug]/new-project.tsx b/studio/pages/integrations/vercel/[slug]/new-project.tsx new file mode 100644 index 00000000000..1e7d6e3e016 --- /dev/null +++ b/studio/pages/integrations/vercel/[slug]/new-project.tsx @@ -0,0 +1,334 @@ +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' +import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' +import { useIntegrationConnectionsCreateMutation } from 'data/integrations/integration-connections-create-mutation' +import { useIntegrationsQuery } from 'data/integrations/integrations-query' +import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' +import { Integration } from 'data/integrations/integrations.types' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import generator from 'generate-password' +import { useStore } from 'hooks' +import { post } from 'lib/common/fetch' +import { + API_URL, + AWS_REGIONS, + DEFAULT_MINIMUM_PASSWORD_STRENGTH, + PRICING_TIER_PRODUCT_IDS, + PROVIDERS, +} from 'lib/constants' +import { passwordStrength } from 'lib/helpers' +import { debounce } from 'lodash' +import { useRouter } from 'next/router' +import { ChangeEvent, useRef, useState } from 'react' +import { NextPageWithLayout } from 'types' +import { Alert, Button, IconBook, IconLifeBuoy, Input, Listbox, LoadingLine } from 'ui' + +const VercelIntegration: NextPageWithLayout = () => { + const [loading, setLoading] = useState(false) + + return ( + <> +
+ + + <> + +

New project

+ <> + + setLoading(e)} /> + +
+ + + + + + + + +
+ +
+ Docs +
+
+ Support +
+
+ + ) +} + +VercelIntegration.getLayout = (page) => {page} + +const CreateProject = ({ setLoading }: { setLoading: (e: boolean) => void }) => { + const router = useRouter() + const { ui } = useStore() + const [projectName, setProjectName] = useState('') + const [dbPass, setDbPass] = useState('') + const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('') + const [passwordStrengthScore, setPasswordStrengthScore] = useState(-1) + const [dbRegion, setDbRegion] = useState(PROVIDERS.AWS.default_region) + // const [loading, setLoading] = useState(false) + const delayedCheckPasswordStrength = useRef( + debounce((value: string) => checkPasswordStrength(value), 300) + ).current + + const { configurationId, next, currentProjectId: foreignProjectId } = useParams() + + const { mutateAsync: createConnections, isLoading: isLoadingCreateConnections } = + useIntegrationConnectionsCreateMutation({}) + + const { data: organizationData, isLoading: isLoadingOrganizationsQuery } = useOrganizationsQuery() + const { slug } = router.query + + console.log('slug') + + const organization = organizationData?.find((x) => x.slug === slug) + + /** + * array of integrations installed + */ + const { data: integrationData, isLoading: integrationDataLoading } = useIntegrationsQuery({}) + + /** + * the vercel integration installed for organization chosen + */ + const organizationIntegration: Integration | undefined = integrationData?.find( + (x) => x.organization.slug === slug + ) + + /** + * Vercel projects available for this integration + */ + const { data: vercelProjects } = useVercelProjectsQuery( + { + organization_integration_id: organizationIntegration?.id, + }, + { enabled: organizationIntegration !== undefined } + ) + + console.log('vercelProjects near top', vercelProjects) + + const canSubmit = + projectName != '' && + passwordStrengthScore >= DEFAULT_MINIMUM_PASSWORD_STRENGTH && + dbRegion != undefined + + function onProjectNameChange(e: ChangeEvent) { + e.target.value = e.target.value.replace(/\./g, '') + setProjectName(e.target.value) + } + + function onDbPassChange(e: ChangeEvent) { + const value = e.target.value + setDbPass(value) + if (value == '') { + setPasswordStrengthScore(-1) + setPasswordStrengthMessage('') + } else delayedCheckPasswordStrength(value) + } + + async function checkPasswordStrength(value: string) { + const { message, strength } = await passwordStrength(value) + setPasswordStrengthScore(strength) + setPasswordStrengthMessage(message) + } + + function generateStrongPassword() { + const password = generator.generate({ + length: 16, + numbers: true, + uppercase: true, + }) + + setDbPass(password) + delayedCheckPasswordStrength(password) + } + + async function createSupabaseProject(dbSql?: string) { + const data = { + cloud_provider: PROVIDERS.AWS.id, // hardcoded for DB instances to be under AWS + org_id: organization?.id, + name: projectName, + db_pass: dbPass, + db_region: dbRegion, + // db_sql: dbSql || '', + db_pricing_tier_id: PRICING_TIER_PRODUCT_IDS.FREE, + // auth_site_url: _store.selectedVercelProjectUrl, + vercel_configuration_id: configurationId, + } + const project = await post(`${API_URL}/projects`, data) + return { ...project, db_host: `db.${project.ref}.supabase.co`, db_password: dbPass } + } + + async function onCreateProject() { + setLoading(true) + + if (!organizationIntegration) { + console.error('No organizationIntegration set') + return + } + + if (!foreignProjectId) { + console.error('No foreignProjectId ID set') + return + } + + if (!configurationId) { + console.error('No configurationId ID set') + return + } + + try { + const response = await createSupabaseProject() + // dbSql + + if (response.error) { + setLoading(false) + ui.setNotification({ + category: 'error', + message: `Failed to create project: ${response.error.message}`, + }) + return + } + + const project = response + + console.log('vercelProjects before projectDetails', vercelProjects) + const projectDetails = vercelProjects?.find((x) => x.id === foreignProjectId) + + console.log('projectDetails', projectDetails) + console.log(project) + console.log('supabase_project_ref', project.ref) + + // Introduce a wait of 10 seconds + try { + await new Promise((resolve) => setTimeout(resolve, 10000)) + } catch (error) { + console.error('An error occurred during the delay:', error) + } + + // Wrap the createConnections function call in a try-catch block + try { + await createConnections({ + organizationIntegrationId: organizationIntegration?.id, + connection: { + foreign_project_id: foreignProjectId, + supabase_project_ref: project.ref, + metadata: { + ...projectDetails, + supabaseConfig: { + projectEnvVars: { + write: true, + }, + }, + }, + }, + orgSlug: ui.selectedOrganization?.slug, + }) + } catch (error) { + console.error('An error occurred during createConnections:', error) + } + + // const query = new URLSearchParams(_store.queryParams).toString() + // router.push(`/vercel/complete?${query}`) + + if (next) { + window.location.href = next + } + } catch (error) { + console.error('Error', error) + setLoading(false) + } + } + + return ( +
+

Project details for integration

+
+ +
+
+ 0} + onChange={onDbPassChange} + descriptionText={ + + } + /> +
+
+
+ setDbRegion(region)} + descriptionText="Select a region close to your users for the best performance." + > + {Object.keys(AWS_REGIONS).map((option: string, i) => { + const label = Object.values(AWS_REGIONS)[i] + return ( + ( + region icon + )} + > + {label} + + ) + })} + +
+
+
+ +
+ {/* */} +
+ ) +} + +export default VercelIntegration diff --git a/studio/pages/integrations/vercel/install.tsx b/studio/pages/integrations/vercel/install.tsx new file mode 100644 index 00000000000..bac48b22523 --- /dev/null +++ b/studio/pages/integrations/vercel/install.tsx @@ -0,0 +1,248 @@ +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' +import { useIntegrationsQuery } from 'data/integrations/integrations-query' +import { IntegrationName } from 'data/integrations/integrations.types' +import { useVercelIntegrationCreateMutation } from 'data/integrations/vercel-integration-create-mutation' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useStore } from 'hooks' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { useVercelIntegrationInstallationState } from 'state/vercel-integration-installation' +import { NextPageWithLayout, Organization } from 'types' +import { Alert, Badge, Button, IconBook, IconHexagon, IconLifeBuoy, Listbox, LoadingLine } from 'ui' + +/** + * Organization type with `installationInstalled` added + */ +interface OrganizationsResponseWithInstalledData extends Organization { + installationInstalled?: boolean +} + +const VercelIntegration: NextPageWithLayout = () => { + const router = useRouter() + const { ui } = useStore() + const { code, configurationId, next, teamId, source, externalId } = useParams() + const [selectedOrg, setSelectedOrg] = useState(null) + const [organizationIntegrationId, setOrganizationIntegrationId] = useState(null) + + const snapshot = useVercelIntegrationInstallationState() + + /** + * array of integrations installed on all + */ + const { data: integrationData, isLoading: integrationDataLoading } = useIntegrationsQuery({}) + + const { data, isLoading: isLoadingOrganizationsQuery } = useOrganizationsQuery({ + onSuccess(organizations) { + const firstOrg = organizations?.[0] + if (firstOrg && selectedOrg === null) { + setSelectedOrg(firstOrg) + snapshot.setSelectedOrganizationSlug(firstOrg.slug) + } + }, + }) + + /** + * A flat array of org slugs that have integration installed + */ + const flatInstalledConnectionsIds = + integrationData && integrationData.length > 0 + ? integrationData?.map((x) => x.organization.slug) + : [] + + /** + * Organizations with extra `installationInstalled` attribute + * Used to show label/badge and allow/disallow installing + */ + const organizationsWithInstalledData: OrganizationsResponseWithInstalledData[] = data + ? data.map((org) => { + return { + ...org, + installationInstalled: flatInstalledConnectionsIds.includes(org.slug) ? true : false, + } + }) + : [] + + const { mutate, isLoading: isLoadingVercelIntegrationCreateMutation } = + useVercelIntegrationCreateMutation({ + onSuccess({ id }) { + setOrganizationIntegrationId(id) + }, + }) + + // console.log('params', params) + // console.log('externalId', externalId) + + function onInstall() { + const orgSlug = selectedOrg?.slug + if (!orgSlug) { + return ui.setNotification({ category: 'error', message: 'Please select an organization' }) + } + + if (!code) { + return ui.setNotification({ category: 'error', message: 'Vercel code missing' }) + } + + if (!configurationId) { + return ui.setNotification({ category: 'error', message: 'Vercel Configuration ID missing' }) + } + + if (!source) { + return ui.setNotification({ + category: 'error', + message: 'Vercel Configuration source missing', + }) + } + + const metadata = {} + + if (!flatInstalledConnectionsIds.includes(orgSlug)) { + mutate({ + code, + configurationId, + orgSlug, + metadata, + source, + teamId: teamId, + }) + } + + router.push({ pathname: `/integrations/vercel/${orgSlug}/new-project`, query: router.query }) + } + + const dataLoading = isLoadingVercelIntegrationCreateMutation || isLoadingOrganizationsQuery + + return ( + <> +
+ + {organizationIntegrationId === null && ( + <> + +

Choose organization

+ <> + + +
+ +
+ +
+ + + + + + + )} + + +
+ +
+ Docs +
+
+ Support +
+
+ + ) +} + +VercelIntegration.getLayout = (page) => {page} + +export interface OrganizationPickerProps { + label?: string + onSelectedOrgChange?: (org: Organization) => void + integrationName: IntegrationName + dataLoading: boolean + organizationsWithInstalledData: OrganizationsResponseWithInstalledData[] +} + +const OrganizationPicker = ({ + label = 'Choose an organization', + onSelectedOrgChange, + integrationName, + dataLoading, + organizationsWithInstalledData, +}: OrganizationPickerProps) => { + const { ui } = useStore() + const [selectedOrg, setSelectedOrg] = useState(null) + + const { data, isLoading } = useOrganizationsQuery({ + onSuccess(organizations) { + const firstOrg = organizations?.[0] + if (firstOrg && selectedOrg === null) { + setSelectedOrg(firstOrg) + onSelectedOrgChange?.(firstOrg) + } + }, + }) + + function _onSelectedOrgChange(slug: string) { + const org = data?.find((org) => org.slug === slug) + + if (org) { + setSelectedOrg(org) + onSelectedOrgChange?.(org) + } + } + + if (dataLoading) { + return ( + + + Loading... + + + ) + } + + return ( + + {organizationsWithInstalledData?.map((org) => { + const label = ( +
+ {org.name} + {org.installationInstalled && Integration Installed} +
+ ) + return ( + } + > + {label} + + ) + })} +
+ ) +} + +export default VercelIntegration diff --git a/studio/state/vercel-integration-installation.ts b/studio/state/vercel-integration-installation.ts new file mode 100644 index 00000000000..737879f6fe1 --- /dev/null +++ b/studio/state/vercel-integration-installation.ts @@ -0,0 +1,15 @@ +import { proxy, snapshot, useSnapshot } from 'valtio' + +export const vercelIntegrationInstallationState = proxy({ + selectedOrganizationSlug: '' as string, + setSelectedOrganizationSlug: (selectedOrganizationSlug: string) => { + vercelIntegrationInstallationState.selectedOrganizationSlug = selectedOrganizationSlug + }, +}) + +export const getVercelIntegrationInstallationState = () => + snapshot(vercelIntegrationInstallationState) + +export const useVercelIntegrationInstallationState = ( + options?: Parameters[1] +) => useSnapshot(vercelIntegrationInstallationState, options)