helpers.ts450 lines · main
1import { UIEvent } from 'react'
2import { v4 as _uuidV4 } from 'uuid'
3
4import type { TablesData } from '../data/tables/tables-query'
5
6export const uuidv4 = () => {
7 return _uuidV4()
8}
9
10export const isAtBottom = ({ currentTarget }: UIEvent<HTMLElement>): boolean => {
11 return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight
12}
13
14export const tryParseJson = (jsonString: any) => {
15 try {
16 const parsed = JSON.parse(jsonString)
17 return parsed
18 } catch (error) {
19 return undefined
20 }
21}
22
23export const minifyJSON = (prettifiedJSON: string) => {
24 try {
25 if (prettifiedJSON.trim() === '') {
26 return null
27 }
28 const res = JSON.stringify(JSON.parse(prettifiedJSON))
29 if (!isNaN(Number(res))) {
30 return Number(res)
31 } else {
32 return res
33 }
34 } catch (err) {
35 throw err
36 }
37}
38
39export const prettifyJSON = (minifiedJSON: string) => {
40 try {
41 if (minifiedJSON && minifiedJSON.length > 0) {
42 return JSON.stringify(JSON.parse(minifiedJSON), undefined, 2)
43 } else {
44 return minifiedJSON
45 }
46 } catch (err) {
47 // dont need to throw error, just return text value
48 // Users have to fix format if they want to save
49 return minifiedJSON
50 }
51}
52
53export const removeJSONTrailingComma = (jsonString: string) => {
54 /**
55 * Remove trailing commas: Delete any comma immediately preceding the closing brace '}' or
56 * bracket ']' using a regular expression.
57 */
58 return jsonString.replace(/,\s*(?=[\}\]])/g, '')
59}
60
61export const timeout = (ms: number) => {
62 return new Promise((resolve) => setTimeout(resolve, ms))
63}
64
65export const getURL = () => {
66 const url =
67 process?.env?.NEXT_PUBLIC_SITE_URL && process.env.NEXT_PUBLIC_SITE_URL !== ''
68 ? process.env.NEXT_PUBLIC_SITE_URL
69 : process?.env?.NEXT_PUBLIC_VERCEL_BRANCH_URL &&
70 process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL !== ''
71 ? process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL
72 : 'https://supabase.com/dashboard'
73 return url.includes('http') ? url : `https://${url}`
74}
75
76/**
77 * Generates a random string using alpha characters
78 */
79export const makeRandomString = (length: number) => {
80 var result = ''
81 var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
82 var charactersLength = characters.length
83 for (var i = 0; i < length; i++) {
84 result += characters.charAt(Math.floor(Math.random() * charactersLength))
85 }
86 return result.toString()
87}
88
89/**
90 * Get a subset of fields from an object
91 * @param {object} model
92 * @param {array} fields a list of properties to pluck. eg: ['first_name', 'last_name']
93 */
94export const pluckObjectFields = (model: any, fields: any[]) => {
95 let o: any = {}
96 fields.forEach((field) => {
97 o[field] = model[field]
98 })
99 return o
100}
101
102/**
103 * Returns undefined if the string isn't parse-able
104 */
105export const tryParseInt = (str: string) => {
106 try {
107 const int = parseInt(str, 10)
108 return isNaN(int) ? undefined : int
109 } catch (error) {
110 return undefined
111 }
112}
113
114// Used as checker for memoized components
115export const propsAreEqual = (prevProps: any, nextProps: any) => {
116 try {
117 Object.keys(prevProps).forEach((key) => {
118 if (typeof prevProps[key] !== 'function') {
119 if (prevProps[key] !== nextProps[key]) {
120 throw new Error()
121 }
122 }
123 })
124 return true
125 } catch (e) {
126 return false
127 }
128}
129
130export const formatBytes = (
131 bytes: any,
132 decimals = 2,
133 size?: 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB'
134) => {
135 const k = 1024
136 const dm = decimals < 0 ? 0 : decimals
137 const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
138
139 if (bytes === 0 || bytes === undefined) return size !== undefined ? `0 ${size}` : '0 bytes'
140
141 // Handle negative values
142 const isNegative = bytes < 0
143 const absBytes = Math.abs(bytes)
144
145 const i = size !== undefined ? sizes.indexOf(size) : Math.floor(Math.log(absBytes) / Math.log(k))
146 const formattedValue = parseFloat((absBytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
147
148 return isNegative ? '-' + formattedValue : formattedValue
149}
150
151export const formatBytesMinMB = (bytes: any, decimals = 2) => {
152 if (bytes === 0 || bytes === undefined) return '0 MB'
153 const MB = 1024 * 1024
154 if (Math.abs(bytes) < MB) return formatBytes(bytes, decimals, 'MB')
155 return formatBytes(bytes, decimals)
156}
157
158export const snakeToCamel = (str: string) =>
159 str.replace(/([-_][a-z])/g, (group: string) =>
160 group.toUpperCase().replace('-', '').replace('_', '')
161 )
162
163export const detectBrowser = () => {
164 if (!navigator) return undefined
165
166 if (navigator.userAgent.indexOf('Chrome') !== -1) {
167 return 'Chrome'
168 } else if (navigator.userAgent.indexOf('Firefox') !== -1) {
169 return 'Firefox'
170 } else if (navigator.userAgent.indexOf('Safari') !== -1) {
171 return 'Safari'
172 }
173}
174
175export const detectOS = () => {
176 if (typeof window === 'undefined' || !window) return undefined
177 if (typeof navigator === 'undefined' || !navigator) return undefined
178
179 const userAgent = window.navigator.userAgent.toLowerCase()
180 const macosPlatforms = /(macintosh|macintel|macppc|mac68k|macos)/i
181 const windowsPlatforms = /(win32|win64|windows|wince)/i
182
183 if (macosPlatforms.test(userAgent)) {
184 return 'macos'
185 } else if (windowsPlatforms.test(userAgent)) {
186 return 'windows'
187 } else {
188 return undefined
189 }
190}
191
192export const getModKeyLabel = () => {
193 const os = detectOS()
194 return os === 'macos' ? '⌘' : 'Ctrl+'
195}
196
197/**
198 * Convert a list of tables to SQL
199 * @param t - The list of tables
200 * @returns The SQL string
201 */
202export function tablesToSQL(t: TablesData) {
203 if (!Array.isArray(t)) return ''
204 const warning =
205 '-- WARNING: This schema is for context only and is not meant to be run.\n-- Table order and constraints may not be valid for execution.\n\n'
206 const sql = t
207 .map((table) => {
208 if (!table || !Array.isArray((table as any).columns)) return ''
209
210 const columns = (table as { columns?: any[] }).columns ?? []
211 const columnLines = columns.map((c) => {
212 let line = ` ${c.name} ${c.data_type}`
213 if (c.is_identity) {
214 line += ' GENERATED ALWAYS AS IDENTITY'
215 }
216 if (c.is_nullable === false) {
217 line += ' NOT NULL'
218 }
219 if (c.default_value !== null && c.default_value !== undefined) {
220 line += ` DEFAULT ${c.default_value}`
221 }
222 if (c.is_unique) {
223 line += ' UNIQUE'
224 }
225 if (c.check) {
226 line += ` CHECK (${c.check})`
227 }
228 return line
229 })
230
231 const constraints: string[] = []
232
233 if (Array.isArray(table.primary_keys) && table.primary_keys.length > 0) {
234 const pkCols = table.primary_keys.map((pk: any) => pk.name).join(', ')
235 constraints.push(` CONSTRAINT ${table.name}_pkey PRIMARY KEY (${pkCols})`)
236 }
237
238 if (Array.isArray(table.relationships)) {
239 table.relationships.forEach((rel: any) => {
240 if (rel && rel.source_table_name === table.name) {
241 constraints.push(
242 ` CONSTRAINT ${rel.constraint_name} FOREIGN KEY (${rel.source_column_name}) REFERENCES ${rel.target_table_schema}.${rel.target_table_name}(${rel.target_column_name})`
243 )
244 }
245 })
246 }
247
248 const allLines = [...columnLines, ...constraints]
249 return `CREATE TABLE ${table.schema}.${table.name} (\n${allLines.join(',\n')}\n);`
250 })
251 .join('\n')
252 return warning + sql
253}
254
255/**
256 * Pluralize a word based on a count
257 */
258export function pluralize(count: number, singular: string, plural?: string) {
259 return count === 1 ? singular : plural || singular + 's'
260}
261
262export const isValidHttpUrl = (value: string) => {
263 let url: URL
264 try {
265 url = new URL(value)
266 } catch (_) {
267 return false
268 }
269 return url.protocol === 'http:' || url.protocol === 'https:'
270}
271
272/**
273 * Remove markdown code blocks (fenced and inline) from text
274 */
275export const stripMarkdownCodeBlocks = (text: string): string => {
276 // Remove fenced code blocks (```...```)
277 const withoutFenced = text.replace(/```[\s\S]*?```/g, '')
278 // Remove inline code (`...`)
279 return withoutFenced.replace(/`[^`]+`/g, '')
280}
281
282interface ExtractUrlsOptions {
283 excludeCodeBlocks?: boolean
284 excludeTemplates?: boolean
285}
286
287/**
288 * Extract URLs from text using regex for URL detection
289 * Matches URLs with protocols (http/https) and common domain patterns
290 * @param text - The text to extract URLs from
291 * @param options - Optional filtering options
292 * @returns Array of extracted URLs with trailing punctuation removed
293 */
294export const extractUrls = (text: string, options?: ExtractUrlsOptions): string[] => {
295 const { excludeCodeBlocks = false, excludeTemplates = false } = options ?? {}
296
297 let processedText = text
298 if (excludeCodeBlocks) {
299 processedText = stripMarkdownCodeBlocks(processedText)
300 }
301
302 // Regex matches URLs with protocols (http/https)
303 // Handles: domains, ports, paths, query params, and fragments
304 // Pattern: https?://domain(:port)?(/path)?(?query)?(#fragment)?
305 const urlRegex = /https?:\/\/(?:[-\w.])+(?::\d+)?(?:\/(?:[\w\/_.~!*'();:@&=+$,?#[\]%-])*)?/gi
306
307 const urls: string[] = []
308 let match
309
310 while ((match = urlRegex.exec(processedText)) !== null) {
311 // Remove trailing punctuation that might have been captured (common in text)
312 const url = match[0].replace(/[.,;:!?)*]+$/, '')
313
314 if (excludeTemplates) {
315 // Skip URLs that were truncated at an angle bracket (template URL)
316 const endPos = match.index + match[0].length
317 if (processedText[endPos] === '<') {
318 continue
319 }
320 }
321
322 urls.push(url)
323 }
324
325 return urls
326}
327
328/**
329 * Helper function to remove comments from SQL.
330 * Disclaimer: Doesn't work as intended for nested comments.
331 */
332export const removeCommentsFromSql = (sql: string) => {
333 // Removing single-line comments:
334 let cleanedSql = sql.replace(/--.*$/gm, '')
335
336 // Removing multi-line comments:
337 cleanedSql = cleanedSql.replace(/\/\*[\s\S]*?\*\//gm, '')
338
339 return cleanedSql
340}
341
342const formatSemver = (version: string) => {
343 // e.g supabase-postgres-14.1.0.88
344 // There's 4 segments instead so we can't use the semver package
345 const segments = version.split('supabase-postgres-')
346 const semver = segments[segments.length - 1]
347
348 // e.g supabase-postgres-14.1.0.99-vault-rc1
349 const formattedSemver = semver.split('-')[0]
350
351 return formattedSemver
352}
353
354export const getSemanticVersion = (version: string) => {
355 if (!version) return 0
356
357 const formattedSemver = formatSemver(version)
358 return Number(formattedSemver.split('.').join(''))
359}
360
361export const getDatabaseMajorVersion = (version: string) => {
362 if (!version) return 0
363
364 const formattedSemver = formatSemver(version)
365 return Number(formattedSemver.split('.')[0])
366}
367
368const deg2rad = (deg: number) => {
369 return deg * (Math.PI / 180)
370}
371
372export const getDistanceLatLonKM = (lat1: number, lon1: number, lat2: number, lon2: number) => {
373 const R = 6371 // Radius of the earth in kilometers
374 const dLat = deg2rad(lat2 - lat1) // deg2rad below
375 const dLon = deg2rad(lon2 - lon1)
376 const a =
377 Math.sin(dLat / 2) * Math.sin(dLat / 2) +
378 Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2)
379 const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
380 const d = R * c // Distance in KM
381 return d
382}
383
384const currencyFormatterDefault = Intl.NumberFormat('en-US', {
385 style: 'currency',
386 currency: 'USD',
387 minimumFractionDigits: 2,
388 maximumFractionDigits: 2,
389})
390
391const currencyFormatterSmallValues = Intl.NumberFormat('en-US', {
392 style: 'currency',
393 currency: 'USD',
394 minimumFractionDigits: 0,
395})
396
397export const formatCurrency = (amount: number | undefined | null): string | null => {
398 if (amount === undefined || amount === null) {
399 return null
400 } else if (amount > 0 && amount < 0.01) {
401 return currencyFormatterSmallValues.format(amount)
402 } else {
403 return currencyFormatterDefault.format(amount)
404 }
405}
406
407/**
408 * [Joshen] This is to address an incredibly weird bug that's happening between Data Grid + Shadcn ContextMenu + Shadcn Overlay
409 * This trifecta is causing a pointer events none style getting left behind on the body element which makes the dashboard become
410 * unresponsive, hence the attempt to clean things up here
411 *
412 * Timeout is made configurable as I've observed it requires a higher timeout sometimes (e.g when closing the cron job sheet)
413 */
414export const cleanPointerEventsNoneOnBody = (timeoutMs: number = 300) => {
415 if (typeof window !== 'undefined') {
416 setTimeout(() => {
417 if (document.body.style.pointerEvents === 'none') {
418 document.body.style.pointerEvents = ''
419 }
420 }, timeoutMs)
421 }
422}
423
424export const createWrappedSymbol = (name: string, display: string): Symbol => {
425 const sym = Symbol(name)
426 const wrapper = Object(sym)
427
428 wrapper.toString = () => display
429
430 Object.freeze(wrapper)
431 return wrapper
432}
433
434// Intentional for generic use; does not affect type safety since this branch is
435// unreachable.
436// eslint-disable-next-line @typescript-eslint/no-explicit-any
437export function neverGuard(_: never): any {}
438
439export function isObject(
440 maybeObject: unknown
441): maybeObject is Record<string | symbol | number, unknown> {
442 return maybeObject !== null && typeof maybeObject === 'object' && !Array.isArray(maybeObject)
443}
444
445export function isObjectContainingKeys<T extends string | symbol | number>(
446 maybeObject: unknown,
447 keys: Array<T>
448): maybeObject is { [K in T]: unknown } {
449 return isObject(maybeObject) && keys.every((key) => key in maybeObject)
450}