From 95c3739a5ce830b66e2031f3f0189439b99aa0e7 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 24 Jul 2023 15:37:13 +0800 Subject: [PATCH 01/13] Add RQ for retrieving branches --- studio/data/__templates/resources-query.ts | 26 ++--- studio/data/api.d.ts | 109 ++++++++++++++++++--- studio/data/branches/branches-query.ts | 31 ++++++ studio/data/branches/keys.ts | 3 + 4 files changed, 141 insertions(+), 28 deletions(-) create mode 100644 studio/data/branches/branches-query.ts create mode 100644 studio/data/branches/keys.ts diff --git a/studio/data/__templates/resources-query.ts b/studio/data/__templates/resources-query.ts index 251110db806..4a46ac33af4 100644 --- a/studio/data/__templates/resources-query.ts +++ b/studio/data/__templates/resources-query.ts @@ -1,35 +1,28 @@ import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' -import { get, isResponseOk } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { get } from 'data/fetchers' import { useCallback } from 'react' import { resourceKeys } from './keys' +import { ResponseError } from 'types' export type ResourcesVariables = { projectRef?: string } -export type ResourcesResponse = { - id: string -} - export async function getResources({ projectRef }: ResourcesVariables, signal?: AbortSignal) { - if (!projectRef) { - throw new Error('projectRef is required') - } + if (!projectRef) throw new Error('projectRef is required') - const response = await get(`${API_URL}/projects/${projectRef}/resources`, { + // Use any of the API endpoints as the parameter here, TS will check if its valid - "branches" here is just an example + const { data, error } = await get(`/v1/projects/{ref}/branches`, { + params: { path: { ref: projectRef } }, signal, }) + if (error) throw error - if (!isResponseOk(response)) { - throw response.error - } - - return response + return data } export type ResourcesData = Awaited> -export type ResourcesError = unknown +export type ResourcesError = ResponseError export const useResourcesQuery = ( { projectRef }: ResourcesVariables, @@ -43,6 +36,7 @@ export const useResourcesQuery = ( /** * useResourcesPrefetch is used for prefetching data. For example, starting a query loading before a page is navigated to. + * Feel free to omit if not required * * @example * const prefetch = useResourcesPrefetch({ projectRef }) diff --git a/studio/data/api.d.ts b/studio/data/api.d.ts index b76fee25169..c7f8b7dc44a 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 */ @@ -1056,6 +1058,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 */ @@ -1247,7 +1251,7 @@ export interface paths { /** Deletes objects */ delete: operations["StorageObjectsController_deleteObjects"]; }; - "/v1/branch/{branch_id}": { + "/v1/branches/{branch_id}": { /** * Get database branch config * @description Fetches configurations of the specified database branch @@ -1339,7 +1343,7 @@ export interface paths { /** Updates project's postgrest config */ patch: operations["PostgrestConfigController_updatePostgRESTConfig"]; }; - "/v1/projects/{ref}/query": { + "/v1/projects/{ref}/database/query": { /** Run sql query */ post: operations["QueryController_runQuery"]; }; @@ -1609,7 +1613,8 @@ export interface components { SMTP_HOST: string; SMTP_PORT: string; SMTP_USER: string; - SMTP_PASS: string; + SMTP_PASS?: string | null; + SMTP_PASS_ENCRYPTED?: string | null; SMTP_MAX_FREQUENCY: number; MAILER_AUTOCONFIRM: boolean; MAILER_URLPATHS_INVITE: string; @@ -1684,7 +1689,6 @@ export interface components { EXTERNAL_SLACK_SECRET: string; SMS_MESSAGEBIRD_ACCESS_KEY: string; SMS_MESSAGEBIRD_ORIGINATOR: string; - SMTP_PASS_ENCRYPTED: string; REFRESH_TOKEN_ROTATION_ENABLED: boolean; EXTERNAL_LINKEDIN_ENABLED: boolean; EXTERNAL_LINKEDIN_CLIENT_ID: string; @@ -1749,7 +1753,8 @@ export interface components { SMTP_HOST: string; SMTP_PORT: string; SMTP_USER: string; - SMTP_PASS: string; + SMTP_PASS?: string | null; + SMTP_PASS_ENCRYPTED?: string | null; SMTP_MAX_FREQUENCY: number; MAILER_AUTOCONFIRM: boolean; MAILER_URLPATHS_INVITE: string; @@ -1824,7 +1829,6 @@ export interface components { EXTERNAL_SLACK_SECRET: string; SMS_MESSAGEBIRD_ACCESS_KEY: string; SMS_MESSAGEBIRD_ORIGINATOR: string; - SMTP_PASS_ENCRYPTED: string; REFRESH_TOKEN_ROTATION_ENABLED: boolean; EXTERNAL_LINKEDIN_ENABLED: boolean; EXTERNAL_LINKEDIN_CLIENT_ID: string; @@ -1889,7 +1893,8 @@ export interface components { SMTP_HOST: string; SMTP_PORT: string; SMTP_USER: string; - SMTP_PASS: string; + SMTP_PASS?: string | null; + SMTP_PASS_ENCRYPTED?: string | null; SMTP_MAX_FREQUENCY: number; MAILER_AUTOCONFIRM: boolean; MAILER_URLPATHS_INVITE: string; @@ -1964,7 +1969,6 @@ export interface components { EXTERNAL_SLACK_SECRET: string; SMS_MESSAGEBIRD_ACCESS_KEY: string; SMS_MESSAGEBIRD_ORIGINATOR: string; - SMTP_PASS_ENCRYPTED: string; REFRESH_TOKEN_ROTATION_ENABLED: boolean; EXTERNAL_LINKEDIN_ENABLED: boolean; EXTERNAL_LINKEDIN_CLIENT_ID: string; @@ -3038,6 +3042,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; @@ -3049,6 +3076,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; @@ -3191,6 +3242,11 @@ export interface components { name: string; limit: number; }; + PreviewTransferInvoiceItem: { + description: string; + quantity: number; + amount: number; + }; PreviewProjectTransferResponse: { valid: boolean; warnings: (components["schemas"]["PreviewTransferInfo"])[]; @@ -3206,6 +3262,8 @@ export interface components { charge_on_target_organization: number; source_subscription_plan: Record; target_subscription_plan: Record | null; + source_invoice_items: (components["schemas"]["PreviewTransferInvoiceItem"])[]; + target_invoice_items: (components["schemas"]["PreviewTransferInvoiceItem"])[]; }; AnalyticsResponse: { error?: OneOf<[{ @@ -6738,11 +6796,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: { @@ -6759,7 +6844,7 @@ export interface operations { responses: { 201: { content: { - "application/json": (Record)[]; + "application/json": (components["schemas"]["UserContentObject"])[]; }; }; /** @description Failed to create project's content */ @@ -6776,7 +6861,7 @@ export interface operations { responses: { 200: { content: { - "application/json": Record; + "application/json": components["schemas"]["UserContentObject"]; }; }; /** @description Failed to delete project's content */ @@ -6798,7 +6883,7 @@ export interface operations { responses: { 200: { content: { - "application/json": (Record)[]; + "application/json": (components["schemas"]["UserContentObject"])[]; }; }; /** @description Failed to update project's content */ diff --git a/studio/data/branches/branches-query.ts b/studio/data/branches/branches-query.ts new file mode 100644 index 00000000000..23ac1bcfa63 --- /dev/null +++ b/studio/data/branches/branches-query.ts @@ -0,0 +1,31 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'data/fetchers' +import { branchKeys } from './keys' +import { ResponseError } from 'types' + +export type BranchesVariables = { + projectRef: string +} + +export async function getBranches({ projectRef }: BranchesVariables, signal?: AbortSignal) { + const { data, error } = await get(`/v1/projects/{ref}/branches`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) throw error + return data +} + +export type BranchesData = Awaited> +export type BranchesError = ResponseError + +export const useResourcesQuery = ( + { projectRef }: BranchesVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + branchKeys.list(projectRef), + ({ signal }) => getBranches({ projectRef }, signal), + { enabled: enabled && typeof projectRef !== 'undefined', ...options } + ) diff --git a/studio/data/branches/keys.ts b/studio/data/branches/keys.ts new file mode 100644 index 00000000000..8826a7a812d --- /dev/null +++ b/studio/data/branches/keys.ts @@ -0,0 +1,3 @@ +export const branchKeys = { + list: (projectRef: string | undefined) => ['projects', projectRef, 'branches'] as const, +} From 74efa6a47fc2578965e9d0d3a45995db9c673902 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 25 Jul 2023 13:14:07 +0800 Subject: [PATCH 02/13] Add RQs for branch management, and init branch dropdown component --- .../layouts/AppLayout/AppHeader.tsx | 2 + .../layouts/AppLayout/BranchDropdown.tsx | 12 ++++ .../data/branches/branch-create-mutation.ts | 65 +++++++++++++++++++ .../data/branches/branch-delete-mutation.ts | 51 +++++++++++++++ studio/data/branches/branch-query.ts | 31 +++++++++ .../data/branches/branch-update-mutation.ts | 59 +++++++++++++++++ studio/data/branches/branches-query.ts | 6 +- studio/data/branches/keys.ts | 1 + 8 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 studio/components/layouts/AppLayout/BranchDropdown.tsx create mode 100644 studio/data/branches/branch-create-mutation.ts create mode 100644 studio/data/branches/branch-delete-mutation.ts create mode 100644 studio/data/branches/branch-query.ts create mode 100644 studio/data/branches/branch-update-mutation.ts diff --git a/studio/components/layouts/AppLayout/AppHeader.tsx b/studio/components/layouts/AppLayout/AppHeader.tsx index 05ba788f92a..dfb07019887 100644 --- a/studio/components/layouts/AppLayout/AppHeader.tsx +++ b/studio/components/layouts/AppLayout/AppHeader.tsx @@ -10,6 +10,7 @@ import OrganizationDropdown from './OrganizationDropdown' import ProjectDropdown from './ProjectDropdown' import SettingsButton from './SettingsButton' import UserSettingsDropdown from './UserSettingsDropdown' +import BranchDropdown from './BranchDropdown' const AppHeader = () => { const router = useRouter() @@ -30,6 +31,7 @@ const AppHeader = () => { {ref !== undefined && } +
diff --git a/studio/components/layouts/AppLayout/BranchDropdown.tsx b/studio/components/layouts/AppLayout/BranchDropdown.tsx new file mode 100644 index 00000000000..d3f8f38ebaf --- /dev/null +++ b/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -0,0 +1,12 @@ +import { useParams } from 'common' +import { useBranchesQuery } from 'data/branches/branches-query' + +const BranchDropdown = () => { + const { ref: projectRef } = useParams() + const { data } = useBranchesQuery({ projectRef }) + console.log(data) + + return
Branch
+} + +export default BranchDropdown diff --git a/studio/data/branches/branch-create-mutation.ts b/studio/data/branches/branch-create-mutation.ts new file mode 100644 index 00000000000..a9296b688af --- /dev/null +++ b/studio/data/branches/branch-create-mutation.ts @@ -0,0 +1,65 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-hot-toast' + +import { post } from 'data/fetchers' +import { ResponseError } from 'types' +import { branchKeys } from './keys' + +export type BranchCreateVariables = { + projectRef: string + branchName: string + gitBranch?: string + region?: string +} + +export async function createBranch({ + projectRef, + branchName, + gitBranch, + region, +}: BranchCreateVariables) { + const { data, error } = await post('/v1/projects/{ref}/branches', { + params: { + path: { ref: projectRef }, + }, + body: { + branch_name: branchName, + git_branch: gitBranch, + region: region, + }, + }) + + if (error) throw error + return data +} + +type BranchCreateData = Awaited> + +export const useBranchCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation( + (vars) => createBranch(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(branchKeys.list(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create branch: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/studio/data/branches/branch-delete-mutation.ts b/studio/data/branches/branch-delete-mutation.ts new file mode 100644 index 00000000000..2f5974fcce7 --- /dev/null +++ b/studio/data/branches/branch-delete-mutation.ts @@ -0,0 +1,51 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-hot-toast' + +import { del } from 'data/fetchers' +import { ResponseError } from 'types' +import { branchKeys } from './keys' + +export type BranchDeleteVariables = { + id: string + projectRef: string +} + +export async function deleteBranch({ id }: BranchDeleteVariables) { + const { data, error } = await del('/v1/branches/{branch_id}', { + params: { path: { branch_id: id } }, + }) + + if (error) throw error + return data +} + +type BranchDeleteData = Awaited> + +export const useBranchDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation( + (vars) => deleteBranch(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(branchKeys.list(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete branch: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/studio/data/branches/branch-query.ts b/studio/data/branches/branch-query.ts new file mode 100644 index 00000000000..3eba27d674f --- /dev/null +++ b/studio/data/branches/branch-query.ts @@ -0,0 +1,31 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'data/fetchers' +import { branchKeys } from './keys' +import { ResponseError } from 'types' + +export type BranchVariables = { + id: string +} + +export async function getBranch({ id }: BranchVariables, signal?: AbortSignal) { + const { data, error } = await get(`/v1/branches/{branch_id}`, { + params: { path: { branch_id: id } }, + signal, + }) + + if (error) throw error + return data +} + +export type BranchData = Awaited> +export type BranchError = ResponseError + +export const useBranchQuery = ( + { id }: BranchVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + branchKeys.detail(id), + ({ signal }) => getBranch({ id }, signal), + { enabled: enabled && typeof id !== 'undefined', ...options } + ) diff --git a/studio/data/branches/branch-update-mutation.ts b/studio/data/branches/branch-update-mutation.ts new file mode 100644 index 00000000000..2b70d82bb48 --- /dev/null +++ b/studio/data/branches/branch-update-mutation.ts @@ -0,0 +1,59 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-hot-toast' + +import { patch } from 'data/fetchers' +import { ResponseError } from 'types' +import { branchKeys } from './keys' + +export type BranchUpdateVariables = { + id: string + projectRef: string + branchName?: string + gitBranch?: string +} + +export async function updateBranch({ id, branchName, gitBranch }: BranchUpdateVariables) { + const { data, error } = await patch('/v1/branches/{branch_id}', { + params: { + path: { branch_id: id }, + }, + body: { + branch_name: branchName, + git_branch: gitBranch, + }, + }) + + if (error) throw error + return data +} + +type BranchUpdateData = Awaited> + +export const useBranchUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation( + (vars) => updateBranch(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(branchKeys.list(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update branch: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/studio/data/branches/branches-query.ts b/studio/data/branches/branches-query.ts index 23ac1bcfa63..d7ed5e31e66 100644 --- a/studio/data/branches/branches-query.ts +++ b/studio/data/branches/branches-query.ts @@ -4,12 +4,12 @@ import { branchKeys } from './keys' import { ResponseError } from 'types' export type BranchesVariables = { - projectRef: string + projectRef?: string } export async function getBranches({ projectRef }: BranchesVariables, signal?: AbortSignal) { const { data, error } = await get(`/v1/projects/{ref}/branches`, { - params: { path: { ref: projectRef } }, + params: { path: { ref: projectRef ?? '' } }, signal, }) @@ -20,7 +20,7 @@ export async function getBranches({ projectRef }: BranchesVariables, signal?: Ab export type BranchesData = Awaited> export type BranchesError = ResponseError -export const useResourcesQuery = ( +export const useBranchesQuery = ( { projectRef }: BranchesVariables, { enabled = true, ...options }: UseQueryOptions = {} ) => diff --git a/studio/data/branches/keys.ts b/studio/data/branches/keys.ts index 8826a7a812d..76180f83f72 100644 --- a/studio/data/branches/keys.ts +++ b/studio/data/branches/keys.ts @@ -1,3 +1,4 @@ export const branchKeys = { list: (projectRef: string | undefined) => ['projects', projectRef, 'branches'] as const, + detail: (id: string | undefined) => ['branches', id] as const, } From 12be178fc2b66cc846c2f4d3a89f336ff1bab412 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 25 Jul 2023 14:25:54 +0800 Subject: [PATCH 03/13] Tiny --- studio/components/layouts/AppLayout/AppHeader.tsx | 2 +- .../components/layouts/AppLayout/BranchDropdown.tsx | 13 ++++++++++--- studio/pages/project/[ref]/index.tsx | 4 ++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/studio/components/layouts/AppLayout/AppHeader.tsx b/studio/components/layouts/AppLayout/AppHeader.tsx index dfb07019887..7a5607f4804 100644 --- a/studio/components/layouts/AppLayout/AppHeader.tsx +++ b/studio/components/layouts/AppLayout/AppHeader.tsx @@ -31,7 +31,7 @@ const AppHeader = () => { {ref !== undefined && } - + {ref !== undefined && }
diff --git a/studio/components/layouts/AppLayout/BranchDropdown.tsx b/studio/components/layouts/AppLayout/BranchDropdown.tsx index d3f8f38ebaf..8c225f4de19 100644 --- a/studio/components/layouts/AppLayout/BranchDropdown.tsx +++ b/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -1,12 +1,19 @@ import { useParams } from 'common' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useBranchesQuery } from 'data/branches/branches-query' const BranchDropdown = () => { const { ref: projectRef } = useParams() - const { data } = useBranchesQuery({ projectRef }) - console.log(data) + const { data: branches, isLoading, isError, isSuccess } = useBranchesQuery({ projectRef }) + console.log({ branches }) - return
Branch
+ return ( + <> + {isLoading && } + {isError &&
} + {isSuccess &&
} + + ) } export default BranchDropdown diff --git a/studio/pages/project/[ref]/index.tsx b/studio/pages/project/[ref]/index.tsx index 0653c6a1261..06b56fd2a10 100644 --- a/studio/pages/project/[ref]/index.tsx +++ b/studio/pages/project/[ref]/index.tsx @@ -2,14 +2,14 @@ import { ClientLibrary, ExampleProject } from 'components/interfaces/Home' import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' import ProjectUsageSection from 'components/interfaces/Home/ProjectUsageSection' import { ProjectLayoutWithAuth } from 'components/layouts' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import ProjectPausedState from 'components/layouts/ProjectLayout/ProjectPausedState' import ProjectUpgradeFailedBanner from 'components/ui/ProjectUpgradeFailedBanner' +import { useSelectedProject } from 'hooks' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import { NextPageWithLayout } from 'types' const Home: NextPageWithLayout = () => { - const { project } = useProjectContext() + const project = useSelectedProject() const projectName = project?.ref !== 'default' && project?.name !== undefined From 53c058f931109fe791aa3183643797291990293e Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 26 Jul 2023 00:05:06 +0800 Subject: [PATCH 04/13] Scaffold branch management branch, list branches, implement delete branch --- .../components/shadcn/ui/dropdown-menu.tsx | 2 +- .../BranchManagement/BranchManagement.tsx | 88 ++++++++++++ .../BranchManagement/BranchPanels.tsx | 132 ++++++++++++++++++ studio/components/interfaces/Markdown.tsx | 6 +- .../layouts/AppLayout/BranchDropdown.tsx | 117 +++++++++++++++- .../layouts/AppLayout/ProjectDropdown.tsx | 2 + .../AppLayout/UserSettingsDropdown.tsx | 7 +- studio/components/ui/ProjectLink.tsx | 103 ++++++++++++++ studio/data/api.d.ts | 1 + studio/data/branches/branches-query.ts | 11 +- studio/pages/project/[ref]/branches.tsx | 17 +++ 11 files changed, 475 insertions(+), 11 deletions(-) create mode 100644 studio/components/interfaces/BranchManagement/BranchManagement.tsx create mode 100644 studio/components/interfaces/BranchManagement/BranchPanels.tsx create mode 100644 studio/components/ui/ProjectLink.tsx create mode 100644 studio/pages/project/[ref]/branches.tsx diff --git a/packages/ui/src/components/shadcn/ui/dropdown-menu.tsx b/packages/ui/src/components/shadcn/ui/dropdown-menu.tsx index e4141e48221..f275d7401de 100644 --- a/packages/ui/src/components/shadcn/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/shadcn/ui/dropdown-menu.tsx @@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef< { + const { ui } = useStore() + const { ref: projectRef } = useParams() + const [selectedBranchToUpdate, setSelectedBranchToUpdate] = useState() + const [selectedBranchToDelete, setSelectedBranchToDelete] = useState() + + const { data: branches, error, isLoading, isError, isSuccess } = useBranchesQuery({ projectRef }) + const [[mainBranch], previewBranches] = partition(branches, (branch) => branch.is_default) + + const { mutate: updateBranch, isLoading: isUpdating } = useBranchUpdateMutation({ + onSuccess: () => { + setSelectedBranchToUpdate(undefined) + ui.setNotification({ category: 'success', message: 'Successfully updated branch' }) + }, + }) + const { mutate: deleteBranch, isLoading: isDeleting } = useBranchDeleteMutation({ + onSuccess: () => { + setSelectedBranchToDelete(undefined) + ui.setNotification({ category: 'success', message: 'Successfully deleted branch' }) + }, + }) + + const onConfirmDeleteBranch = () => { + if (selectedBranchToDelete == undefined) return console.error('No branch selected') + if (projectRef == undefined) return console.error('Project ref is required') + deleteBranch({ id: selectedBranchToDelete?.id, projectRef }) + } + + return ( + <> + + +
+

Branches

+
+ {isLoading && } + {isError && } + {isSuccess && ( + <> + {}} /> + + {previewBranches.map((branch) => ( + {}} + onSelectDelete={() => setSelectedBranchToDelete(branch)} + /> + ))} + + )} +
+
+
+
+ + setSelectedBranchToDelete(undefined)} + onConfirm={() => onConfirmDeleteBranch()} + title="Delete branch" + loading={isDeleting} + confirmLabel={`Delete branch`} + confirmPlaceholder="Type in name of branch" + confirmString={selectedBranchToDelete?.name ?? ''} + text={`This will delete your branch "${selectedBranchToDelete?.name}"`} + alert="You cannot recover this branch once it is deleted!" + /> + + ) +} + +export default BranchManagement diff --git a/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/studio/components/interfaces/BranchManagement/BranchPanels.tsx new file mode 100644 index 00000000000..92667e38dc1 --- /dev/null +++ b/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -0,0 +1,132 @@ +import dayjs from 'dayjs' +import { forwardRef, useState } from 'react' +import { + Badge, + Button, + DropdownMenuContent_Shadcn_, + DropdownMenuItem_Shadcn_, + DropdownMenuTrigger_Shadcn_, + DropdownMenu_Shadcn_, + IconEdit, + IconGitBranch, + IconMoreVertical, + IconShield, + IconTrash, + cn, +} from 'ui' + +import { Markdown } from 'components/interfaces/Markdown' +import { Branch } from 'data/branches/branches-query' +import { noop } from 'lodash' +import { useParams } from 'common' + +interface BranchPanelProps { + branch?: Branch + onSelectUpdate?: () => void + onSelectDelete?: () => void +} + +const BranchPanel = ({ + branch, + onSelectUpdate = noop, + onSelectDelete = noop, +}: BranchPanelProps) => { + const { ref } = useParams() + const [open, setOpen] = useState(false) + + const isActive = ref === branch?.project_ref + const daysFromNow = dayjs().diff(dayjs(branch?.created_at), 'day') + const formattedTimeFromNow = dayjs(branch?.created_at).fromNow() + const formattedCreatedAt = dayjs(branch?.created_at).format('DD MMM YYYY, HH:mm:ss (ZZ)') + + if (branch?.is_default) { + return ( +
+
+ +

{branch?.name}

+ Production + {isActive && Selected} +
+
+ + setOpen(!open)} modal={false}> + + + + + onSelectUpdate()}> + + Edit branch + + + +
+
+ ) + } + + return ( +
  • +
    +
    +
    + +

    {branch?.name}

    + {isActive && Selected} +

    + {daysFromNow > 1 + ? `Created on ${formattedCreatedAt}` + : `Created ${formattedTimeFromNow}`} +

    +
    +
    + setOpen(!open)} modal={false}> + + + + + onSelectUpdate()}> + + Edit branch + + onSelectDelete()}> + + Delete branch + + + +
    +
    +
  • + ) +} + +interface BranchHeader extends React.HTMLAttributes { + name?: string + markdown?: string +} + +const BranchHeader = forwardRef( + ({ className, name, markdown = '', ...props }, ref) => { + return ( +
    + +
    + ) + } +) + +BranchHeader.displayName = 'BranchHeader' +export { BranchHeader, BranchPanel } diff --git a/studio/components/interfaces/Markdown.tsx b/studio/components/interfaces/Markdown.tsx index 1755f86c843..8aff7074cd5 100644 --- a/studio/components/interfaces/Markdown.tsx +++ b/studio/components/interfaces/Markdown.tsx @@ -9,7 +9,11 @@ interface Props extends Omit { const Markdown = ({ className, content, ...props }: Props) => { return ( - +

    {children}

    }} + {...props} + className={cn('prose text-sm', className)} + > {content}
    ) diff --git a/studio/components/layouts/AppLayout/BranchDropdown.tsx b/studio/components/layouts/AppLayout/BranchDropdown.tsx index 8c225f4de19..47c8b62150c 100644 --- a/studio/components/layouts/AppLayout/BranchDropdown.tsx +++ b/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -1,17 +1,130 @@ +import { useRouter } from 'next/router' +import { useRef, useState } from 'react' +import Link from 'next/link' + import { useParams } from 'common' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useBranchesQuery } from 'data/branches/branches-query' +import { + Badge, + Button, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + Command_Shadcn_, + IconCheck, + IconCode, + IconPlus, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, +} from 'ui' const BranchDropdown = () => { + const router = useRouter() const { ref: projectRef } = useParams() + const branchNameRef = useRef(null) const { data: branches, isLoading, isError, isSuccess } = useBranchesQuery({ projectRef }) - console.log({ branches }) + + const [open, setOpen] = useState(false) + const popoverOffset = (branchNameRef.current?.offsetWidth ?? 0) + 12 + const selectedBranch = branches?.find((branch) => branch.project_ref === projectRef) + + const onSwitchBranch = async () => { + console.log('Fetch branch details and update connection string or something') + } return ( <> {isLoading && } + {isError &&
    } - {isSuccess &&
    } + + {isSuccess && branches.length > 0 && ( +
    + + +

    {selectedBranch?.name}

    + {selectedBranch?.is_default && Production} +
    + + + + +
    + )} ) } diff --git a/studio/components/layouts/AppLayout/ProjectDropdown.tsx b/studio/components/layouts/AppLayout/ProjectDropdown.tsx index 2d467b1864a..3053db7a22c 100644 --- a/studio/components/layouts/AppLayout/ProjectDropdown.tsx +++ b/studio/components/layouts/AppLayout/ProjectDropdown.tsx @@ -105,6 +105,8 @@ const ProjectDropdown = () => { const projectNameRef = useRef(null) const { data: allProjects, isLoading: isLoadingProjects } = useProjectsQuery() + console.log({ selectedProject }) + const isOrgBilling = !!selectedOrganization?.subscription_id const { data: subscription, isSuccess } = useProjectSubscriptionV2Query( { projectRef: selectedProject?.ref }, diff --git a/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx b/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx index b1e37ff0ef9..9a52dda2c84 100644 --- a/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx +++ b/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx @@ -1,7 +1,6 @@ -import Link from 'next/link' +import Link from 'components/ui/ProjectLink' import { useRouter } from 'next/router' import { - DropdownMenuCheckboxItem_Shadcn_, DropdownMenuContent_Shadcn_, DropdownMenuGroup_Shadcn_, DropdownMenuItem_Shadcn_, @@ -10,12 +9,8 @@ import { DropdownMenuRadioItem_Shadcn_, DropdownMenuSeparator_Shadcn_, DropdownMenuShortcut_Shadcn_, - DropdownMenuSubContent_Shadcn_, - DropdownMenuSubTrigger_Shadcn_, - DropdownMenuSub_Shadcn_, DropdownMenuTrigger_Shadcn_, DropdownMenu_Shadcn_, - IconCheck, IconUser, useCommandMenu, } from 'ui' diff --git a/studio/components/ui/ProjectLink.tsx b/studio/components/ui/ProjectLink.tsx new file mode 100644 index 00000000000..5e956142497 --- /dev/null +++ b/studio/components/ui/ProjectLink.tsx @@ -0,0 +1,103 @@ +import NextLink, { LinkProps as NextLinkProps } from 'next/link' +import { useRouter } from 'next/router' +import { forwardRef, PropsWithChildren, PropsWithoutRef, useMemo } from 'react' + +export type Override = Omit & T2 + +function isValidUrl(urlString: string) { + try { + return Boolean(new URL(urlString)) + } catch (e) { + return false + } +} + +const ProjectLink = forwardRef< + HTMLAnchorElement, + Override< + Override, NextLinkProps>, + PropsWithChildren<{}> + > +>( + ( + { + href, + as: nextAs, + prefetch, + replace, + scroll, + shallow, + children, + className: overrideClassName, + ...props + }, + ref + ) => { + const { query } = useRouter() + const { branch } = query + + const hrefWithBranch = useMemo(() => { + if (typeof href === 'string') { + const url = isValidUrl(href) + ? new URL(href) + : new URL(`https://supabase.com/${href.replace(/^\//, '')}`) + const branchId = url.searchParams.get('branch') + url.searchParams.delete('branch') + + const existingSearchParams = [...url.searchParams.entries()].reduce( + (acc, [key, value]) => { + let temp = acc[key] + if (temp) { + if (Array.isArray(temp)) { + temp.push(value) + } else { + temp = [temp, value] + } + } else { + temp = value + } + acc[key] = temp + return acc + }, + {} as { + [key: string]: string | string[] + } + ) + + return { + pathname: url.pathname, + query: { + ...((branch !== undefined || branchId !== null) && { branch: branchId || branch }), + ...existingSearchParams, + }, + } + } else { + return { + ...href, + query: { ...(branch !== undefined && { branch }), ...(href.query as object) }, + } + } + }, [branch, href]) + + return ( + + {children} + + ) + } +) + +ProjectLink.displayName = 'ProjectLink' +export default ProjectLink diff --git a/studio/data/api.d.ts b/studio/data/api.d.ts index c7f8b7dc44a..5a30a524614 100644 --- a/studio/data/api.d.ts +++ b/studio/data/api.d.ts @@ -3127,6 +3127,7 @@ export interface components { id: string; name: string; project_ref: string; + parent_project_ref: string; is_default: boolean; git_branch: string; created_at: string; diff --git a/studio/data/branches/branches-query.ts b/studio/data/branches/branches-query.ts index d7ed5e31e66..6b8bfe4966c 100644 --- a/studio/data/branches/branches-query.ts +++ b/studio/data/branches/branches-query.ts @@ -2,18 +2,27 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { get } from 'data/fetchers' import { branchKeys } from './keys' import { ResponseError } from 'types' +import { components } from 'data/api' export type BranchesVariables = { projectRef?: string } +export type Branch = components['schemas']['BranchResponse'] + export async function getBranches({ projectRef }: BranchesVariables, signal?: AbortSignal) { const { data, error } = await get(`/v1/projects/{ref}/branches`, { params: { path: { ref: projectRef ?? '' } }, signal, }) - if (error) throw error + if (error) { + if ((error as ResponseError).message === 'Preview branching is not enabled for this project.') { + return [] + } else { + throw error + } + } return data } diff --git a/studio/pages/project/[ref]/branches.tsx b/studio/pages/project/[ref]/branches.tsx new file mode 100644 index 00000000000..bee6a18c20a --- /dev/null +++ b/studio/pages/project/[ref]/branches.tsx @@ -0,0 +1,17 @@ +import BranchManagement from 'components/interfaces/BranchManagement/BranchManagement' +import { ProjectLayoutWithAuth } from 'components/layouts' +import { NextPageWithLayout } from 'types' + +const BranchManagementPage: NextPageWithLayout = () => { + return +} + +BranchManagementPage.getLayout = (page) => ( + +
    + {page} +
    +
    +) + +export default BranchManagementPage From 06de0750e3a351949482c8c0fc22f8efbcd3738f Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 26 Jul 2023 17:05:12 +0800 Subject: [PATCH 05/13] Implement branch switching logic --- .../BranchManagement/BranchManagement.tsx | 10 +- .../BranchManagement/BranchPanels.tsx | 4 +- .../layouts/AppLayout/AppHeader.tsx | 5 +- .../layouts/AppLayout/BranchDropdown.tsx | 22 +-- .../layouts/AppLayout/ProjectDropdown.tsx | 9 +- .../AppLayout/UserSettingsDropdown.tsx | 2 +- .../layouts/ProjectLayout/ProjectLayout.tsx | 7 +- studio/components/ui/ProjectLink.tsx | 103 -------------- studio/data/api.d.ts | 128 +++++++++++++++--- studio/data/branches/branches-query.ts | 4 +- studio/types/base.ts | 2 + 11 files changed, 151 insertions(+), 145 deletions(-) delete mode 100644 studio/components/ui/ProjectLink.tsx diff --git a/studio/components/interfaces/BranchManagement/BranchManagement.tsx b/studio/components/interfaces/BranchManagement/BranchManagement.tsx index 012f82d1cf7..adbca4688e1 100644 --- a/studio/components/interfaces/BranchManagement/BranchManagement.tsx +++ b/studio/components/interfaces/BranchManagement/BranchManagement.tsx @@ -9,12 +9,18 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useBranchDeleteMutation } from 'data/branches/branch-delete-mutation' import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' import { Branch, useBranchesQuery } from 'data/branches/branches-query' -import { useStore } from 'hooks' +import { useSelectedProject, useStore } from 'hooks' import { BranchHeader, BranchPanel } from './BranchPanels' const BranchManagement = () => { const { ui } = useStore() - const { ref: projectRef } = useParams() + const { ref } = useParams() + const projectDetails = useSelectedProject() + + const isBranch = projectDetails?.parent_project_ref !== undefined + const projectRef = + projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined + const [selectedBranchToUpdate, setSelectedBranchToUpdate] = useState() const [selectedBranchToDelete, setSelectedBranchToDelete] = useState() diff --git a/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/studio/components/interfaces/BranchManagement/BranchPanels.tsx index 92667e38dc1..c39052f64c1 100644 --- a/studio/components/interfaces/BranchManagement/BranchPanels.tsx +++ b/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -41,7 +41,7 @@ const BranchPanel = ({ if (branch?.is_default) { return ( -
    +

    {branch?.name}

    @@ -71,7 +71,7 @@ const BranchPanel = ({ return (
  • -
    +

    {branch?.name}

    diff --git a/studio/components/layouts/AppLayout/AppHeader.tsx b/studio/components/layouts/AppLayout/AppHeader.tsx index 8879ac701d5..e6bcbe9bca9 100644 --- a/studio/components/layouts/AppLayout/AppHeader.tsx +++ b/studio/components/layouts/AppLayout/AppHeader.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useParams } from 'common' -import { useSelectedOrganization } from 'hooks' +import { useFlag, useSelectedOrganization } from 'hooks' import FeedbackDropdown from '../ProjectLayout/LayoutHeader/FeedbackDropdown' import HelpPopover from '../ProjectLayout/LayoutHeader/HelpPopover' import NotificationsPopover from '../ProjectLayout/LayoutHeader/NotificationsPopover' @@ -16,6 +16,7 @@ const AppHeader = () => { const router = useRouter() const { ref } = useParams() const organization = useSelectedOrganization() + const enableBranchManagement = useFlag('branchManagement') return (
    @@ -31,7 +32,7 @@ const AppHeader = () => { {ref !== undefined && } - {ref !== undefined && } + {ref !== undefined && enableBranchManagement && }
    diff --git a/studio/components/layouts/AppLayout/BranchDropdown.tsx b/studio/components/layouts/AppLayout/BranchDropdown.tsx index 47c8b62150c..adbbf63d607 100644 --- a/studio/components/layouts/AppLayout/BranchDropdown.tsx +++ b/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -21,20 +21,22 @@ import { PopoverTrigger_Shadcn_, Popover_Shadcn_, } from 'ui' +import { useSelectedProject } from 'hooks' const BranchDropdown = () => { const router = useRouter() - const { ref: projectRef } = useParams() + const { ref } = useParams() + const projectDetails = useSelectedProject() const branchNameRef = useRef(null) + + const isBranch = projectDetails?.parent_project_ref !== undefined + const projectRef = + projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined const { data: branches, isLoading, isError, isSuccess } = useBranchesQuery({ projectRef }) const [open, setOpen] = useState(false) const popoverOffset = (branchNameRef.current?.offsetWidth ?? 0) + 12 - const selectedBranch = branches?.find((branch) => branch.project_ref === projectRef) - - const onSwitchBranch = async () => { - console.log('Fetch branch details and update connection string or something') - } + const selectedBranch = branches?.find((branch) => branch.project_ref === ref) return ( <> @@ -47,7 +49,11 @@ const BranchDropdown = () => {

    {selectedBranch?.name}

    - {selectedBranch?.is_default && Production} + {selectedBranch?.is_default ? ( + Production + ) : ( + Branch + )}
    @@ -84,11 +90,9 @@ const BranchDropdown = () => { className="cursor-pointer w-full flex items-center justify-between" onSelect={() => { setOpen(false) - onSwitchBranch() router.push(href) }} onClick={() => { - onSwitchBranch() setOpen(false) }} > diff --git a/studio/components/layouts/AppLayout/ProjectDropdown.tsx b/studio/components/layouts/AppLayout/ProjectDropdown.tsx index 411531e687e..70cba5c7c7e 100644 --- a/studio/components/layouts/AppLayout/ProjectDropdown.tsx +++ b/studio/components/layouts/AppLayout/ProjectDropdown.tsx @@ -106,7 +106,10 @@ const ProjectDropdown = () => { const projectNameRef = useRef(null) const { data: allProjects, isLoading: isLoadingProjects } = useProjectsQuery() - console.log({ selectedProject }) + const isBranch = projectDetails?.parent_project_ref !== undefined + const parentProject = isBranch + ? allProjects?.find((project) => project.ref === projectDetails.parent_project_ref) + : undefined const isOrgBilling = !!selectedOrganization?.subscription_id const { data: subscription, isSuccess } = useProjectSubscriptionV2Query( @@ -117,7 +120,9 @@ const ProjectDropdown = () => { ?.filter((x) => x.status !== PROJECT_STATUS.INACTIVE) .filter((x) => x.organization_id === selectedOrganization?.id) .sort((a, b) => a.name.localeCompare(b.name)) - const selectedProject = projectDetails || projects?.find((project) => project.ref === ref) + const selectedProject = isBranch + ? parentProject + : projectDetails || projects?.find((project) => project.ref === ref) const [open, setOpen] = useState(false) const popoverOffset = (projectNameRef.current?.offsetWidth ?? 0) + 12 diff --git a/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx b/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx index 9a52dda2c84..51bba7ff4d7 100644 --- a/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx +++ b/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx @@ -1,4 +1,4 @@ -import Link from 'components/ui/ProjectLink' +import Link from 'next/link' import { useRouter } from 'next/router' import { DropdownMenuContent_Shadcn_, diff --git a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 4b23582c1ef..10116f2f31f 100644 --- a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -29,6 +29,8 @@ const routesToIgnoreProjectDetailsRequest = [ '/project/[ref]/settings/billing/invoices', ] +const routesToIgnoreDBConnection = ['/project/[ref]/branches'] + const routesToIgnorePostgrestConnection = [ '/project/[ref]/reports', '/project/[ref]/settings/general', @@ -161,8 +163,9 @@ const ContentWrapper = ({ isLoading, children }: ContentWrapperProps) => { const router = useRouter() const requiresDbConnection: boolean = - !router.pathname.includes('/project/[ref]/settings') || - router.pathname.includes('/project/[ref]/settings/vault') + (!router.pathname.includes('/project/[ref]/settings') && + !routesToIgnoreDBConnection.includes(router.pathname)) || + router.pathname === '/project/[ref]/settings/vault' const requiresPostgrestConnection = !routesToIgnorePostgrestConnection.includes(router.pathname) const requiresProjectDetails = !routesToIgnoreProjectDetailsRequest.includes(router.pathname) diff --git a/studio/components/ui/ProjectLink.tsx b/studio/components/ui/ProjectLink.tsx deleted file mode 100644 index 5e956142497..00000000000 --- a/studio/components/ui/ProjectLink.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import NextLink, { LinkProps as NextLinkProps } from 'next/link' -import { useRouter } from 'next/router' -import { forwardRef, PropsWithChildren, PropsWithoutRef, useMemo } from 'react' - -export type Override = Omit & T2 - -function isValidUrl(urlString: string) { - try { - return Boolean(new URL(urlString)) - } catch (e) { - return false - } -} - -const ProjectLink = forwardRef< - HTMLAnchorElement, - Override< - Override, NextLinkProps>, - PropsWithChildren<{}> - > ->( - ( - { - href, - as: nextAs, - prefetch, - replace, - scroll, - shallow, - children, - className: overrideClassName, - ...props - }, - ref - ) => { - const { query } = useRouter() - const { branch } = query - - const hrefWithBranch = useMemo(() => { - if (typeof href === 'string') { - const url = isValidUrl(href) - ? new URL(href) - : new URL(`https://supabase.com/${href.replace(/^\//, '')}`) - const branchId = url.searchParams.get('branch') - url.searchParams.delete('branch') - - const existingSearchParams = [...url.searchParams.entries()].reduce( - (acc, [key, value]) => { - let temp = acc[key] - if (temp) { - if (Array.isArray(temp)) { - temp.push(value) - } else { - temp = [temp, value] - } - } else { - temp = value - } - acc[key] = temp - return acc - }, - {} as { - [key: string]: string | string[] - } - ) - - return { - pathname: url.pathname, - query: { - ...((branch !== undefined || branchId !== null) && { branch: branchId || branch }), - ...existingSearchParams, - }, - } - } else { - return { - ...href, - query: { ...(branch !== undefined && { branch }), ...(href.query as object) }, - } - } - }, [branch, href]) - - return ( - - {children} - - ) - } -) - -ProjectLink.displayName = 'ProjectLink' -export default ProjectLink diff --git a/studio/data/api.d.ts b/studio/data/api.d.ts index 5a30a524614..8a16ffd26b7 100644 --- a/studio/data/api.d.ts +++ b/studio/data/api.d.ts @@ -1489,7 +1489,6 @@ export interface paths { post: operations["OAuthAppsController_createOAuthApp"]; }; "/v1/organizations/{slug}/oauth/apps/{id}": { - get: operations["OAuthAppsController_getOAuthApp"]; /** Update an oauth app */ put: operations["OAuthAppsController_updateOAuthApp"]; /** Remove a published oauth app */ @@ -3154,6 +3153,7 @@ export interface components { maxDatabasePreprovisionGb?: number; lastDatabaseResizeAt?: string; preview_branches: (components["schemas"]["BranchResponse"])[]; + parent_project_ref?: string; }; ProjectRefResponse: { id: number; @@ -4146,19 +4146,56 @@ export interface components { created_at?: string; updated_at?: string; }; + OAuthAppResponse: { + id: string; + name: string; + website: string; + icon?: string; + authorized_at?: string; + created_at?: string; + client_id?: string; + client_secret_alias?: string; + redirect_uris?: (string)[]; + }; CreateOAuthAppBody: { name: string; website: string; icon?: string; redirect_uris: (string)[]; }; - UpdateOAuthAppBody: { + CreateOAuthAppResponse: { + id: string; + client_id: string; + client_secret: string; + }; + PutOAuthAppResponse: { + id: string; + client_id: string; + client_secret_alias: string; + created_at: string; name: string; website: string; - redirect_uris: (string)[]; icon?: string; + redirect_uris: (string)[]; }; - TokenDTO: { + RevokeAuthorizedOAuthAppResponse: { + id: string; + name: string; + website: string; + icon?: string; + authorized_at: string; + }; + DeleteOAuthAppResponse: { + id: string; + name: string; + website: string; + icon?: string; + created_at: string; + client_id: string; + client_secret_alias: string; + redirect_uris: (string)[]; + }; + OAuthTokenBody: { /** @enum {string} */ grant_type: "authorization_code" | "refresh_token"; client_id: string; @@ -4168,9 +4205,31 @@ export interface components { redirect_uri?: string; refresh_token?: string; }; + OAuthTokenResponse: { + /** @enum {string} */ + token_type: "Bearer"; + access_token: string; + refresh_token: string; + expires_in: number; + }; + GetAuthorizationResponse: { + name: string; + website: string; + icon?: string; + domain: string; + expires_at: string; + approved_at?: string; + approved_organization_slug?: string; + }; AuthorizationsApproveBody: { organization_id: string; }; + ApproveAuthorizationResponse: { + url: string; + }; + DeclineAuthorizationResponse: { + id: string; + }; }; responses: never; parameters: never; @@ -9754,7 +9813,7 @@ export interface operations { responses: { 200: { content: { - "application/json": Record; + "application/json": (components["schemas"]["OAuthAppResponse"])[]; }; }; }; @@ -9772,12 +9831,11 @@ export interface operations { }; }; responses: { - 201: never; - }; - }; - OAuthAppsController_getOAuthApp: { - responses: { - 200: never; + 201: { + content: { + "application/json": components["schemas"]["CreateOAuthAppResponse"]; + }; + }; }; }; /** Update an oauth app */ @@ -9790,11 +9848,15 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["UpdateOAuthAppBody"]; + "application/json": components["schemas"]["CreateOAuthAppBody"]; }; }; responses: { - 200: never; + 200: { + content: { + "application/json": components["schemas"]["PutOAuthAppResponse"]; + }; + }; }; }; /** Remove a published oauth app */ @@ -9806,7 +9868,11 @@ export interface operations { }; }; responses: { - 200: never; + 200: { + content: { + "application/json": components["schemas"]["DeleteOAuthAppResponse"]; + }; + }; }; }; /** Revoke an authorized oauth app */ @@ -9818,7 +9884,11 @@ export interface operations { }; }; responses: { - 201: never; + 201: { + content: { + "application/json": components["schemas"]["RevokeAuthorizedOAuthAppResponse"]; + }; + }; }; }; /** Authorize user through oauth */ @@ -9836,18 +9906,22 @@ export interface operations { }; }; responses: { - 200: never; + 303: never; }; }; /** Exchange auth code for user's access and refresh token */ OAuthController_token: { requestBody: { content: { - "application/x-www-form-urlencoded": components["schemas"]["TokenDTO"]; + "application/x-www-form-urlencoded": components["schemas"]["OAuthTokenBody"]; }; }; responses: { - 201: never; + 201: { + content: { + "application/json": components["schemas"]["OAuthTokenResponse"]; + }; + }; }; }; AuthorizationsController_getAuthorizationRequest: { @@ -9857,7 +9931,11 @@ export interface operations { }; }; responses: { - 200: never; + 200: { + content: { + "application/json": components["schemas"]["GetAuthorizationResponse"]; + }; + }; }; }; /** Approve oauth app authorization request */ @@ -9873,7 +9951,11 @@ export interface operations { }; }; responses: { - 201: never; + 201: { + content: { + "application/json": components["schemas"]["ApproveAuthorizationResponse"]; + }; + }; }; }; /** Decline oauth app authorization request */ @@ -9884,7 +9966,11 @@ export interface operations { }; }; responses: { - 200: never; + 200: { + content: { + "application/json": components["schemas"]["DeclineAuthorizationResponse"]; + }; + }; }; }; } diff --git a/studio/data/branches/branches-query.ts b/studio/data/branches/branches-query.ts index 6b8bfe4966c..b7dd3bf0042 100644 --- a/studio/data/branches/branches-query.ts +++ b/studio/data/branches/branches-query.ts @@ -11,8 +11,10 @@ export type BranchesVariables = { export type Branch = components['schemas']['BranchResponse'] export async function getBranches({ projectRef }: BranchesVariables, signal?: AbortSignal) { + if (!projectRef) throw new Error('Project ref is required') + const { data, error } = await get(`/v1/projects/{ref}/branches`, { - params: { path: { ref: projectRef ?? '' } }, + params: { path: { ref: projectRef } }, signal, }) diff --git a/studio/types/base.ts b/studio/types/base.ts index 706ea36740d..6cb3096544a 100644 --- a/studio/types/base.ts +++ b/studio/types/base.ts @@ -22,6 +22,7 @@ export interface ProjectBase { region: string inserted_at: string subscription_id: string + preview_branches: any[] } export interface Project extends ProjectBase { @@ -32,6 +33,7 @@ export interface Project extends ProjectBase { restUrl?: string lastDatabaseResizeAt?: string | null maxDatabasePreprovisionGb?: string | null + parent_project_ref?: string /** * postgrestStatus is available on client side only. From d3f362458bfa6d572b0aebbc733652b6b86dbdc0 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 26 Jul 2023 17:33:42 +0800 Subject: [PATCH 06/13] Implement branch update --- .../BranchManagement/BranchManagement.tsx | 30 +++--- .../BranchManagement/BranchPanels.tsx | 50 ++++++---- .../UpdateBranchSidePanel.tsx | 97 +++++++++++++++++++ .../layouts/AppLayout/BranchDropdown.tsx | 2 +- 4 files changed, 149 insertions(+), 30 deletions(-) create mode 100644 studio/components/interfaces/BranchManagement/UpdateBranchSidePanel.tsx diff --git a/studio/components/interfaces/BranchManagement/BranchManagement.tsx b/studio/components/interfaces/BranchManagement/BranchManagement.tsx index adbca4688e1..3192f449c0d 100644 --- a/studio/components/interfaces/BranchManagement/BranchManagement.tsx +++ b/studio/components/interfaces/BranchManagement/BranchManagement.tsx @@ -7,10 +7,11 @@ import AlertError from 'components/ui/AlertError' import TextConfirmModal from 'components/ui/Modals/TextConfirmModal' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useBranchDeleteMutation } from 'data/branches/branch-delete-mutation' -import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' import { Branch, useBranchesQuery } from 'data/branches/branches-query' import { useSelectedProject, useStore } from 'hooks' -import { BranchHeader, BranchPanel } from './BranchPanels' +import { Button, IconSearch, Input } from 'ui' +import { BranchHeader, BranchPanel, MainBranchPanel } from './BranchPanels' +import UpdateBranchSidePanel from './UpdateBranchSidePanel' const BranchManagement = () => { const { ui } = useStore() @@ -27,12 +28,6 @@ const BranchManagement = () => { const { data: branches, error, isLoading, isError, isSuccess } = useBranchesQuery({ projectRef }) const [[mainBranch], previewBranches] = partition(branches, (branch) => branch.is_default) - const { mutate: updateBranch, isLoading: isUpdating } = useBranchUpdateMutation({ - onSuccess: () => { - setSelectedBranchToUpdate(undefined) - ui.setNotification({ category: 'success', message: 'Successfully updated branch' }) - }, - }) const { mutate: deleteBranch, isLoading: isDeleting } = useBranchDeleteMutation({ onSuccess: () => { setSelectedBranchToDelete(undefined) @@ -51,19 +46,25 @@ const BranchManagement = () => {
    -

    Branches

    -
    +

    Branch Manager

    +
    +
    + } /> +
    + +
    +
    {isLoading && } {isError && } {isSuccess && ( <> - {}} /> + {}} /> {previewBranches.map((branch) => ( {}} + onSelectUpdate={() => setSelectedBranchToUpdate(branch)} onSelectDelete={() => setSelectedBranchToDelete(branch)} /> ))} @@ -87,6 +88,11 @@ const BranchManagement = () => { text={`This will delete your branch "${selectedBranchToDelete?.name}"`} alert="You cannot recover this branch once it is deleted!" /> + + setSelectedBranchToUpdate(undefined)} + /> ) } diff --git a/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/studio/components/interfaces/BranchManagement/BranchPanels.tsx index c39052f64c1..5b23a440225 100644 --- a/studio/components/interfaces/BranchManagement/BranchPanels.tsx +++ b/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -9,6 +9,7 @@ import { DropdownMenu_Shadcn_, IconEdit, IconGitBranch, + IconGitHub, IconMoreVertical, IconShield, IconTrash, @@ -26,30 +27,31 @@ interface BranchPanelProps { onSelectDelete?: () => void } -const BranchPanel = ({ - branch, - onSelectUpdate = noop, - onSelectDelete = noop, -}: BranchPanelProps) => { +const MainBranchPanel = ({ branch, onSelectUpdate = noop }: BranchPanelProps) => { const { ref } = useParams() const [open, setOpen] = useState(false) - const isActive = ref === branch?.project_ref - const daysFromNow = dayjs().diff(dayjs(branch?.created_at), 'day') - const formattedTimeFromNow = dayjs(branch?.created_at).fromNow() - const formattedCreatedAt = dayjs(branch?.created_at).format('DD MMM YYYY, HH:mm:ss (ZZ)') - if (branch?.is_default) { - return ( -
    + return ( +
    +
    - +
    + +
    +

    Github branch workflow

    +
    +
    +
    +
    +
    + +

    {branch?.name}

    Production {isActive && Selected}
    - setOpen(!open)} modal={false}>
    - ) - } +
    + ) +} + +const BranchPanel = ({ + branch, + onSelectUpdate = noop, + onSelectDelete = noop, +}: BranchPanelProps) => { + const { ref } = useParams() + const [open, setOpen] = useState(false) + + const isActive = ref === branch?.project_ref + const daysFromNow = dayjs().diff(dayjs(branch?.created_at), 'day') + const formattedTimeFromNow = dayjs(branch?.created_at).fromNow() + const formattedCreatedAt = dayjs(branch?.created_at).format('DD MMM YYYY, HH:mm:ss (ZZ)') return (
  • @@ -129,4 +145,4 @@ const BranchHeader = forwardRef( ) BranchHeader.displayName = 'BranchHeader' -export { BranchHeader, BranchPanel } +export { BranchHeader, MainBranchPanel, BranchPanel } diff --git a/studio/components/interfaces/BranchManagement/UpdateBranchSidePanel.tsx b/studio/components/interfaces/BranchManagement/UpdateBranchSidePanel.tsx new file mode 100644 index 00000000000..4899a922c11 --- /dev/null +++ b/studio/components/interfaces/BranchManagement/UpdateBranchSidePanel.tsx @@ -0,0 +1,97 @@ +import { useParams } from 'common' +import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' +import { Branch } from 'data/branches/branches-query' +import { useStore } from 'hooks' +import { useRef } from 'react' +import { Button, Form, Input, SidePanel } from 'ui' + +interface UpdateBranchSidePanelProps { + selectedBranch?: Branch + onClose: () => void +} + +const UpdateBranchSidePanel = ({ selectedBranch, onClose }: UpdateBranchSidePanelProps) => { + const { ui } = useStore() + const submitRef: any = useRef() + const { ref: projectRef } = useParams() + const { mutate: updateBranch, isLoading: isUpdating } = useBranchUpdateMutation({ + onSuccess: () => { + onClose() + ui.setNotification({ category: 'success', message: 'Successfully updated branch' }) + }, + }) + + const formId = 'update-branch-form' + const initialValues = { + branchName: selectedBranch?.name, + gitBranch: selectedBranch?.git_branch, + } + + const validate = (values: any) => { + const errors: any = {} + if (values.branchName.length === 0) errors.branchName = 'Please provide a branch name' + if (values.gitBranch.length === 0) errors.gitBranch = 'Please provide a Git branch' + return errors + } + + const onConfirmUpdate = (values: any) => { + if (selectedBranch === undefined) return console.error('No branch selected') + if (projectRef === undefined) return console.error('Project ref is required') + updateBranch({ ...values, id: selectedBranch.id, projectRef }) + } + + return ( + + + +
  • + } + > +
    + +
    + {() => { + return ( +
    + + +
    + ) + }} +
    +
    +
    + + ) +} + +export default UpdateBranchSidePanel diff --git a/studio/components/layouts/AppLayout/BranchDropdown.tsx b/studio/components/layouts/AppLayout/BranchDropdown.tsx index adbbf63d607..974e3f08085 100644 --- a/studio/components/layouts/AppLayout/BranchDropdown.tsx +++ b/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -52,7 +52,7 @@ const BranchDropdown = () => { {selectedBranch?.is_default ? ( Production ) : ( - Branch + Preview Branch )} From def5e104f274902c6e1280393b6d85877bb045aa Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 26 Jul 2023 18:08:35 +0800 Subject: [PATCH 07/13] Implement updated pull requests and preview branches UIO --- .../BranchManagement/BranchManagement.tsx | 17 ++-- .../BranchManagement/BranchPanels.tsx | 86 +++++++++++-------- .../BranchManagement/PreviewBranches.tsx | 61 +++++++++++++ .../BranchManagement/PullRequests.tsx | 59 +++++++++++++ 4 files changed, 177 insertions(+), 46 deletions(-) create mode 100644 studio/components/interfaces/BranchManagement/PreviewBranches.tsx create mode 100644 studio/components/interfaces/BranchManagement/PullRequests.tsx diff --git a/studio/components/interfaces/BranchManagement/BranchManagement.tsx b/studio/components/interfaces/BranchManagement/BranchManagement.tsx index 3192f449c0d..da56682d816 100644 --- a/studio/components/interfaces/BranchManagement/BranchManagement.tsx +++ b/studio/components/interfaces/BranchManagement/BranchManagement.tsx @@ -12,6 +12,8 @@ import { useSelectedProject, useStore } from 'hooks' import { Button, IconSearch, Input } from 'ui' import { BranchHeader, BranchPanel, MainBranchPanel } from './BranchPanels' import UpdateBranchSidePanel from './UpdateBranchSidePanel' +import PullRequests from './PullRequests' +import PreviewBranches from './PreviewBranches' const BranchManagement = () => { const { ui } = useStore() @@ -59,15 +61,12 @@ const BranchManagement = () => { {isSuccess && ( <> {}} /> - - {previewBranches.map((branch) => ( - setSelectedBranchToUpdate(branch)} - onSelectDelete={() => setSelectedBranchToDelete(branch)} - /> - ))} + + )}
    diff --git a/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/studio/components/interfaces/BranchManagement/BranchPanels.tsx index 5b23a440225..c65a293c92a 100644 --- a/studio/components/interfaces/BranchManagement/BranchPanels.tsx +++ b/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -1,5 +1,7 @@ +import clsx from 'clsx' import dayjs from 'dayjs' -import { forwardRef, useState } from 'react' +import { noop } from 'lodash' +import { PropsWithChildren, forwardRef, useState } from 'react' import { Badge, Button, @@ -16,10 +18,9 @@ import { cn, } from 'ui' +import { useParams } from 'common' import { Markdown } from 'components/interfaces/Markdown' import { Branch } from 'data/branches/branches-query' -import { noop } from 'lodash' -import { useParams } from 'common' interface BranchPanelProps { branch?: Branch @@ -71,6 +72,22 @@ const MainBranchPanel = ({ branch, onSelectUpdate = noop }: BranchPanelProps) => ) } +const BranchContainer = ({ className, children }: PropsWithChildren<{ className?: string }>) => { + return ( +
    +
    +
    + {children} +
    +
    + ) +} + const BranchPanel = ({ branch, onSelectUpdate = noop, @@ -85,40 +102,35 @@ const BranchPanel = ({ const formattedCreatedAt = dayjs(branch?.created_at).format('DD MMM YYYY, HH:mm:ss (ZZ)') return ( -
  • -
    -
    -
    - -

    {branch?.name}

    - {isActive && Selected} -

    - {daysFromNow > 1 - ? `Created on ${formattedCreatedAt}` - : `Created ${formattedTimeFromNow}`} -

    -
    -
    - setOpen(!open)} modal={false}> - - - - - onSelectUpdate()}> - - Edit branch - - onSelectDelete()}> - - Delete branch - - - -
    + +
    + +

    {branch?.name}

    + {isActive && Selected} +

    + {daysFromNow > 1 ? `Created on ${formattedCreatedAt}` : `Created ${formattedTimeFromNow}`} +

    -
  • +
    + setOpen(!open)} modal={false}> + + + + + onSelectUpdate()}> + + Edit branch + + onSelectDelete()}> + + Delete branch + + + +
    + ) } @@ -145,4 +157,4 @@ const BranchHeader = forwardRef( ) BranchHeader.displayName = 'BranchHeader' -export { BranchHeader, MainBranchPanel, BranchPanel } +export { BranchHeader, BranchContainer, BranchPanel, MainBranchPanel } diff --git a/studio/components/interfaces/BranchManagement/PreviewBranches.tsx b/studio/components/interfaces/BranchManagement/PreviewBranches.tsx new file mode 100644 index 00000000000..4bd7f567b27 --- /dev/null +++ b/studio/components/interfaces/BranchManagement/PreviewBranches.tsx @@ -0,0 +1,61 @@ +import { Branch } from 'data/branches/branches-query' +import { BranchContainer, BranchHeader, BranchPanel } from './BranchPanels' +import { Button, IconExternalLink, IconGitBranch } from 'ui' + +interface PreviewBranchesProps { + previewBranches: Branch[] + onSelectUpdateBranch: (branch: Branch) => void + onSelectDeleteBranch: (branch: Branch) => void +} + +const PreviewBranches = ({ + previewBranches, + onSelectUpdateBranch, + onSelectDeleteBranch, +}: PreviewBranchesProps) => { + return ( + <> + 0 ? `#### Preview branches` : undefined} /> + {previewBranches.length === 0 ? ( + +
    +

    No database preview branches

    +

    Database preview branches will be shown here

    +
    +
    +
    + +
    +

    Create a preview branch

    +

    Start developing in preview

    +
    +
    + +
    +
    +
    +

    Not sure what to do?

    +

    Browse our documentation

    +
    + +
    +
    +
    +
    + ) : ( + previewBranches.map((branch) => ( + onSelectUpdateBranch(branch)} + onSelectDelete={() => onSelectDeleteBranch(branch)} + /> + )) + )} + + ) +} + +export default PreviewBranches diff --git a/studio/components/interfaces/BranchManagement/PullRequests.tsx b/studio/components/interfaces/BranchManagement/PullRequests.tsx new file mode 100644 index 00000000000..0e1fd0aeefd --- /dev/null +++ b/studio/components/interfaces/BranchManagement/PullRequests.tsx @@ -0,0 +1,59 @@ +import { Branch } from 'data/branches/branches-query' +import { BranchContainer, BranchHeader } from './BranchPanels' +import { Button, IconExternalLink, IconGitBranch } from 'ui' + +interface PullRequestsProps { + previewBranches: Branch[] +} + +const PullRequests = ({ previewBranches }: PullRequestsProps) => { + const pullRequests = [] + + return ( + <> + 0 && previewBranches.length > 0 + ? `#### Open pull requests` + : undefined + } + /> + {previewBranches.length === 0 || pullRequests.length === 0 ? ( + +
    +

    No pull requests made yet for this repository

    +

    + Only pull requests with the ./migration directory changes will show here. +

    + {previewBranches.length > 0 && ( +
    +
    +
    + +

    Create a pull request

    +
    + +
    +
    +
    +

    Not sure what to do?

    +

    Browse our documentation

    +
    + +
    +
    + )} +
    +
    + ) : ( + State 3 + )} + + ) +} + +export default PullRequests From 67c95661b231c771805f94d63f706f6d6b07793a Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 27 Jul 2023 11:43:45 +0800 Subject: [PATCH 08/13] Address feedback and scaffold create branch side panel --- .../BranchManagement/BranchManagement.tsx | 17 ++- .../CreateBranchSidePanel.tsx | 121 ++++++++++++++++++ .../layouts/AppLayout/BranchDropdown.tsx | 24 ++-- .../layouts/AppLayout/ProjectDropdown.tsx | 2 +- 4 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx diff --git a/studio/components/interfaces/BranchManagement/BranchManagement.tsx b/studio/components/interfaces/BranchManagement/BranchManagement.tsx index da56682d816..f2e3f37db6e 100644 --- a/studio/components/interfaces/BranchManagement/BranchManagement.tsx +++ b/studio/components/interfaces/BranchManagement/BranchManagement.tsx @@ -1,5 +1,6 @@ import { partition } from 'lodash' import { useState } from 'react' +import { Button, IconSearch, Input } from 'ui' import { useParams } from 'common' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' @@ -9,11 +10,11 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useBranchDeleteMutation } from 'data/branches/branch-delete-mutation' import { Branch, useBranchesQuery } from 'data/branches/branches-query' import { useSelectedProject, useStore } from 'hooks' -import { Button, IconSearch, Input } from 'ui' -import { BranchHeader, BranchPanel, MainBranchPanel } from './BranchPanels' -import UpdateBranchSidePanel from './UpdateBranchSidePanel' -import PullRequests from './PullRequests' +import { MainBranchPanel } from './BranchPanels' +import CreateBranchSidePanel from './CreateBranchSidePanel' import PreviewBranches from './PreviewBranches' +import PullRequests from './PullRequests' +import UpdateBranchSidePanel from './UpdateBranchSidePanel' const BranchManagement = () => { const { ui } = useStore() @@ -24,6 +25,7 @@ const BranchManagement = () => { const projectRef = projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined + const [showCreateBranch, setShowCreateBranch] = useState(false) const [selectedBranchToUpdate, setSelectedBranchToUpdate] = useState() const [selectedBranchToDelete, setSelectedBranchToDelete] = useState() @@ -53,7 +55,7 @@ const BranchManagement = () => {
    } />
    - +
    {isLoading && } @@ -88,6 +90,11 @@ const BranchManagement = () => { alert="You cannot recover this branch once it is deleted!" /> + setShowCreateBranch(false)} + /> + setSelectedBranchToUpdate(undefined)} diff --git a/studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx b/studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx new file mode 100644 index 00000000000..2d375152ec6 --- /dev/null +++ b/studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx @@ -0,0 +1,121 @@ +import { useRef } from 'react' +import { Button, Form, Input, Listbox, SidePanel } from 'ui' + +import { useParams } from 'common' +import { useBranchCreateMutation } from 'data/branches/branch-create-mutation' +import { useStore } from 'hooks' + +interface CreateBranchSidePanelProps { + visible: boolean + onClose: () => void +} + +const MOCK_BRANCHES = [ + { id: 1, name: 'feat/branch-01' }, + { id: 2, name: 'feat/branch-02' }, +] + +const CreateBranchSidePanel = ({ visible, onClose }: CreateBranchSidePanelProps) => { + const { ui } = useStore() + const submitRef: any = useRef() + const { ref: projectRef } = useParams() + + const formId = 'create-branch-form' + const initialValues = { branchName: '', gitBranch: 'no-selection' } + const { mutate: createBranch, isLoading: isCreating } = useBranchCreateMutation({ + onSuccess: () => { + ui.setNotification({ category: 'success', message: `Successfully created new branch` }) + onClose() + }, + }) + + const validate = (values: any) => { + const errors: any = {} + if (values.branchName.length === 0) + errors.branchName = 'Please provide a name for your preview branch' + if (values.gitBranch.length === 0 || values.gitBranch === 'no-selection') + errors.gitBranch = 'Please select a Git branch to link to this database preview branch' + return errors + } + + const onConfirmCreate = (values: any) => { + if (!projectRef) return console.error('Project ref is required') + createBranch({ projectRef, ...values }) + } + + return ( + + + +
    + } + > +
    + +
    + {() => { + return ( +
    + + + + --- + + {MOCK_BRANCHES.map((branch) => ( + +

    {branch.name}

    +
    + ))} +
    + +
    + ) + }} +
    +
    +
    + + ) +} + +export default CreateBranchSidePanel diff --git a/studio/components/layouts/AppLayout/BranchDropdown.tsx b/studio/components/layouts/AppLayout/BranchDropdown.tsx index 974e3f08085..50672277b06 100644 --- a/studio/components/layouts/AppLayout/BranchDropdown.tsx +++ b/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -16,6 +16,7 @@ import { Command_Shadcn_, IconCheck, IconCode, + IconGitBranch, IconPlus, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, @@ -46,9 +47,14 @@ const BranchDropdown = () => { {isSuccess && branches.length > 0 && (
    - + + + {selectedBranch?.name} + + + + -

    {selectedBranch?.name}

    {selectedBranch?.is_default ? ( Production ) : ( @@ -86,7 +92,7 @@ const BranchDropdown = () => { { setOpen(false) @@ -105,24 +111,24 @@ const BranchDropdown = () => { ) })} - {/* - + + { setOpen(false) - router.push('/') + router.push(`/project/${ref}/branches`) }} onClick={() => setOpen(false)} >
    - -

    New branch

    + +

    Manage branches

    - */} + diff --git a/studio/components/layouts/AppLayout/ProjectDropdown.tsx b/studio/components/layouts/AppLayout/ProjectDropdown.tsx index 70cba5c7c7e..db802d7fe65 100644 --- a/studio/components/layouts/AppLayout/ProjectDropdown.tsx +++ b/studio/components/layouts/AppLayout/ProjectDropdown.tsx @@ -133,7 +133,7 @@ const ProjectDropdown = () => { return IS_PLATFORM ? (
    - +

    {selectedProject?.name}

    {isSuccess && !isOrgBilling && {subscription?.plan.name}} From ce5f4a4042d888b9ddb543b9b92faa5c39f3cf91 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 28 Jul 2023 13:45:28 +0800 Subject: [PATCH 09/13] Sanitize URL in branch dropdown --- .../layouts/AppLayout/BranchDropdown.tsx | 76 ++++++++++++------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/studio/components/layouts/AppLayout/BranchDropdown.tsx b/studio/components/layouts/AppLayout/BranchDropdown.tsx index 50672277b06..7b1564bfa39 100644 --- a/studio/components/layouts/AppLayout/BranchDropdown.tsx +++ b/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { useParams } from 'common' import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { useBranchesQuery } from 'data/branches/branches-query' +import { Branch, useBranchesQuery } from 'data/branches/branches-query' import { Badge, Button, @@ -23,6 +23,44 @@ import { Popover_Shadcn_, } from 'ui' import { useSelectedProject } from 'hooks' +import { sanitizeRoute } from './ProjectDropdown' + +const BranchLink = ({ + branch, + isSelected, + setOpen, +}: { + branch: Branch + isSelected: boolean + setOpen: (value: boolean) => void +}) => { + const router = useRouter() + const sanitizedRoute = sanitizeRoute(router.route, router.query) + const href = + sanitizedRoute?.replace('[ref]', branch.project_ref) ?? `/project/${branch.project_ref}` + + return ( + + { + setOpen(false) + router.push(href) + }} + onClick={() => { + setOpen(false) + }} + > +
    + {branch.name} + {isSelected && } + + + + ) +} const BranchDropdown = () => { const router = useRouter() @@ -82,34 +120,14 @@ const BranchDropdown = () => { No branches found - {branches?.map((branch) => { - const href = { - pathname: router.pathname, - query: { ...router.query, ref: branch.project_ref }, - } - - return ( - - { - setOpen(false) - router.push(href) - }} - onClick={() => { - setOpen(false) - }} - > - - {branch.name} - {branch.id === selectedBranch?.id && } - - - - ) - })} + {branches?.map((branch) => ( + + ))} From 6aa55fc1fe6a6829cf86f3ed6e41054bcf55967c Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 28 Jul 2023 15:22:33 +0800 Subject: [PATCH 10/13] Gate certain project settings while on a branch, and fix route validation wrapper --- .../interfaces/App/RouteValidationWrapper.tsx | 3 +- .../interfaces/Settings/Addons/Addons.tsx | 42 ++++++++++++++++--- .../interfaces/Settings/General/General.tsx | 39 +++++++++++++++-- .../AppLayout/OrganizationDropdown.tsx | 2 - studio/hooks/misc/useSelectedProject.ts | 7 ++++ .../pages/project/[ref]/settings/general.tsx | 23 ++++++---- studio/types/base.ts | 2 +- 7 files changed, 96 insertions(+), 22 deletions(-) diff --git a/studio/components/interfaces/App/RouteValidationWrapper.tsx b/studio/components/interfaces/App/RouteValidationWrapper.tsx index 9fd0b0c8cbf..8e9ef20f0f6 100644 --- a/studio/components/interfaces/App/RouteValidationWrapper.tsx +++ b/studio/components/interfaces/App/RouteValidationWrapper.tsx @@ -68,8 +68,9 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { // Check validity of project that the user is trying to access const projects = projectsRef.current ?? [] const isValidProject = projects.some((project) => project.ref === ref) + const isValidBranch = projects.some((project) => project.preview_branch_refs.includes(ref)) - if (!isValidProject) { + if (!isValidProject && !isValidBranch) { ui.setNotification({ category: 'error', message: 'This project does not exist' }) router.push(navLayoutV2 ? `/org/${organizations?.[0].slug}` : '/projects') return diff --git a/studio/components/interfaces/Settings/Addons/Addons.tsx b/studio/components/interfaces/Settings/Addons/Addons.tsx index bfc323c04b7..88035a87b70 100644 --- a/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -20,11 +20,20 @@ import AlertError from 'components/ui/AlertError' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useInfraMonitoringQuery } from 'data/analytics/infra-monitoring-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' -import { useFlag } from 'hooks' +import { useFlag, useProjectByRef } from 'hooks' import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' -import { BASE_PATH, PROJECT_STATUS } from 'lib/constants' +import { BASE_PATH } from 'lib/constants' import { SUBSCRIPTION_PANEL_KEYS, useSubscriptionPageStateSnapshot } from 'state/subscription-page' -import { Alert, Button, IconChevronRight, IconExternalLink } from 'ui' +import { + Alert, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + IconAlertCircle, + IconChevronRight, + IconExternalLink, +} from 'ui' import { ComputeInstanceSidePanel, CustomDomainSidePanel, PITRSidePanel } from './' const Addons = () => { @@ -34,6 +43,8 @@ const Addons = () => { const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const { project: selectedProject } = useProjectContext() + const parentProject = useProjectByRef(selectedProject?.parent_project_ref) + const isBranch = parentProject !== undefined const isProjectActive = useIsProjectActive() const allowedPanelValues = ['computeInstance', 'pitr', 'customDomain'] if (panel && typeof panel === 'string' && allowedPanelValues.includes(panel)) { @@ -78,6 +89,25 @@ const Addons = () => { + {isBranch && ( + + + + + You are currently on a preview branch of your project + + + Updating addons are not available while you're on a preview branch. To manage your + addons, you may return to your{' '} + + main branch + + . + + + + )} + {isLoading && ( @@ -161,7 +191,7 @@ const Addons = () => { type="default" className="mt-2 pointer-events-auto" onClick={() => snap.setPanelKey('computeInstance')} - disabled={!isProjectActive || projectUpdateDisabled} + disabled={isBranch || !isProjectActive || projectUpdateDisabled} > Change optimized compute @@ -356,7 +386,7 @@ const Addons = () => { type="default" className="mt-2 pointer-events-auto" onClick={() => snap.setPanelKey('pitr')} - disabled={!isProjectActive || projectUpdateDisabled} + disabled={isBranch || !isProjectActive || projectUpdateDisabled} > Change point in time recovery @@ -420,7 +450,7 @@ const Addons = () => { type="default" className="mt-2 pointer-events-auto" onClick={() => snap.setPanelKey('customDomain')} - disabled={!isProjectActive || projectUpdateDisabled} + disabled={isBranch || !isProjectActive || projectUpdateDisabled} > Change custom domain diff --git a/studio/components/interfaces/Settings/General/General.tsx b/studio/components/interfaces/Settings/General/General.tsx index a4f2ac6b4dd..ff3789a44dd 100644 --- a/studio/components/interfaces/Settings/General/General.tsx +++ b/studio/components/interfaces/Settings/General/General.tsx @@ -1,6 +1,15 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import Link from 'next/link' -import { Button, Form, IconBarChart2, Input } from 'ui' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + Form, + IconAlertCircle, + IconBarChart2, + Input, +} from 'ui' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { @@ -14,15 +23,19 @@ import { import Panel from 'components/ui/Panel' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useProjectUpdateMutation } from 'data/projects/project-update-mutation' -import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { useCheckPermissions, useProjectByRef, useSelectedOrganization, useStore } from 'hooks' import PauseProjectButton from './Infrastructure/PauseProjectButton' import RestartServerButton from './Infrastructure/RestartServerButton' +import { useProjectsQuery } from 'data/projects/projects-query' const General = () => { const { ui } = useStore() const { project } = useProjectContext() const organization = useSelectedOrganization() + const parentProject = useProjectByRef(project?.parent_project_ref) + const isBranch = parentProject !== undefined + const isOrgBilling = !!organization?.subscription_id const formId = 'project-general-settings' const initialValues = { name: project?.name ?? '', ref: project?.ref ?? '' } @@ -41,6 +54,24 @@ const General = () => { return (
    + + {isBranch && ( + + + + You are currently on a preview branch of your project + + + Certain settings are not available while you're on a preview branch. To adjust your + project settings, you may return to your{' '} + + main branch + + . + + + )} + {project === undefined ? ( ) : ( @@ -72,7 +103,7 @@ const General = () => { id="name" size="small" label="Project name" - disabled={!canUpdateProject} + disabled={isBranch || !canUpdateProject} /> @@ -82,7 +113,7 @@ const General = () => { }} )} - {isOrgBilling && ( + {!isBranch && isOrgBilling && ( <>
    diff --git a/studio/components/layouts/AppLayout/OrganizationDropdown.tsx b/studio/components/layouts/AppLayout/OrganizationDropdown.tsx index 035e4073307..79fe2d975e5 100644 --- a/studio/components/layouts/AppLayout/OrganizationDropdown.tsx +++ b/studio/components/layouts/AppLayout/OrganizationDropdown.tsx @@ -76,8 +76,6 @@ const OrganizationDropdown = () => { {organizations?.map((org) => { const href = router.pathname.includes('[slug]') ? router.pathname.replace('[slug]', org.slug) - : router.pathname.includes('[ref]/settings') - ? `/org/${org.slug}/general` : `/org/${org.slug}` return ( diff --git a/studio/hooks/misc/useSelectedProject.ts b/studio/hooks/misc/useSelectedProject.ts index d5c1d82dfc2..56ec25002ba 100644 --- a/studio/hooks/misc/useSelectedProject.ts +++ b/studio/hooks/misc/useSelectedProject.ts @@ -1,8 +1,15 @@ import { useParams } from 'common' import { useProjectDetailQuery } from 'data/projects/project-detail-query' +import { useProjectsQuery } from 'data/projects/projects-query' export function useSelectedProject() { const { ref } = useParams() const { data } = useProjectDetailQuery({ ref }) return data } + +export function useProjectByRef(ref?: string) { + const { data: projects } = useProjectsQuery() + if (!ref) return undefined + return projects?.find((project) => project.ref === ref) +} diff --git a/studio/pages/project/[ref]/settings/general.tsx b/studio/pages/project/[ref]/settings/general.tsx index ca94e4d7bbd..9db217b9dff 100644 --- a/studio/pages/project/[ref]/settings/general.tsx +++ b/studio/pages/project/[ref]/settings/general.tsx @@ -1,16 +1,19 @@ -import { NextPageWithLayout } from 'types' -import { SettingsLayout } from 'components/layouts' import { - General, - Infrastructure, CustomDomainConfig, DeleteProjectPanel, + General, + Infrastructure, TransferProjectPanel, } from 'components/interfaces/Settings/General' +import { SettingsLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useFlag, useSelectedOrganization } from 'hooks' +import { NextPageWithLayout } from 'types' const ProjectSettings: NextPageWithLayout = () => { + const { project } = useProjectContext() const organization = useSelectedOrganization() + const isBranch = !!project?.parent_project_ref const isOrgBilling = !!organization?.subscription_id const transferProjectEnabled = useFlag('transferProject') @@ -18,10 +21,14 @@ const ProjectSettings: NextPageWithLayout = () => { return (
    - {!isOrgBilling && } - - {transferProjectEnabled && } - + {!isBranch ? ( + <> + {!isOrgBilling && } + {} + {transferProjectEnabled && } + + + ) : null}
    ) } diff --git a/studio/types/base.ts b/studio/types/base.ts index 6cb3096544a..dd506a691b1 100644 --- a/studio/types/base.ts +++ b/studio/types/base.ts @@ -22,7 +22,7 @@ export interface ProjectBase { region: string inserted_at: string subscription_id: string - preview_branches: any[] + preview_branch_refs: string[] } export interface Project extends ProjectBase { From fb8c68007bd73d03b5fa5f733e04a2280bbe8892 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 28 Jul 2023 15:28:05 +0800 Subject: [PATCH 11/13] Route backt to main branch, if deleting a branch that user is currently on --- .../BranchManagement/BranchManagement.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/studio/components/interfaces/BranchManagement/BranchManagement.tsx b/studio/components/interfaces/BranchManagement/BranchManagement.tsx index f2e3f37db6e..cbe3798f39b 100644 --- a/studio/components/interfaces/BranchManagement/BranchManagement.tsx +++ b/studio/components/interfaces/BranchManagement/BranchManagement.tsx @@ -15,9 +15,11 @@ import CreateBranchSidePanel from './CreateBranchSidePanel' import PreviewBranches from './PreviewBranches' import PullRequests from './PullRequests' import UpdateBranchSidePanel from './UpdateBranchSidePanel' +import { useRouter } from 'next/router' const BranchManagement = () => { const { ui } = useStore() + const router = useRouter() const { ref } = useParams() const projectDetails = useSelectedProject() @@ -34,8 +36,17 @@ const BranchManagement = () => { const { mutate: deleteBranch, isLoading: isDeleting } = useBranchDeleteMutation({ onSuccess: () => { + if (selectedBranchToDelete?.project_ref === ref) { + ui.setNotification({ + category: 'success', + message: + 'Successfully deleted branch. You are now currently on the main branch of your project.', + }) + router.push(`/project/${projectRef}/branches`) + } else { + ui.setNotification({ category: 'success', message: 'Successfully deleted branch' }) + } setSelectedBranchToDelete(undefined) - ui.setNotification({ category: 'success', message: 'Successfully deleted branch' }) }, }) From 23b737a5089f7623d7876b810baa344cb0cd2d09 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 28 Jul 2023 15:30:27 +0800 Subject: [PATCH 12/13] Fix create branch to use parent projec tref --- .../BranchManagement/CreateBranchSidePanel.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx b/studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx index 2d375152ec6..7ca6d3f87af 100644 --- a/studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx +++ b/studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx @@ -3,7 +3,7 @@ import { Button, Form, Input, Listbox, SidePanel } from 'ui' import { useParams } from 'common' import { useBranchCreateMutation } from 'data/branches/branch-create-mutation' -import { useStore } from 'hooks' +import { useSelectedProject, useStore } from 'hooks' interface CreateBranchSidePanelProps { visible: boolean @@ -18,7 +18,12 @@ const MOCK_BRANCHES = [ const CreateBranchSidePanel = ({ visible, onClose }: CreateBranchSidePanelProps) => { const { ui } = useStore() const submitRef: any = useRef() - const { ref: projectRef } = useParams() + const { ref } = useParams() + const projectDetails = useSelectedProject() + + const isBranch = projectDetails?.parent_project_ref !== undefined + const projectRef = + projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined const formId = 'create-branch-form' const initialValues = { branchName: '', gitBranch: 'no-selection' } @@ -99,7 +104,7 @@ const CreateBranchSidePanel = ({ visible, onClose }: CreateBranchSidePanelProps)

    {branch.name}

    From 07d98248aa881d5744cfae6a94195cce891f7841 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 31 Jul 2023 15:53:52 +0800 Subject: [PATCH 13/13] Address comments --- .../interfaces/BranchManagement/BranchPanels.tsx | 16 ++++++++-------- .../interfaces/Settings/General/General.tsx | 1 - .../layouts/AppLayout/BranchDropdown.tsx | 8 +++++++- studio/hooks/misc/useSelectedProject.ts | 7 +++++-- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/studio/components/interfaces/BranchManagement/BranchPanels.tsx index c65a293c92a..383902267ca 100644 --- a/studio/components/interfaces/BranchManagement/BranchPanels.tsx +++ b/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -23,7 +23,7 @@ import { Markdown } from 'components/interfaces/Markdown' import { Branch } from 'data/branches/branches-query' interface BranchPanelProps { - branch?: Branch + branch: Branch onSelectUpdate?: () => void onSelectDelete?: () => void } @@ -31,7 +31,7 @@ interface BranchPanelProps { const MainBranchPanel = ({ branch, onSelectUpdate = noop }: BranchPanelProps) => { const { ref } = useParams() const [open, setOpen] = useState(false) - const isActive = ref === branch?.project_ref + const isActive = ref === branch.project_ref return (
    @@ -48,7 +48,7 @@ const MainBranchPanel = ({ branch, onSelectUpdate = noop }: BranchPanelProps) =>
    -

    {branch?.name}

    +

    {branch.name}

    Production {isActive && Selected}
    @@ -96,16 +96,16 @@ const BranchPanel = ({ const { ref } = useParams() const [open, setOpen] = useState(false) - const isActive = ref === branch?.project_ref - const daysFromNow = dayjs().diff(dayjs(branch?.created_at), 'day') - const formattedTimeFromNow = dayjs(branch?.created_at).fromNow() - const formattedCreatedAt = dayjs(branch?.created_at).format('DD MMM YYYY, HH:mm:ss (ZZ)') + const isActive = ref === branch.project_ref + const daysFromNow = dayjs().diff(dayjs(branch.created_at), 'day') + const formattedTimeFromNow = dayjs(branch.created_at).fromNow() + const formattedCreatedAt = dayjs(branch.created_at).format('DD MMM YYYY, HH:mm:ss (ZZ)') return (
    -

    {branch?.name}

    +

    {branch.name}

    {isActive && Selected}

    {daysFromNow > 1 ? `Created on ${formattedCreatedAt}` : `Created ${formattedTimeFromNow}`} diff --git a/studio/components/interfaces/Settings/General/General.tsx b/studio/components/interfaces/Settings/General/General.tsx index ff3789a44dd..af429ec80fc 100644 --- a/studio/components/interfaces/Settings/General/General.tsx +++ b/studio/components/interfaces/Settings/General/General.tsx @@ -26,7 +26,6 @@ import { useProjectUpdateMutation } from 'data/projects/project-update-mutation' import { useCheckPermissions, useProjectByRef, useSelectedOrganization, useStore } from 'hooks' import PauseProjectButton from './Infrastructure/PauseProjectButton' import RestartServerButton from './Infrastructure/RestartServerButton' -import { useProjectsQuery } from 'data/projects/projects-query' const General = () => { const { ui } = useStore() diff --git a/studio/components/layouts/AppLayout/BranchDropdown.tsx b/studio/components/layouts/AppLayout/BranchDropdown.tsx index 7b1564bfa39..4e7e3378cb1 100644 --- a/studio/components/layouts/AppLayout/BranchDropdown.tsx +++ b/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -14,6 +14,7 @@ import { CommandItem_Shadcn_, CommandList_Shadcn_, Command_Shadcn_, + IconAlertCircle, IconCheck, IconCode, IconGitBranch, @@ -81,7 +82,12 @@ const BranchDropdown = () => { <> {isLoading && } - {isError &&

    } + {isError && ( +
    + +

    Failed to load branches

    +
    + )} {isSuccess && branches.length > 0 && (
    diff --git a/studio/hooks/misc/useSelectedProject.ts b/studio/hooks/misc/useSelectedProject.ts index 56ec25002ba..514d7adc20c 100644 --- a/studio/hooks/misc/useSelectedProject.ts +++ b/studio/hooks/misc/useSelectedProject.ts @@ -1,6 +1,7 @@ import { useParams } from 'common' import { useProjectDetailQuery } from 'data/projects/project-detail-query' import { useProjectsQuery } from 'data/projects/projects-query' +import { useMemo } from 'react' export function useSelectedProject() { const { ref } = useParams() @@ -10,6 +11,8 @@ export function useSelectedProject() { export function useProjectByRef(ref?: string) { const { data: projects } = useProjectsQuery() - if (!ref) return undefined - return projects?.find((project) => project.ref === ref) + return useMemo(() => { + if (!ref) return undefined + return projects?.find((project) => project.ref === ref) + }, [projects, ref]) }