mirror of
https://github.com/nocodb/nocodb.git
synced 2026-05-01 02:07:07 +00:00
365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
import type { DefaultOptionType } from 'ant-design-vue/lib/select'
|
|
import type { SortableOptions } from 'sortablejs'
|
|
import type { AutoScrollOptions } from 'sortablejs/plugins'
|
|
import type { UserType } from 'nocodb-sdk'
|
|
import { ncIsArray } from 'nocodb-sdk'
|
|
import GraphemeSplitter from 'grapheme-splitter'
|
|
|
|
export const modalSizes = {
|
|
xs: {
|
|
width: 'min(calc(100vw - 32px), 448px)',
|
|
height: 'min(90vh, 448px)',
|
|
},
|
|
sm: {
|
|
width: 'min(calc(100vw - 32px), 640px)',
|
|
height: 'min(90vh, 424px)',
|
|
},
|
|
md: {
|
|
width: 'min(80vw, 900px)',
|
|
height: 'min(90vh, 540px)',
|
|
mobile: {
|
|
width: 'min(calc(100vw - 32px), 900px)',
|
|
height: 'min(90vh, 540px)',
|
|
},
|
|
},
|
|
lg: {
|
|
width: 'min(80vw, 1280px)',
|
|
height: 'min(90vh, 864px)',
|
|
mobile: {
|
|
width: 'min(calc(100vw - 32px), 1280px)',
|
|
height: 'min(90vh, 864px)',
|
|
},
|
|
},
|
|
xl: {
|
|
width: 'min(90vw, 1280px)',
|
|
height: 'min(90vh, 864px)',
|
|
mobile: {
|
|
width: 'min(calc(100vw - 32px), 1280px)',
|
|
height: 'min(90vh, 864px)',
|
|
},
|
|
},
|
|
fullscreen: {
|
|
width: '100vw',
|
|
height: '100vh',
|
|
},
|
|
extensionFullscreen: {
|
|
width: 'calc(100vw - 32px)',
|
|
height: 'calc(100vh - var(--topbar-height) - 16px)',
|
|
},
|
|
}
|
|
|
|
/**
|
|
* Creates a promise that resolves after a specified delay.
|
|
*
|
|
* @param ms - The delay in milliseconds.
|
|
* @returns A promise that resolves after the specified delay.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Wait for 2 seconds
|
|
* await delay(2000);
|
|
* console.log('2 seconds have passed');
|
|
* ```
|
|
*/
|
|
export const ncDelay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
/**
|
|
* Generates an array of a given length with content generated by the provided callback function.
|
|
*
|
|
* @param length - The length of the array to be created.
|
|
* @param contentCallback - Optional function to generate content for each index. Defaults to using the index as content.
|
|
* @returns The generated array with content.
|
|
*
|
|
* @example
|
|
* // Generate an array of length 5 with default content
|
|
* const array = ncArrayFrom(5);
|
|
* console.log(array); // ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']
|
|
*
|
|
* @example
|
|
* // Generate an array of length 3 with custom content
|
|
* const customArray = ncArrayFrom(3, (i) => `Custom Content ${i}`);
|
|
* console.log(customArray); // ['Custom Content 0', 'Custom Content 1', 'Custom Content 2']
|
|
*/
|
|
export const ncArrayFrom = <T>(
|
|
length: number,
|
|
contentCallback: (i: number) => T = (i) => `Item ${i + 1}` as unknown as T,
|
|
): T[] => {
|
|
return Array.from({ length }, (_, i) => contentCallback(i))
|
|
}
|
|
|
|
/**
|
|
* Checks if a string contains Unicode emojis.
|
|
*
|
|
* @param emoji - The string to check.
|
|
* @returns A boolean indicating if the string contains Unicode emojis.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const hasEmoji = isUnicodeEmoji('Hello World 😊');
|
|
* console.log(hasEmoji); // Output: true
|
|
* ```
|
|
*/
|
|
export const isUnicodeEmoji = (emoji: string) => {
|
|
return !!emoji?.match(/(\p{Emoji}|\p{Extended_Pictographic})/gu)
|
|
}
|
|
|
|
/**
|
|
* Get safe initials, emoji-safe & surrogate-safe.
|
|
*
|
|
* Rules: if limitEmojiToOne is true, then:
|
|
* - Emoji + Letter → keep both
|
|
* - Emoji + Emoji → keep only first emoji
|
|
*/
|
|
export const getSafeInitials = (title?: string, initialsLength: number = 2, limitEmojiToOne: boolean = false): string => {
|
|
if (!title?.trim()) return ''
|
|
|
|
const splitter = new GraphemeSplitter()
|
|
const splitGraphemes = (str: string) => splitter.splitGraphemes(str)
|
|
|
|
const words = title.trim().split(/\s+/).filter(Boolean)
|
|
const initials: string[] = []
|
|
|
|
// First take initials from each word
|
|
for (const word of words) {
|
|
if (initials.length >= initialsLength) break
|
|
const g = splitGraphemes(word)
|
|
if (g.length > 0) initials.push(g[0]!)
|
|
}
|
|
|
|
// If only 1 word or initialsLength > words.length
|
|
if (initials.length < initialsLength && words.length > 0) {
|
|
const g = splitGraphemes(words[0] ?? '')
|
|
const remaining = initialsLength - initials.length
|
|
initials.push(...g.slice(1, 1 + remaining)) // continue slicing same word
|
|
}
|
|
|
|
// Final slice by allowed length
|
|
const resultClusters = splitGraphemes(initials.join('')).slice(0, initialsLength)
|
|
|
|
// ✅ Smart emoji limit:
|
|
// If ALL characters are emoji & more than 1 → limit to 1
|
|
if (limitEmojiToOne && resultClusters.length > 1 && resultClusters.every((ch) => isUnicodeEmoji(ch))) {
|
|
return resultClusters[0] ?? '' // only first emoji
|
|
}
|
|
|
|
return resultClusters.join('')
|
|
}
|
|
|
|
/**
|
|
* Performs a case-insensitive search to check if the `query` exists within the `source`.
|
|
*
|
|
* - Handles strings, numbers, and arrays (including nested arrays) of strings/numbers.
|
|
* - Treats `undefined` as an empty string.
|
|
*
|
|
* @param source - The value to search within. Can be:
|
|
* - A string or number.
|
|
* - A single-level or nested array of strings/numbers.
|
|
* @param query - The value to search for. Treated as an empty string if `undefined`.
|
|
* @returns `true` if the `query` is found within the `source` (case-insensitively), otherwise `false`.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* // Single string or number search
|
|
* searchCompare("Hello World", "world"); // true
|
|
* searchCompare(12345, "234"); // true
|
|
*
|
|
* // Array search
|
|
* searchCompare(["apple", "banana", "cherry"], "Banana"); // true
|
|
* searchCompare([123, 456, 789], "456"); // true
|
|
*
|
|
* // Nested array search
|
|
* searchCompare(["apple", ["banana", ["cherry"]]], "cherry"); // true
|
|
* searchCompare([123, [456, [789]]], "456"); // true
|
|
*
|
|
* // Handling undefined
|
|
* searchCompare(undefined, "test"); // false
|
|
* searchCompare("test", undefined); // true
|
|
* ```
|
|
*/
|
|
export const searchCompare = (
|
|
source?: NestedArray<string | number | undefined>,
|
|
query?: string,
|
|
onMatch?: (source: string | number | undefined) => void,
|
|
): boolean => {
|
|
if (ncIsArray(source)) {
|
|
return source.some((item) => searchCompare(item, query, onMatch))
|
|
}
|
|
|
|
const isMatch = (source || '')
|
|
.toString()
|
|
.toLowerCase()
|
|
.includes((query || '').toLowerCase())
|
|
|
|
if (isMatch && onMatch) {
|
|
onMatch(source)
|
|
}
|
|
|
|
return isMatch
|
|
}
|
|
|
|
/**
|
|
* Filters options for an Ant Design Select component based on an input value.
|
|
*
|
|
* @param inputValue - The input value to filter against.
|
|
* @param option - The option to evaluate for filtering.
|
|
* @param searchKey - The key(s) in the option object to compare against the input value. Defaults to 'key'.
|
|
* @returns `true` if the option matches the input value, otherwise `false`.
|
|
*/
|
|
export const antSelectFilterOption = (
|
|
inputValue: string,
|
|
option?: DefaultOptionType | NcListItemType,
|
|
searchKey: keyof DefaultOptionType | keyof NcListItemType | (keyof NcListItemType)[] | (keyof DefaultOptionType)[] = 'key',
|
|
) => {
|
|
if (!option) return false
|
|
|
|
const optionValue = ncIsArray(searchKey) ? searchKey.map((key) => option[key]) : [option[searchKey]]
|
|
|
|
return searchCompare(optionValue, inputValue)
|
|
}
|
|
|
|
/**
|
|
* Extracts the name from an email address.
|
|
*
|
|
* @param email - The email address to extract the name from.
|
|
* @returns The name extracted from the email address.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const name = extractNameFromEmail('john.doe@example.com');
|
|
* console.log(name); // Output: 'john.doe'
|
|
* ```
|
|
*/
|
|
export const extractNameFromEmail = (email?: string) => {
|
|
if (!email) return ''
|
|
|
|
return email?.slice(0, email.indexOf('@')) ?? ''
|
|
}
|
|
|
|
/**
|
|
* Extracts the display name or email from a user object.
|
|
*
|
|
* @param user - The user object to extract the display name or email from.
|
|
* @returns The display name or email extracted from the user object.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const name = extractUserDisplayNameOrEmail({ display_name: 'John Doe', email: 'john.doe@example.com' });
|
|
* console.log(name); // Output: 'John Doe'
|
|
*/
|
|
export const extractUserDisplayNameOrEmail = (user?: UserType | Record<string, string>) => {
|
|
if (!user) return ''
|
|
|
|
if (user?.display_name) return user.display_name.trim()
|
|
|
|
return extractNameFromEmail(user.email)
|
|
}
|
|
|
|
/**
|
|
* Wait for a condition to be truthy
|
|
* @param conditionFn - Function that returns the condition to check
|
|
* @param interval - Polling interval in milliseconds (default: 100)
|
|
* @returns Promise that resolves when condition becomes truthy
|
|
*/
|
|
export function waitForCondition(conditionFn: () => unknown, interval: number = 100): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
const check = (): void => {
|
|
if (conditionFn()) {
|
|
resolve()
|
|
} else {
|
|
setTimeout(check, interval)
|
|
}
|
|
}
|
|
check()
|
|
})
|
|
}
|
|
|
|
export const pollUntil = <T>(conditionFn: () => T | null | undefined | false, interval: number = 100): Promise<T> => {
|
|
return new Promise((resolve, reject) => {
|
|
const check = () => {
|
|
try {
|
|
const result = conditionFn()
|
|
if (result) {
|
|
resolve(result)
|
|
} else {
|
|
setTimeout(check, interval)
|
|
}
|
|
} catch (error) {
|
|
reject(error)
|
|
}
|
|
}
|
|
|
|
check()
|
|
})
|
|
}
|
|
|
|
export const getDraggableAutoScrollOptions = (
|
|
params: Partial<AutoScrollOptions & { direction: SortableOptions['direction'] }> = {},
|
|
): Partial<AutoScrollOptions & { direction: SortableOptions['direction'] }> => {
|
|
return {
|
|
/**
|
|
* scroll property is used to enable auto scroll plugin
|
|
*/
|
|
scroll: true,
|
|
/**
|
|
* force the autoscroll fallback to kick in
|
|
* if this value is false then updated `scrollSensitivity` will not work
|
|
*/
|
|
forceAutoScrollFallback: true,
|
|
/**
|
|
* px, how near the mouse must be to an edge to start scrolling.
|
|
*/
|
|
scrollSensitivity: 50,
|
|
direction: 'vertical',
|
|
...params,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sorts two objects based on the provided sorting options.
|
|
* @param a - The first object to sort.
|
|
* @param b - The second object to sort.
|
|
* @param sortOption - The sorting options to apply.
|
|
* @returns The sorted objects.
|
|
*/
|
|
export const ncArrSortCallback = <T>(
|
|
a: T,
|
|
b: T,
|
|
sortOption: {
|
|
/** The key of the object being used for sorting. */
|
|
key: keyof T & string
|
|
/**
|
|
* The type of sorting to apply (e.g., string, number).
|
|
* @default 'number'
|
|
*/
|
|
sortType?: 'string' | 'number' | 'boolean' | 'count'
|
|
/** Optional formatter to preprocess the value before sorting. */
|
|
format?: (value: any) => any
|
|
},
|
|
) => {
|
|
const { key, sortType = 'number', format } = sortOption || {}
|
|
|
|
const valueA = format ? format(a[key]) : a[key]
|
|
const valueB = format ? format(b[key]) : b[key]
|
|
|
|
switch (sortType) {
|
|
case 'number':
|
|
return (valueA ?? Infinity) - (valueB ?? Infinity)
|
|
case 'string': {
|
|
return String(valueA).localeCompare(String(valueB))
|
|
}
|
|
case 'boolean': {
|
|
return valueA === valueB ? 0 : valueA ? 1 : -1
|
|
}
|
|
case 'count': {
|
|
const lengthA = ncIsString(valueA) || ncIsArray(valueA) ? valueA.length : Infinity
|
|
const lengthB = ncIsString(valueB) || ncIsArray(valueB) ? valueB.length : Infinity
|
|
|
|
return lengthA === lengthB ? 0 : lengthA > lengthB ? 1 : -1
|
|
}
|
|
|
|
default: {
|
|
return 0
|
|
}
|
|
}
|
|
}
|