mirror of
https://github.com/nocodb/nocodb.git
synced 2026-05-01 05:26:53 +00:00
1426 lines
49 KiB
Vue
1426 lines
49 KiB
Vue
<script setup lang="ts">
|
|
import dayjs from 'dayjs'
|
|
import utc from 'dayjs/plugin/utc'
|
|
import type { ColumnType, TableType } from 'nocodb-sdk'
|
|
import {
|
|
PermissionEntity,
|
|
PermissionKey,
|
|
SqlUiFactory,
|
|
UITypes,
|
|
getDateFormat,
|
|
getDateTimeFormat,
|
|
isSystemColumn,
|
|
isVirtualCol,
|
|
parseStringDate,
|
|
validateDateWithUnknownFormat,
|
|
} from 'nocodb-sdk'
|
|
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface'
|
|
import { srcDestMappingColumns, tableColumns } from './utils'
|
|
|
|
interface Props {
|
|
quickImportType: 'csv' | 'excel' | 'json'
|
|
baseTemplate: Record<string, any>
|
|
importData: Record<string, any>
|
|
importColumns: any[]
|
|
importDataOnly: boolean
|
|
maxRowsToParse: number
|
|
baseId: string
|
|
sourceId: string
|
|
importWorker: Worker
|
|
tableIcon?: string
|
|
}
|
|
|
|
interface Option {
|
|
label: string
|
|
value: string
|
|
}
|
|
|
|
const { quickImportType, baseTemplate, importData, importColumns, importDataOnly, maxRowsToParse, baseId, sourceId } =
|
|
defineProps<Props>()
|
|
|
|
const emit = defineEmits(['import', 'error', 'change'])
|
|
|
|
dayjs.extend(utc)
|
|
|
|
const { t } = useI18n()
|
|
|
|
const { getMeta } = useMetas()
|
|
|
|
const { isAllowed } = usePermissions()
|
|
|
|
const { appInfo } = useGlobal()
|
|
|
|
const meta = inject(MetaInj, ref())
|
|
|
|
const filterForDestinationColumn = (col: ColumnType): boolean => {
|
|
if ([UITypes.ForeignKey, UITypes.ID].includes(col.uidt as UITypes)) {
|
|
return true
|
|
} else {
|
|
return !isSystemColumn(col) && !isVirtualCol(col) && !isAttachment(col)
|
|
}
|
|
}
|
|
|
|
const columns = computed(() =>
|
|
(meta.value?.columns || [])
|
|
?.filter((col) => filterForDestinationColumn(col))
|
|
.map((col) => {
|
|
// If it is import data only, then we need to check if the field is editable
|
|
const isEditAllowed = importDataOnly ? isAllowed(PermissionEntity.FIELD, col.id!, PermissionKey.RECORD_FIELD_EDIT) : true
|
|
|
|
// We allow to link record throw foreign key, so we don't need to check if the field is readonly
|
|
const isReadonlyCol = col.readonly && col.uidt !== UITypes.ForeignKey
|
|
|
|
return {
|
|
...col,
|
|
readonly: isReadonlyCol || !isEditAllowed,
|
|
permissions: {
|
|
isEditAllowed,
|
|
tooltip: isReadonlyCol
|
|
? t('msg.info.fieldReadonly')
|
|
: !isEditAllowed
|
|
? t('tooltip.youDontHavePermissionToEditThisField')
|
|
: '',
|
|
},
|
|
}
|
|
}),
|
|
)
|
|
|
|
const reloadHook = inject(ReloadViewDataHookInj, createEventHook())
|
|
|
|
const useForm = Form.useForm
|
|
|
|
const { $api, $state } = useNuxtApp()
|
|
|
|
const basesStore = useBases()
|
|
|
|
const { bases } = storeToRefs(basesStore)
|
|
|
|
const baseStore = useBase()
|
|
|
|
const { isMysql, isPg } = baseStore
|
|
|
|
const { base: activeBase } = storeToRefs(baseStore)
|
|
|
|
const base = computed(() => bases.value.get(baseId) || activeBase.value)
|
|
|
|
const tablesStore = useTablesStore()
|
|
const { openTable, loadProjectTables } = tablesStore
|
|
const { baseTables } = storeToRefs(tablesStore)
|
|
|
|
const sqlUis = computed(() => {
|
|
const temp: Record<string, any> = {}
|
|
|
|
for (const source of base.value.sources ?? []) {
|
|
if (source.id) {
|
|
temp[source.id] = SqlUiFactory.create({ client: source.type })
|
|
}
|
|
}
|
|
|
|
return temp
|
|
})
|
|
|
|
const sqlUi = computed(() => sqlUis.value[sourceId] || Object.values(sqlUis.value)[0])
|
|
|
|
const hasSelectColumn = ref<boolean[]>([])
|
|
|
|
const expansionPanel = ref<number[]>([])
|
|
|
|
const autoInsertOption = ref<boolean>(false)
|
|
|
|
const inputRefs = ref<HTMLInputElement[]>([])
|
|
|
|
const isImporting = ref(false)
|
|
|
|
const importingTips = ref<Record<string, string>>({})
|
|
|
|
const importingTableTips = ref<Record<string, number>>({})
|
|
|
|
const formError = ref()
|
|
|
|
const srcDestMapping = ref<Record<string, Record<string, any>[]>>({})
|
|
|
|
const data = reactive<{
|
|
title: string | null
|
|
name: string
|
|
tables: (TableType & { ref_table_name: string; columns: (ColumnType & { key: number; _disableSelect?: boolean })[] })[]
|
|
}>({
|
|
title: null,
|
|
name: 'Base Name',
|
|
tables: [],
|
|
})
|
|
|
|
const validators = computed(() =>
|
|
data.tables.reduce<Record<string, [ReturnType<typeof fieldRequiredValidator>]>>((acc: Record<string, any>, table, tableIdx) => {
|
|
acc[`tables.${tableIdx}.table_name`] = [
|
|
validateTableName,
|
|
{
|
|
validator: (_rule: any, value: any) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
if (!importDataOnly && data.tables.some((item, idx) => idx !== tableIdx && item.table_name === value)) {
|
|
return reject(new Error(t('msg.error.duplicateTableName')))
|
|
}
|
|
resolve()
|
|
})
|
|
},
|
|
},
|
|
{
|
|
validator: (_rule: any, value: any) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
if (value !== value?.trim()) {
|
|
return reject(new Error('Table names should not have whitespace in the beginning or their end.'))
|
|
}
|
|
resolve()
|
|
})
|
|
},
|
|
},
|
|
{
|
|
validator: (rule: any, value: any) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
let tableNameLengthLimit = 255
|
|
if (isMysql(sourceId)) {
|
|
tableNameLengthLimit = 64
|
|
} else if (isPg(sourceId)) {
|
|
tableNameLengthLimit = 63
|
|
}
|
|
|
|
const basePrefix = base?.value?.prefix || ''
|
|
if ((basePrefix + value).length > tableNameLengthLimit) {
|
|
return reject(new Error(`Table name exceeds ${tableNameLengthLimit} characters`))
|
|
}
|
|
resolve()
|
|
})
|
|
},
|
|
},
|
|
]
|
|
|
|
acc[`tables.${tableIdx}.columns`] = [
|
|
{
|
|
validator: (_rule: any, value: any) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
if (!importDataOnly && ncIsArray(value) && !value.some((item) => item.selected)) {
|
|
return reject(new Error(t('msg.error.selectAtleastOneColumn')))
|
|
}
|
|
|
|
resolve()
|
|
})
|
|
},
|
|
},
|
|
]
|
|
|
|
hasSelectColumn.value[tableIdx] = false
|
|
|
|
table.columns?.forEach((column, columnIdx) => {
|
|
acc[`tables.${tableIdx}.columns.${columnIdx}.title`] = [
|
|
fieldRequiredValidator(),
|
|
{
|
|
validator: (_rule: any, value: any) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const field = table.columns.find((_item, idx) => idx === columnIdx)
|
|
|
|
if (!field || !field?.selected) return resolve()
|
|
|
|
const fieldToCheck = table.columns.filter((item) => item.selected)
|
|
if (
|
|
fieldToCheck.length &&
|
|
table.columns.some((item, idx) => idx !== columnIdx && item.selected && item.title === value)
|
|
) {
|
|
return reject(new Error(t('msg.error.duplicateColumnName')))
|
|
}
|
|
resolve()
|
|
})
|
|
},
|
|
},
|
|
{
|
|
validator: (rule: any, value: any) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const field = table.columns.find((_item, idx) => idx === columnIdx)
|
|
|
|
if (!field || !field?.selected) return resolve()
|
|
|
|
return reservedFieldNameValidator()
|
|
.validator(rule, value)
|
|
.then(() => {
|
|
resolve()
|
|
})
|
|
.catch((e) => {
|
|
reject(e)
|
|
})
|
|
})
|
|
},
|
|
},
|
|
{
|
|
validator: (rule: any, value: any) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const field = table.columns.find((_item, idx) => idx === columnIdx)
|
|
|
|
if (!field || !field?.selected) return resolve()
|
|
|
|
return fieldLengthValidator()
|
|
.validator(rule, value)
|
|
.then(() => {
|
|
resolve()
|
|
})
|
|
.catch((e) => {
|
|
reject(e)
|
|
})
|
|
})
|
|
},
|
|
},
|
|
]
|
|
acc[`tables.${tableIdx}.columns.${columnIdx}.uidt`] = [fieldRequiredValidator()]
|
|
if (isSelect(column)) {
|
|
hasSelectColumn.value[tableIdx] = true
|
|
}
|
|
})
|
|
|
|
return acc
|
|
}, {}),
|
|
)
|
|
|
|
const { validate, validateInfos, modelRef } = useForm(data, validators)
|
|
|
|
const isValid = ref(!importDataOnly)
|
|
|
|
const importError = ref('')
|
|
|
|
const formRef = ref()
|
|
|
|
watch(
|
|
[() => srcDestMapping.value],
|
|
() => {
|
|
let res = true
|
|
if (importDataOnly) {
|
|
for (const tn of Object.keys(srcDestMapping.value)) {
|
|
let flag = false
|
|
if (atLeastOneEnabledValidation(tn)) {
|
|
res = false
|
|
}
|
|
for (const record of srcDestMapping.value[tn]) {
|
|
if (!fieldsValidation(record, tn)) {
|
|
res = false
|
|
flag = true
|
|
break
|
|
}
|
|
}
|
|
if (flag) {
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
for (const [_, o] of Object.entries(validateInfos)) {
|
|
if (o?.validateStatus) {
|
|
if (o.validateStatus === 'error') {
|
|
res = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
isValid.value = res
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
onMounted(() => {
|
|
parseAndLoadTemplate()
|
|
|
|
if (importDataOnly) {
|
|
mapDefaultColumns()
|
|
}
|
|
|
|
nextTick(() => {
|
|
inputRefs.value[0]?.focus()
|
|
})
|
|
})
|
|
|
|
function filterOption(input: string, option: Option) {
|
|
return option.value.toUpperCase().includes(input.toUpperCase())
|
|
}
|
|
|
|
function parseAndLoadTemplate() {
|
|
if (baseTemplate) {
|
|
parseTemplate(baseTemplate)
|
|
|
|
expansionPanel.value = Array.from({ length: data.tables.length || 0 }, (_, i) => i)
|
|
|
|
hasSelectColumn.value = Array.from({ length: data.tables.length || 0 }, () => false)
|
|
}
|
|
}
|
|
|
|
function parseTemplate({ tables = [], ...rest }: Props['baseTemplate']) {
|
|
const parsedTemplate = {
|
|
...rest,
|
|
tables: tables.map(({ v = [], columns = [], ...rest }) => ({
|
|
...rest,
|
|
columns: [
|
|
...columns.map((c: any, idx: number) => {
|
|
if (!importDataOnly && c.column_name?.toLowerCase() === 'id') {
|
|
const cn = populateUniqueColumnName('id', [], columns)
|
|
c.column_name = cn
|
|
}
|
|
c.key = idx
|
|
c.selected = true
|
|
return c
|
|
}),
|
|
...v.map((v: any) => ({
|
|
column_name: v.title,
|
|
selected: true,
|
|
table_name: {
|
|
...v,
|
|
},
|
|
})),
|
|
],
|
|
})),
|
|
}
|
|
|
|
Object.assign(data, parsedTemplate)
|
|
}
|
|
|
|
function isSelect(col: ColumnType) {
|
|
return col.uidt === 'MultiSelect' || col.uidt === 'SingleSelect'
|
|
}
|
|
|
|
function _deleteTable(tableIdx: number) {
|
|
data.tables.splice(tableIdx, 1)
|
|
}
|
|
|
|
function remapColNames(batchData: any[], columns: ColumnType[]) {
|
|
const dateFormatMap: Record<number, string> = {}
|
|
return batchData.map((data) =>
|
|
(columns || []).reduce((aggObj, col: Record<string, any>) => {
|
|
// we renaming existing id column and using our own auto increment id
|
|
if (col.uidt === UITypes.ID) return aggObj
|
|
|
|
// for excel & json, if the column name is changed in TemplateEditor,
|
|
// then only col.column_name exists in data, else col.ref_column_name
|
|
// for csv, col.column_name always exists in data
|
|
// since it streams the data in getData() with the updated col.column_name
|
|
const key = col.title in data ? col.title : col.ref_column_name
|
|
let d = data[key]
|
|
if (col.uidt === UITypes.Date && d) {
|
|
let dateFormat
|
|
if (col?.meta?.date_format) {
|
|
dateFormat = col.meta.date_format
|
|
dateFormatMap[col.key] = dateFormat
|
|
} else if (col.key in dateFormatMap) {
|
|
dateFormat = dateFormatMap[col.key]
|
|
} else {
|
|
dateFormat = getDateFormat(d)
|
|
dateFormatMap[col.key] = dateFormat
|
|
}
|
|
d = parseStringDate(d, dateFormat)
|
|
} else if (col.uidt === UITypes.DateTime && d) {
|
|
const dateTimeFormat = getDateTimeFormat(data[key])
|
|
d = dayjs(data[key], dateTimeFormat).format('YYYY-MM-DD HH:mm')
|
|
}
|
|
return {
|
|
...aggObj,
|
|
[col.title]: d,
|
|
}
|
|
}, {}),
|
|
)
|
|
}
|
|
|
|
function missingRequiredColumnsValidation(tn: string, showError = false) {
|
|
const missingRequiredColumns = columns.value.filter(
|
|
(c: Record<string, any>) =>
|
|
(c.pk ? !c.ai && !c.cdf && !c.meta?.ag : !c.cdf && c.rqd) &&
|
|
!srcDestMapping.value[tn].some((r: Record<string, any>) => r.destCn === c.title),
|
|
)
|
|
|
|
if (missingRequiredColumns.length) {
|
|
const error = `${t('msg.error.columnsRequired')} : ${missingRequiredColumns.map((c) => c.title).join(', ')}`
|
|
if (showError) {
|
|
message.error(error)
|
|
} else {
|
|
return error
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function atLeastOneEnabledValidation(tn: string, showError = false) {
|
|
if (srcDestMapping.value[tn].filter((v: Record<string, any>) => v.enabled === true).length === 0) {
|
|
const err = t('msg.error.selectAtleastOneColumn')
|
|
if (showError) {
|
|
message.error(err)
|
|
} else {
|
|
return err
|
|
}
|
|
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function getUnselectedFields(record: Record<string, any>, tn: string) {
|
|
// if it is not selected, then pass validation
|
|
const allRecord = srcDestMapping.value[tn]
|
|
|
|
return columns.value.filter((c) => {
|
|
// Exclude columns that are already mapped, except for the current record's `destCn`
|
|
|
|
return !allRecord?.some((item) => item.srcTitle !== record.srcTitle && item.destCn === c.title)
|
|
})
|
|
}
|
|
|
|
function fieldsValidation(record: Record<string, any>, tn: string) {
|
|
// if it is not selected, then pass validation
|
|
if (!record.enabled) {
|
|
return true
|
|
}
|
|
|
|
if (!record.destCn) {
|
|
message.error(`${t('msg.error.columnDescriptionNotFound')} ${record.srcTitle}`)
|
|
return false
|
|
}
|
|
|
|
if ((srcDestMapping.value[tn] || []).filter((v: Record<string, any>) => v.destCn === record.destCn).length > 1) {
|
|
message.error(t('msg.error.duplicateMappingFound'))
|
|
return false
|
|
}
|
|
|
|
const v = columns.value.find((c) => c.title === record.destCn) as Record<string, any>
|
|
|
|
if (!v) {
|
|
message.error(`Column '${record.destCn}' not found`)
|
|
return false
|
|
}
|
|
|
|
for (const tableName of Object.keys(importData)) {
|
|
// check if the input contains null value for a required column
|
|
if (v.pk ? !v.ai && !v.cdf : !v.cdf && v.rqd) {
|
|
if (
|
|
importData[tableName]
|
|
.slice(0, maxRowsToParse)
|
|
.some((r: Record<string, any>) => r[record.srcCn] === null || r[record.srcCn] === undefined || r[record.srcCn] === '')
|
|
) {
|
|
message.error(t('msg.error.nullValueViolatesNotNull'))
|
|
}
|
|
}
|
|
|
|
switch (v.uidt) {
|
|
case UITypes.Number:
|
|
if (
|
|
importData[tableName]
|
|
.slice(0, maxRowsToParse)
|
|
.some(
|
|
(r: Record<string, any>) => r[record.sourceCn] !== null && r[record.srcCn] !== undefined && isNaN(+r[record.srcCn]),
|
|
)
|
|
) {
|
|
message.error(t('msg.error.sourceHasInvalidNumbers'))
|
|
return false
|
|
}
|
|
|
|
break
|
|
case UITypes.Checkbox:
|
|
if (
|
|
importData[tableName].slice(0, maxRowsToParse).some((r: Record<string, any>) => {
|
|
if (r[record.srcCn] !== null && r[record.srcCn] !== undefined) {
|
|
let input = r[record.srcCn]
|
|
if (typeof input === 'string') {
|
|
input = input.replace(/["']/g, '').toLowerCase().trim()
|
|
return !(
|
|
input === 'false' ||
|
|
input === 'no' ||
|
|
input === 'n' ||
|
|
input === '0' ||
|
|
input === 'true' ||
|
|
input === 'yes' ||
|
|
input === 'y' ||
|
|
input === '1'
|
|
)
|
|
}
|
|
|
|
return input !== 1 && input !== 0 && input !== true && input !== false
|
|
}
|
|
return false
|
|
})
|
|
) {
|
|
message.error(t('msg.error.sourceHasInvalidBoolean'))
|
|
return false
|
|
}
|
|
break
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
function updateImportTips(baseName: string, tableName: string, progress: number, total: number) {
|
|
importingTips.value[`${baseName}-${tableName}`] = `Importing data to ${baseName} - ${tableName}: ${progress}/${total} records`
|
|
importingTableTips.value[tableName] = parseInt(`${(progress / total) * 100}`)
|
|
}
|
|
|
|
async function importTemplate() {
|
|
if (importDataOnly) {
|
|
for (const table of data.tables) {
|
|
// validate required columns
|
|
const validationErrors = getErrorByTableName(table.table_name)
|
|
if (validationErrors.length) throw new Error(`${validationErrors[0]}`)
|
|
}
|
|
|
|
try {
|
|
isImporting.value = true
|
|
// collapse table
|
|
expansionPanel.value = []
|
|
|
|
const tableId = meta.value?.id
|
|
const baseId = base.value.id!
|
|
const table_names = data.tables.map((t: Record<string, any>) => t.table_name)
|
|
|
|
await Promise.all(
|
|
Object.keys(importData).map((key: string) =>
|
|
(async (k) => {
|
|
if (!table_names.includes(k)) {
|
|
return
|
|
}
|
|
const data = importData[k]
|
|
const total = data.length
|
|
let operationId
|
|
for (let i = 0, progress = 0; i < total; i += maxRowsToParse) {
|
|
const batchData = data.slice(i, i + maxRowsToParse).map((row: Record<string, any>) =>
|
|
srcDestMapping.value[k].reduce((res: Record<string, any>, col: Record<string, any>) => {
|
|
if (col.enabled && col.destCn) {
|
|
const v = columns.value.find((c: Record<string, any>) => c.title === col.destCn) as Record<string, any>
|
|
let input = row[col.srcCn]
|
|
// parse potential boolean values
|
|
if (v.uidt === UITypes.Checkbox) {
|
|
if (typeof input === 'string') {
|
|
input = input ? input.replace(/["']/g, '').toLowerCase().trim() : 'false'
|
|
}
|
|
input = input ?? 'false'
|
|
if (input === 'false' || input === 'no' || input === 'n') {
|
|
input = '0'
|
|
} else if (input === 'true' || input === 'yes' || input === 'y') {
|
|
input = '1'
|
|
}
|
|
} else if (v.uidt === UITypes.Number) {
|
|
if (input === '') {
|
|
input = null
|
|
}
|
|
} else if (v.uidt === UITypes.SingleSelect || v.uidt === UITypes.MultiSelect) {
|
|
if (input === '') {
|
|
input = null
|
|
}
|
|
} else if (v.uidt === UITypes.Date) {
|
|
if (input === '' || input === null || input === undefined) {
|
|
input = null
|
|
} else if (input instanceof Date) {
|
|
// Handle JS Date objects from Excel parser
|
|
const d = dayjs(input)
|
|
input = d.isValid() ? d.format('YYYY-MM-DD') : null
|
|
} else {
|
|
const originalInput = String(input)
|
|
|
|
if (validateDateWithUnknownFormat(originalInput)) {
|
|
// Known format matched with strict parsing — parse it
|
|
input = parseStringDate(originalInput, v.meta.date_format)
|
|
if (input === 'Invalid Date') {
|
|
const detectedFormat = getDateFormat(originalInput)
|
|
input = dayjs(originalInput, detectedFormat, true).format('YYYY-MM-DD')
|
|
}
|
|
} else if (/\d/.test(originalInput) && dayjs(originalInput).isValid()) {
|
|
// Fallback: contains digits and dayjs native parsing accepts it
|
|
// Handles formats like 2024-01-15T10:30:00, 15-Jan-24, etc.
|
|
input = dayjs(originalInput).format('YYYY-MM-DD')
|
|
} else {
|
|
throw new Error(
|
|
`Invalid date value "${originalInput}" provided for field "${col.destCn}" in row ${
|
|
data.indexOf(row) + 1
|
|
}`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
res[col.destCn] = input
|
|
}
|
|
return res
|
|
}, {}),
|
|
)
|
|
const res = await $api.dbTableRow.bulkCreate(
|
|
'noco',
|
|
baseId,
|
|
tableId,
|
|
batchData,
|
|
{
|
|
'wrapped': 'true',
|
|
'headers[nc-import-type]': quickImportType,
|
|
'operation_id': operationId,
|
|
'typecast': isEeUI && autoInsertOption.value ? 'true' : undefined,
|
|
},
|
|
{
|
|
headers: {
|
|
'xc-auth': $state.token.value as string,
|
|
'nc-operation-id': operationId,
|
|
'nc-import-type': quickImportType,
|
|
},
|
|
},
|
|
)
|
|
|
|
operationId = res.headers?.['nc-operation-id']
|
|
updateImportTips(baseId, tableId!, progress, total)
|
|
progress += batchData.length
|
|
if (autoInsertOption.value) {
|
|
await getMeta(baseId, tableId, true)
|
|
}
|
|
}
|
|
})(key),
|
|
),
|
|
)
|
|
|
|
// reload table
|
|
reloadHook.trigger()
|
|
|
|
// Successfully imported table data
|
|
message.success(t('msg.success.tableDataImported'))
|
|
} catch (e: any) {
|
|
console.log(e)
|
|
throw e
|
|
} finally {
|
|
isImporting.value = false
|
|
}
|
|
} else {
|
|
// check if form is valid
|
|
try {
|
|
await validate()
|
|
} catch (errorInfo) {
|
|
throw new Error('Please fill all the required values')
|
|
}
|
|
|
|
try {
|
|
isImporting.value = true
|
|
// collapse table
|
|
expansionPanel.value = []
|
|
// tab info to be used to show the tab after successful import
|
|
const tab = {
|
|
id: '',
|
|
title: '',
|
|
baseId: '',
|
|
}
|
|
|
|
// create tables
|
|
for (const table of data.tables) {
|
|
// enrich system fields if not provided
|
|
// e.g. id, created_at, updated_at
|
|
const systemColumns = sqlUi?.value.getNewTableColumns().filter((c: ColumnType) => c.column_name !== 'title')
|
|
for (const systemColumn of systemColumns) {
|
|
if (!table.columns?.some((c) => c.column_name?.toLowerCase() === systemColumn.column_name.toLowerCase())) {
|
|
table.columns?.push(systemColumn)
|
|
}
|
|
}
|
|
|
|
table.columns = table.columns?.filter((c) => !('selected' in c) || (c as any).selected)
|
|
|
|
if (table.columns) {
|
|
for (const column of table.columns) {
|
|
// set pk & rqd if ID is provided
|
|
if (column.column_name?.toLowerCase() === 'id' && !('pk' in column)) {
|
|
column.pk = true
|
|
column.rqd = true
|
|
}
|
|
if (
|
|
(!isSystemColumn(column) || ['created_at', 'updated_at'].includes(column.column_name!)) &&
|
|
column.uidt !== UITypes.SingleSelect &&
|
|
column.uidt !== UITypes.MultiSelect
|
|
) {
|
|
// delete dtxp if the final data type is not single & multi select
|
|
// e.g. import -> detect as single / multi select -> switch to SingleLineText
|
|
// the correct dtxp will be generated during column creation
|
|
delete column.dtxp
|
|
}
|
|
}
|
|
}
|
|
const createdTable = await $api.source.tableCreate(base.value?.id as string, (sourceId || base.value?.sources?.[0].id)!, {
|
|
table_name: table.table_name,
|
|
// leave title empty to get a generated one based on table_name
|
|
title: '',
|
|
columns: table.columns || [],
|
|
})
|
|
|
|
if (process.env.NC_SANITIZE_COLUMN_NAME !== 'false') {
|
|
// column_name could have been updated in tableCreate
|
|
// e.g. sanitize column name to something like field_1, field_2, and etc
|
|
// todo: see why we have extra columns when json is imported through pasting
|
|
createdTable.columns.forEach((column, i) => {
|
|
if (table.columns[i]) {
|
|
table.columns[i].column_name = column.column_name
|
|
}
|
|
})
|
|
}
|
|
|
|
table.id = createdTable.id
|
|
table.title = createdTable.title
|
|
|
|
// open the first table after import
|
|
if (tab.id === '' && tab.title === '' && tab.baseId === '') {
|
|
tab.id = createdTable.id as string
|
|
tab.title = createdTable.title as string
|
|
tab.baseId = base.value.id as string
|
|
}
|
|
}
|
|
|
|
// bulk insert data
|
|
if (importData) {
|
|
const offset = maxRowsToParse
|
|
const baseName = base.value.title as string
|
|
await Promise.all(
|
|
data.tables.map((table: Record<string, any>) =>
|
|
(async (tableMeta) => {
|
|
let progress = 0
|
|
let total = 0
|
|
// use ref_table_name here instead of table_name
|
|
// since importData[talbeMeta.table_name] would be empty after renaming
|
|
const data = importData[tableMeta.ref_table_name]
|
|
if (data) {
|
|
total += data.length
|
|
for (let i = 0; i < data.length; i += offset) {
|
|
updateImportTips(baseName, tableMeta.title, progress, total)
|
|
const batchData = remapColNames(data.slice(i, i + offset), tableMeta.columns)
|
|
await $api.dbTableRow.bulkCreate('noco', base.value.id, tableMeta.id, batchData)
|
|
progress += batchData.length
|
|
}
|
|
updateImportTips(baseName, tableMeta.title, total, total)
|
|
}
|
|
})(table),
|
|
),
|
|
)
|
|
}
|
|
|
|
// Successfully imported table
|
|
message.success(t(`msg.success.${data.tables.length > 1 ? 'tableImportedPlural' : 'tableImported'}`))
|
|
|
|
// reload table list
|
|
await loadProjectTables(base.value.id, true)
|
|
} catch (e: any) {
|
|
console.log(e)
|
|
throw e
|
|
} finally {
|
|
isImporting.value = false
|
|
}
|
|
}
|
|
|
|
if (!data.tables?.length) return
|
|
|
|
const tables = baseTables.value.get(base.value!.id!)
|
|
const toBeNavigatedTable = tables?.find((t) => t.id === data.tables[0].id)
|
|
if (!toBeNavigatedTable) return
|
|
|
|
openTable(toBeNavigatedTable)
|
|
}
|
|
|
|
function mapDefaultColumns() {
|
|
srcDestMapping.value = {}
|
|
for (let i = 0; i < data.tables.length; i++) {
|
|
for (const col of importColumns[i]) {
|
|
const o = { srcCn: col.column_name, srcTitle: col.title, destCn: undefined, enabled: true }
|
|
if (columns.value) {
|
|
const tableColumn = columns.value.find((c) => !c.readonly && (c.title === col.title || c.column_name === col.column_name))
|
|
if (tableColumn) {
|
|
o.destCn = tableColumn.title as string
|
|
} else {
|
|
o.enabled = false
|
|
}
|
|
}
|
|
if (!(data.tables[i].table_name in srcDestMapping.value)) {
|
|
srcDestMapping.value[data.tables[i].table_name] = []
|
|
}
|
|
srcDestMapping.value[data.tables[i].table_name].push(o)
|
|
}
|
|
}
|
|
}
|
|
|
|
defineExpose({
|
|
importTemplate,
|
|
isValid,
|
|
importError,
|
|
updateImportError: (err: string) => {
|
|
importError.value = err
|
|
},
|
|
})
|
|
|
|
function getMappedColumns(tableName: string) {
|
|
return (srcDestMapping.value[tableName] || []).filter((item) => item.destCn)
|
|
}
|
|
|
|
function isAllMappedSelected(tableName: string) {
|
|
const cols = getMappedColumns(tableName)
|
|
return !!cols.length && getMappedColumns(tableName).every((item) => item.enabled)
|
|
}
|
|
|
|
function isSomeMappedSelected(tableName: string) {
|
|
const cols = getMappedColumns(tableName)
|
|
|
|
return cols.length && getMappedColumns(tableName).some((item) => item.destCn && item.enabled)
|
|
}
|
|
|
|
function handleCheckAllRecord(event: CheckboxChangeEvent, tableName: string) {
|
|
const isChecked = event.target.checked
|
|
for (const record of srcDestMapping.value[tableName]) {
|
|
if (!record.destCn && isChecked) continue
|
|
|
|
record.enabled = isChecked
|
|
}
|
|
}
|
|
|
|
const setErrorState = (errorsFields: any[] = []) => {
|
|
const errorMap: any = {}
|
|
for (const error of errorsFields) {
|
|
errorMap[error.name] = error.errors
|
|
}
|
|
|
|
formError.value = errorMap
|
|
}
|
|
|
|
function populateUniqueColumnName(cn: string, draftCn: string[] = [], columns: ColumnType[]) {
|
|
let c = 2
|
|
let columnName = `${cn}${1}`
|
|
while (
|
|
draftCn.includes(columnName) ||
|
|
columns?.some((c) => {
|
|
return c.column_name === columnName || c.title === columnName
|
|
})
|
|
) {
|
|
columnName = `${cn}${c++}`
|
|
}
|
|
return columnName
|
|
}
|
|
|
|
watch(formRef, () => {
|
|
setTimeout(async () => {
|
|
try {
|
|
await validate()
|
|
emit('change')
|
|
formError.value = null
|
|
} catch (e: any) {
|
|
emit('error', e)
|
|
setErrorState(e?.errorFields)
|
|
}
|
|
}, 500)
|
|
})
|
|
|
|
watch(modelRef, async () => {
|
|
try {
|
|
await validate()
|
|
emit('change')
|
|
formError.value = null
|
|
} catch (e: any) {
|
|
emit('error', e)
|
|
setErrorState(e?.errorFields)
|
|
}
|
|
})
|
|
|
|
function toggleTableSelecteds(table: any) {
|
|
if (table.columns.every((it: any) => it.selected)) {
|
|
for (const column of table.columns) {
|
|
column.selected = false
|
|
}
|
|
} else {
|
|
for (const column of table.columns) {
|
|
column.selected = true
|
|
}
|
|
}
|
|
}
|
|
|
|
const currentColumnToEdit = ref('')
|
|
const currentTableToEdit = ref<number | undefined>()
|
|
|
|
const getErrorForTable = (tableIdx: number) => {
|
|
return (formError.value?.[`tables.${tableIdx}.table_name`] || []).concat(formError.value?.[`tables.${tableIdx}.columns`] || [])
|
|
}
|
|
|
|
function getErrorByTableName(tableName: string) {
|
|
const errors = []
|
|
|
|
const atLeastOneEnabledValidationErr = atLeastOneEnabledValidation(tableName)
|
|
|
|
if (atLeastOneEnabledValidationErr) {
|
|
errors.push(atLeastOneEnabledValidationErr)
|
|
}
|
|
|
|
const missingRequiredColumnsValidationErr = missingRequiredColumnsValidation(tableName)
|
|
|
|
if (missingRequiredColumnsValidationErr) {
|
|
errors.push(missingRequiredColumnsValidationErr)
|
|
}
|
|
|
|
return errors
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="relative text-nc-content-gray">
|
|
<div v-if="importDataOnly">
|
|
<a-form :model="data" name="import-only">
|
|
<p v-if="data.tables && quickImportType === 'excel'" class="text-center">
|
|
{{ data.tables.length }} sheet{{ data.tables.length > 1 ? 's' : '' }}
|
|
available for import
|
|
</p>
|
|
</a-form>
|
|
|
|
<a-collapse
|
|
v-if="data.tables && data.tables.length"
|
|
v-model:active-key="expansionPanel"
|
|
class="template-collapse !rounded-lg !overflow-hidden"
|
|
accordion
|
|
expand-icon-position="right"
|
|
>
|
|
<template #expandIcon="{ isActive }">
|
|
<GeneralIcon
|
|
v-if="!isImporting"
|
|
icon="ncChevronDown"
|
|
class="text-lg !-translate-y-1/2 !transition text-nc-content-gray-subtle"
|
|
:class="{ '!transform !rotate-180': isActive }"
|
|
/>
|
|
</template>
|
|
|
|
<a-collapse-panel
|
|
v-for="(table, tableIdx) of data.tables"
|
|
:key="tableIdx"
|
|
class="nc-import-table-box nc-upload-box !overflow-hidden"
|
|
:class="{
|
|
'pointer-events-none': isImporting,
|
|
}"
|
|
>
|
|
<template #header>
|
|
<div
|
|
class="flex items-center space-x-3 group min-h-8"
|
|
:class="{
|
|
'w-[calc(100%_-_30px)]': !isImporting,
|
|
'w-full': isImporting,
|
|
}"
|
|
>
|
|
<div class="w-8 h-8 flex items-center justify-center bg-nc-bg-gray-extralight rounded-md">
|
|
<GeneralIcon :icon="tableIcon" class="w-5 h-5" />
|
|
</div>
|
|
<NcTooltip :title="table.table_name" show-on-truncate-only class="flex-1 truncate text-nc-content-gray">
|
|
<span>
|
|
{{ table.table_name }}
|
|
</span>
|
|
</NcTooltip>
|
|
<NcTooltip v-if="!isImporting && getErrorByTableName(table.table_name).length" class="ml-2">
|
|
<template #title>
|
|
<div v-for="(err, idx) of getErrorByTableName(table.table_name)" :key="idx" class="mb-1 last-of-type:mb-0">
|
|
{{ idx > 0 ? `${idx + 1}.` : '' }} {{ err }}
|
|
</div>
|
|
</template>
|
|
<NcBadge color="red" :border="false" class="w-8 !px-2 flex-none !bg-nc-bg-red-light">
|
|
<GeneralIcon icon="ncInfo" class="text-nc-content-red-dark" />
|
|
</NcBadge>
|
|
</NcTooltip>
|
|
<div v-if="isImporting" class="w-[150px]">
|
|
<a-progress
|
|
:percent="importingTableTips[meta!.id!] ?? 0"
|
|
size="small"
|
|
status="normal"
|
|
stroke-color="var(--nc-content-brand)"
|
|
trail-color="var(--nc-bg-brand-inverted)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div v-if="srcDestMapping" class="bg-nc-bg-gray-extralight pl-4 flex-1 flex">
|
|
<NcTable
|
|
class="template-form flex-1 max-h-[310px]"
|
|
header-row-class-name="relative"
|
|
body-row-class-name="template-form-row"
|
|
:data="srcDestMapping[table.table_name]"
|
|
:columns="srcDestMappingColumns"
|
|
:bordered="false"
|
|
header-row-height="40px"
|
|
row-height="40px"
|
|
>
|
|
<template #headerCell="{ column }">
|
|
<template v-if="column.key === 'source_column'">
|
|
<NcTooltip>
|
|
<template #title>
|
|
{{
|
|
isAllMappedSelected(table.table_name)
|
|
? $t('activity.deselectAllFields')
|
|
: $t('tooltip.selectAllMappedFields')
|
|
}}
|
|
</template>
|
|
<div>
|
|
<NcCheckbox
|
|
:indeterminate="!isAllMappedSelected(table.table_name) && isSomeMappedSelected(table.table_name)"
|
|
:checked="isAllMappedSelected(table.table_name)"
|
|
@change="handleCheckAllRecord($event, table.table_name)"
|
|
/>
|
|
</div>
|
|
</NcTooltip>
|
|
<div class="absolute h-1 border-b bottom-0 border-nc-border-gray-medium left-3 right-3" />
|
|
</template>
|
|
|
|
<span
|
|
v-if="column.key !== 'action'"
|
|
class="font-weight-700 text-nc-content-gray-subtle2 text-small"
|
|
:class="{
|
|
'pl-3': column.key !== 'source_column',
|
|
}"
|
|
>
|
|
{{ column.title }}
|
|
</span>
|
|
</template>
|
|
|
|
<template #bodyCell="{ column, record, recordIndex }">
|
|
<div v-if="column.key === 'source_column'" class="w-full">
|
|
<label class="w-full flex items-center gap-3 h-full">
|
|
<NcTooltip :disabled="record.enabled || !!record.destCn">
|
|
<template #title>Select NocoDB field to map</template>
|
|
<NcCheckbox v-model:checked="record.enabled" :disabled="!record.destCn" />
|
|
</NcTooltip>
|
|
|
|
<NcTooltip
|
|
class="inline-block flex-1 max-w-[calc(100%_-_50px)] truncate text-nc-content-gray text-sm font-weight-500"
|
|
show-on-truncate-only
|
|
>
|
|
<template #title>{{ record.srcTitle }}</template>
|
|
{{ record.srcTitle }}
|
|
</NcTooltip>
|
|
|
|
<GeneralIcon icon="ncArrowRight" class="w-4 h-4 flex-shrink-0 mr-2" />
|
|
</label>
|
|
<div
|
|
v-if="recordIndex"
|
|
class="absolute h-1 border-t border-nc-border-gray-medium top-0 left-3 right-3 cursor-default"
|
|
/>
|
|
</div>
|
|
|
|
<template v-else-if="column.key === 'destination_column'">
|
|
<a-form-item class="!my-0 w-full">
|
|
<NcSelect
|
|
v-model:value="record.destCn"
|
|
class="nc-field-select-input w-full nc-select-shadow !border-none"
|
|
show-search
|
|
allow-clear
|
|
:placeholder="`-${$t('labels.multiField.selectField').toLowerCase()}-`"
|
|
:filter-option="filterOption"
|
|
dropdown-class-name="nc-dropdown-filter-field"
|
|
@update:value="
|
|
(value) => {
|
|
record.enabled = !!value
|
|
}
|
|
"
|
|
>
|
|
<template #suffixIcon>
|
|
<GeneralIcon icon="arrowDown" class="text-current" />
|
|
</template>
|
|
<a-select-option
|
|
v-for="(col, i) of getUnselectedFields(record, table.table_name)"
|
|
:key="i"
|
|
:value="col.title"
|
|
:disabled="col.readonly"
|
|
>
|
|
<div class="flex items-center gap-2 w-full">
|
|
<SmartsheetHeaderIcon
|
|
:column="col"
|
|
class="flex-none w-3.5 h-3.5 !mx-0"
|
|
color="text-nc-content-gray-muted"
|
|
/>
|
|
<NcTooltip class="truncate flex-1" :show-on-truncate-only="!col.readonly">
|
|
<template #title>
|
|
{{ col.readonly ? col.permissions?.tooltip || t('msg.info.fieldReadonly') : col.title }}
|
|
</template>
|
|
{{ col.title }}
|
|
</NcTooltip>
|
|
<component
|
|
:is="iconMap.check"
|
|
v-if="record.destCn === col.title"
|
|
id="nc-selected-item-icon"
|
|
class="flex-none text-primary w-4 h-4"
|
|
/>
|
|
</div>
|
|
</a-select-option>
|
|
</NcSelect>
|
|
</a-form-item>
|
|
</template>
|
|
</template>
|
|
</NcTable>
|
|
</div>
|
|
</a-collapse-panel>
|
|
</a-collapse>
|
|
|
|
<div v-if="appInfo.ee" class="pt-4 pr-2">
|
|
<label class="flex">
|
|
<NcCheckbox v-model:checked="autoInsertOption" :disabled="isImporting" />
|
|
<span class="ml-2">{{ $t('labels.autoCreateMissingSelectionOptions') }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<a-card v-else class="!border-none !px-0 !mx-0" :body-style="{ padding: '0 !important' }">
|
|
<a-form ref="formRef" :model="data" name="template-editor-form" @keydown.enter="emit('import')">
|
|
<a-collapse
|
|
v-if="data.tables && data.tables.length"
|
|
v-model:active-key="expansionPanel"
|
|
class="template-collapse !rounded-lg !overflow-hidden"
|
|
accordion
|
|
expand-icon-position="right"
|
|
>
|
|
<template #expandIcon="{ isActive }">
|
|
<GeneralIcon
|
|
v-if="!isImporting"
|
|
icon="ncChevronDown"
|
|
class="text-lg !-translate-y-1/2 !transition text-nc-content-gray-subtle"
|
|
:class="{ '!transform !rotate-180': isActive }"
|
|
/>
|
|
</template>
|
|
|
|
<a-collapse-panel v-for="(table, tableIdx) of data.tables" :key="tableIdx" class="nc-import-table-box !overflow-hidden">
|
|
<template #header>
|
|
<div
|
|
class="flex items-center space-x-3 nc-table-name-wrapper group min-h-6"
|
|
:class="{
|
|
'w-[calc(100%_-_30px)]': !isImporting,
|
|
'w-full': isImporting,
|
|
}"
|
|
>
|
|
<GeneralIcon icon="table" class="w-4 h-4 text-nc-content-gray-subtle" />
|
|
<a-form-item
|
|
v-if="!isImporting && currentTableToEdit === tableIdx"
|
|
v-bind="validateInfos[`tables.${tableIdx}.table_name`]"
|
|
class="!flex-1 !-my-1"
|
|
>
|
|
<a-input
|
|
:ref="(el: HTMLInputElement) => el?.focus?.()"
|
|
v-model:value="table.table_name"
|
|
class="!rounded-md animate-sidebar-node-input-padding !text-nc-content-gray"
|
|
hide-details
|
|
:bordered="true"
|
|
@click.stop
|
|
@keydown.enter.prevent.stop="currentTableToEdit = undefined"
|
|
@keydown.esc.prevent.stop="currentTableToEdit = undefined"
|
|
@blur.prevent.stop="currentTableToEdit = undefined"
|
|
/>
|
|
</a-form-item>
|
|
<template v-else>
|
|
<NcTooltip :title="table.table_name" show-on-truncate-only class="flex-1 truncate text-nc-content-gray">
|
|
<span class="nc-import-table-name" @click.stop="currentTableToEdit = tableIdx">
|
|
{{ table.table_name }}
|
|
</span>
|
|
</NcTooltip>
|
|
<NcButton
|
|
v-if="!isImporting"
|
|
type="text"
|
|
size="xsmall"
|
|
class="!hidden group-hover:!block !h-6 !w-6"
|
|
icon-only
|
|
@click.stop="currentTableToEdit = tableIdx"
|
|
>
|
|
<template #icon>
|
|
<GeneralIcon icon="pencil" class="text-nc-content-gray-subtle2" />
|
|
</template>
|
|
</NcButton>
|
|
</template>
|
|
|
|
<NcTooltip v-if="!isImporting && getErrorForTable(tableIdx).length" class="ml-2">
|
|
<template #title>
|
|
<div v-for="(err, idx) of getErrorForTable(tableIdx)" :key="idx" class="mb-1 last-of-type:mb-0">
|
|
{{ idx > 0 ? `${idx + 1}.` : '' }} {{ err }}
|
|
</div>
|
|
</template>
|
|
<NcBadge color="red" :border="false" class="w-8 !px-2 flex-none !bg-nc-bg-red-light">
|
|
<GeneralIcon icon="ncInfo" class="text-nc-content-red-dark" />
|
|
</NcBadge>
|
|
</NcTooltip>
|
|
|
|
<div v-if="isImporting" class="w-[150px]">
|
|
<a-progress
|
|
:percent="importingTableTips[table.title] ?? 0"
|
|
size="small"
|
|
status="normal"
|
|
stroke-color="var(--nc-content-brand)"
|
|
trail-color="var(--nc-bg-brand-inverted)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-if="table.columns && table.columns.length" class="bg-nc-bg-gray-extralight pl-3 flex-1 flex">
|
|
<NcTable
|
|
class="template-form flex-1 max-h-[310px]"
|
|
body-row-class-name="template-form-row"
|
|
header-row-class-name="relative"
|
|
:data="table.columns"
|
|
:columns="tableColumns"
|
|
:bordered="false"
|
|
header-row-height="40px"
|
|
row-height="40px"
|
|
:pagination="table.columns.length > 50 ? { defaultPageSize: 50, position: ['bottomCenter'] } : false"
|
|
>
|
|
<template #headerCell="{ column }">
|
|
<template v-if="column.key === 'enabled'">
|
|
<NcCheckbox
|
|
:indeterminate="
|
|
table.columns.length &&
|
|
table.columns.some((it) => it.selected) &&
|
|
!table.columns.every((it) => it.selected)
|
|
"
|
|
:checked="table.columns.every((it) => it.selected)"
|
|
@click="toggleTableSelecteds(table)"
|
|
/>
|
|
</template>
|
|
<template v-if="column.key === 'column_name'">
|
|
<span class="font-weight-700 text-small text-nc-content-gray-subtle2">
|
|
{{
|
|
table.columns.every((it) => it.selected)
|
|
? $t('activity.deselectAllFields')
|
|
: $t('activity.selectAllFields')
|
|
}}
|
|
</span>
|
|
<div class="absolute h-1 border-b bottom-0 border-nc-border-gray-medium left-3 right-3" />
|
|
</template>
|
|
</template>
|
|
<template #bodyCell="{ column, record, recordIndex }">
|
|
<template v-if="column.key === 'enabled'">
|
|
<NcCheckbox v-model:checked="record.selected" />
|
|
</template>
|
|
<template v-if="column.key === 'column_name'">
|
|
<template v-if="`${tableIdx}-${record.column_name}` === currentColumnToEdit">
|
|
<a-form-item
|
|
v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.title`]"
|
|
class="nc-table-field-name !mb-0 w-full"
|
|
>
|
|
<a-input
|
|
:ref="(el: HTMLInputElement) => {inputRefs[record.key] = el; el?.focus?.(); return el;}"
|
|
v-model:value="record.title"
|
|
class="!rounded-md animate-sidebar-node-input-padding !font-weight-500 !text-nc-content-gray"
|
|
:autofocus="true"
|
|
@keydown.enter.prevent.stop="currentColumnToEdit = ''"
|
|
@keydown.esc.prevent.stop="currentColumnToEdit = ''"
|
|
@blur.esc.prevent.stop="currentColumnToEdit = ''"
|
|
/>
|
|
</a-form-item>
|
|
</template>
|
|
<template v-else>
|
|
<div
|
|
class="relative group w-full flex items-center min-h-6"
|
|
@click="currentColumnToEdit = `${tableIdx}-${record.column_name}`"
|
|
>
|
|
<span
|
|
class="font-weight-500 text-nc-content-gray max-w-[300px] inline-block truncate nc-import-table-field-name"
|
|
>
|
|
{{ record.title }}
|
|
</span>
|
|
<NcButton
|
|
type="text"
|
|
size="small"
|
|
class="!absolute right-0 top-1/2 transform -translate-y-1/2 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto"
|
|
@click="currentColumnToEdit = `${tableIdx}-${record.column_name}`"
|
|
>
|
|
<GeneralIcon icon="pencil" />
|
|
</NcButton>
|
|
</div>
|
|
</template>
|
|
<NcTooltip v-if="formError?.[`tables.${tableIdx}.columns.${record.key}.title`]" class="flex">
|
|
<template #title>
|
|
<div
|
|
v-for="(err, idx) of formError?.[`tables.${tableIdx}.columns.${record.key}.title`] || []"
|
|
:key="idx"
|
|
class="mb-1 last-of-type:mb-0"
|
|
>
|
|
{{ idx > 0 ? `${idx + 1}.` : '' }} {{ err }}
|
|
</div>
|
|
</template>
|
|
<GeneralIcon icon="info" class="h-4 w-4 text-nc-content-red-medium flex-none ml-2" />
|
|
</NcTooltip>
|
|
<div v-if="recordIndex" class="absolute h-1 border-t border-nc-border-gray-medium top-0 left-3 right-3" />
|
|
</template>
|
|
</template>
|
|
</NcTable>
|
|
</div>
|
|
</a-collapse-panel>
|
|
</a-collapse>
|
|
</a-form>
|
|
</a-card>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.template-collapse {
|
|
@apply bg-nc-bg-default border-nc-border-gray-medium;
|
|
}
|
|
|
|
:deep(.ant-collapse-header) {
|
|
@apply !items-center !py-2 !px-3;
|
|
& > div {
|
|
@apply flex;
|
|
}
|
|
}
|
|
.nc-table-field-name {
|
|
:deep(.ant-form-item-explain) {
|
|
@apply hidden;
|
|
}
|
|
}
|
|
:deep(.nc-import-table-box:last-child) {
|
|
@apply !rounded-b-lg;
|
|
}
|
|
:deep(.nc-import-table-box .ant-collapse-content) {
|
|
@apply !border-t-0;
|
|
}
|
|
:deep(.nc-import-table-box .ant-collapse-content-box) {
|
|
@apply !p-0;
|
|
|
|
.nc-table-header-row,
|
|
.nc-table-row {
|
|
@apply !border-none relative;
|
|
}
|
|
}
|
|
|
|
:deep(.ant-collapse > .ant-collapse-item) {
|
|
@apply !border-nc-border-gray-medium;
|
|
}
|
|
:deep(.nc-import-table-box.nc-upload-box .ant-collapse-content-box) {
|
|
.nc-table-header-row {
|
|
@apply !flex !h-auto !border-none !h-10;
|
|
span {
|
|
@apply !font-weight-700 text-[13px];
|
|
}
|
|
}
|
|
}
|
|
|
|
:deep(.nc-table-name-wrapper .ant-form-item-explain) {
|
|
@apply hidden;
|
|
}
|
|
|
|
:deep(.ant-progress-outer) {
|
|
margin-right: calc(-2em - 16px);
|
|
padding-right: calc(2em + 16px);
|
|
}
|
|
|
|
:deep(.ant-progress-text) {
|
|
@apply text-nc-content-gray-muted;
|
|
}
|
|
|
|
:deep(.nc-field-select-input.ant-select) {
|
|
.ant-select-selector {
|
|
@apply !bg-transparent rounded-lg;
|
|
|
|
.ant-select-selection-item {
|
|
@apply text-nc-content-gray text-sm font-weight-500;
|
|
}
|
|
}
|
|
|
|
&:not(.ant-select-focused):hover .ant-select-selector {
|
|
@apply !bg-nc-bg-gray-medium;
|
|
}
|
|
|
|
&:not(.ant-select-disabled):not(:hover):not(.ant-select-focused) .ant-select-selector,
|
|
&:not(.ant-select-disabled):hover.ant-select-disabled .ant-select-selector {
|
|
@apply !shadow-none;
|
|
}
|
|
|
|
&:hover:not(.ant-select-focused):not(.ant-select-disabled) .ant-select-selector {
|
|
@apply shadow-none;
|
|
}
|
|
&:not(.ant-select-focused):not(.ant-select-disabled) .ant-select-selector {
|
|
@apply !border-transparent;
|
|
}
|
|
|
|
&:not(.ant-select-focused):hover .ant-select-clear {
|
|
@apply !bg-nc-bg-gray-medium;
|
|
}
|
|
}
|
|
</style>
|