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