多维表格
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

487 lines
16 KiB

import { type ColumnType, FieldNameFromUITypes, UITypes } from 'nocodb-sdk'
import isURL from 'validator/lib/isURL'
import { pluralize } from 'inflection'
// This regex pattern matches email addresses by looking for sequences that start with characters before the "@" symbol, followed by the domain.
// It's designed to capture most email formats, including those with periods and "+" symbols in the local part.
const validateEmail = (v: string) =>
/^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i.test(v)
export const extractEmail = (v: string) => {
const matches = v.match(
/(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})/i,
)
return matches ? matches[0] : null
}
const booleanOptions = [
{ checked: true, unchecked: false },
{ 'x': true, '': false },
{ yes: true, no: false },
{ y: true, n: false },
{ 1: true, 0: false },
{ '[x]': true, '[]': false, '[ ]': false },
{ '☑': true, '': false },
{ '✅': true, '': false },
{ '✓': true, '': false },
{ '✔': true, '': false },
{ enabled: true, disabled: false },
{ on: true, off: false },
{ 'done': true, '': false },
{ true: true, false: false },
]
const aggBooleanOptions: any = booleanOptions.reduce((obj, o) => ({ ...obj, ...o }), {})
const getColVal = (row: any, col?: number) => {
return row && col !== undefined ? row[col] : row
}
export const isCheckboxType: any = (values: [], col?: number) => {
let options = booleanOptions
for (let i = 0; i < values.length; i++) {
const val = getColVal(values[i], col)
if (val === null || val === undefined || val.toString().trim() === '') {
continue
}
options = options.filter((v) => val in v)
if (!options.length) {
return false
}
}
return true
}
export const getCheckboxValue = (value: any) => {
return value && aggBooleanOptions[value]
}
export const isMultiLineTextType = (values: [], col?: number) => {
return values.some(
(r) => (getColVal(r, col) || '').toString().match(/[\r\n]/) || (getColVal(r, col) || '').toString().length > 255,
)
}
export const extractMultiOrSingleSelectProps = (colData: []) => {
const maxSelectOptionsAllowed = 64
const colProps: any = {}
if (colData.some((v: any) => v && (v || '').toString().includes(','))) {
const flattenedVals = colData.flatMap((v: any) =>
v
? v
.toString()
.trim()
.split(/\s*,\s*/)
: [],
)
const uniqueVals = [
...new Set(flattenedVals.filter((v) => v !== null && v !== undefined).map((v: any) => v.toString().trim())),
]
if (uniqueVals.length > maxSelectOptionsAllowed) {
// too many options are detected, convert the column to SingleLineText instead
colProps.uidt = UITypes.SingleLineText
// _disableSelect is used to disable the <a-select-option/> in TemplateEditor
colProps._disableSelect = true
} else {
// assume the column type is multiple select if there are repeated values
if (flattenedVals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(flattenedVals.length / 2)) {
colProps.uidt = UITypes.MultiSelect
}
// set dtxp here so that users can have the options even they switch the type from other types to MultiSelect
// once it's set, dtxp needs to be reset if the final column type is not MultiSelect
colProps.dtxp = `${uniqueVals.map((v) => `'${v.replace(/'/gi, "''")}'`).join(',')}`
}
} else {
const uniqueVals = [...new Set(colData.filter((v) => v !== null && v !== undefined).map((v: any) => v.toString().trim()))]
if (uniqueVals.length > maxSelectOptionsAllowed) {
// too many options are detected, convert the column to SingleLineText instead
colProps.uidt = UITypes.SingleLineText
// _disableSelect is used to disable the <a-select-option/> in TemplateEditor
colProps._disableSelect = true
} else {
// assume the column type is single select if there are repeated values
// once it's set, dtxp needs to be reset if the final column type is not Single Select
if (colData.length > uniqueVals.length && uniqueVals.length <= Math.ceil(colData.length / 2)) {
colProps.uidt = UITypes.SingleSelect
}
// set dtxp here so that users can have the options even they switch the type from other types to SingleSelect
// once it's set, dtxp needs to be reset if the final column type is not SingleSelect
colProps.dtxp = `${uniqueVals.map((v) => `'${v.replace(/'/gi, "''")}'`).join(',')}`
}
return colProps
}
}
export const extractSelectOptions = (colData: [], type: UITypes.SingleSelect | UITypes.MultiSelect): { dtxp: string } => {
const colProps: any = {}
if (type === UITypes.MultiSelect) {
const flattenedVals = colData.flatMap((v: any) =>
v
? v
.toString()
.trim()
.split(/\s*,\s*/)
: [],
)
const uniqueVals = [...new Set(flattenedVals.map((v: any) => v.toString().trim()))]
colProps.uidt = UITypes.MultiSelect
colProps.dtxp = `${uniqueVals.map((v) => `'${v.replace(/'/gi, "''")}'`).join(',')}`
} else {
const uniqueVals = [...new Set(colData.map((v: any) => v.toString().trim()))]
colProps.uidt = UITypes.SingleSelect
colProps.dtxp = `${uniqueVals.map((v) => `'${v.replace(/'/gi, "''")}'`).join(',')}`
}
return colProps
}
export const isDecimalType = (colData: []) =>
colData.some((v: any) => {
return v && parseInt(v) !== +v
})
export const isEmailType = (colData: [], col?: number) =>
colData.some((r: any) => {
const v = getColVal(r, col)
return v && validateEmail(v)
})
export const isUrlType = (colData: [], col?: number) =>
colData.some((r: any) => {
const v = getColVal(r, col)
// convert to string since isURL only accepts string
// and cell data value can be number or any other types
return v && isURL(v.toString())
})
export const getColumnUIDTAndMetas = (colData: [], defaultType: string) => {
const colProps = { uidt: defaultType }
if (colProps.uidt === UITypes.SingleLineText) {
// check for long text
if (isMultiLineTextType(colData)) {
colProps.uidt = UITypes.LongText
}
if (isEmailType(colData)) {
colProps.uidt = UITypes.Email
}
if (isUrlType(colData)) {
colProps.uidt = UITypes.URL
} else {
if (isCheckboxType(colData)) {
colProps.uidt = UITypes.Checkbox
} else {
Object.assign(colProps, extractMultiOrSingleSelectProps(colData))
}
}
} else if (colProps.uidt === UITypes.Number) {
if (isDecimalType(colData)) {
colProps.uidt = UITypes.Decimal
}
}
// TODO(import): currency
// TODO(import): date / datetime
return colProps
}
export const filterNullOrUndefinedObjectProperties = <T extends Record<string, any>>(obj: T): T => {
return Object.keys(obj).reduce((result, propName) => {
const value = obj[propName]
if (value !== null && value !== undefined) {
if (!Array.isArray(value) && typeof value === 'object') {
// Recursively filter nested objects
result[propName] = filterNullOrUndefinedObjectProperties(value)
} else {
result[propName] = value
}
}
return result
}, {} as Record<string, any>) as T
}
/**
* Extracts the next default name based on the provided namesData, defaultName, and splitOperator.
*
* @param namesData - An array of strings containing existing names data.
* @param defaultName - The default name to extract and generate the next name from.
* @param splitOperator - The separator used to split the defaultName and numbers in existing namesData.
* Defaults to '-'. Example: If defaultName is 'Token' and splitOperator is '-',
* existing names like 'Token-1', 'Token-2', etc., will be considered.
* @returns The next default name with an incremented number based on existing namesData.
*/
export const extractNextDefaultName = (namesData: string[], defaultName: string, splitOperator: string = '-'): string => {
// Extract and sort numbers associated with the provided defaultName
const extractedSortedNumbers =
(namesData
.map((name) => {
const [_defaultName, number] = name.split(splitOperator)
if (_defaultName === defaultName && !isNaN(Number(number?.trim()))) {
return Number(number?.trim())
}
return undefined
})
.filter((e) => e)
.sort((a, b) => {
if (a !== undefined && b !== undefined) {
return a - b
}
return 0
}) as number[]) || []
return extractedSortedNumbers.length
? `${defaultName}${splitOperator}${extractedSortedNumbers[extractedSortedNumbers.length - 1] + 1}`
: `${defaultName}${splitOperator}1`
}
export const getFormattedViewTabTitle = ({
viewName,
tableName,
baseName,
isDefaultView = false,
charLimit = 20,
isSharedView = false,
}: {
viewName: string
tableName: string
baseName: string
isDefaultView?: boolean
charLimit?: number
isSharedView?: boolean
}) => {
if (isSharedView) {
return viewName || 'NocoDB'
}
let title = `${viewName} | ${tableName} | ${baseName}`
if (isDefaultView) {
charLimit = 30
title = `${tableName} | ${baseName}`
}
if (title.length <= 60) {
return title
}
// Function to truncate text and add ellipsis if needed
const truncateText = (text: string) => {
return text.length > charLimit ? `${text.substring(0, charLimit - 3)}...` : text
}
if (isDefaultView) {
title = `${truncateText(tableName)} | ${truncateText(baseName)}`
} else {
title = `${truncateText(viewName)} | ${truncateText(tableName)} | ${truncateText(baseName)}`
}
return title
}
export const generateUniqueColumnSuffix = ({
tableExplorerColumns,
metaColumns,
}: {
tableExplorerColumns?: ColumnType[]
metaColumns: ColumnType[]
}) => {
let suffix = (metaColumns?.length || 0) + 1
let columnName = `title${suffix}`
while (
(tableExplorerColumns || metaColumns)?.some(
(c) =>
(c.column_name || '').toLowerCase() === columnName.toLowerCase() ||
(c.title || '').toLowerCase() === columnName.toLowerCase(),
)
) {
suffix++
columnName = `title${suffix}`
}
return suffix
}
const extractNextDefaultColumnName = ({
tableExplorerColumns,
metaColumns,
defaultColumnName,
newFieldTitles,
formState,
}: {
tableExplorerColumns?: ColumnType[]
metaColumns: ColumnType[]
defaultColumnName: string
newFieldTitles: string[]
formState: Record<string, any>
}): string => {
// Extract and sort numbers associated with the provided defaultName
const namesData = ((tableExplorerColumns || metaColumns)
?.flatMap((c) => {
if (formState?.temp_id && c?.temp_id && formState?.temp_id === c?.temp_id) {
return []
}
if (c.title !== c.column_name) {
return [c.title?.toLowerCase(), c.column_name?.toLowerCase()]
}
return [c.title?.toLowerCase()]
})
.filter((t) => t && t.startsWith(defaultColumnName.toLowerCase())) || []) as string[]
if (![...namesData, ...newFieldTitles].includes(defaultColumnName.toLowerCase())) {
return defaultColumnName
}
const extractedSortedNumbers =
(namesData
.map((name) => {
const [_defaultName, number] = name.split(/ (?!.* )/)
if (_defaultName === defaultColumnName.toLowerCase() && !isNaN(Number(number?.trim()))) {
return Number(number?.trim())
}
return undefined
})
.filter((e) => e)
.sort((a, b) => {
if (a !== undefined && b !== undefined) {
return a - b
}
return 0
}) as number[]) || []
return extractedSortedNumbers.length
? `${defaultColumnName} ${extractedSortedNumbers[extractedSortedNumbers.length - 1] + 1}`
: `${defaultColumnName} 1`
}
export const generateUniqueColumnName = ({
tableExplorerColumns,
metaColumns,
formState,
newFieldTitles,
}: {
tableExplorerColumns?: ColumnType[]
metaColumns: ColumnType[]
formState: Record<string, any>
newFieldTitles?: string[]
}) => {
let defaultColumnName = FieldNameFromUITypes[formState.uidt as UITypes]
if (!defaultColumnName) {
return `title${generateUniqueColumnSuffix({ tableExplorerColumns, metaColumns })}`
}
switch (formState.uidt) {
case UITypes.User: {
if (formState.meta.is_multi) {
defaultColumnName = `${defaultColumnName}s`
}
break
}
case UITypes.Links:
case UITypes.LinkToAnotherRecord: {
if (!formState.childTableTitle) {
return `title${generateUniqueColumnSuffix({ tableExplorerColumns, metaColumns })}`
}
let childTableTitle = formState.childTableTitle
// Use plural for links except oo relation type
if (formState.uidt === UITypes.Links && formState?.type !== 'oo') {
childTableTitle = pluralize(childTableTitle)
}
// Calculate the remaining length available for childTableTitle
const maxLength = 255 - (defaultColumnName.length - 11 + '{TableName}'.length)
// Truncate childTableTitle if it exceeds the maxLength
if (childTableTitle.length > maxLength) {
childTableTitle = `${childTableTitle.slice(0, maxLength - 3)}...`
}
// Replace {TableName} with the potentially truncated childTableTitle
defaultColumnName = defaultColumnName.replace('{TableName}', childTableTitle)
// Ensure the final defaultColumnName is less than 255 characters
if (defaultColumnName.length >= 255) {
defaultColumnName = `${defaultColumnName.slice(0, 252)}...`
}
break
}
case UITypes.Lookup: {
if (!formState.lookupTableTitle || !formState.lookupColumnTitle) {
return `title${generateUniqueColumnSuffix({ tableExplorerColumns, metaColumns })}`
}
let lookupTableTitle = formState.lookupTableTitle
let lookupColumnTitle = formState.lookupColumnTitle
// Calculate the lengths of the placeholders
const placeholderLength = '{TableName}'.length + '{FieldName}'.length
const baseLength = defaultColumnName.length - placeholderLength
// Calculate the maximum length allowed for both titles combined
const maxTotalLength = 255 - baseLength
const maxLengthPerTitle = Math.floor(maxTotalLength / 2)
// Truncate the titles if necessary
if (lookupTableTitle.length > maxLengthPerTitle) {
lookupTableTitle = `${lookupTableTitle.slice(0, maxLengthPerTitle - 3)}...`
}
if (lookupColumnTitle.length > maxLengthPerTitle) {
lookupColumnTitle = `${lookupColumnTitle.slice(0, maxLengthPerTitle - 3)}...`
}
// Replace placeholders
defaultColumnName = defaultColumnName.replace('{TableName}', lookupTableTitle).replace('{FieldName}', lookupColumnTitle)
break
}
case UITypes.Rollup: {
if (!formState.rollupTableTitle || !formState.rollupColumnTitle || !formState?.rollup_function_name) {
return `title${generateUniqueColumnSuffix({ tableExplorerColumns, metaColumns })}`
}
let rollupTableTitle = formState.rollupTableTitle
let rollupColumnTitle = formState.rollupColumnTitle
// Update rollup function name
defaultColumnName = defaultColumnName.replace('{RollupFunction}', formState.rollup_function_name)
// Calculate the lengths of the placeholders
const placeholderLength = '{TableName}'.length + '{FieldName}'.length
const baseLength = defaultColumnName.length - placeholderLength
// Calculate the maximum length allowed for both titles combined
const maxTotalLength = 255 - baseLength
const maxLengthPerTitle = Math.floor(maxTotalLength / 2)
// Truncate the titles if necessary
if (rollupTableTitle.length > maxLengthPerTitle) {
rollupTableTitle = `${rollupTableTitle.slice(0, maxLengthPerTitle - 3)}...`
}
if (rollupColumnTitle.length > maxLengthPerTitle) {
rollupColumnTitle = `${rollupColumnTitle.slice(0, maxLengthPerTitle - 3)}...`
}
// Replace placeholders
defaultColumnName = defaultColumnName.replace('{TableName}', rollupTableTitle).replace('{FieldName}', rollupColumnTitle)
break
}
}
return extractNextDefaultColumnName({
tableExplorerColumns,
metaColumns,
defaultColumnName,
newFieldTitles: newFieldTitles || [],
formState,
})
}