Merge pull request #16052 from supabase/feat/branch-management

feat/branch management part 1
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-07-31 16:29:38 +08:00
commit e6c0f439d5
30 files changed
+1395 -84

No files matched your search

@@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-overlay-hover focus:text-strong data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-overlay-hover focus:text-strong data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer',
inset && 'pl-8',
className
)}
@@ -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
@@ -0,0 +1,117 @@
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'
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 { Branch, useBranchesQuery } from 'data/branches/branches-query'
import { useSelectedProject, useStore } from 'hooks'
import { MainBranchPanel } from './BranchPanels'
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()
const isBranch = projectDetails?.parent_project_ref !== undefined
const projectRef =
projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined
const [showCreateBranch, setShowCreateBranch] = useState(false)
const [selectedBranchToUpdate, setSelectedBranchToUpdate] = useState<Branch>()
const [selectedBranchToDelete, setSelectedBranchToDelete] = useState<Branch>()
const { data: branches, error, isLoading, isError, isSuccess } = useBranchesQuery({ projectRef })
const [[mainBranch], previewBranches] = partition(branches, (branch) => branch.is_default)
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)
},
})
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 (
<>
<ScaffoldContainer>
<ScaffoldSection>
<div className="col-span-12">
<h3 className="text-xl mb-8">Branch Manager</h3>
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Input placeholder="Search branch" size="small" icon={<IconSearch />} />
</div>
<Button onClick={() => setShowCreateBranch(true)}>Create preview branch</Button>
</div>
<div className="">
{isLoading && <GenericSkeletonLoader />}
{isError && <AlertError error={error} subject="Failed to retrieve branches" />}
{isSuccess && (
<>
<MainBranchPanel branch={mainBranch} onSelectUpdate={() => {}} />
<PullRequests previewBranches={previewBranches} />
<PreviewBranches
previewBranches={previewBranches}
onSelectUpdateBranch={setSelectedBranchToUpdate}
onSelectDeleteBranch={setSelectedBranchToDelete}
/>
</>
)}
</div>
</div>
</ScaffoldSection>
</ScaffoldContainer>
<TextConfirmModal
size="medium"
visible={selectedBranchToDelete !== undefined}
onCancel={() => 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!"
/>
<CreateBranchSidePanel
visible={showCreateBranch}
onClose={() => setShowCreateBranch(false)}
/>
<UpdateBranchSidePanel
selectedBranch={selectedBranchToUpdate}
onClose={() => setSelectedBranchToUpdate(undefined)}
/>
</>
)
}
export default BranchManagement
@@ -0,0 +1,160 @@
import clsx from 'clsx'
import dayjs from 'dayjs'
import { noop } from 'lodash'
import { PropsWithChildren, forwardRef, useState } from 'react'
import {
Badge,
Button,
DropdownMenuContent_Shadcn_,
DropdownMenuItem_Shadcn_,
DropdownMenuTrigger_Shadcn_,
DropdownMenu_Shadcn_,
IconEdit,
IconGitBranch,
IconGitHub,
IconMoreVertical,
IconShield,
IconTrash,
cn,
} from 'ui'
import { useParams } from 'common'
import { Markdown } from 'components/interfaces/Markdown'
import { Branch } from 'data/branches/branches-query'
interface BranchPanelProps {
branch: Branch
onSelectUpdate?: () => void
onSelectDelete?: () => void
}
const MainBranchPanel = ({ branch, onSelectUpdate = noop }: BranchPanelProps) => {
const { ref } = useParams()
const [open, setOpen] = useState(false)
const isActive = ref === branch.project_ref
return (
<div className="border rounded-lg">
<div className="bg-surface-200 shadow-sm flex justify-between items-center pl-8 pr-6 py-3 rounded-t-lg text-sm">
<div className="flex items-center space-x-4">
<div className="w-8 h-8 bg-scale-200 rounded-md flex items-center justify-center">
<IconGitHub size={18} strokeWidth={2} />
</div>
<p>Github branch workflow</p>
</div>
</div>
<div className="bg-surface-100 border-t shadow-sm flex justify-between items-center pl-8 pr-6 py-3 rounded-b-lg text-sm">
<div className="flex items-center space-x-4">
<div className="w-8 h-8 bg-amber-300 rounded-md flex items-center justify-center">
<IconShield size={18} strokeWidth={2} className="text-amber-900" />
</div>
<p>{branch.name}</p>
<Badge color="amber">Production</Badge>
{isActive && <Badge color="green">Selected</Badge>}
</div>
<div className="flex items-center space-x-4">
<DropdownMenu_Shadcn_ open={open} onOpenChange={() => setOpen(!open)} modal={false}>
<DropdownMenuTrigger_Shadcn_>
<Button asChild type="text" className="px-1" icon={<IconMoreVertical size={14} />}>
<span></span>
</Button>
</DropdownMenuTrigger_Shadcn_>
<DropdownMenuContent_Shadcn_ side="bottom" align="end">
<DropdownMenuItem_Shadcn_ className="flex gap-2" onSelect={() => onSelectUpdate()}>
<IconEdit size={14} />
Edit branch
</DropdownMenuItem_Shadcn_>
</DropdownMenuContent_Shadcn_>
</DropdownMenu_Shadcn_>
</div>
</div>
</div>
)
}
const BranchContainer = ({ className, children }: PropsWithChildren<{ className?: string }>) => {
return (
<div className="list-none ml-6 pl-8 pb-4 border-l border-scale-600 dark:border-scale-400 relative last:!border-transparent">
<div className="absolute w-[33px] rounded-bl-full border-b border-l border-scale-600 dark:border-scale-400 h-10 -left-px" />
<div
className={clsx(
'border shadow-sm flex justify-between items-center pl-8 pr-6 py-4 rounded-lg text-sm',
className
)}
>
{children}
</div>
</div>
)
}
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 (
<BranchContainer className="bg-surface-100">
<div className="flex items-center space-x-4">
<IconGitBranch className="text-brand-900" size={16} strokeWidth={2} />
<p>{branch.name}</p>
{isActive && <Badge color="green">Selected</Badge>}
<p className="text-scale-1000">
{daysFromNow > 1 ? `Created on ${formattedCreatedAt}` : `Created ${formattedTimeFromNow}`}
</p>
</div>
<div>
<DropdownMenu_Shadcn_ open={open} onOpenChange={() => setOpen(!open)} modal={false}>
<DropdownMenuTrigger_Shadcn_>
<Button asChild type="text" className="px-1" icon={<IconMoreVertical size={14} />}>
<span></span>
</Button>
</DropdownMenuTrigger_Shadcn_>
<DropdownMenuContent_Shadcn_ side="bottom" align="end">
<DropdownMenuItem_Shadcn_ className="flex gap-2" onSelect={() => onSelectUpdate()}>
<IconEdit size={14} />
Edit branch
</DropdownMenuItem_Shadcn_>
<DropdownMenuItem_Shadcn_ className="flex gap-2" onSelect={() => onSelectDelete()}>
<IconTrash size={14} />
Delete branch
</DropdownMenuItem_Shadcn_>
</DropdownMenuContent_Shadcn_>
</DropdownMenu_Shadcn_>
</div>
</BranchContainer>
)
}
interface BranchHeader extends React.HTMLAttributes<HTMLDivElement> {
name?: string
markdown?: string
}
const BranchHeader = forwardRef<HTMLDivElement, BranchHeader>(
({ className, name, markdown = '', ...props }, ref) => {
return (
<div
{...props}
ref={ref}
className={cn(
'border-l border-scale-600 dark:border-scale-400 ml-6 pl-8 pt-6 pb-3',
className
)}
>
<Markdown content={markdown} className="" />
</div>
)
}
)
BranchHeader.displayName = 'BranchHeader'
export { BranchHeader, BranchContainer, BranchPanel, MainBranchPanel }
@@ -0,0 +1,126 @@
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 { useSelectedProject, 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 } = 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' }
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 (
<SidePanel
visible={visible}
loading={isCreating}
onCancel={onClose}
header="Create a new database preview branch"
customFooter={
<div className="flex w-full justify-end space-x-3 border-t border-scale-500 px-3 py-4">
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onClose}
disabled={isCreating}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
htmlType="button"
disabled={isCreating}
loading={isCreating}
onClick={() => submitRef?.current?.click()}
>
Confirm
</Button>
</div>
}
>
<div className="py-6">
<SidePanel.Content>
<Form
validateOnBlur
id={formId}
initialValues={initialValues}
validate={validate}
onSubmit={onConfirmCreate}
>
{() => {
return (
<div className="space-y-6">
<Input id="branchName" label="Branch name" />
<Listbox size="medium" id="gitBranch" name="gitBranch" label="Git branch">
<Listbox.Option
label="---"
key="no-selection"
id="no-selection"
value="no-selection"
>
---
</Listbox.Option>
{MOCK_BRANCHES.map((branch) => (
<Listbox.Option
key={branch.id}
id={branch.id.toString()}
value={branch.name}
label={branch.name}
>
<p className="text-scale-1200">{branch.name}</p>
</Listbox.Option>
))}
</Listbox>
<button ref={submitRef} type="submit" className="hidden" />
</div>
)
}}
</Form>
</SidePanel.Content>
</div>
</SidePanel>
)
}
export default CreateBranchSidePanel
@@ -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 (
<>
<BranchHeader markdown={previewBranches.length > 0 ? `#### Preview branches` : undefined} />
{previewBranches.length === 0 ? (
<BranchContainer>
<div className="flex items-center flex-col justify-center w-full py-8">
<p>No database preview branches</p>
<p className="text-scale-1000">Database preview branches will be shown here</p>
<div className="w-[500px] border rounded-md mt-4">
<div className="px-5 py-3 bg-surface-100 flex items-center justify-between">
<div className="flex items-center space-x-4">
<IconGitBranch strokeWidth={2} className="text-scale-1100" />
<div>
<p>Create a preview branch</p>
<p className="text-scale-1000">Start developing in preview</p>
</div>
</div>
<Button type="default">Create preview branch</Button>
</div>
<div className="px-5 py-3 border-t flex items-center justify-between">
<div>
<p>Not sure what to do?</p>
<p className="text-scale-1000">Browse our documentation</p>
</div>
<Button type="default" iconRight={<IconExternalLink />}>
Docs
</Button>
</div>
</div>
</div>
</BranchContainer>
) : (
previewBranches.map((branch) => (
<BranchPanel
key={branch.id}
branch={branch}
onSelectUpdate={() => onSelectUpdateBranch(branch)}
onSelectDelete={() => onSelectDeleteBranch(branch)}
/>
))
)}
</>
)
}
export default PreviewBranches
@@ -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 (
<>
<BranchHeader
markdown={
pullRequests.length > 0 && previewBranches.length > 0
? `#### Open pull requests`
: undefined
}
/>
{previewBranches.length === 0 || pullRequests.length === 0 ? (
<BranchContainer>
<div className="flex items-center flex-col justify-center w-full py-8">
<p>No pull requests made yet for this repository</p>
<p className="text-scale-1000">
Only pull requests with the ./migration directory changes will show here.
</p>
{previewBranches.length > 0 && (
<div className="w-96 border rounded-md mt-4">
<div className="px-5 py-3 bg-surface-100 flex items-center justify-between">
<div className="flex items-center space-x-4">
<IconGitBranch strokeWidth={2} className="text-scale-1100" />
<p>Create a pull request</p>
</div>
<Button type="default" iconRight={<IconExternalLink />}>
Github
</Button>
</div>
<div className="px-5 py-3 border-t flex items-center justify-between">
<div>
<p>Not sure what to do?</p>
<p className="text-scale-1000">Browse our documentation</p>
</div>
<Button type="default" iconRight={<IconExternalLink />}>
Docs
</Button>
</div>
</div>
)}
</div>
</BranchContainer>
) : (
<BranchContainer>State 3</BranchContainer>
)}
</>
)
}
export default PullRequests
@@ -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 (
<SidePanel
visible={selectedBranch !== undefined}
header={`Update branch: ${selectedBranch?.name}`}
onCancel={onClose}
customFooter={
<div className="flex w-full justify-end space-x-3 border-t border-scale-500 px-3 py-4">
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onClose}
disabled={isUpdating}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
htmlType="button"
disabled={isUpdating}
loading={isUpdating}
onClick={() => submitRef?.current?.click()}
>
Confirm
</Button>
</div>
}
>
<div className="py-6">
<SidePanel.Content>
<Form
validateOnBlur
id={formId}
initialValues={initialValues}
validate={validate}
onSubmit={onConfirmUpdate}
>
{() => {
return (
<div className="space-y-6">
<Input id="branchName" label="Branch name" />
<Input disabled id="gitBranch" label="Git branch" />
<button ref={submitRef} type="submit" className="hidden" />
</div>
)
}}
</Form>
</SidePanel.Content>
</div>
</SidePanel>
)
}
export default UpdateBranchSidePanel
+5 -1
View File
@@ -9,7 +9,11 @@ interface Props extends Omit<ReactMarkdownProps, 'children' | 'node'> {
const Markdown = ({ className, content, ...props }: Props) => {
return (
<ReactMarkdown {...props} className={cn('prose text-sm', className)}>
<ReactMarkdown
components={{ h3: ({ children }) => <h3 className="mb-1">{children}</h3> }}
{...props}
className={cn('prose text-sm', className)}
>
{content}
</ReactMarkdown>
)
@@ -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 = () => {
<ScaffoldDivider />
{isBranch && (
<ScaffoldContainer>
<Alert_Shadcn_ variant="default" className="mt-6">
<IconAlertCircle strokeWidth={2} />
<AlertTitle_Shadcn_>
You are currently on a preview branch of your project
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Updating addons are not available while you're on a preview branch. To manage your
addons, you may return to your{' '}
<Link passHref href={`/project/${parentProject.ref}/settings/general`}>
<a className="text-brand-900">main branch</a>
</Link>
.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</ScaffoldContainer>
)}
{isLoading && (
<ScaffoldContainer>
<ScaffoldSection>
@@ -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
</Button>
@@ -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
</Button>
@@ -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
</Button>
@@ -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,7 +23,7 @@ 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'
@@ -23,6 +32,9 @@ const General = () => {
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 +53,24 @@ const General = () => {
return (
<div>
<FormHeader title="Project Settings" description="" />
{isBranch && (
<Alert_Shadcn_ variant="default" className="mb-6">
<IconAlertCircle strokeWidth={2} />
<AlertTitle_Shadcn_>
You are currently on a preview branch of your project
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Certain settings are not available while you're on a preview branch. To adjust your
project settings, you may return to your{' '}
<Link passHref href={`/project/${parentProject.ref}/settings/general`}>
<a className="text-brand-900">main branch</a>
</Link>
.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
{project === undefined ? (
<GenericSkeletonLoader />
) : (
@@ -72,7 +102,7 @@ const General = () => {
id="name"
size="small"
label="Project name"
disabled={!canUpdateProject}
disabled={isBranch || !canUpdateProject}
/>
<Input copy disabled id="ref" size="small" label="Reference ID" />
</FormSectionContent>
@@ -82,7 +112,7 @@ const General = () => {
}}
</Form>
)}
{isOrgBilling && (
{!isBranch && isOrgBilling && (
<>
<div className="mt-6">
<FormPanel>
@@ -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'
@@ -10,11 +10,13 @@ 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()
const { ref } = useParams()
const organization = useSelectedOrganization()
const enableBranchManagement = useFlag('branchManagement')
return (
<div className="flex items-center justify-between px-4 py-1 bg-scale-200 border-b">
@@ -30,6 +32,7 @@ const AppHeader = () => {
</Link>
<OrganizationDropdown />
{ref !== undefined && <ProjectDropdown />}
{ref !== undefined && enableBranchManagement && <BranchDropdown />}
</div>
<div className="flex items-center gap-4">
@@ -0,0 +1,166 @@
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 { Branch, useBranchesQuery } from 'data/branches/branches-query'
import {
Badge,
Button,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Command_Shadcn_,
IconAlertCircle,
IconCheck,
IconCode,
IconGitBranch,
IconPlus,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
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 (
<Link passHref href={href}>
<CommandItem_Shadcn_
asChild
value={branch.name}
className="cursor-pointer w-full flex items-center justify-between"
onSelect={() => {
setOpen(false)
router.push(href)
}}
onClick={() => {
setOpen(false)
}}
>
<a>
{branch.name}
{isSelected && <IconCheck />}
</a>
</CommandItem_Shadcn_>
</Link>
)
}
const BranchDropdown = () => {
const router = useRouter()
const { ref } = useParams()
const projectDetails = useSelectedProject()
const branchNameRef = useRef<HTMLAnchorElement>(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 === ref)
return (
<>
{isLoading && <ShimmeringLoader className="w-[90px]" />}
{isError && (
<div className="flex items-center space-x-2 text-amber-900">
<IconAlertCircle size={16} strokeWidth={2} />
<p className="text-sm">Failed to load branches</p>
</div>
)}
{isSuccess && branches.length > 0 && (
<div className="flex items-center space-x-2 px-2">
<Link passHref href={`/project/${ref}`}>
<a ref={branchNameRef} className="flex items-center space-x-2 text-sm">
{selectedBranch?.name}
</a>
</Link>
<Link passHref href={`/project/${ref}/branches`}>
<a ref={branchNameRef} className="flex items-center space-x-2">
{selectedBranch?.is_default ? (
<Badge color="amber">Production</Badge>
) : (
<Badge color="green">Preview Branch</Badge>
)}
</a>
</Link>
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<Button
type="text"
className="px-1"
icon={<IconCode className="text-scale-1100 rotate-90" strokeWidth={2} size={12} />}
/>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
className="p-0"
side="bottom"
align="start"
style={{ marginLeft: `-${popoverOffset}px` }}
>
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Find branch..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No branches found</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
{branches?.map((branch) => (
<BranchLink
key={branch.id}
branch={branch}
isSelected={branch.id === selectedBranch?.id}
setOpen={setOpen}
/>
))}
</CommandGroup_Shadcn_>
<CommandGroup_Shadcn_ className="border-t">
<Link passHref href={`/project/${ref}/branches`}>
<CommandItem_Shadcn_
asChild
className="cursor-pointer flex items-center space-x-2 w-full"
onSelect={(e) => {
setOpen(false)
router.push(`/project/${ref}/branches`)
}}
onClick={() => setOpen(false)}
>
<a>
<IconGitBranch size={14} strokeWidth={1.5} />
<p>Manage branches</p>
</a>
</CommandItem_Shadcn_>
</Link>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</div>
)}
</>
)
}
export default BranchDropdown
@@ -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 (
<Link passHref href={href} key={org.slug}>
@@ -106,6 +106,11 @@ const ProjectDropdown = () => {
const projectNameRef = useRef<HTMLAnchorElement>(null)
const { data: allProjects, isLoading: isLoadingProjects } = useProjectsQuery()
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(
{ projectRef: ref },
@@ -115,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
@@ -126,7 +133,7 @@ const ProjectDropdown = () => {
return IS_PLATFORM ? (
<div className="flex items-center space-x-2 px-2">
<Link href={`/project/${selectedProject?.ref}`}>
<Link href={`/project/${ref}`}>
<a ref={projectNameRef} className="flex items-center space-x-2">
<p className="text-sm">{selectedProject?.name}</p>
{isSuccess && !isOrgBilling && <Badge color="slate">{subscription?.plan.name}</Badge>}
@@ -1,7 +1,6 @@
import Link from 'next/link'
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'
@@ -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)
+10 -16
View File
@@ -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<ResourcesResponse[]>(`${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<ReturnType<typeof getResources>>
export type ResourcesError = unknown
export type ResourcesError = ResponseError
export const useResourcesQuery = <TData = ResourcesData>(
{ projectRef }: ResourcesVariables,
@@ -43,6 +36,7 @@ export const useResourcesQuery = <TData = ResourcesData>(
/**
* 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 })
+205 -33
View File
@@ -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"];
};
@@ -1485,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 */
@@ -1609,7 +1612,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 +1688,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 +1752,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 +1828,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 +1892,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 +1968,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 +3041,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<string, never>;
visibility: Record<string, never>;
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 +3075,30 @@ export interface components {
content?: Record<string, never>;
owner_id?: number;
};
UserContentObject: {
id: string;
inserted_at: string;
updated_at: string;
type: Record<string, never>;
visibility: Record<string, never>;
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<string, never>;
owner_id?: number;
project_id: number;
};
UpdateContentParams: {
id?: string;
name?: string;
@@ -3076,6 +3126,7 @@ export interface components {
id: string;
name: string;
project_ref: string;
parent_project_ref: string;
is_default: boolean;
git_branch: string;
created_at: string;
@@ -3102,6 +3153,7 @@ export interface components {
maxDatabasePreprovisionGb?: number;
lastDatabaseResizeAt?: string;
preview_branches: (components["schemas"]["BranchResponse"])[];
parent_project_ref?: string;
};
ProjectRefResponse: {
id: number;
@@ -3191,6 +3243,11 @@ export interface components {
name: string;
limit: number;
};
PreviewTransferInvoiceItem: {
description: string;
quantity: number;
amount: number;
};
PreviewProjectTransferResponse: {
valid: boolean;
warnings: (components["schemas"]["PreviewTransferInfo"])[];
@@ -3206,6 +3263,8 @@ export interface components {
charge_on_target_organization: number;
source_subscription_plan: Record<string, never>;
target_subscription_plan: Record<string, unknown> | null;
source_invoice_items: (components["schemas"]["PreviewTransferInvoiceItem"])[];
target_invoice_items: (components["schemas"]["PreviewTransferInvoiceItem"])[];
};
AnalyticsResponse: {
error?: OneOf<[{
@@ -4087,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;
@@ -4109,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;
@@ -6738,11 +6856,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 +6904,7 @@ export interface operations {
responses: {
201: {
content: {
"application/json": (Record<string, never>)[];
"application/json": (components["schemas"]["UserContentObject"])[];
};
};
/** @description Failed to create project's content */
@@ -6776,7 +6921,7 @@ export interface operations {
responses: {
200: {
content: {
"application/json": Record<string, never>;
"application/json": components["schemas"]["UserContentObject"];
};
};
/** @description Failed to delete project's content */
@@ -6798,7 +6943,7 @@ export interface operations {
responses: {
200: {
content: {
"application/json": (Record<string, never>)[];
"application/json": (components["schemas"]["UserContentObject"])[];
};
};
/** @description Failed to update project's content */
@@ -9668,7 +9813,7 @@ export interface operations {
responses: {
200: {
content: {
"application/json": Record<string, never>;
"application/json": (components["schemas"]["OAuthAppResponse"])[];
};
};
};
@@ -9686,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 */
@@ -9704,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 */
@@ -9720,7 +9868,11 @@ export interface operations {
};
};
responses: {
200: never;
200: {
content: {
"application/json": components["schemas"]["DeleteOAuthAppResponse"];
};
};
};
};
/** Revoke an authorized oauth app */
@@ -9732,7 +9884,11 @@ export interface operations {
};
};
responses: {
201: never;
201: {
content: {
"application/json": components["schemas"]["RevokeAuthorizedOAuthAppResponse"];
};
};
};
};
/** Authorize user through oauth */
@@ -9750,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: {
@@ -9771,7 +9931,11 @@ export interface operations {
};
};
responses: {
200: never;
200: {
content: {
"application/json": components["schemas"]["GetAuthorizationResponse"];
};
};
};
};
/** Approve oauth app authorization request */
@@ -9787,7 +9951,11 @@ export interface operations {
};
};
responses: {
201: never;
201: {
content: {
"application/json": components["schemas"]["ApproveAuthorizationResponse"];
};
};
};
};
/** Decline oauth app authorization request */
@@ -9798,7 +9966,11 @@ export interface operations {
};
};
responses: {
200: never;
200: {
content: {
"application/json": components["schemas"]["DeclineAuthorizationResponse"];
};
};
};
};
}
@@ -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<ReturnType<typeof createBranch>>
export const useBranchCreateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<BranchCreateData, ResponseError, BranchCreateVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<BranchCreateData, ResponseError, BranchCreateVariables>(
(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,
}
)
}
@@ -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<ReturnType<typeof deleteBranch>>
export const useBranchDeleteMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<BranchDeleteData, ResponseError, BranchDeleteVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<BranchDeleteData, ResponseError, BranchDeleteVariables>(
(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,
}
)
}
+31
View File
@@ -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<ReturnType<typeof getBranch>>
export type BranchError = ResponseError
export const useBranchQuery = <TData = BranchData>(
{ id }: BranchVariables,
{ enabled = true, ...options }: UseQueryOptions<BranchData, BranchError, TData> = {}
) =>
useQuery<BranchData, BranchError, TData>(
branchKeys.detail(id),
({ signal }) => getBranch({ id }, signal),
{ enabled: enabled && typeof id !== 'undefined', ...options }
)
@@ -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<ReturnType<typeof updateBranch>>
export const useBranchUpdateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<BranchUpdateData, ResponseError, BranchUpdateVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<BranchUpdateData, ResponseError, BranchUpdateVariables>(
(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,
}
)
}
+42
View File
@@ -0,0 +1,42 @@
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) {
if (!projectRef) throw new Error('Project ref is required')
const { data, error } = await get(`/v1/projects/{ref}/branches`, {
params: { path: { ref: projectRef } },
signal,
})
if (error) {
if ((error as ResponseError).message === 'Preview branching is not enabled for this project.') {
return []
} else {
throw error
}
}
return data
}
export type BranchesData = Awaited<ReturnType<typeof getBranches>>
export type BranchesError = ResponseError
export const useBranchesQuery = <TData = BranchesData>(
{ projectRef }: BranchesVariables,
{ enabled = true, ...options }: UseQueryOptions<BranchesData, BranchesError, TData> = {}
) =>
useQuery<BranchesData, BranchesError, TData>(
branchKeys.list(projectRef),
({ signal }) => getBranches({ projectRef }, signal),
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
)
+4
View File
@@ -0,0 +1,4 @@
export const branchKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'branches'] as const,
detail: (id: string | undefined) => ['branches', id] as const,
}
+10
View File
@@ -1,8 +1,18 @@
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()
const { data } = useProjectDetailQuery({ ref })
return data
}
export function useProjectByRef(ref?: string) {
const { data: projects } = useProjectsQuery()
return useMemo(() => {
if (!ref) return undefined
return projects?.find((project) => project.ref === ref)
}, [projects, ref])
}
+17
View File
@@ -0,0 +1,17 @@
import BranchManagement from 'components/interfaces/BranchManagement/BranchManagement'
import { ProjectLayoutWithAuth } from 'components/layouts'
import { NextPageWithLayout } from 'types'
const BranchManagementPage: NextPageWithLayout = () => {
return <BranchManagement />
}
BranchManagementPage.getLayout = (page) => (
<ProjectLayoutWithAuth>
<main style={{ maxHeight: '100vh' }} className="flex-1 overflow-y-auto">
{page}
</main>
</ProjectLayoutWithAuth>
)
export default BranchManagementPage
+2 -2
View File
@@ -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
@@ -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 (
<div className="1xl:px-28 mx-auto flex flex-col gap-10 px-5 py-6 lg:px-16 xl:px-24 2xl:px-32 ">
<General />
{!isOrgBilling && <Infrastructure />}
<CustomDomainConfig />
{transferProjectEnabled && <TransferProjectPanel />}
<DeleteProjectPanel />
{!isBranch ? (
<>
{!isOrgBilling && <Infrastructure />}
{<CustomDomainConfig />}
{transferProjectEnabled && <TransferProjectPanel />}
<DeleteProjectPanel />
</>
) : null}
</div>
)
}
+2
View File
@@ -22,6 +22,7 @@ export interface ProjectBase {
region: string
inserted_at: string
subscription_id: string
preview_branch_refs: string[]
}
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.