PublicationsList.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import { PermissionAction } from '@supabase/shared-types/out/constants'
  2. import { useParams } from 'common'
  3. import { AlertCircle, Info, Search } from 'lucide-react'
  4. import Link from 'next/link'
  5. import { useRef, useState } from 'react'
  6. import { toast } from 'sonner'
  7. import {
  8. Button,
  9. Card,
  10. Switch,
  11. Table,
  12. TableBody,
  13. TableCell,
  14. TableHead,
  15. TableHeader,
  16. TableRow,
  17. Tooltip,
  18. TooltipContent,
  19. TooltipTrigger,
  20. } from 'ui'
  21. import { Input } from 'ui-patterns/DataInputs/Input'
  22. import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
  23. import { PublicationSkeleton } from './PublicationSkeleton'
  24. import AlertError from '@/components/ui/AlertError'
  25. import InformationBox from '@/components/ui/InformationBox'
  26. import { NoSearchResults } from '@/components/ui/NoSearchResults'
  27. import { useDatabasePublicationsQuery } from '@/data/database-publications/database-publications-query'
  28. import { useDatabasePublicationUpdateMutation } from '@/data/database-publications/database-publications-update-mutation'
  29. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  30. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  31. import { onSearchInputEscape } from '@/lib/keyboard'
  32. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  33. import { useShortcut } from '@/state/shortcuts/useShortcut'
  34. interface PublicationEvent {
  35. event: string
  36. key: string
  37. }
  38. export const PublicationsList = () => {
  39. const { ref } = useParams()
  40. const { data: project } = useSelectedProjectQuery()
  41. const [filterString, setFilterString] = useState<string>('')
  42. const searchInputRef = useRef<HTMLInputElement>(null)
  43. useShortcut(
  44. SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
  45. () => {
  46. searchInputRef.current?.focus()
  47. searchInputRef.current?.select()
  48. },
  49. { label: 'Search publications' }
  50. )
  51. useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
  52. setFilterString('')
  53. })
  54. const {
  55. data = [],
  56. error,
  57. isPending: isLoading,
  58. isSuccess,
  59. isError,
  60. } = useDatabasePublicationsQuery({
  61. projectRef: project?.ref,
  62. connectionString: project?.connectionString,
  63. })
  64. const { mutate: updatePublications } = useDatabasePublicationUpdateMutation({
  65. onSuccess: () => {
  66. toast.success('Successfully updated event')
  67. setToggleListenEventValue(null)
  68. },
  69. })
  70. const { can: canUpdatePublications, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
  71. PermissionAction.TENANT_SQL_ADMIN_WRITE,
  72. 'publications'
  73. )
  74. const publicationEvents: PublicationEvent[] = [
  75. { event: 'Insert', key: 'publish_insert' },
  76. { event: 'Update', key: 'publish_update' },
  77. { event: 'Delete', key: 'publish_delete' },
  78. { event: 'Truncate', key: 'publish_truncate' },
  79. ]
  80. const publications = (
  81. filterString.length === 0
  82. ? data
  83. : data.filter((publication) => publication.name.includes(filterString))
  84. ).sort((a, b) => a.id - b.id)
  85. const [toggleListenEventValue, setToggleListenEventValue] = useState<{
  86. publication: any
  87. event: PublicationEvent
  88. currentStatus: any
  89. } | null>(null)
  90. const toggleListenEvent = async () => {
  91. if (!toggleListenEventValue || !project) return
  92. const { publication, event, currentStatus } = toggleListenEventValue
  93. const payload = {
  94. projectRef: project.ref,
  95. connectionString: project.connectionString,
  96. id: publication.id,
  97. } as any
  98. payload[`publish_${event.event.toLowerCase()}`] = !currentStatus
  99. updatePublications(payload)
  100. }
  101. return (
  102. <>
  103. <div className="flex items-center justify-between">
  104. <div className="flex items-center">
  105. <Input
  106. ref={searchInputRef}
  107. size="tiny"
  108. icon={<Search />}
  109. className="w-48"
  110. placeholder="Search for a publication"
  111. value={filterString}
  112. onChange={(e) => setFilterString(e.target.value)}
  113. onKeyDown={onSearchInputEscape(filterString, setFilterString)}
  114. />
  115. </div>
  116. {isPermissionsLoaded && !canUpdatePublications && (
  117. <div className="w-[500px]">
  118. <InformationBox
  119. icon={<AlertCircle className="text-foreground-light" strokeWidth={2} />}
  120. title="You need additional permissions to update database publications"
  121. />
  122. </div>
  123. )}
  124. </div>
  125. <div className="w-full overflow-hidden overflow-x-auto">
  126. <Card>
  127. <Table>
  128. <TableHeader>
  129. <TableRow>
  130. <TableHead>Name</TableHead>
  131. <TableHead>System ID</TableHead>
  132. <TableHead>Insert</TableHead>
  133. <TableHead>Update</TableHead>
  134. <TableHead>Delete</TableHead>
  135. <TableHead>Truncate</TableHead>
  136. <TableHead />
  137. </TableRow>
  138. </TableHeader>
  139. <TableBody>
  140. {isLoading &&
  141. Array.from({ length: 2 }).map((_, i) => <PublicationSkeleton key={i} index={i} />)}
  142. {isError && (
  143. <TableRow>
  144. <TableCell colSpan={7}>
  145. <AlertError error={error} subject="Failed to retrieve publications" />
  146. </TableCell>
  147. </TableRow>
  148. )}
  149. {!isLoading && publications.length === 0 && (
  150. <TableRow>
  151. <TableCell colSpan={7}>
  152. <NoSearchResults
  153. searchString={filterString}
  154. onResetFilter={() => setFilterString('')}
  155. className="border-none !p-0"
  156. />
  157. </TableCell>
  158. </TableRow>
  159. )}
  160. {isSuccess &&
  161. publications.map((x) => (
  162. <TableRow key={x.name}>
  163. <TableCell>
  164. <div className="flex items-center gap-x-2">
  165. {x.name}
  166. {/* [Joshen] Making this tooltip very specific for these 2 publications */}
  167. {['briven_realtime', 'briven_realtime_messages_publication'].includes(
  168. x.name
  169. ) && (
  170. <Tooltip>
  171. <TooltipTrigger>
  172. <Info size={14} className="text-foreground-light" />
  173. </TooltipTrigger>
  174. <TooltipContent side="bottom">
  175. {x.name === 'briven_realtime'
  176. ? 'Managed by Briven and handles Postgres changes'
  177. : x.name === 'briven_realtime_messages_publication'
  178. ? 'Managed by Briven and handles broadcasts from the database'
  179. : undefined}
  180. </TooltipContent>
  181. </Tooltip>
  182. )}
  183. </div>
  184. </TableCell>
  185. <TableCell>{x.id}</TableCell>
  186. {publicationEvents.map((event) => (
  187. <TableCell key={event.key}>
  188. <Switch
  189. size="small"
  190. checked={(x as any)[event.key]}
  191. disabled={!canUpdatePublications}
  192. onClick={() => {
  193. setToggleListenEventValue({
  194. publication: x,
  195. event,
  196. currentStatus: (x as any)[event.key],
  197. })
  198. }}
  199. />
  200. </TableCell>
  201. ))}
  202. <TableCell>
  203. <div className="flex justify-end">
  204. <Button asChild type="default" style={{ paddingTop: 3, paddingBottom: 3 }}>
  205. <Link href={`/project/${ref}/database/publications/${x.id}`}>
  206. {x.tables === null
  207. ? 'All tables'
  208. : `${x.tables.length} ${x.tables.length === 1 ? 'table' : 'tables'}`}
  209. </Link>
  210. </Button>
  211. </div>
  212. </TableCell>
  213. </TableRow>
  214. ))}
  215. </TableBody>
  216. </Table>
  217. </Card>
  218. </div>
  219. <ConfirmationModal
  220. visible={toggleListenEventValue !== null}
  221. title={`Confirm to toggle sending ${toggleListenEventValue?.event.event.toLowerCase()} events`}
  222. confirmLabel="Confirm"
  223. confirmLabelLoading="Updating"
  224. onCancel={() => setToggleListenEventValue(null)}
  225. onConfirm={() => {
  226. toggleListenEvent()
  227. }}
  228. >
  229. <p className="text-sm text-foreground-light">
  230. Are you sure you want to {toggleListenEventValue?.currentStatus ? 'stop' : 'start'}{' '}
  231. sending {toggleListenEventValue?.event.event.toLowerCase()} events for{' '}
  232. {toggleListenEventValue?.publication.name}?
  233. </p>
  234. </ConfirmationModal>
  235. </>
  236. )
  237. }