Migrations.tsx238 lines · main
| 1 | import { SupportCategories } from '@supabase/shared-types/out/constants' |
| 2 | import { Search } from 'lucide-react' |
| 3 | import { useRef, useState } from 'react' |
| 4 | import { |
| 5 | Button, |
| 6 | Card, |
| 7 | cn, |
| 8 | SidePanel, |
| 9 | Table, |
| 10 | TableBody, |
| 11 | TableCell, |
| 12 | TableHead, |
| 13 | TableHeader, |
| 14 | TableRow, |
| 15 | Tooltip, |
| 16 | TooltipContent, |
| 17 | TooltipTrigger, |
| 18 | } from 'ui' |
| 19 | import { Admonition, TimestampInfo } from 'ui-patterns' |
| 20 | import { Input } from 'ui-patterns/DataInputs/Input' |
| 21 | import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' |
| 22 | |
| 23 | import { MigrationsEmptyState } from './MigrationsEmptyState' |
| 24 | import { SupportLink } from '@/components/interfaces/Support/SupportLink' |
| 25 | import CodeEditor from '@/components/ui/CodeEditor/CodeEditor' |
| 26 | import { InlineLink } from '@/components/ui/InlineLink' |
| 27 | import { DatabaseMigration, useMigrationsQuery } from '@/data/database/migrations-query' |
| 28 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 29 | import { DOCS_URL } from '@/lib/constants' |
| 30 | import { formatMigrationVersionLabel, parseMigrationVersion } from '@/lib/migration-utils' |
| 31 | import { SHORTCUT_IDS } from '@/state/shortcuts/registry' |
| 32 | import { useShortcut } from '@/state/shortcuts/useShortcut' |
| 33 | |
| 34 | const Migrations = () => { |
| 35 | const [search, setSearch] = useState('') |
| 36 | const [selectedMigration, setSelectedMigration] = useState<DatabaseMigration>() |
| 37 | const searchInputRef = useRef<HTMLInputElement>(null) |
| 38 | |
| 39 | useShortcut( |
| 40 | SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH, |
| 41 | () => { |
| 42 | searchInputRef.current?.focus() |
| 43 | searchInputRef.current?.select() |
| 44 | }, |
| 45 | { label: 'Search migrations' } |
| 46 | ) |
| 47 | |
| 48 | useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => setSearch('')) |
| 49 | |
| 50 | const { data: project } = useSelectedProjectQuery() |
| 51 | const { |
| 52 | data = [], |
| 53 | isPending: isLoading, |
| 54 | isSuccess, |
| 55 | isError, |
| 56 | error, |
| 57 | } = useMigrationsQuery({ |
| 58 | projectRef: project?.ref, |
| 59 | connectionString: project?.connectionString, |
| 60 | }) |
| 61 | const migrations = |
| 62 | search.length === 0 |
| 63 | ? data |
| 64 | : (data.filter( |
| 65 | (migration) => migration.version.includes(search) || migration.name?.includes(search) |
| 66 | ) ?? []) |
| 67 | |
| 68 | return ( |
| 69 | <> |
| 70 | {isLoading && ( |
| 71 | <div className="space-y-2"> |
| 72 | <ShimmeringLoader /> |
| 73 | <ShimmeringLoader className="w-3/4" /> |
| 74 | <ShimmeringLoader className="w-1/2" /> |
| 75 | </div> |
| 76 | )} |
| 77 | |
| 78 | <div> |
| 79 | {isError && ( |
| 80 | <Admonition |
| 81 | type="warning" |
| 82 | title="Failed to retrieve migration history for database" |
| 83 | description={ |
| 84 | <> |
| 85 | <p className="mb-1"> |
| 86 | Try refreshing your browser, but if the issue persists for more than a few |
| 87 | minutes, please reach out to us via support. |
| 88 | </p> |
| 89 | <p className="mb-4">Error: {error?.message ?? 'Unknown'}</p> |
| 90 | </> |
| 91 | } |
| 92 | > |
| 93 | <Button key="contact-support" asChild type="default"> |
| 94 | <SupportLink |
| 95 | queryParams={{ |
| 96 | projectRef: project?.ref, |
| 97 | category: SupportCategories.DASHBOARD_BUG, |
| 98 | subject: 'Unable to view database migrations', |
| 99 | }} |
| 100 | > |
| 101 | Contact support |
| 102 | </SupportLink> |
| 103 | </Button> |
| 104 | </Admonition> |
| 105 | )} |
| 106 | {isSuccess && ( |
| 107 | <div> |
| 108 | {data.length <= 0 && <MigrationsEmptyState />} |
| 109 | |
| 110 | {data.length > 0 && ( |
| 111 | <div className="flex flex-col gap-y-4"> |
| 112 | <Input |
| 113 | ref={searchInputRef} |
| 114 | size="tiny" |
| 115 | placeholder="Search for a migration" |
| 116 | value={search} |
| 117 | className="w-full lg:w-52" |
| 118 | onChange={(e: any) => setSearch(e.target.value)} |
| 119 | icon={<Search />} |
| 120 | /> |
| 121 | <Card> |
| 122 | <Table> |
| 123 | <TableHeader> |
| 124 | <TableRow> |
| 125 | <TableHead key="version" style={{ width: '180px' }}> |
| 126 | Version |
| 127 | </TableHead> |
| 128 | <TableHead key="name">Name</TableHead> |
| 129 | <TableHead key="insertedAt">Inserted at (UTC)</TableHead> |
| 130 | <TableHead key="buttons" /> |
| 131 | </TableRow> |
| 132 | </TableHeader> |
| 133 | <TableBody> |
| 134 | {migrations.length > 0 ? ( |
| 135 | migrations.map((migration) => { |
| 136 | const versionDayjs = parseMigrationVersion(migration.version) |
| 137 | const label = formatMigrationVersionLabel(migration.version) |
| 138 | const insertedAt = versionDayjs ? versionDayjs.toISOString() : undefined |
| 139 | |
| 140 | return ( |
| 141 | <TableRow key={migration.version}> |
| 142 | <TableCell>{migration.version}</TableCell> |
| 143 | <TableCell |
| 144 | className={cn( |
| 145 | (migration?.name ?? '').length === 0 && 'text-foreground-lighter!' |
| 146 | )} |
| 147 | > |
| 148 | {migration?.name ?? 'Name not available'} |
| 149 | </TableCell> |
| 150 | <TableCell> |
| 151 | <Tooltip> |
| 152 | <TooltipTrigger> |
| 153 | {!!insertedAt ? ( |
| 154 | <TimestampInfo |
| 155 | className="text-sm" |
| 156 | label={label} |
| 157 | utcTimestamp={insertedAt} |
| 158 | /> |
| 159 | ) : ( |
| 160 | <p className="text-foreground-lighter">Unknown</p> |
| 161 | )} |
| 162 | </TooltipTrigger> |
| 163 | {!insertedAt && ( |
| 164 | <TooltipContent side="right" className="w-64 text-center"> |
| 165 | This migration was not generated via the{' '} |
| 166 | <InlineLink |
| 167 | href={`${DOCS_URL}/guides/deployment/database-migrations`} |
| 168 | > |
| 169 | Briven CLI |
| 170 | </InlineLink>{' '} |
| 171 | and hence we're unable to parse when this migration was |
| 172 | inserted at. |
| 173 | </TooltipContent> |
| 174 | )} |
| 175 | </Tooltip> |
| 176 | </TableCell> |
| 177 | <TableCell align="right"> |
| 178 | <Button |
| 179 | type="default" |
| 180 | onClick={() => setSelectedMigration(migration)} |
| 181 | > |
| 182 | View migration SQL |
| 183 | </Button> |
| 184 | </TableCell> |
| 185 | </TableRow> |
| 186 | ) |
| 187 | }) |
| 188 | ) : ( |
| 189 | <TableRow> |
| 190 | <TableCell colSpan={3}> |
| 191 | <p className="text-sm text-foreground">No results found</p> |
| 192 | <p className="text-sm text-foreground-light"> |
| 193 | Your search for "{search}" did not return any results |
| 194 | </p> |
| 195 | </TableCell> |
| 196 | </TableRow> |
| 197 | )} |
| 198 | </TableBody> |
| 199 | </Table> |
| 200 | </Card> |
| 201 | </div> |
| 202 | )} |
| 203 | </div> |
| 204 | )} |
| 205 | </div> |
| 206 | |
| 207 | <SidePanel |
| 208 | size="large" |
| 209 | visible={selectedMigration !== undefined} |
| 210 | header={`Migration: ${selectedMigration?.version}`} |
| 211 | onCancel={() => setSelectedMigration(undefined)} |
| 212 | customFooter={ |
| 213 | <div className="flex items-center justify-end p-4 border-t border-overlay-border"> |
| 214 | <Button type="default" onClick={() => setSelectedMigration(undefined)}> |
| 215 | Close |
| 216 | </Button> |
| 217 | </div> |
| 218 | } |
| 219 | > |
| 220 | <div className="h-full"> |
| 221 | <div className="relative h-full"> |
| 222 | <CodeEditor |
| 223 | isReadOnly |
| 224 | id={selectedMigration?.version ?? ''} |
| 225 | language="pgsql" |
| 226 | defaultValue={ |
| 227 | selectedMigration?.statements?.join(';\n') + |
| 228 | (selectedMigration?.statements?.length ? ';' : '') |
| 229 | } |
| 230 | /> |
| 231 | </div> |
| 232 | </div> |
| 233 | </SidePanel> |
| 234 | </> |
| 235 | ) |
| 236 | } |
| 237 | |
| 238 | export default Migrations |