mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
fix: added logic to check and parse valid day.js (#40960)
* wip: added logic for migration checking * updated null check * updated to return undefined * updated use memo check * updated migration text * updated text based on migrations data * re-added tabbed deleted code * Updated insertedAt date logic * Fit missing gap + add tooltip * Niot --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
1 parent
47d99bb1a6
commit
74b13e2cef
4 files changed
+173
-27
No files matched your search
@@ -1,17 +1,19 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { SupportCategories } from '@supabase/shared-types/out/constants'
|
||||
import { SupportLink } from 'components/interfaces/Support/SupportLink'
|
||||
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { Search } from 'lucide-react'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { parseMigrationVersion } from 'lib/migration-utils'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
cn,
|
||||
SidePanel,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,8 +21,12 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { MigrationsEmptyState } from './MigrationsEmptyState'
|
||||
|
||||
const Migrations = () => {
|
||||
@@ -28,14 +34,20 @@ const Migrations = () => {
|
||||
const [selectedMigration, setSelectedMigration] = useState<DatabaseMigration>()
|
||||
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data, isLoading, isSuccess, isError, error } = useMigrationsQuery({
|
||||
const {
|
||||
data = [],
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isError,
|
||||
error,
|
||||
} = useMigrationsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const migrations =
|
||||
search.length === 0
|
||||
? data ?? []
|
||||
: data?.filter(
|
||||
? data
|
||||
: data.filter(
|
||||
(migration) => migration.version.includes(search) || migration.name?.includes(search)
|
||||
) ?? []
|
||||
|
||||
@@ -82,7 +94,7 @@ const Migrations = () => {
|
||||
{data.length <= 0 && <MigrationsEmptyState />}
|
||||
|
||||
{data.length > 0 && (
|
||||
<>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Input
|
||||
size="tiny"
|
||||
placeholder="Search for a migration"
|
||||
@@ -98,32 +110,46 @@ const Migrations = () => {
|
||||
<TableHead key="version" style={{ width: '180px' }}>
|
||||
Version
|
||||
</TableHead>
|
||||
<TableHead key="version">Name</TableHead>
|
||||
<TableHead key="version">Inserted at (UTC)</TableHead>
|
||||
<TableHead key="buttons"></TableHead>
|
||||
<TableHead key="name">Name</TableHead>
|
||||
<TableHead key="insertedAt">Inserted at (UTC)</TableHead>
|
||||
<TableHead key="buttons" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{migrations.length > 0 ? (
|
||||
migrations.map((migration) => {
|
||||
// [Joshen] LEFT OFF HERE
|
||||
const insertedAt = dayjs(migration.version, 'YYYYMMDDHHmmss').format(
|
||||
'DD MMM YYYY, HH:mm:ss'
|
||||
)
|
||||
const versionDayjs = parseMigrationVersion(migration.version)
|
||||
const insertedAt = versionDayjs
|
||||
? versionDayjs.format('DD MMM YYYY, HH:mm:ss')
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<TableRow key={migration.version}>
|
||||
<TableCell>{migration.version}</TableCell>
|
||||
<TableCell
|
||||
className={
|
||||
(migration?.name ?? '').length === 0
|
||||
? '!text-foreground-lighter'
|
||||
: ''
|
||||
}
|
||||
className={cn(
|
||||
(migration?.name ?? '').length === 0 && '!text-foreground-lighter'
|
||||
)}
|
||||
>
|
||||
{migration?.name ?? 'Name not available'}
|
||||
</TableCell>
|
||||
<TableCell>{insertedAt}</TableCell>
|
||||
<TableCell className={cn(!insertedAt && 'text-foreground-lighter')}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{insertedAt ?? 'Unknown'}</TooltipTrigger>
|
||||
{!insertedAt && (
|
||||
<TooltipContent side="right" className="w-64 text-center">
|
||||
This migration was not generated via the{' '}
|
||||
<InlineLink
|
||||
href={`${DOCS_URL}/guides/deployment/database-migrations`}
|
||||
>
|
||||
Supabase CLI
|
||||
</InlineLink>{' '}
|
||||
and hence we're unable to parse when this migration was
|
||||
inserted at.
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button
|
||||
type="default"
|
||||
@@ -148,7 +174,7 @@ const Migrations = () => {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,8 +6,9 @@ import { useParams } from 'common'
|
||||
import { SingleStat } from 'components/ui/SingleStat'
|
||||
import { useBranchesQuery } from 'data/branches/branches-query'
|
||||
import { useBackupsQuery } from 'data/database/backups-query'
|
||||
import { useMigrationsQuery } from 'data/database/migrations-query'
|
||||
import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { parseMigrationVersion } from 'lib/migration-utils'
|
||||
import { cn, Skeleton } from 'ui'
|
||||
import { TimestampInfo } from 'ui-patterns'
|
||||
import { ServiceStatus } from './ServiceStatus'
|
||||
@@ -40,7 +41,10 @@ export const ActivityStats = () => {
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const latestMigration = useMemo(() => (migrationsData ?? [])[0], [migrationsData])
|
||||
const latestMigration = useMemo<DatabaseMigration | undefined>(
|
||||
() => (migrationsData ?? [])[0],
|
||||
[migrationsData]
|
||||
)
|
||||
|
||||
const { data: backupsData, isLoading: isLoadingBackups } = useBackupsQuery({
|
||||
projectRef: project?.ref,
|
||||
@@ -53,6 +57,21 @@ export const ActivityStats = () => {
|
||||
.sort((a, b) => new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf())[0]
|
||||
}, [backupsData])
|
||||
|
||||
const [versionLabel, versionTimestamp] = useMemo(() => {
|
||||
const version = latestMigration?.version
|
||||
|
||||
const versionDayjs = parseMigrationVersion(version)
|
||||
if (versionDayjs) {
|
||||
return [versionDayjs.fromNow(), versionDayjs.toISOString()]
|
||||
}
|
||||
|
||||
return [undefined, undefined]
|
||||
}, [latestMigration])
|
||||
|
||||
const hasValidVersion = versionLabel && versionTimestamp
|
||||
|
||||
const versionLabelText = migrationsData && migrationsData.length > 0 ? 'Unknown' : 'No migrations'
|
||||
|
||||
return (
|
||||
<div className="@container">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 gap-2 @md:gap-6 flex-wrap">
|
||||
@@ -69,14 +88,14 @@ export const ActivityStats = () => {
|
||||
value={
|
||||
isLoadingMigrations ? (
|
||||
<Skeleton className="h-6 w-24" />
|
||||
) : latestMigration ? (
|
||||
) : hasValidVersion ? (
|
||||
<TimestampInfo
|
||||
className="text-base"
|
||||
label={dayjs(latestMigration.version, 'YYYYMMDDHHmmss').fromNow()}
|
||||
utcTimestamp={dayjs(latestMigration.version, 'YYYYMMDDHHmmss').toISOString()}
|
||||
label={versionLabel}
|
||||
utcTimestamp={versionTimestamp}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-foreground-lighter">No migrations</p>
|
||||
<p className="text-foreground-lighter">{versionLabelText}</p>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseMigrationVersion } from './migration-utils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
describe('parseMigrationVersion', () => {
|
||||
it('should parse valid migration version in YYYYMMDDHHmmss format', () => {
|
||||
const result = parseMigrationVersion('20231128095400')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.isValid()).toBe(true)
|
||||
expect(result?.year()).toBe(2023)
|
||||
expect(result?.month()).toBe(10)
|
||||
expect(result?.date()).toBe(28)
|
||||
expect(result?.hour()).toBe(9)
|
||||
expect(result?.minute()).toBe(54)
|
||||
expect(result?.second()).toBe(0)
|
||||
})
|
||||
|
||||
it('should return undefined for invalid version format like "001"', () => {
|
||||
const result = parseMigrationVersion('001')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for invalid version format like "002"', () => {
|
||||
const result = parseMigrationVersion('002')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for invalid version format like "003"', () => {
|
||||
const result = parseMigrationVersion('003')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for empty string', () => {
|
||||
const result = parseMigrationVersion('')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for random string', () => {
|
||||
const result = parseMigrationVersion('not-a-date')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for partial date format', () => {
|
||||
const result = parseMigrationVersion('20231128')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle edge case date values that dayjs can parse', () => {
|
||||
// dayjs is lenient and will wrap invalid values to valid dates
|
||||
// This is acceptable for our use case - the main goal is to reject
|
||||
// non-date formats like "001", "002", etc.
|
||||
const result = parseMigrationVersion('20231399000000')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.isValid()).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow chaining dayjs methods when valid', () => {
|
||||
const result = parseMigrationVersion('20231128095400')
|
||||
|
||||
expect(result?.fromNow()).toBeDefined()
|
||||
expect(result?.toISOString()).toBeDefined()
|
||||
expect(result?.format('DD MMM YYYY')).toBe('28 Nov 2023')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
|
||||
/**
|
||||
* Safely parses a migration version string as a date.
|
||||
* Migration versions are typically in the format YYYYMMDDHHmmss (e.g., "20231128095400").
|
||||
* However, some projects may have custom version formats (e.g., "001", "002") that cannot be parsed as dates.
|
||||
*
|
||||
* @param version - Migration version string
|
||||
* @returns Dayjs object if the version is a valid datetime, null otherwise
|
||||
*
|
||||
* @example
|
||||
* const parsed = parseMigrationVersion('20231128095400')
|
||||
* if (parsed) {
|
||||
* console.log(parsed.fromNow()) // "2 hours ago"
|
||||
* console.log(parsed.format('DD MMM YYYY')) // "28 Nov 2023"
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* const invalid = parseMigrationVersion('001') // returns null
|
||||
*/
|
||||
export function parseMigrationVersion(version: string | null | undefined): Dayjs | undefined {
|
||||
if (!version) return undefined
|
||||
|
||||
// Must contain only digits
|
||||
if (!/^\d{14}$/.test(version)) return undefined
|
||||
|
||||
const parsed = dayjs(version, 'YYYYMMDDHHmmss', true)
|
||||
return parsed.isValid() ? parsed : undefined
|
||||
}
|
||||
Reference in new issue
Block a user