DatabaseSelector.tsx246 lines · main
1import { useParams } from 'common'
2import { noop } from 'lodash'
3import { Check, ChevronDown, Loader2, Plus } from 'lucide-react'
4import Link from 'next/link'
5import { useRouter } from 'next/router'
6import { parseAsBoolean, useQueryState } from 'nuqs'
7import { useEffect, useState } from 'react'
8import {
9 Button,
10 ButtonProps,
11 cn,
12 Command,
13 CommandGroup,
14 CommandItem,
15 CommandList,
16 Popover,
17 PopoverContent,
18 PopoverTrigger,
19 ScrollArea,
20 Tooltip,
21 TooltipContent,
22 TooltipTrigger,
23} from 'ui'
24
25import { Markdown } from '@/components/interfaces/Markdown'
26import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
27import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
28import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils'
29import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
30import { IS_PLATFORM } from '@/lib/constants'
31import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
32
33interface DatabaseSelectorProps {
34 selectedDatabaseId?: string // To override initial state
35 variant?: 'regular' | 'connected-on-right' | 'connected-on-left' | 'connected-on-both'
36 additionalOptions?: { id: string; name: string }[]
37 buttonProps?: ButtonProps
38 onSelectId?: (id: string) => void // Optional callback
39 className?: string
40 align?: 'start' | 'end'
41 isForm?: boolean
42}
43
44export const DatabaseSelector = ({
45 selectedDatabaseId: _selectedDatabaseId,
46 variant = 'regular',
47 additionalOptions = [],
48 onSelectId = noop,
49 buttonProps,
50 align = 'end',
51 className,
52 isForm = false,
53}: DatabaseSelectorProps) => {
54 const router = useRouter()
55 const { ref: projectRef } = useParams()
56 const [open, setOpen] = useState(false)
57 const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
58
59 const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
60
61 const state = useDatabaseSelectorStateSnapshot()
62 const selectedDatabaseId = _selectedDatabaseId ?? state.selectedDatabaseId
63
64 const { data, isPending: isLoading, isSuccess } = useReadReplicasQuery({ projectRef })
65 const databases = data ?? []
66 const sortedDatabases = databases
67 .sort((a, b) => (a.inserted_at > b.inserted_at ? 1 : 0))
68 .sort((database) => (database.identifier === projectRef ? -1 : 0))
69
70 const selectedDatabase = databases.find((db) => db.identifier === selectedDatabaseId)
71 const selectedDatabaseRegion = formatDatabaseRegion(selectedDatabase?.region ?? '')
72 const formattedDatabaseId = formatDatabaseID(selectedDatabaseId ?? '')
73
74 const selectedAdditionalOption = additionalOptions.find((x) => x.id === selectedDatabaseId)
75
76 const newReplicaURL = `/project/${projectRef}/database/replication?type=Read+Replica`
77
78 useEffect(() => {
79 if (_selectedDatabaseId && !isForm) state.setSelectedDatabaseId(_selectedDatabaseId)
80 // eslint-disable-next-line react-hooks/exhaustive-deps
81 }, [_selectedDatabaseId])
82
83 return (
84 <Popover open={open} onOpenChange={setOpen} modal={false}>
85 <PopoverTrigger asChild>
86 <div className={cn('flex cursor-pointer', className)}>
87 {!isForm && (
88 <span className="flex items-center text-foreground-lighter px-3 rounded-lg rounded-r-none text-xs border border-button border-r-0">
89 Source
90 </span>
91 )}
92 <Button
93 type="default"
94 icon={isLoading && <Loader2 className="animate-spin" />}
95 iconRight={<ChevronDown strokeWidth={1.5} size={12} />}
96 {...buttonProps}
97 className={cn(
98 'justify-start',
99 !isForm && 'rounded-l-none',
100 variant === 'connected-on-right' && 'rounded-r-none',
101 variant === 'connected-on-left' && 'rounded-l-none border-l-0',
102 variant === 'connected-on-both' && 'rounded-none border-x-0',
103 buttonProps?.className
104 )}
105 >
106 {selectedAdditionalOption ? (
107 <span>{selectedAdditionalOption.name}</span>
108 ) : (
109 <>
110 <span className="capitalize">
111 {isLoading || selectedDatabase?.identifier === projectRef
112 ? 'Primary database'
113 : 'Read replica'}
114 </span>{' '}
115 {isSuccess && selectedDatabase?.identifier !== projectRef && (
116 <span>
117 ({selectedDatabaseRegion} - {formattedDatabaseId})
118 </span>
119 )}
120 </>
121 )}
122 </Button>
123 </div>
124 </PopoverTrigger>
125 <PopoverContent className="p-0 w-64" side="bottom" align={align}>
126 <Command>
127 <CommandList>
128 {additionalOptions.length > 0 && (
129 <CommandGroup className="border-b">
130 {additionalOptions.map((option) => (
131 <CommandItem
132 key={option.id}
133 value={option.id}
134 className="cursor-pointer w-full"
135 onSelect={() => {
136 if (!isForm) state.setSelectedDatabaseId(option.id)
137 setOpen(false)
138 onSelectId(option.id)
139 }}
140 onClick={() => {
141 if (!isForm) state.setSelectedDatabaseId(option.id)
142 setOpen(false)
143 onSelectId(option.id)
144 }}
145 >
146 <div className="w-full flex items-center justify-between">
147 <p>{option.name}</p>
148 {option.id === selectedDatabaseId && <Check size={14} />}
149 </div>
150 </CommandItem>
151 ))}
152 </CommandGroup>
153 )}
154 <CommandGroup>
155 <ScrollArea className={(databases || []).length > 7 ? 'h-[210px]' : ''}>
156 {sortedDatabases?.map((database) => {
157 const region = formatDatabaseRegion(database.region)
158 const id = formatDatabaseID(database.identifier)
159
160 if (database.status !== 'ACTIVE_HEALTHY') {
161 const status = [
162 REPLICA_STATUS.INIT_READ_REPLICA,
163 REPLICA_STATUS.COMING_UP,
164 ].includes(database.status)
165 ? 'coming up'
166 : 'not healthy'
167
168 return (
169 <Tooltip key={database.identifier}>
170 <TooltipTrigger asChild>
171 <div className="px-2 py-1.5 w-full flex items-center justify-between">
172 <p className="text-xs text-foreground-lighter">
173 Read replica ({region} - {id})
174 </p>
175 </div>
176 </TooltipTrigger>
177 <TooltipContent side="right" className="w-80">
178 <Markdown
179 className="text-xs text-foreground"
180 content={`Replica unable to accept requests as its ${status}. [View infrastructure settings](/project/${projectRef}/settings/infrastructure) for more information.`}
181 />
182 </TooltipContent>
183 </Tooltip>
184 )
185 }
186
187 return (
188 <CommandItem
189 key={database.identifier}
190 value={database.identifier}
191 className="cursor-pointer w-full"
192 onSelect={() => {
193 if (!isForm) state.setSelectedDatabaseId(database.identifier)
194 setOpen(false)
195 onSelectId(database.identifier)
196 }}
197 onClick={() => {
198 if (!isForm) state.setSelectedDatabaseId(database.identifier)
199 setOpen(false)
200 onSelectId(database.identifier)
201 }}
202 >
203 <div className="w-full flex items-center justify-between">
204 <p>
205 {database.identifier === projectRef
206 ? 'Primary database'
207 : `Read replica (${region} - ${id})`}
208 </p>
209 {database.identifier === selectedDatabaseId && <Check size={16} />}
210 </div>
211 </CommandItem>
212 )
213 })}
214 </ScrollArea>
215 </CommandGroup>
216 {IS_PLATFORM && infrastructureReadReplicas && (
217 <CommandGroup className="border-t">
218 <CommandItem
219 className="cursor-pointer w-full"
220 onSelect={() => {
221 setOpen(false)
222 router.push(newReplicaURL)
223 }}
224 onClick={() => setOpen(false)}
225 >
226 <Link
227 href={newReplicaURL}
228 onClick={async () => {
229 setOpen(false)
230 // [Joshen] This is used in the Connect UI which is available across all pages
231 setShowConnect(false)
232 }}
233 className="w-full flex items-center gap-2"
234 >
235 <Plus size={14} strokeWidth={1.5} />
236 <p>Create a new read replica</p>
237 </Link>
238 </CommandItem>
239 </CommandGroup>
240 )}
241 </CommandList>
242 </Command>
243 </PopoverContent>
244 </Popover>
245 )
246}