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 = ( 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, 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) => { 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 { return new Promise((resolve) => { const check = (): void => { if (conditionFn()) { resolve() } else { setTimeout(check, interval) } } check() }) } export const pollUntil = (conditionFn: () => T | null | undefined | false, interval: number = 100): Promise => { 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 = {}, ): Partial => { 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 = ( 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 } } }