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< ) => { // 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/BranchManagement/BranchManagement.tsx b/studio/components/interfaces/BranchManagement/BranchManagement.tsx new file mode 100644 index 00000000000..cbe3798f39b --- /dev/null +++ b/studio/components/interfaces/BranchManagement/BranchManagement.tsx @@ -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() + const [selectedBranchToDelete, setSelectedBranchToDelete] = useState() + + 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 ( + <> + + +
+

Branch Manager

+
+
+ } /> +
+ +
+
+ {isLoading && } + {isError && } + {isSuccess && ( + <> + {}} /> + + + + )} +
+
+
+
+ + 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!" + /> + + setShowCreateBranch(false)} + /> + + setSelectedBranchToUpdate(undefined)} + /> + + ) +} + +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..383902267ca --- /dev/null +++ b/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -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 ( +
+
+
+
+ +
+

Github branch workflow

+
+
+
+
+
+ +
+

{branch.name}

+ Production + {isActive && Selected} +
+
+ setOpen(!open)} modal={false}> + + + + + onSelectUpdate()}> + + Edit branch + + + +
+
+
+ ) +} + +const BranchContainer = ({ className, children }: PropsWithChildren<{ className?: string }>) => { + return ( +
+
+
+ {children} +
+
+ ) +} + +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 ( + +
+ +

{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, BranchContainer, BranchPanel, MainBranchPanel } diff --git a/studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx b/studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx new file mode 100644 index 00000000000..7ca6d3f87af --- /dev/null +++ b/studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx @@ -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 ( + + + +
+ } + > +
+ +
+ {() => { + return ( +
+ + + + --- + + {MOCK_BRANCHES.map((branch) => ( + +

{branch.name}

+
+ ))} +
+ +
+ ) + }} +
+
+
+ + ) +} + +export default CreateBranchSidePanel 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 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/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/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..af429ec80fc 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,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 (
+ + {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 +102,7 @@ const General = () => { id="name" size="small" label="Project name" - disabled={!canUpdateProject} + disabled={isBranch || !canUpdateProject} /> @@ -82,7 +112,7 @@ const General = () => { }} )} - {isOrgBilling && ( + {!isBranch && isOrgBilling && ( <>
diff --git a/studio/components/layouts/AppLayout/AppHeader.tsx b/studio/components/layouts/AppLayout/AppHeader.tsx index 5ea83c6e56a..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' @@ -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 (
@@ -30,6 +32,7 @@ const AppHeader = () => { {ref !== undefined && } + {ref !== undefined && enableBranchManagement && }
diff --git a/studio/components/layouts/AppLayout/BranchDropdown.tsx b/studio/components/layouts/AppLayout/BranchDropdown.tsx new file mode 100644 index 00000000000..4e7e3378cb1 --- /dev/null +++ b/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -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 ( + + { + setOpen(false) + router.push(href) + }} + onClick={() => { + setOpen(false) + }} + > + + {branch.name} + {isSelected && } + + + + ) +} + +const BranchDropdown = () => { + const router = useRouter() + 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 === ref) + + return ( + <> + {isLoading && } + + {isError && ( +
+ +

Failed to load branches

+
+ )} + + {isSuccess && branches.length > 0 && ( +
+ + + {selectedBranch?.name} + + + + + + {selectedBranch?.is_default ? ( + Production + ) : ( + Preview Branch + )} + + + + + +
+ )} + + ) +} + +export default BranchDropdown 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/components/layouts/AppLayout/ProjectDropdown.tsx b/studio/components/layouts/AppLayout/ProjectDropdown.tsx index 9a882441d23..db802d7fe65 100644 --- a/studio/components/layouts/AppLayout/ProjectDropdown.tsx +++ b/studio/components/layouts/AppLayout/ProjectDropdown.tsx @@ -106,6 +106,11 @@ const ProjectDropdown = () => { const projectNameRef = useRef(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 ? (
- +

{selectedProject?.name}

{isSuccess && !isOrgBilling && {subscription?.plan.name}} diff --git a/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx b/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx index b1e37ff0ef9..51bba7ff4d7 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 { 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/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/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..8a16ffd26b7 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"]; }; @@ -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; + 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 +3075,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; @@ -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; target_subscription_plan: Record | 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)[]; + "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; + "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)[]; + "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; + "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"]; + }; + }; }; }; } 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 new file mode 100644 index 00000000000..b7dd3bf0042 --- /dev/null +++ b/studio/data/branches/branches-query.ts @@ -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> +export type BranchesError = ResponseError + +export const useBranchesQuery = ( + { 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..76180f83f72 --- /dev/null +++ b/studio/data/branches/keys.ts @@ -0,0 +1,4 @@ +export const branchKeys = { + list: (projectRef: string | undefined) => ['projects', projectRef, 'branches'] as const, + detail: (id: string | undefined) => ['branches', id] as const, +} diff --git a/studio/hooks/misc/useSelectedProject.ts b/studio/hooks/misc/useSelectedProject.ts index d5c1d82dfc2..514d7adc20c 100644 --- a/studio/hooks/misc/useSelectedProject.ts +++ b/studio/hooks/misc/useSelectedProject.ts @@ -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]) +} 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 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 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 706ea36740d..dd506a691b1 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_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.