diff --git a/studio/components/interfaces/App/CommandMenuWrapper.tsx b/studio/components/interfaces/App/CommandMenuWrapper.tsx index bed20fc50a3..a1be2dc01dd 100644 --- a/studio/components/interfaces/App/CommandMenuWrapper.tsx +++ b/studio/components/interfaces/App/CommandMenuWrapper.tsx @@ -75,12 +75,14 @@ const CommandMenuWrapper = ({ children }: PropsWithChildren<{}>) => { try { const snippet = createSqlSnippetSkeleton({ + id: uuidv4(), name: title || 'Generated query', owner_id: profile?.id, + project_id: selectedProject?.id, sql: formattedSql, }) - const data = { ...snippet, id: uuidv4() } - snap.addSnippet(data as SqlSnippet, ref) + + snap.addSnippet(snippet as SqlSnippet, ref) ui.setNotification({ category: 'success', message: `Successfully saved snippet!`, diff --git a/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index 1b5bdee8ee3..2452670ac5f 100644 --- a/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -15,21 +15,27 @@ export const getResultsMarkdown = (results: any[]) => { } export const createSqlSnippetSkeleton = ({ + id, name, sql, owner_id, + project_id, }: { + id?: string name?: string sql?: string owner_id?: number + project_id?: number } = {}): UserContent => { return { ...NEW_SQL_SNIPPET_SKELETON, + id, ...(name && { name }), ...(owner_id && { owner_id }), + ...(project_id && { project_id }), content: { ...NEW_SQL_SNIPPET_SKELETON.content, - content_id: '', + content_id: id ?? '', sql: sql ?? '', }, } diff --git a/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx b/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx index 6641f68fa72..62c3cfb01ac 100644 --- a/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx +++ b/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx @@ -4,6 +4,7 @@ import { observer } from 'mobx-react-lite' import { useParams, useTelemetryProps } from 'common' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.constants' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { SqlSnippet } from 'data/content/sql-snippets-query' import { useCheckPermissions, useStore } from 'hooks' import { uuidv4 } from 'lib/helpers' @@ -19,6 +20,7 @@ const SQLTemplates = observer(() => { const { ref } = useParams() const router = useRouter() const { profile } = useProfile() + const { project } = useProjectContext() const [sql, quickStart] = partition(SQL_TEMPLATES, { type: 'template' }) const telemetryProps = useTelemetryProps() @@ -28,10 +30,9 @@ const SQLTemplates = observer(() => { subject: { id: profile?.id }, }) - // [Joshen TODO] Removed optimistic query creation logic for now, need to figure out - // how to do that after using ids as part of the URL const handleNewQuery = async (sql: string, name: string) => { - if (!ref) return console.error('Project ref is required') + if (!project) return console.error('Project is required') + if (!profile) return console.error('Profile is required') if (!canCreateSQLSnippet) { return ui.setNotification({ category: 'info', @@ -40,10 +41,16 @@ const SQLTemplates = observer(() => { } try { - const snippet = createSqlSnippetSkeleton({ name, sql, owner_id: profile?.id }) - const data = { ...snippet, id: uuidv4() } - snap.addSnippet(data as SqlSnippet, ref) - router.push(`/project/${ref}/sql/${data.id}`) + const snippet = createSqlSnippetSkeleton({ + id: uuidv4(), + name, + sql, + owner_id: profile.id, + project_id: project.id, + }) + + snap.addSnippet(snippet as SqlSnippet, project.ref) + router.push(`/project/${project.ref}/sql/${snippet.id}`) } catch (error: any) { ui.setNotification({ category: 'error', diff --git a/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index 1e527362996..cc532678ae9 100644 --- a/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -16,6 +16,7 @@ import { useProfile } from 'lib/profile' import { useSnippets, useSqlEditorStateSnapshot } from 'state/sql-editor' import { Button, Dropdown, IconPlus, IconSearch, IconX, Input, Menu, useCommandMenu } from 'ui' import { COMMAND_ROUTES } from 'ui/src/components/Command/Command.constants' +import { useProjectContext } from '../ProjectLayout/ProjectContext' import QueryItem from './QueryItem' const SideBarContent = observer(() => { @@ -26,6 +27,7 @@ const SideBarContent = observer(() => { const [filterString, setFilterString] = useState('') const { setPages, setIsOpen } = useCommandMenu() const showCmdkHelper = useFlag('dashboardCmdk') + const { project } = useProjectContext() const snap = useSqlEditorStateSnapshot() const { isLoading, isSuccess } = useSqlSnippetsQuery(ref, { @@ -56,7 +58,8 @@ const SideBarContent = observer(() => { }) const handleNewQuery = async () => { - if (!ref) return console.error('Project ref is required') + if (!project) return console.error('Project is required') + if (!profile) return console.error('Profile is required') if (!canCreateSQLSnippet) { return ui.setNotification({ category: 'info', @@ -65,12 +68,16 @@ const SideBarContent = observer(() => { } try { - const snippet = createSqlSnippetSkeleton({ name: 'Untitled query', owner_id: profile?.id }) - const data = { ...snippet, id: uuidv4() } + const snippet = createSqlSnippetSkeleton({ + id: uuidv4(), + name: 'Untitled query', + owner_id: profile.id, + project_id: project.id, + }) - snap.addSnippet(data as SqlSnippet, ref) + snap.addSnippet(snippet as SqlSnippet, project.ref) - router.push(`/project/${ref}/sql/${data.id}`) + router.push(`/project/${project.ref}/sql/${snippet.id}`) } catch (error: any) { ui.setNotification({ category: 'error', diff --git a/studio/data/api.d.ts b/studio/data/api.d.ts index 57c1537e7bf..175e2d8a400 100644 --- a/studio/data/api.d.ts +++ b/studio/data/api.d.ts @@ -396,6 +396,8 @@ export interface paths { "/platform/projects/{ref}/content": { /** Gets project's content */ get: operations["ContentController_getContent"]; + /** Updates project's content */ + put: operations["ContentController_updateWholeContent"]; /** Creates project's content */ post: operations["ContentController_createContent"]; /** Deletes project's content */ @@ -688,9 +690,13 @@ export interface paths { */ post: operations["VercelIntegrationController_createVercelIntegration"]; }; + "/platform/integrations/vercel/{organization_integration_id}": { + /** Removes Vercel organization integration with the given id */ + delete: operations["VercelIntegrationController_removeVercelIntegration"]; + }; "/platform/integrations/vercel/projects/{organization_integration_id}": { - /** Gets vercel projects for the given organization */ - get: operations["VercelProjectController_getProjects"]; + /** Gets vercel projects with the given organization integration id */ + get: operations["VercelProjectController_getVercelProjects"]; }; "/platform/integrations/vercel/connections/{organization_integration_id}": { /** Gets installed vercel project connections for the given organization integration */ @@ -700,6 +706,10 @@ export interface paths { /** Connects a Vercel project to a supabase project */ post: operations["VercelConnectionsController_createVercelConnection"]; }; + "/platform/integrations/vercel/connections/{connection_id}/sync-envs": { + /** Syncs supabase project envs with given connection id */ + post: operations["VercelConnectionsController_syncVercelConnectionEnvs"]; + }; "/platform/integrations/vercel/connections/{connection_id}": { /** Deletes vercel project connection */ delete: operations["VercelConnectionsController_deleteVercelConnection"]; @@ -1044,6 +1054,8 @@ export interface paths { "/v0/projects/{ref}/content": { /** Gets project's content */ get: operations["ContentController_getContent"]; + /** Updates project's content */ + put: operations["ContentController_updateWholeContent"]; /** Creates project's content */ post: operations["ContentController_createContent"]; /** Deletes project's content */ @@ -1235,6 +1247,23 @@ export interface paths { /** Deletes objects */ delete: operations["StorageObjectsController_deleteObjects"]; }; + "/v1/branch/{branch_id}": { + /** + * Get database branch config + * @description Fetches configurations of the specified database branch + */ + get: operations["BranchController_getBranchDetails"]; + /** + * Delete a database branch + * @description Deletes the specified database branch + */ + delete: operations["BranchController_deleteBranch"]; + /** + * Update database branch config + * @description Updates the configuration of the specified database branch + */ + patch: operations["BranchController_updateBranch"]; + }; "/v1/projects": { /** * List all projects @@ -1247,6 +1276,23 @@ export interface paths { "/v1/projects/{ref}/api-keys": { get: operations["ApiKeysController_getProjectApiKeys"]; }; + "/v1/projects/{ref}/branches": { + /** + * List all database branches + * @description Returns all database branches of the specified project. + */ + get: operations["BranchesController_getBranches"]; + /** + * Create a database branch + * @description Creates a database branch from the specified project. + */ + post: operations["BranchesController_createBranch"]; + /** + * Disables preview branching + * @description Disables preview branching for the specified project + */ + delete: operations["BranchesController_disableBranch"]; + }; "/v1/projects/{ref}/custom-hostname": { /** Gets project's custom hostname config */ get: operations["CustomHostnamesController_getCustomHostnameConfig"]; @@ -2992,6 +3038,29 @@ export interface components { anon_key: string; service_key: string; }; + GetUserContentObject: { + owner: { + id?: number; + username?: string; + }; + updated_by: { + id?: number; + username?: string; + }; + id: string; + inserted_at: string; + updated_at: string; + type: Record; + visibility: Record; + name: string; + description?: string; + project_id: number; + owner_id: number; + last_updated_by: number; + }; + GetUserContentResponse: { + data: (components["schemas"]["GetUserContentObject"])[]; + }; CreateContentParams: { id: string; name: string; @@ -3003,6 +3072,30 @@ export interface components { content?: Record; owner_id?: number; }; + UserContentObject: { + id: string; + inserted_at: string; + updated_at: string; + type: Record; + visibility: Record; + name: string; + description?: string; + project_id: number; + owner_id: number; + last_updated_by: number; + }; + UpsertContentParams: { + id: string; + name: string; + description: string; + /** @enum {string} */ + type: "sql" | "report" | "log_sql"; + /** @enum {string} */ + visibility: "user" | "project" | "org" | "public"; + content?: Record; + owner_id?: number; + project_id: number; + }; UpdateContentParams: { id?: string; name?: string; @@ -3026,6 +3119,15 @@ export interface components { postgrest: string; "supabase-postgres": string; }; + BranchResponse: { + id: string; + name: string; + project_ref: string; + is_default: boolean; + git_branch: string; + created_at: string; + updated_at: string; + }; ProjectDetailResponse: { cloud_provider: string; db_host: string; @@ -3046,6 +3148,7 @@ export interface components { volumeSizeGb?: number; maxDatabasePreprovisionGb?: number; lastDatabaseResizeAt?: string; + preview_branches: (components["schemas"]["BranchResponse"])[]; }; ProjectRefResponse: { id: number; @@ -3704,6 +3807,14 @@ export interface components { framework?: string | null; link?: components["schemas"]["VercelProjectLink"]; }; + GetVercelProjectsResponse: { + projects: (components["schemas"]["IntegrationVercelProject"])[]; + pagination: { + count?: number; + next?: number | null; + prev?: number | null; + }; + }; GetVercelConnections: { id: string; inserted_at: string; @@ -3798,6 +3909,21 @@ export interface components { GetMetricsResponse: { metrics: (components["schemas"]["ProjectMetric"])[]; }; + BranchDetailResponse: { + db_port: number; + ref: string; + postgres_version: string; + /** @enum {string} */ + status: "ACTIVE_HEALTHY" | "ACTIVE_UNHEALTHY" | "COMING_UP" | "GOING_DOWN" | "INACTIVE" | "INIT_FAILED" | "REMOVED" | "RESTORING" | "UNKNOWN" | "UPGRADING" | "PAUSING"; + db_host: string; + db_user?: string; + db_pass?: string; + jwt_secret?: string; + }; + UpdateBranchBody: { + branch_name?: string; + git_branch?: string; + }; DatabaseResponse: { /** @description Database host */ host: string; @@ -3808,6 +3934,11 @@ export interface components { name: string; api_key: string; }; + CreateBranchBody: { + branch_name: string; + git_branch?: string; + region?: string; + }; UpdateCustomHostnameResponse: { /** @enum {string} */ status: "1_not_started" | "2_initiated" | "3_challenge_verified" | "4_origin_setup_completed" | "5_services_reconfigured"; @@ -6639,11 +6770,38 @@ export interface operations { }; }; responses: { - 200: never; + 200: { + content: { + "application/json": components["schemas"]["GetUserContentResponse"]; + }; + }; /** @description Failed to retrieve project's content */ 500: never; }; }; + /** Updates project's content */ + ContentController_updateWholeContent: { + parameters: { + path: { + /** @description Project ref */ + ref: string; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpsertContentParams"]; + }; + }; + responses: { + 200: { + content: { + "application/json": components["schemas"]["UserContentObject"]; + }; + }; + /** @description Failed to update project's content */ + 500: never; + }; + }; /** Creates project's content */ ContentController_createContent: { parameters: { @@ -6660,7 +6818,7 @@ export interface operations { responses: { 201: { content: { - "application/json": (Record)[]; + "application/json": (components["schemas"]["UserContentObject"])[]; }; }; /** @description Failed to create project's content */ @@ -6677,7 +6835,7 @@ export interface operations { responses: { 200: { content: { - "application/json": Record; + "application/json": components["schemas"]["UserContentObject"]; }; }; /** @description Failed to delete project's content */ @@ -6699,7 +6857,7 @@ export interface operations { responses: { 200: { content: { - "application/json": (Record)[]; + "application/json": (components["schemas"]["UserContentObject"])[]; }; }; /** @description Failed to update project's content */ @@ -8072,20 +8230,38 @@ export interface operations { 500: never; }; }; - /** Gets vercel projects for the given organization */ - VercelProjectController_getProjects: { + /** Removes Vercel organization integration with the given id */ + VercelIntegrationController_removeVercelIntegration: { parameters: { path: { organization_integration_id: string; }; }; + responses: { + 200: never; + /** @description Failed to remove Vercel organization integration with the given id */ + 500: never; + }; + }; + /** Gets vercel projects with the given organization integration id */ + VercelProjectController_getVercelProjects: { + parameters: { + query: { + search?: string; + from?: string; + limit: string; + }; + path: { + organization_integration_id: string; + }; + }; responses: { 200: { content: { - "application/json": (components["schemas"]["IntegrationVercelProject"])[]; + "application/json": components["schemas"]["GetVercelProjectsResponse"]; }; }; - /** @description Failed to get vercel projects for the given organization */ + /** @description Failed to get vercel projects with the given organization integration id */ 500: never; }; }; @@ -8119,6 +8295,19 @@ export interface operations { 500: never; }; }; + /** Syncs supabase project envs with given connection id */ + VercelConnectionsController_syncVercelConnectionEnvs: { + parameters: { + path: { + connection_id: string; + }; + }; + responses: { + 201: never; + /** @description Failed to sync supabase project envs with given connection id */ + 500: never; + }; + }; /** Deletes vercel project connection */ VercelConnectionsController_deleteVercelConnection: { parameters: { @@ -8517,6 +8706,70 @@ export interface operations { }; }; }; + /** + * Get database branch config + * @description Fetches configurations of the specified database branch + */ + BranchController_getBranchDetails: { + parameters: { + path: { + /** @description Branch ID */ + branch_id: string; + }; + }; + responses: { + 200: { + content: { + "application/json": components["schemas"]["BranchDetailResponse"]; + }; + }; + /** @description Failed to update database branch */ + 500: never; + }; + }; + /** + * Delete a database branch + * @description Deletes the specified database branch + */ + BranchController_deleteBranch: { + parameters: { + path: { + /** @description Branch ID */ + branch_id: string; + }; + }; + responses: { + 200: never; + /** @description Failed to delete database branch */ + 500: never; + }; + }; + /** + * Update database branch config + * @description Updates the configuration of the specified database branch + */ + BranchController_updateBranch: { + parameters: { + path: { + /** @description Branch ID */ + branch_id: string; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateBranchBody"]; + }; + }; + responses: { + 200: { + content: { + "application/json": components["schemas"]["BranchResponse"]; + }; + }; + /** @description Failed to update database branch */ + 500: never; + }; + }; ApiKeysController_getProjectApiKeys: { parameters: { path: { @@ -8533,6 +8786,70 @@ export interface operations { 403: never; }; }; + /** + * List all database branches + * @description Returns all database branches of the specified project. + */ + BranchesController_getBranches: { + parameters: { + path: { + /** @description Project ref */ + ref: string; + }; + }; + responses: { + 200: { + content: { + "application/json": (components["schemas"]["BranchResponse"])[]; + }; + }; + /** @description Failed to retrieve database branches */ + 500: never; + }; + }; + /** + * Create a database branch + * @description Creates a database branch from the specified project. + */ + BranchesController_createBranch: { + parameters: { + path: { + /** @description Project ref */ + ref: string; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateBranchBody"]; + }; + }; + responses: { + 201: { + content: { + "application/json": components["schemas"]["BranchResponse"]; + }; + }; + /** @description Failed to create database branch */ + 500: never; + }; + }; + /** + * Disables preview branching + * @description Disables preview branching for the specified project + */ + BranchesController_disableBranch: { + parameters: { + path: { + /** @description Project ref */ + ref: string; + }; + }; + responses: { + 200: never; + /** @description Failed to disable preview branching */ + 500: never; + }; + }; /** Gets project's custom hostname config */ CustomHostnamesController_getCustomHostnameConfig: { parameters: { diff --git a/studio/data/content/content-upsert-mutation.ts b/studio/data/content/content-upsert-mutation.ts index 1f498509c47..d003b453fe3 100644 --- a/studio/data/content/content-upsert-mutation.ts +++ b/studio/data/content/content-upsert-mutation.ts @@ -1,28 +1,43 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' -import { put } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { UserContent } from 'types' -import { contentKeys } from './keys' -type UpsertContentVariables = { +import { components } from 'data/api' +import { put } from 'data/fetchers' +import { contentKeys } from './keys' +import { Content } from './content-query' + +export type UpsertContentPayload = Omit & { + content: Content['content'] +} + +export type UpsertContentVariables = { projectRef: string - id: string - payload: Partial + payload: UpsertContentPayload } export async function upsertContent( { projectRef, payload }: UpsertContentVariables, signal?: AbortSignal ) { - const response = await put(`${API_URL}/projects/${projectRef}/content`, payload, { + const { data, error } = await put('/platform/projects/{ref}/content', { + params: { path: { ref: projectRef } }, + body: { + id: payload.id, + name: payload.name, + description: payload.description, + project_id: payload.project_id, + owner_id: payload.owner_id, + type: payload.type, + visibility: payload.visibility, + content: payload.content as any, + }, signal, }) + if (error) throw error - if (response.error) throw response.error - return { content: response } + return data } -type UpsertContentData = Awaited> +export type UpsertContentData = Awaited> export const useContentUpsertMutation = ({ onSuccess, diff --git a/studio/state/sql-editor.ts b/studio/state/sql-editor.ts index 372d5eafae2..3307a073e62 100644 --- a/studio/state/sql-editor.ts +++ b/studio/state/sql-editor.ts @@ -1,8 +1,8 @@ -import { Content } from 'data/content/content-query' -import { upsertContent } from 'data/content/content-upsert-mutation' +import { UpsertContentPayload, upsertContent } from 'data/content/content-upsert-mutation' import { SqlSnippet } from 'data/content/sql-snippets-query' import { debounce, memoize } from 'lodash' import { useMemo } from 'react' +import { SqlSnippets } from 'types' import { proxy, snapshot, subscribe, useSnapshot } from 'valtio' import { devtools, proxySet } from 'valtio/utils' @@ -170,10 +170,13 @@ export const useSnippets = (projectRef: string | undefined) => { }, [projectRef, snapshot.orders, snapshot.snippets]) } -async function upsert(id: string, projectRef: string, content: Partial) { +async function upsert(id: string, projectRef: string, payload: UpsertContentPayload) { try { sqlEditorState.savingStates[id] = 'UPDATING' - await upsertContent({ projectRef, id, payload: content }) + await upsertContent({ + projectRef, + payload, + }) sqlEditorState.savingStates[id] = 'IDLE' } catch (error) { sqlEditorState.savingStates[id] = 'UPDATING_FAILED' @@ -181,8 +184,8 @@ async function upsert(id: string, projectRef: string, content: Partial) } const memoizedUpdate = memoize((_id: string) => debounce(upsert, 1000)) -const debouncedUpdate = (id: string, projectRef: string, content: Partial) => - memoizedUpdate(id)(id, projectRef, content) +const debouncedUpdate = (id: string, projectRef: string, payload: UpsertContentPayload) => + memoizedUpdate(id)(id, projectRef, payload) if (typeof window !== 'undefined') { devtools(sqlEditorState, { name: 'sqlEditorState', enabled: true }) @@ -195,12 +198,14 @@ if (typeof window !== 'undefined') { if (snippet) { debouncedUpdate(id, snippet.projectRef, { - content: { ...snippet.snippet.content, content_id: id }, + ...snippet.snippet, + name: snippet.snippet.name ?? 'Untitled', + description: snippet.snippet.description ?? '', + visibility: 'user', + project_id: snippet.snippet.project_id ?? 0, + content: { ...snippet.snippet.content, content_id: id } as SqlSnippets.Content, type: 'sql', id, - name: snippet.snippet.name, - description: snippet.snippet.description, - visibility: snippet.snippet.visibility ?? 'project', }) sqlEditorState.needsSaving.delete(id)