Browse Source

Merge pull request #2795 from nocodb/feat/gui-v2-quick-import

feat(gui-v2): quick import
pull/2852/head
աɨռɢӄաօռɢ 2 years ago committed by GitHub
parent
commit
469749531b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      packages/nc-gui-v2/app.vue
  2. 3
      packages/nc-gui-v2/components.d.ts
  3. 139
      packages/nc-gui-v2/components/dashboard/TabView.vue
  4. 311
      packages/nc-gui-v2/components/dlg/AirtableImport.vue
  5. 338
      packages/nc-gui-v2/components/dlg/QuickImport.vue
  6. 198
      packages/nc-gui-v2/components/dlg/TableCreate.vue
  7. 39
      packages/nc-gui-v2/components/monaco/Editor.vue
  8. 40
      packages/nc-gui-v2/components/template/Editor.vue
  9. 44993
      packages/nc-gui-v2/package-lock.json
  10. 2
      packages/nc-gui-v2/package.json
  11. 42
      packages/nc-gui-v2/utils/parsers/CSVTemplateAdapter.ts
  12. 240
      packages/nc-gui-v2/utils/parsers/ExcelTemplateAdapter.ts
  13. 27
      packages/nc-gui-v2/utils/parsers/ExcelUrlTemplateAdapter.ts
  14. 168
      packages/nc-gui-v2/utils/parsers/JSONTemplateAdapter.ts
  15. 25
      packages/nc-gui-v2/utils/parsers/JSONUrlTemplateAdapter.ts
  16. 25
      packages/nc-gui-v2/utils/parsers/TemplateGenerator.ts
  17. 7
      packages/nc-gui-v2/utils/parsers/index.ts
  18. 95
      packages/nc-gui-v2/utils/parsers/parserHelpers.ts
  19. 40
      packages/nc-gui-v2/utils/validation.ts

4
packages/nc-gui-v2/app.vue

@ -75,7 +75,9 @@ const sidebarOpen = computed({
<a-menu-item key="1" class="!rounded-b"> <a-menu-item key="1" class="!rounded-b">
<div v-t="['a:navbar:user:sign-out']" class="group flex items-center py-2" @click="signOut"> <div v-t="['a:navbar:user:sign-out']" class="group flex items-center py-2" @click="signOut">
<MdiLogout class="dark:text-white group-hover:(!text-red-500)" />&nbsp; <MdiLogout class="dark:text-white group-hover:(!text-red-500)" />&nbsp;
<span class="prose font-semibold text-gray-500 group-hover:text-black nc-user-menu-signout">{{ $t('general.signOut') }}</span> <span class="prose font-semibold text-gray-500 group-hover:text-black nc-user-menu-signout">{{
$t('general.signOut')
}}</span>
</div> </div>
</a-menu-item> </a-menu-item>
</a-menu> </a-menu>

3
packages/nc-gui-v2/components.d.ts vendored

@ -7,6 +7,7 @@ export {}
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
AAnchorLink: typeof import('ant-design-vue/es')['AnchorLink']
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete'] AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
AButton: typeof import('ant-design-vue/es')['Button'] AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card'] ACard: typeof import('ant-design-vue/es')['Card']
@ -26,6 +27,8 @@ declare module '@vue/runtime-core' {
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent'] ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader'] ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider'] ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
AList: typeof import('ant-design-vue/es')['List']
AListItem: typeof import('ant-design-vue/es')['ListItem']
AMenu: typeof import('ant-design-vue/es')['Menu'] AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider'] AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem'] AMenuItem: typeof import('ant-design-vue/es')['MenuItem']

139
packages/nc-gui-v2/components/dashboard/TabView.vue

@ -1,22 +1,137 @@
<script setup lang="ts"> <script setup lang="ts">
import useTabs from '~/composables/useTabs' import useTabs from '~/composables/useTabs'
import MdiCloseIcon from '~icons/mdi/close'
import MdiPlusIcon from '~icons/mdi/plus' import MdiPlusIcon from '~icons/mdi/plus'
import MdiTableIcon from '~icons/mdi/table'
import MdiCsvIcon from '~icons/mdi/file-document-outline'
import MdiExcelIcon from '~icons/mdi/file-excel'
import MdiJSONIcon from '~icons/mdi/code-json'
import MdiAirTableIcon from '~icons/mdi/table-large'
import MdiRequestDataSourceIcon from '~icons/mdi/open-in-new'
import MdiAccountGroupIcon from '~icons/mdi/account-group'
const { tabs, activeTab, closeTab } = useTabs() const { tabs, activeTab, closeTab } = useTabs()
const { isUIAllowed } = useUIPermission()
const tableCreateDialog = ref(false) const tableCreateDialog = ref(false)
const airtableImportDialog = ref(false)
const quickImportDialog = ref(false)
const importType = ref('')
const currentMenu = ref<string[]>(['addORImport'])
function onEdit(targetKey: number, action: string) {
if (action !== 'add') {
closeTab(targetKey)
}
}
function openQuickImportDialog(type: string) {
quickImportDialog.value = true
importType.value = type
}
</script> </script>
<template> <template>
<div> <div>
<v-tabs v-model="activeTab" height="32" density="compact" color="primary"> <a-tabs v-model:activeKey="activeTab" hide-add type="editable-card" :tab-position="top" @edit="onEdit">
<v-tab v-for="(tab, i) in tabs" :key="i" :value="i" :class="`text-capitalize nc-tab-${tab.title}`"> <a-tab-pane v-for="(tab, i) in tabs" :key="i" :value="i" class="text-capitalize" :closable="true">
{{ tab.title }} <template #tab>
<MdiCloseIcon class="ml-2 text-gray-500/50 mdi-close" @click.stop="closeTab(i)"></MdiCloseIcon> <span class="flex items-center gap-2">
</v-tab> <MdiAccountGroupIcon v-if="tab.type === 'auth'" class="text-primary" />
<MdiPlusIcon @click="tableCreateDialog = true" /> <MdiTableIcon v-else class="text-primary" />
<DlgTableCreate v-if="tableCreateDialog" v-model="tableCreateDialog" /> {{ tab.title }}
</v-tabs> </span>
</template>
</a-tab-pane>
<template #leftExtra>
<a-menu v-model:selectedKeys="currentMenu" mode="horizontal">
<a-sub-menu key="addORImport">
<template #title>
<span class="flex items-center gap-2">
<MdiPlusIcon />
Add / Import
</span>
</template>
<a-menu-item-group v-if="isUIAllowed('addTable')">
<a-menu-item key="add-new-table" v-t="['a:actions:create-table']" @click="tableCreateDialog = true">
<span class="flex items-center gap-2">
<MdiTableIcon class="text-primary" />
<!-- Add new table -->
{{ $t('tooltip.addTable') }}
</span>
</a-menu-item>
</a-menu-item-group>
<a-menu-item-group title="QUICK IMPORT FROM">
<a-menu-item
v-if="isUIAllowed('airtableImport')"
key="quick-import-airtable"
v-t="['a:actions:import-airtable']"
@click="airtableImportDialog = true"
>
<span class="flex items-center gap-2">
<MdiAirTableIcon class="text-primary" />
<!-- TODO: i18n -->
Airtable
</span>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('csvImport')"
key="quick-import-csv"
v-t="['a:actions:import-csv']"
@click="openQuickImportDialog('csv')"
>
<span class="flex items-center gap-2">
<MdiCsvIcon class="text-primary" />
<!-- TODO: i18n -->
CSV file
</span>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('jsonImport')"
key="quick-import-json"
v-t="['a:actions:import-json']"
@click="openQuickImportDialog('json')"
>
<span class="flex items-center gap-2">
<MdiJSONIcon class="text-primary" />
<!-- TODO: i18n -->
JSON file
</span>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('excelImport')"
key="quick-import-excel"
v-t="['a:actions:import-excel']"
@click="openQuickImportDialog('excel')"
>
<span class="flex items-center gap-2">
<MdiExcelIcon class="text-primary" />
<!-- TODO: i18n -->
Microsoft Excel
</span>
</a-menu-item>
</a-menu-item-group>
<a-divider class="ma-0 mb-2" />
<a-menu-item
v-if="isUIAllowed('importRequest')"
key="add-new-table"
v-t="['e:datasource:import-request']"
class="ma-0 mt-3"
>
<a href="https://github.com/nocodb/nocodb/issues/2052" target="_blank" class="prose-sm pa-0">
<span class="flex items-center gap-2">
<MdiRequestDataSourceIcon class="text-primary" />
<!-- TODO: i18n -->
Request a data source you need?
</span>
</a>
</a-menu-item>
</a-sub-menu>
</a-menu>
</template>
</a-tabs>
<DlgTableCreate v-if="tableCreateDialog" v-model="tableCreateDialog" />
<DlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" :import-type="importType" />
<DlgAirtableImport v-if="airtableImportDialog" v-model="airtableImportDialog" />
<v-window v-model="activeTab"> <v-window v-model="activeTab">
<v-window-item v-for="(tab, i) in tabs" :key="i" :value="i"> <v-window-item v-for="(tab, i) in tabs" :key="i" :value="i">
@ -27,4 +142,8 @@ const tableCreateDialog = ref(false)
</div> </div>
</template> </template>
<style scoped></style> <style scoped lang="scss">
:deep(.ant-menu-item-group-list) .ant-menu-item {
@apply m-0 pa-0 pl-4 pr-16;
}
</style>

311
packages/nc-gui-v2/components/dlg/AirtableImport.vue

@ -0,0 +1,311 @@
<script setup lang="ts">
import io from 'socket.io-client'
import type { Socket } from 'socket.io-client'
import { Form } from 'ant-design-vue'
import { useToast } from 'vue-toastification'
import { fieldRequiredValidator } from '~/utils/validation'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import MdiCloseCircleOutlineIcon from '~icons/mdi/close-circle-outline'
import MdiCurrencyUsdIcon from '~icons/mdi/currency-usd'
import MdiLoadingIcon from '~icons/mdi/loading'
interface Props {
modelValue: boolean
}
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
// TODO: handle baseURL
const baseURL = 'http://localhost:8080' // this.$axios.defaults.baseURL
const { $state } = useNuxtApp()
const toast = useToast()
const { sqlUi, project, loadTables } = useProject()
const loading = ref(false)
const step = ref(1)
const progress = ref<Record<string, any>[]>([])
let socket: Socket | null
const syncSource = ref({
id: '',
type: 'Airtable',
details: {
syncInterval: '15mins',
syncDirection: 'Airtable to NocoDB',
syncRetryCount: 1,
apiKey: '',
shareId: '',
syncSourceUrlOrId: '',
options: {
syncViews: true,
syncData: true,
syncRollup: false,
syncLookup: true,
syncFormula: false,
syncAttachment: true,
},
},
})
const validators = computed(() => {
return {
'details.apiKey': [fieldRequiredValidator],
'details.syncSourceUrlOrId': [fieldRequiredValidator],
}
})
const dialogShow = computed({
get() {
return modelValue
},
set(v) {
emit('update:modelValue', v)
},
})
const useForm = Form.useForm
const { resetFields, validate, validateInfos } = useForm(syncSource, validators)
const disableImportButton = computed(() => {
return !syncSource.value.details.apiKey || !syncSource.value.details.syncSourceUrlOrId
})
async function saveAndSync() {
await createOrUpdate()
await sync()
}
async function createOrUpdate() {
try {
const { id, ...payload } = syncSource.value
if (id !== '') {
await $fetch(`/api/v1/db/meta/syncs/${id}`, {
baseURL,
method: 'PATCH',
headers: { 'xc-auth': $state.token.value as string },
body: payload,
})
} else {
const data: any = await $fetch(`/api/v1/db/meta/projects/${project.value.id}/syncs`, {
baseURL,
method: 'POST',
headers: { 'xc-auth': $state.token.value as string },
body: payload,
})
syncSource.value = data
}
} catch (e: any) {
toast.error(await extractSdkResponseErrorMsg(e))
}
}
async function loadSyncSrc() {
const data: any = await $fetch(`/api/v1/db/meta/projects/${project.value.id}/syncs`, {
baseURL,
method: 'GET',
headers: { 'xc-auth': $state.token.value as string },
})
const { list: srcs } = data
if (srcs && srcs[0]) {
srcs[0].details = srcs[0].details || {}
syncSource.value = migrateSync(srcs[0])
syncSource.value.details.syncSourceUrlOrId = srcs[0].details.shareId
} else {
syncSource.value = {
id: '',
type: 'Airtable',
details: {
syncInterval: '15mins',
syncDirection: 'Airtable to NocoDB',
syncRetryCount: 1,
apiKey: '',
shareId: '',
syncSourceUrlOrId: '',
options: {
syncViews: true,
syncData: true,
syncRollup: false,
syncLookup: true,
syncFormula: false,
syncAttachment: true,
},
},
}
}
}
async function sync() {
step.value = 2
try {
await $fetch(`/api/v1/db/meta/syncs/${syncSource.value.id}/trigger`, {
baseURL,
method: 'POST',
headers: { 'xc-auth': $state.token.value as string },
params: {
id: socket.id,
},
})
} catch (e: any) {
console.log(e)
toast.error(e)
}
}
function migrateSync(src: any) {
if (!src.details?.options) {
src.details.options = {
syncViews: false,
syncData: true,
syncRollup: false,
syncLookup: true,
syncFormula: false,
syncAttachment: true,
}
src.details.options.syncViews = src.syncViews
delete src.syncViews
}
return src
}
watch(
() => syncSource.value.details.syncSourceUrlOrId,
(v) => {
if (syncSource.value.details) {
const m = v && v.match(/(exp|shr).{14}/g)
syncSource.value.details.shareId = m ? m[0] : ''
}
},
)
onMounted(async () => {
socket = io(new URL(baseURL, window.location.href.split(/[?#]/)[0]).href, {
extraHeaders: { 'xc-auth': $state.token.value as string },
})
socket.on('connect_error', () => {
socket?.disconnect()
socket = null
})
// connect event does not provide data
socket.on('connect', () => {
console.log(socket?.id)
console.log('socket connected')
})
socket.on('progress', async (d: Record<string, any>) => {
progress.value.push(d)
if (d.status === 'COMPLETED') {
await loadTables()
// TODO: add tab of the first table
}
})
await loadSyncSrc()
})
onBeforeUnmount(() => {
if (socket) {
socket.disconnect()
}
})
</script>
<template>
<a-modal v-model:visible="dialogShow" width="max(90vw, 600px)" @keydown.esc="dialogShow = false">
<template #footer>
<div v-if="step === 1">
<a-button key="back" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button>
<a-button
key="submit"
v-t="['c:sync-airtable:save-and-sync']"
type="primary"
:disabled="disableImportButton"
@click="saveAndSync"
>Import
</a-button>
</div>
</template>
<a-typography-title class="ml-5 mt-5" type="secondary" :level="5">QUICK IMPORT - AIRTABLE</a-typography-title>
<div class="ml-5 mr-5">
<a-divider />
<div v-if="step === 1">
<div class="mb-4">
<span class="prose-xl font-bold mr-3">Credentials</span>
<a
href="https://docs.nocodb.com/setup-and-usages/import-airtable-to-sql-database-within-a-minute-for-free/#get-airtable-credentials"
class="prose-sm underline text-grey"
target="_blank"
>Where to find this?
</a>
</div>
<a-form ref="form" :model="syncSource" name="quick-import-airtable-form" layout="horizontal" class="ma-0">
<a-form-item v-bind="validateInfos['details.apiKey']">
<a-input-password v-model:value="syncSource.details.apiKey" placeholder="Api Key" size="large" />
</a-form-item>
<a-form-item v-bind="validateInfos['details.syncSourceUrlOrId']">
<a-input v-model:value="syncSource.details.syncSourceUrlOrId" placeholder="Shared Base ID / URL" size="large" />
</a-form-item>
<span class="prose-xl font-bold self-center my-4">Advanced Settings</span>
<a-divider class="mt-2 mb-5" />
<div class="mt-0 my-2">
<a-checkbox v-model:checked="syncSource.details.options.syncData">Import Data</a-checkbox>
</div>
<div class="my-2">
<a-checkbox v-model:checked="syncSource.details.options.syncViews">Import Secondary Views</a-checkbox>
</div>
<div class="my-2">
<a-checkbox v-model:checked="syncSource.details.options.syncRollup">Import Rollup Columns</a-checkbox>
</div>
<div class="my-2">
<a-checkbox v-model:checked="syncSource.details.options.syncLookup">Import Lookup Columns</a-checkbox>
</div>
<div class="my-2">
<a-checkbox v-model:checked="syncSource.details.options.syncAttachment">Import Attachment Columns</a-checkbox>
</div>
<a-tooltip placement="top">
<template #title>
<span>Coming Soon!</span>
</template>
<a-checkbox v-model:checked="syncSource.details.options.syncFormula" disabled>Import Formula Columns</a-checkbox>
</a-tooltip>
</a-form>
<a-divider />
<div>
<a href="https://github.com/nocodb/nocodb/issues/2052" target="_blank">Questions / Help - Reach out here</a>
<br />
<div>
This feature is currently in beta and more information can be found
<a class="prose-sm" href="https://github.com/nocodb/nocodb/discussions/2122" target="_blank">here</a>.
</div>
</div>
</div>
<div v-if="step === 2">
<div class="mb-4 prose-xl font-bold">Logs</div>
<a-card body-style="background-color: #000000; height:400px; overflow: auto;">
<div v-for="({ msg, status }, i) in progress" :key="i">
<div v-if="status === 'FAILED'" class="flex items-center">
<MdiCloseCircleOutlineIcon class="text-red-500" />
<span class="text-red-500 ml-2">{{ msg }}</span>
</div>
<div v-else class="flex items-center">
<MdiCurrencyUsdIcon class="text-green-500" />
<span class="text-green-500 ml-2">{{ msg }}</span>
</div>
</div>
<div
v-if="
!progress ||
!progress.length ||
(progress[progress.length - 1].status !== 'COMPLETED' && progress[progress.length - 1].status !== 'FAILED')
"
class="flex items-center"
>
<MdiLoadingIcon class="text-green-500 animate-spin" />
<span class="text-green-500 ml-2"> Importing</span>
</div>
</a-card>
</div>
</div>
</a-modal>
</template>
<style scoped lang="scss"></style>

338
packages/nc-gui-v2/components/dlg/QuickImport.vue

@ -0,0 +1,338 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import { Form } from 'ant-design-vue'
import type { TableType } from 'nocodb-sdk'
import type { UploadChangeParam } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import MdiFileIcon from '~icons/mdi/file-plus-outline'
import MdiFileUploadOutlineIcon from '~icons/mdi/file-upload-outline'
import MdiLinkVariantIcon from '~icons/mdi/link-variant'
import MdiCodeJSONIcon from '~icons/mdi/code-json'
import { fieldRequiredValidator, importCsvUrlValidator, importExcelUrlValidator, importUrlValidator } from '~/utils/validation'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { ExcelTemplateAdapter, ExcelUrlTemplateAdapter, JSONTemplateAdapter, JSONUrlTemplateAdapter } from '~/utils/parsers'
import { useProject } from '#imports'
interface Props {
modelValue: boolean
importType: 'csv' | 'json' | 'excel'
}
const { modelValue, importType } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const { tables } = useProject()
const toast = useToast()
const activeKey = ref('uploadTab')
const jsonEditorRef = ref()
const templateEditorRef = ref()
const loading = ref(false)
const templateData = ref()
const importData = ref()
const templateEditorModal = ref(false)
const useForm = Form.useForm
const importState = reactive({
fileList: [] as Record<string, any>,
url: '',
jsonEditor: {},
parserConfig: {
maxRowsToParse: 500,
normalizeNested: true,
importData: true,
},
})
const isImportTypeJson = computed(() => importType === 'json')
const isImportTypeCsv = computed(() => importType === 'csv')
const IsImportTypeExcel = computed(() => importType === 'excel')
const validators = computed(() => {
return {
url: [fieldRequiredValidator, importUrlValidator, isImportTypeCsv.value ? importCsvUrlValidator : importExcelUrlValidator],
maxRowsToParse: [fieldRequiredValidator],
}
})
const { resetFields, validate, validateInfos } = useForm(importState, validators)
const importMeta = computed(() => {
if (IsImportTypeExcel.value) {
return {
header: 'QUICK IMPORT - EXCEL',
uploadHint: t('msg.info.excelSupport'),
urlInputLabel: t('msg.info.excelURL'),
loadUrlDirective: ['c:quick-import:excel:load-url'],
acceptTypes: '.xls, .xlsx, .xlsm, .ods, .ots',
}
} else if (isImportTypeCsv.value) {
return {
header: 'QUICK IMPORT - CSV',
uploadHint: '',
urlInputLabel: t('msg.info.csvURL'),
loadUrlDirective: ['c:quick-import:csv:load-url'],
acceptTypes: '.csv',
}
} else if (isImportTypeJson.value) {
return {
header: 'QUICK IMPORT - JSON',
uploadHint: '',
acceptTypes: '.json',
}
}
return {}
})
const dialogShow = computed({
get() {
return modelValue
},
set(v) {
emit('update:modelValue', v)
},
})
const disablePreImportButton = computed(() => {
if (activeKey.value === 'uploadTab') {
return !(importState.fileList.length > 0)
} else if (activeKey.value === 'urlTab') {
if (!validateInfos.url.validateStatus) return true
return validateInfos.url.validateStatus === 'error'
} else if (activeKey.value === 'jsonEditorTab') {
return !jsonEditorRef.value?.isValid
}
})
const disableImportButton = computed(() => {
return !templateEditorRef.value?.isValid
})
const disableFormatJsonButton = computed(() => !jsonEditorRef.value?.isValid)
async function handlePreImport() {
loading.value = true
if (activeKey.value === 'uploadTab') {
await parseAndExtractData(importState.fileList[0].data, importState.fileList[0].name)
} else if (activeKey.value === 'urlTab') {
try {
await validate()
await parseAndExtractData(importState.url, '')
} catch (e: any) {
toast.error(await extractSdkResponseErrorMsg(e))
}
} else if (activeKey.value === 'jsonEditorTab') {
await parseAndExtractData(JSON.stringify(importState.jsonEditor), '')
}
loading.value = false
}
async function handleImport() {
loading.value = true
await templateEditorRef.value.importTemplate()
loading.value = false
dialogShow.value = false
}
async function parseAndExtractData(val: any, name: string) {
try {
templateData.value = null
importData.value = null
const templateGenerator: any = getAdapter(name, val)
if (!templateGenerator) {
toast.error('Template Generator cannot be found!')
return
}
await templateGenerator.init()
templateGenerator.parse()
templateData.value = templateGenerator.getTemplate()
templateData.value.tables[0].table_name = populateUniqueTableName()
importData.value = templateGenerator.getData()
templateEditorModal.value = true
} catch (e: any) {
console.log(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
}
function rejectDrop(fileList: any[]) {
fileList.map((file) => {
return toast.error(`Failed to upload file ${file.name}`)
})
}
function handleChange(info: UploadChangeParam) {
const status = info.file.status
if (status !== 'uploading') {
const reader: any = new FileReader()
reader.onload = (e: any) => {
const target: any = importState.fileList.find((f: any) => f?.uid === info.file.uid)
if (target) {
target.data = e.target.result
}
}
reader.readAsArrayBuffer(info.file.originFileObj)
}
if (status === 'done') {
toast.success(`Uploaded file ${info.file.name} successfully`)
} else if (status === 'error') {
toast.error(`Failed to upload file ${info.file.name}`)
}
}
function formatJson() {
jsonEditorRef.value?.format()
}
function populateUniqueTableName() {
let c = 1
while (tables.value.some((t: TableType) => t.title === `Sheet${c}`)) {
c++
}
return `Sheet${c}`
}
function getAdapter(name: string, val: any) {
if (IsImportTypeExcel.value || isImportTypeCsv.value) {
switch (activeKey.value) {
case 'uploadTab':
return new ExcelTemplateAdapter(name, val, importState.parserConfig)
case 'urlTab':
return new ExcelUrlTemplateAdapter(val, importState.parserConfig)
}
} else if (isImportTypeJson.value) {
switch (activeKey.value) {
case 'uploadTab':
return new JSONTemplateAdapter(name, val, importState.parserConfig)
case 'urlTab':
return new JSONUrlTemplateAdapter(val, importState.parserConfig)
case 'jsonEditorTab':
return new JSONTemplateAdapter(name, val, importState.parserConfig)
}
}
return null
}
</script>
<template>
<a-modal v-model:visible="dialogShow" width="max(90vw, 600px)" @keydown.esc="dialogShow = false">
<a-typography-title class="ml-5 mt-5 mb-5" type="secondary" :level="5">{{ importMeta.header }}</a-typography-title>
<template #footer>
<a-button v-if="templateEditorModal" key="back" @click="templateEditorModal = false">Back</a-button>
<a-button v-else key="cancel" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button>
<a-button v-if="activeKey === 'jsonEditorTab'" key="format" :disabled="disableFormatJsonButton" @click="formatJson"
>Format JSON</a-button
>
<a-button
v-if="!templateEditorModal"
key="pre-import"
type="primary"
:loading="loading"
:disabled="disablePreImportButton"
@click="handlePreImport"
>{{ $t('activity.import') }}
</a-button>
<a-button v-else key="import" type="primary" :loading="loading" :disabled="disableImportButton" @click="handleImport">{{
$t('activity.import')
}}</a-button>
</template>
<div class="ml-5 mr-5">
<TemplateEditor
v-if="templateEditorModal"
ref="templateEditorRef"
:project-template="templateData"
:import-data="importData"
:quick-import-type="importType"
/>
<a-tabs v-else v-model:activeKey="activeKey" hide-add type="editable-card" :tab-position="top">
<a-tab-pane key="uploadTab" :closable="false">
<template #tab>
<span class="flex items-center gap-2">
<MdiFileUploadOutlineIcon />
Upload
</span>
</template>
<div class="pr-10 pb-0 pt-5">
<a-upload-dragger
v-model:fileList="importState.fileList"
name="file"
:accept="importMeta.acceptTypes"
:max-count="1"
list-type="picture"
@change="handleChange"
@reject="rejectDrop"
>
<MdiFileIcon size="large" />
<p class="ant-upload-text">Click or drag file to this area to upload</p>
<p class="ant-upload-hint">
{{ importMeta.uploadHint }}
</p>
</a-upload-dragger>
</div>
</a-tab-pane>
<a-tab-pane v-if="isImportTypeJson" key="jsonEditorTab" :closable="false">
<template #tab>
<span class="flex items-center gap-2">
<MdiCodeJSONIcon />
Json Editor
</span>
</template>
<div class="pb-3 pt-3">
<MonacoEditor ref="jsonEditorRef" v-model="importState.jsonEditor" class="min-h-60 max-h-80" />
</div>
</a-tab-pane>
<a-tab-pane v-else key="urlTab" :closable="false">
<template #tab>
<span class="flex items-center gap-2">
<MdiLinkVariantIcon />
Url
</span>
</template>
<div class="pr-10 pt-5">
<a-form :model="importState" name="quick-import-url-form" layout="horizontal" class="mb-0">
<a-form-item :label="importMeta.urlInputLabel" v-bind="validateInfos.url">
<a-input v-model:value="importState.url" size="large" />
</a-form-item>
</a-form>
</div>
</a-tab-pane>
</a-tabs>
</div>
<div v-if="!templateEditorModal" class="ml-5 mr-5">
<a-divider />
<div class="mb-4">
<span class="prose-xl font-bold">Advanced Settings</span>
<a-form-item class="mt-4 mb-2" :label="t('msg.info.footMsg')" v-bind="validateInfos.maxRowsToParse">
<a-input-number v-model:value="importState.parserConfig.maxRowsToParse" :min="1" :max="50000" />
</a-form-item>
<div v-if="isImportTypeJson" class="mt-3">
<a-checkbox v-model:checked="importState.parserConfig.normalizeNested">
<span class="caption">Flatten nested</span>
</a-checkbox>
</div>
<div v-if="isImportTypeJson" class="mt-4">
<a-checkbox v-model:checked="importState.parserConfig.importData">Import data</a-checkbox>
</div>
</div>
</div>
</a-modal>
</template>
<style scoped lang="scss">
:deep(.ant-upload-list) {
@apply max-h-80 overflow-auto;
}
</style>

198
packages/nc-gui-v2/components/dlg/TableCreate.vue

@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ComponentPublicInstance } from '@vue/runtime-core' import type { ComponentPublicInstance } from '@vue/runtime-core'
import { Form } from 'ant-design-vue'
import { useToast } from 'vue-toastification'
import { onMounted, useProject, useTableCreate, useTabs } from '#imports' import { onMounted, useProject, useTableCreate, useTabs } from '#imports'
import { validateTableName } from '~/utils/validation' import { validateTableName } from '~/utils/validation'
@ -17,7 +19,7 @@ const idTypes = [
{ value: 'AI', text: 'Auto increment number' }, { value: 'AI', text: 'Auto increment number' },
{ value: 'AG', text: 'Auto generated string' }, { value: 'AG', text: 'Auto generated string' },
] ]
const toast = useToast()
const valid = ref(false) const valid = ref(false)
const isIdToggleAllowed = ref(false) const isIdToggleAllowed = ref(false)
const isAdvanceOptVisible = ref(false) const isAdvanceOptVisible = ref(false)
@ -50,6 +52,24 @@ const validateDuplicate = (v: string) => {
} }
const inputEl = ref<ComponentPublicInstance>() const inputEl = ref<ComponentPublicInstance>()
const useForm = Form.useForm
const formState = reactive({
title: '',
table_name: '',
columns: {
id: true,
title: true,
created_at: true,
updated_at: true,
},
})
const validators = computed(() => {
return {
title: [validateTableName, validateDuplicateAlias],
table_name: [validateTableName],
}
})
const { resetFields, validate, validateInfos } = useForm(formState, validators)
onMounted(() => { onMounted(() => {
generateUniqueTitle() generateUniqueTitle()
@ -63,130 +83,74 @@ onMounted(() => {
</script> </script>
<template> <template>
<v-dialog <a-modal
v-model="dialogShow" v-model:visible="dialogShow"
persistent width="max(90vw, 600px)"
max-width="550"
@keydown.esc="dialogShow = false" @keydown.esc="dialogShow = false"
@keydown.enter="$emit('create', table)" @keydown.enter="$emit('create', table)"
> >
<!-- Create A New Table --> <template #footer>
<v-card class="elevation-1 backgroundColor nc-create-table-card"> <a-button key="back" size="large" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button>
<v-form ref="form" v-model="valid"> <a-button key="submit" size="large" type="primary" @click="createTable">{{ $t('general.submit') }}</a-button>
<v-card-title class="primary subheading white--text py-2"> </template>
{{ $t('activity.createTable') }} <div class="pl-10 pr-10 pt-5">
</v-card-title> <a-form :model="formState" name="create-new-table-form">
<!-- Create A New Table -->
<v-card-text class="py-6 px-10"> <div class="prose-xl font-bold self-center my-4">{{ $t('activity.createTable') }}</div>
<!-- hint="Enter table name" --> <!-- hint="Enter table name" -->
<v-text-field <div class="mb-2">Table Name</div>
<a-form-item v-bind="validateInfos.title">
<a-input
ref="inputEl" ref="inputEl"
v-model="table.title" v-model:value="table.title"
solo size="large"
flat hide-details
persistent-hint :placeholder="$t('msg.info.enterTableName')"
dense
hide-details1
:rules="[validateTableName, validateDuplicateAlias]"
:hint="$t('msg.info.enterTableName')"
class="mt-4 caption nc-table-name"
/> />
</a-form-item>
<div class="d-flex justify-end"> <div class="flex justify-end">
<div class="grey--text caption pointer" @click="isAdvanceOptVisible = !isAdvanceOptVisible"> <div class="pointer" @click="isAdvanceOptVisible = !isAdvanceOptVisible">
{{ isAdvanceOptVisible ? 'Hide' : 'Show' }} more {{ isAdvanceOptVisible ? 'Hide' : 'Show' }} more
<v-icon x-small color="grey"> <v-icon x-small color="grey">
{{ isAdvanceOptVisible ? 'mdi-minus-circle-outline' : 'mdi-plus-circle-outline' }} {{ isAdvanceOptVisible ? 'mdi-minus-circle-outline' : 'mdi-plus-circle-outline' }}
</v-icon> </v-icon>
</div>
</div> </div>
</div>
<div class="nc-table-advanced-options" :class="{ active: isAdvanceOptVisible }"> <div class="nc-table-advanced-options" :class="{ active: isAdvanceOptVisible }">
<!-- hint="Table name as saved in database" --> <!-- hint="Table name as saved in database" -->
<v-text-field <div class="mb-2">{{ $t('msg.info.tableNameInDb') }}</div>
v-if="!project.prefix" <a-form-item v-if="!project.prefix" v-bind="validateInfos.table_name">
v-model="table.table_name" <a-input v-model:value="table.table_name" size="large" hide-details :placeholder="$t('msg.info.tableNameInDb')" />
solo </a-form-item>
flat <div>
dense <div class="mb-5">
persistent-hint <!-- Add Default Columns -->
:rules="[validateDuplicate]" {{ $t('msg.info.addDefaultColumns') }}
:hint="$t('msg.info.tableNameInDb')"
class="mt-4 caption nc-table-name-alias"
/>
<div class="mt-5">
<label class="add-default-title grey--text">
<!-- Add Default Columns -->
{{ $t('msg.info.addDefaultColumns') }}
</label>
<div class="d-flex caption justify-space-between align-center">
<v-checkbox
key="chk1"
v-model="table.columns"
dense
class="mt-0"
color="info"
hide-details
value="id"
@click.capture.prevent.stop="
() => {
$toast.info('ID column is required, you can rename this later if required.').goAway(3000)
if (!table.columns.includes('id')) {
table.columns.push('id')
}
}
"
>
<template #label>
<div>
<span v-if="!isIdToggleAllowed" class="caption" @dblclick="isIdToggleAllowed = true">id</span>
<v-select
v-else
v-model="idType"
style="max-width: 100px"
class="caption"
outlined
dense
hide-details
:items="idTypes"
/>
</div>
</template>
</v-checkbox>
<v-checkbox key="chk2" v-model="table.columns" dense class="mt-0" color="info" hide-details value="title">
<template #label>
<span class="caption">title</span>
</template>
</v-checkbox>
<v-checkbox key="chk3" v-model="table.columns" dense class="mt-0" color="info" hide-details value="created_at">
<template #label>
<span class="caption">created_at</span>
</template>
</v-checkbox>
<v-checkbox key="chk4" v-model="table.columns" dense class="mt-0" color="info" hide-details value="updated_at">
<template #label>
<span class="caption">updated_at</span>
</template>
</v-checkbox>
</div>
</div> </div>
<a-row>
<a-col :span="6">
<a-tooltip placement="top">
<template #title>
<span>ID column is required, you can rename this later if required.</span>
</template>
<a-checkbox v-model:checked="formState.columns.id" disabled>ID</a-checkbox>
</a-tooltip>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formState.columns.title"> title </a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formState.columns.created_at"> created_at </a-checkbox>
</a-col>
<a-col :span="6">
<a-checkbox v-model:checked="formState.columns.updated_at"> updated_at </a-checkbox>
</a-col>
</a-row>
</div> </div>
</v-card-text> </div>
<v-divider /> </a-form>
<v-card-actions class="py-4 px-10"> </div>
<v-spacer /> </a-modal>
<v-btn class="" @click="dialogShow = false">
{{ $t('general.cancel') }}
</v-btn>
<v-btn :disabled="!valid" color="primary" class="nc-create-table-submit" @click="createTable">
{{ $t('general.submit') }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">

39
packages/nc-gui-v2/components/monaco/Editor.vue

@ -1,14 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
import * as monaco from 'monaco-editor' import * as monaco from 'monaco-editor'
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import { onMounted } from '#imports' import { onMounted } from '#imports'
import { deepCompare } from '~/utils/deepCompare' import { deepCompare } from '~/utils/deepCompare'
const { modelValue } = defineProps<{ modelValue: any }>() const { modelValue } = defineProps<{ modelValue: any }>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const isValid = ref(true)
/**
* Adding monaco editor to Vite
*
* @ts-expect-error */
self.MonacoEnvironment = {
getWorker(_: any, label: string) {
if (label === 'json') {
return new JsonWorker()
}
return new EditorWorker()
},
}
const root = ref<HTMLDivElement>() const root = ref<HTMLDivElement>()
let editor: monaco.editor.IStandaloneCodeEditor let editor: monaco.editor.IStandaloneCodeEditor
const format = () => {
editor.setValue(JSON.stringify(JSON.parse(editor?.getValue() as string), null, 2))
}
defineExpose({
format,
isValid,
})
onMounted(() => { onMounted(() => {
if (root.value) { if (root.value) {
const model = monaco.editor.createModel(JSON.stringify(modelValue, null, 2), 'json') const model = monaco.editor.createModel(JSON.stringify(modelValue, null, 2), 'json')
@ -20,15 +46,24 @@ onMounted(() => {
editor = monaco.editor.create(root.value, { editor = monaco.editor.create(root.value, {
model, model,
theme: 'dark', theme: 'vs',
foldingStrategy: 'indentation',
selectOnLineNumbers: true,
scrollbar: {
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
},
tabSize: 2,
automaticLayout: true,
}) })
editor.onDidChangeModelContent(async (e) => { editor.onDidChangeModelContent(async (e) => {
try { try {
// console.log(e) isValid.value = true
const obj = JSON.parse(editor.getValue()) const obj = JSON.parse(editor.getValue())
if (!deepCompare(modelValue, obj)) emit('update:modelValue', obj) if (!deepCompare(modelValue, obj)) emit('update:modelValue', obj)
} catch (e) { } catch (e) {
isValid.value = false
console.log(e) console.log(e)
} }
}) })

40
packages/nc-gui-v2/components/template/Editor.vue

@ -92,8 +92,6 @@ const validators = computed(() =>
}, {}), }, {}),
) )
const editorTitle = computed(() => `${quickImportType.toUpperCase()} Import: ${data.title}`)
const { validate, validateInfos } = useForm(data, validators) const { validate, validateInfos } = useForm(data, validators)
function filterOption(input: string, option: Option) { function filterOption(input: string, option: Option) {
@ -229,7 +227,7 @@ async function importTemplate() {
await $api.dbTableColumn.primaryColumnSet(tableMeta.columns[0].id as string) await $api.dbTableColumn.primaryColumnSet(tableMeta.columns[0].id as string)
} }
} }
// bulk imsert data // bulk insert data
if (importData) { if (importData) {
let total = 0 let total = 0
let progress = 0 let progress = 0
@ -244,7 +242,7 @@ async function importTemplate() {
for (let i = 0; i < data.length; i += offset) { for (let i = 0; i < data.length; i += offset) {
importingTip.value = `Importing data to ${projectName}: ${progress}/${total} records` importingTip.value = `Importing data to ${projectName}: ${progress}/${total} records`
const batchData = remapColNames(data.slice(i, i + offset), tableMeta.columns) const batchData = remapColNames(data.slice(i, i + offset), tableMeta.columns)
await $api.dbTableRow.bulkCreate('noco', projectName, tableMeta.table_title, batchData) await $api.dbTableRow.bulkCreate('noco', projectName, tableMeta.title, batchData)
progress += batchData.length progress += batchData.length
} }
} }
@ -261,20 +259,30 @@ async function importTemplate() {
} catch (e: any) { } catch (e: any) {
toast.error(await extractSdkResponseErrorMsg(e)) toast.error(await extractSdkResponseErrorMsg(e))
} finally { } finally {
// TODO: close dialog when the integration is ready
isImporting.value = false isImporting.value = false
} }
} }
const isValid = computed(() => {
for (const [_, o] of Object.entries(validateInfos)) {
if (o?.validateStatus) {
if (o.validateStatus === 'error') {
return false
}
}
}
return true
})
defineExpose({
importTemplate,
isValid,
})
</script> </script>
<template> <template>
<a-spin :spinning="isImporting" :tip="importingTip" size="large"> <a-spin :spinning="isImporting" :tip="importingTip" size="large">
<a-card :title="editorTitle"> <a-card>
<template #extra>
<a-button type="primary" size="large" @click="importTemplate">
{{ $t('activity.import') }}
</a-button>
</template>
<a-form :model="data" name="template-editor-form"> <a-form :model="data" name="template-editor-form">
<p v-if="data.tables && quickImportType === 'excel'" class="text-center"> <p v-if="data.tables && quickImportType === 'excel'" class="text-center">
{{ data.tables.length }} sheet{{ data.tables.length > 1 ? 's' : '' }} {{ data.tables.length }} sheet{{ data.tables.length > 1 ? 's' : '' }}
@ -291,19 +299,15 @@ async function importTemplate() {
<a-form-item v-if="editableTn[tableIdx]" v-bind="validateInfos[`tables.${tableIdx}.table_name`]" no-style> <a-form-item v-if="editableTn[tableIdx]" v-bind="validateInfos[`tables.${tableIdx}.table_name`]" no-style>
<a-input <a-input
v-model:value="table.table_name" v-model:value="table.table_name"
class="max-w-xs"
size="large" size="large"
style="max-width: 300px"
hide-details hide-details
@click="(e) => e.stopPropagation()" @click="(e) => e.stopPropagation()"
@blur="setEditableTn(tableIdx, false)" @blur="setEditableTn(tableIdx, false)"
@keydown.enter="setEditableTn(tableIdx, false)" @keydown.enter="setEditableTn(tableIdx, false)"
/> />
</a-form-item> </a-form-item>
<span <span v-else class="font-weight-bold text-lg flex items-center gap-2" @click="setEditableTn(tableIdx, true)">
v-else
class="font-weight-bold text-lg flex items-center gap-2"
@click="$event.stopPropagation() && setEditableTn(tableIdx, true)"
>
<MdiTableIcon class="text-primary" /> <MdiTableIcon class="text-primary" />
{{ table.table_name }} {{ table.table_name }}
</span> </span>
@ -362,10 +366,10 @@ async function importTemplate() {
<a-form-item v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.${column.key}`]"> <a-form-item v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.${column.key}`]">
<a-auto-complete <a-auto-complete
v-model:value="record.uidt" v-model:value="record.uidt"
class="w-52"
size="large" size="large"
:options="uiTypeOptions" :options="uiTypeOptions"
:filter-option="filterOption" :filter-option="filterOption"
style="width: 200px"
/> />
</a-form-item> </a-form-item>
</template> </template>

44993
packages/nc-gui-v2/package-lock.json generated

File diff suppressed because it is too large Load Diff

2
packages/nc-gui-v2/package.json

@ -18,6 +18,7 @@
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"nocodb-sdk": "file:../nocodb-sdk", "nocodb-sdk": "file:../nocodb-sdk",
"papaparse": "^5.3.2",
"socket.io-client": "^4.5.1", "socket.io-client": "^4.5.1",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
@ -33,6 +34,7 @@
"@iconify-json/mdi": "^1.1.25", "@iconify-json/mdi": "^1.1.25",
"@iconify-json/ri": "^1.1.3", "@iconify-json/ri": "^1.1.3",
"@intlify/vite-plugin-vue-i18n": "^4.0.0", "@intlify/vite-plugin-vue-i18n": "^4.0.0",
"@types/papaparse": "^5.3.2",
"@types/sortablejs": "^1.13.0", "@types/sortablejs": "^1.13.0",
"@vitejs/plugin-vue": "^2.3.3", "@vitejs/plugin-vue": "^2.3.3",
"@vitest/ui": "^0.18.0", "@vitest/ui": "^0.18.0",

42
packages/nc-gui-v2/utils/parsers/CSVTemplateAdapter.ts

@ -0,0 +1,42 @@
import Papaparse from 'papaparse'
import TemplateGenerator from './TemplateGenerator'
export default class CSVTemplateAdapter extends TemplateGenerator {
fileName: string
project: object
data: object
csv: any
csvData: any
columns: object
constructor(name: string, data: object) {
super()
this.fileName = name
this.csvData = data
this.project = {
title: this.fileName,
tables: [],
}
this.data = {}
this.csv = {}
this.columns = {}
this.csvData = {}
}
async init() {
this.csv = Papaparse.parse(this.csvData, { header: true })
}
parseData() {
this.columns = this.csv.meta.fields
this.data = this.csv.data
}
getColumns() {
return this.columns
}
getData() {
return this.data
}
}

240
packages/nc-gui-v2/utils/parsers/ExcelTemplateAdapter.ts

@ -0,0 +1,240 @@
import XLSX from 'xlsx'
import { UITypes } from 'nocodb-sdk'
import TemplateGenerator from './TemplateGenerator'
import { getCheckboxValue, isCheckboxType } from './parserHelpers'
const excelTypeToUidt: Record<any, any> = {
d: UITypes.DateTime,
b: UITypes.Checkbox,
n: UITypes.Number,
s: UITypes.SingleLineText,
}
export default class ExcelTemplateAdapter extends TemplateGenerator {
config: Record<string, any>
name: string
excelData: any
project: Record<string, any>
data: Record<string, any>
wb: any
constructor(name = '', data = {}, parserConfig = {}) {
super()
this.config = {
maxRowsToParse: 500,
...parserConfig,
}
this.name = name
this.excelData = data
this.project = {
title: this.name,
tables: [],
}
this.data = {}
}
async init() {
const options: Record<any, boolean> = {
cellText: true,
cellDates: true,
}
if (this.name.slice(-3) === 'csv') {
this.wb = XLSX.read(new TextDecoder().decode(new Uint8Array(this.excelData)), {
type: 'string',
...options,
})
} else {
this.wb = XLSX.read(new Uint8Array(this.excelData), {
type: 'array',
...options,
})
}
}
parse() {
const tableNamePrefixRef: any = {}
for (let i = 0; i < this.wb.SheetNames.length; i++) {
const columnNamePrefixRef: Record<any, any> = { id: 0 }
const sheet: any = this.wb.SheetNames[i]
let tn: string = (sheet || 'table').replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_').trim()
while (tn in tableNamePrefixRef) {
tn = `${tn}${++tableNamePrefixRef[tn]}`
}
tableNamePrefixRef[tn] = 0
const table: Record<string, any> = { table_name: tn, ref_table_name: tn, columns: [] }
this.data[tn] = []
const ws: any = this.wb.Sheets[sheet]
const range = XLSX.utils.decode_range(ws['!ref'])
let rows: any = XLSX.utils.sheet_to_json(ws, { header: 1, blankrows: false, defval: null })
if (this.name.slice(-3) !== 'csv') {
// fix precision bug & timezone offset issues introduced by xlsx
const basedate = new Date(1899, 11, 30, 0, 0, 0)
// number of milliseconds since base date
const dnthresh = basedate.getTime() + (new Date().getTimezoneOffset() - basedate.getTimezoneOffset()) * 60000
// number of milliseconds in a day
const day_ms = 24 * 60 * 60 * 1000
// handle date1904 property
const fixImportedDate = (date: Date) => {
const parsed = XLSX.SSF.parse_date_code((date.getTime() - dnthresh) / day_ms, {
date1904: this.wb.Workbook.WBProps.date1904,
})
return new Date(parsed.y, parsed.m, parsed.d, parsed.H, parsed.M, parsed.S)
}
// fix imported date
rows = rows.map((r: any) =>
r.map((v: any) => {
return v instanceof Date ? fixImportedDate(v) : v
}),
)
}
const columnNameRowExist = +rows[0].every((v: any) => v === null || typeof v === 'string')
// const colLen = Math.max()
for (let col = 0; col < rows[0].length; col++) {
let cn: string = ((columnNameRowExist && rows[0] && rows[0][col] && rows[0][col].toString().trim()) || `field_${col + 1}`)
.replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_')
.trim()
while (cn in columnNamePrefixRef) {
cn = `${cn}${++columnNamePrefixRef[cn]}`
}
columnNamePrefixRef[cn] = 0
const column: Record<string, any> = {
column_name: cn,
ref_column_name: cn,
}
table.columns.push(column)
// const cellId = `${col.toString(26).split('').map(s => (parseInt(s, 26) + 10).toString(36).toUpperCase())}2`;
const cellId = XLSX.utils.encode_cell({
c: range.s.c + col,
r: columnNameRowExist,
})
const cellProps = ws[cellId] || {}
column.uidt = excelTypeToUidt[cellProps.t] || UITypes.SingleLineText
// todo: optimize
if (column.uidt === UITypes.SingleLineText) {
// check for long text
if (rows.some((r: any) => (r[col] || '').toString().match(/[\r\n]/) || (r[col] || '').toString().length > 255)) {
column.uidt = UITypes.LongText
} else {
const vals = rows
.slice(columnNameRowExist ? 1 : 0)
.map((r: any) => r[col])
.filter((v: any) => v !== null && v !== undefined && v.toString().trim() !== '')
const checkboxType = isCheckboxType(vals)
if (checkboxType.length === 1) {
column.uidt = UITypes.Checkbox
} else {
// todo: optimize
// check column is multi or single select by comparing unique values
// todo:
if (vals.some((v: any) => v && v.toString().includes(','))) {
let flattenedVals = vals.flatMap((v: any) =>
v
? v
.toString()
.trim()
.split(/\s*,\s*/)
: [],
)
const uniqueVals = (flattenedVals = flattenedVals.filter(
(v: any, i: any, arr: any) => i === arr.findIndex((v1: any) => v.toLowerCase() === v1.toLowerCase()),
))
if (flattenedVals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(flattenedVals.length / 2)) {
column.uidt = UITypes.MultiSelect
column.dtxp = `'${uniqueVals.join("','")}'`
}
} else {
const uniqueVals = vals
.map((v: any) => v.toString().trim())
.filter((v: any, i: any, arr: any) => i === arr.findIndex((v1: any) => v.toLowerCase() === v1.toLowerCase()))
if (vals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(vals.length / 2)) {
column.uidt = UITypes.SingleSelect
column.dtxp = `'${uniqueVals.join("','")}'`
}
}
}
}
} else if (column.uidt === UITypes.Number) {
if (
rows.slice(1, this.config.maxRowsToParse).some((v: any) => {
return v && v[col] && parseInt(v[col]) !== +v[col]
})
) {
column.uidt = UITypes.Decimal
}
if (
rows.slice(1, this.config.maxRowsToParse).every((v: any, i: any) => {
const cellId = XLSX.utils.encode_cell({
c: range.s.c + col,
r: i + columnNameRowExist,
})
const cellObj = ws[cellId]
return !cellObj || (cellObj.w && cellObj.w.startsWith('$'))
})
) {
column.uidt = UITypes.Currency
}
} else if (column.uidt === UITypes.DateTime) {
if (
rows.slice(1, this.config.maxRowsToParse).every((v: any, i: any) => {
const cellId = XLSX.utils.encode_cell({
c: range.s.c + col,
r: i + columnNameRowExist,
})
const cellObj = ws[cellId]
return !cellObj || (cellObj.w && cellObj.w.split(' ').length === 1)
})
) {
column.uidt = UITypes.Date
}
}
}
let rowIndex = 0
for (const row of rows.slice(1)) {
const rowData: Record<string, any> = {}
for (let i = 0; i < table.columns.length; i++) {
if (table.columns[i].uidt === UITypes.Checkbox) {
rowData[table.columns[i].column_name] = getCheckboxValue(row[i])
} else if (table.columns[i].uidt === UITypes.Currency) {
const cellId = XLSX.utils.encode_cell({
c: range.s.c + i,
r: rowIndex + columnNameRowExist,
})
const cellObj = ws[cellId]
rowData[table.columns[i].column_name] = (cellObj && cellObj.w && cellObj.w.replace(/[^\d.]+/g, '')) || row[i]
} else if (table.columns[i].uidt === UITypes.SingleSelect || table.columns[i].uidt === UITypes.MultiSelect) {
rowData[table.columns[i].column_name] = (row[i] || '').toString().trim() || null
} else {
// toto: do parsing if necessary based on type
rowData[table.columns[i].column_name] = row[i]
}
}
this.data[tn].push(rowData)
rowIndex++
}
this.project.tables.push(table)
}
}
getTemplate() {
return this.project
}
getData() {
return this.data
}
}

27
packages/nc-gui-v2/utils/parsers/ExcelUrlTemplateAdapter.ts

@ -0,0 +1,27 @@
import ExcelTemplateAdapter from './ExcelTemplateAdapter'
import { useNuxtApp } from '#imports'
export default class ExcelUrlTemplateAdapter extends ExcelTemplateAdapter {
url: string
excelData: any
$api: any
constructor(url: string, parserConfig: Record<string, any>) {
const { $api } = useNuxtApp()
const name = url?.split('/').pop()
super(name, parserConfig)
this.url = url
this.excelData = null
this.$api = $api
}
async init() {
const data: any = await this.$api.utils.axiosRequestMake({
apiMeta: {
url: this.url,
},
})
this.excelData = data.data
await super.init()
}
}

168
packages/nc-gui-v2/utils/parsers/JSONTemplateAdapter.ts

@ -0,0 +1,168 @@
import { UITypes } from 'nocodb-sdk'
import {
extractMultiOrSingleSelectProps,
getCheckboxValue,
isCheckboxType,
isDecimalType,
isEmailType,
isMultiLineTextType,
isUrlType,
} from './parserHelpers'
import TemplateGenerator from './TemplateGenerator'
const jsonTypeToUidt: Record<string, string> = {
number: UITypes.Number,
string: UITypes.SingleLineText,
date: UITypes.DateTime,
boolean: UITypes.Checkbox,
object: UITypes.JSON,
}
const extractNestedData: any = (obj: any, path: any) => path.reduce((val: any, key: any) => val && val[key], obj)
export default class JSONTemplateAdapter extends TemplateGenerator {
config: Record<string, any>
name: string
data: Record<string, any>
_jsonData: string | Record<string, any>
jsonData: Record<string, any>
project: Record<string, any>
columns: object
constructor(name = 'test', data: object, parserConfig = {}) {
super()
this.config = {
maxRowsToParse: 500,
...parserConfig,
}
this.name = name
this._jsonData = data
this.project = {
title: this.name,
tables: [],
}
this.jsonData = []
this.data = []
this.columns = {}
}
async init() {
const parsedJsonData =
typeof this._jsonData === 'string'
? // for json editor
JSON.parse(this._jsonData)
: // for file upload
JSON.parse(new TextDecoder().decode(this._jsonData as BufferSource))
this.jsonData = Array.isArray(parsedJsonData) ? parsedJsonData : [parsedJsonData]
}
getColumns(): any {
return this.columns
}
getData(): any {
return this.data
}
parse(): any {
const jsonData = this.jsonData
const tn = 'table'
const table: any = { table_name: tn, ref_table_name: tn, columns: [] }
this.data[tn] = []
for (const col of Object.keys(jsonData[0])) {
const columns = this._parseColumn([col], jsonData)
table.columns.push(...columns)
}
if (this.config.importData) {
this._parseTableData(table)
}
this.project.tables.push(table)
}
getTemplate() {
return this.project
}
_parseColumn(
path: any = [],
jsonData = this.jsonData,
firstRowVal = path.reduce((val: any, k: any) => val && val[k], this.jsonData[0]),
): any {
const columns = []
// parse nested
if (firstRowVal && typeof firstRowVal === 'object' && !Array.isArray(firstRowVal) && this.config.normalizeNested) {
for (const key of Object.keys(firstRowVal)) {
const normalizedNestedColumns = this._parseColumn([...path, key], this.jsonData, firstRowVal[key])
columns.push(...normalizedNestedColumns)
}
} else {
const cn = path.join('_').replace(/\W/g, '_').trim()
const column: Record<string, any> = {
column_name: cn,
ref_column_name: cn,
path,
}
column.uidt = jsonTypeToUidt[typeof firstRowVal] || UITypes.SingleLineText
const colData = jsonData.map((r: any) => extractNestedData(r, path))
Object.assign(column, this._getColumnUIDTAndMetas(colData, column.uidt))
columns.push(column)
}
return columns
}
_getColumnUIDTAndMetas(colData: any, defaultType: any) {
const colProps = { uidt: defaultType }
// todo: optimize
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 {
const checkboxType = isCheckboxType(colData)
if (checkboxType.length === 1) {
colProps.uidt = UITypes.Checkbox
} else {
Object.assign(colProps, extractMultiOrSingleSelectProps(colData))
}
}
} else if (colProps.uidt === UITypes.Number) {
if (isDecimalType(colData)) {
colProps.uidt = UITypes.Decimal
}
}
return colProps
}
_parseTableData(tableMeta: any) {
for (const row of this.jsonData as any) {
const rowData: any = {}
for (let i = 0; i < tableMeta.columns.length; i++) {
const value = extractNestedData(row, tableMeta.columns[i].path || [])
if (tableMeta.columns[i].uidt === UITypes.Checkbox) {
rowData[tableMeta.columns[i].ref_column_name] = getCheckboxValue(value)
} else if (tableMeta.columns[i].uidt === UITypes.SingleSelect || tableMeta.columns[i].uidt === UITypes.MultiSelect) {
rowData[tableMeta.columns[i].ref_column_name] = (value || '').toString().trim() || null
} else if (tableMeta.columns[i].uidt === UITypes.JSON) {
rowData[tableMeta.columns[i].ref_column_name] = JSON.stringify(value)
} else {
// toto: do parsing if necessary based on type
rowData[tableMeta.columns[i].column_name] = value
}
}
this.data[tableMeta.ref_table_name].push(rowData)
}
}
}

25
packages/nc-gui-v2/utils/parsers/JSONUrlTemplateAdapter.ts

@ -0,0 +1,25 @@
import JSONTemplateAdapter from './JSONTemplateAdapter'
import { useNuxtApp } from '#imports'
export default class JSONUrlTemplateAdapter extends JSONTemplateAdapter {
url: string
$api: any
constructor(url: string, parserConfig: Record<string, any>) {
const { $api } = useNuxtApp()
const name = url.split('/').pop()
super(name, parserConfig)
this.url = url
this.$api = $api
}
async init() {
const data = await this.$api.utils.axiosRequestMake({
apiMeta: {
url: this.url,
},
})
this._jsonData = data
await super.init()
}
}

25
packages/nc-gui-v2/utils/parsers/TemplateGenerator.ts

@ -0,0 +1,25 @@
export default class TemplateGenerator {
parse() {
throw new Error("'parse' method is not implemented")
}
parseData() {
throw new Error("'parseData' method is not implemented")
}
parseTemplate() {
throw new Error("'parseTemplate' method is not implemented")
}
getColumns() {
throw new Error("'getColumns' method is not implemented")
}
getTemplate() {
throw new Error("'getTemplate' method is not implemented")
}
getData() {
throw new Error("'getData' method is not implemented")
}
}

7
packages/nc-gui-v2/utils/parsers/index.ts

@ -0,0 +1,7 @@
import JSONTemplateAdapter from './JSONTemplateAdapter'
import JSONUrlTemplateAdapter from './JSONUrlTemplateAdapter'
import CSVTemplateAdapter from './CSVTemplateAdapter'
import ExcelTemplateAdapter from './ExcelTemplateAdapter'
import ExcelUrlTemplateAdapter from './ExcelUrlTemplateAdapter'
export { JSONTemplateAdapter, JSONUrlTemplateAdapter, CSVTemplateAdapter, ExcelTemplateAdapter, ExcelUrlTemplateAdapter }

95
packages/nc-gui-v2/utils/parsers/parserHelpers.ts

@ -0,0 +1,95 @@
import { UITypes } from 'nocodb-sdk'
import { isValidURL } from '~/utils/urlUtils'
import { isEmail } from '~/utils/validation'
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 = null) => {
return row && col ? row[col] : row
}
export const isCheckboxType: any = (values: [], col = null) => {
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 options
}
export const getCheckboxValue = (value: number) => {
return value && aggBooleanOptions[value]
}
export const isMultiLineTextType = (values: [], col = null) => {
return values.some(
(r) => (getColVal(r, col) || '').toString().match(/[\r\n]/) || (getColVal(r, col) || '').toString().length > 255,
)
}
export const extractMultiOrSingleSelectProps = (colData: []) => {
const colProps: any = {}
if (colData.some((v: any) => v && (v || '').toString().includes(','))) {
let flattenedVals = colData.flatMap((v: any) =>
v
? v
.toString()
.trim()
.split(/\s*,\s*/)
: [],
)
const uniqueVals = (flattenedVals = flattenedVals.filter(
(v, i, arr) => i === arr.findIndex((v1) => v.toLowerCase() === v1.toLowerCase()),
))
if (flattenedVals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(flattenedVals.length / 2)) {
colProps.uidt = UITypes.MultiSelect
colProps.dtxp = `'${uniqueVals.join("','")}'`
}
} else {
const uniqueVals = colData
.map((v: any) => (v || '').toString().trim())
.filter((v, i, arr) => i === arr.findIndex((v1) => v.toLowerCase() === v1.toLowerCase()))
if (colData.length > uniqueVals.length && uniqueVals.length <= Math.ceil(colData.length / 2)) {
colProps.uidt = UITypes.SingleSelect
colProps.dtxp = `'${uniqueVals.join("','")}'`
}
}
return colProps
}
export const isDecimalType = (colData: []) =>
colData.some((v: any) => {
return v && parseInt(v) !== +v
})
export const isEmailType = (colData: []) =>
!colData.some((v: any) => {
return v && !isEmail(v)
})
export const isUrlType = (colData: []) =>
!colData.some((v: any) => {
return v && !isValidURL(v)
})

40
packages/nc-gui-v2/utils/validation.ts

@ -83,7 +83,47 @@ export const fieldRequiredValidator = {
required: true, required: true,
message: 'Field is required', message: 'Field is required',
} }
export const getRequiredValidator = (field = 'Field') => ({ export const getRequiredValidator = (field = 'Field') => ({
required: true, required: true,
message: `${field} is required`, message: `${field} is required`,
}) })
export const importUrlValidator = {
validator: (rule: any, value: any) => {
return new Promise((resolve, reject) => {
if (
/(10)(\.([2]([0-5][0-5]|[01234][6-9])|[1][0-9][0-9]|[1-9][0-9]|[0-9])){3}|(172)\.(1[6-9]|2[0-9]|3[0-1])(\.(2[0-4][0-9]|25[0-5]|[1][0-9][0-9]|[1-9][0-9]|[0-9])){2}|(192)\.(168)(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])){2}|(0.0.0.0)|localhost?/g.test(
value,
)
) {
return reject(new Error('IP Not allowed!'))
}
return resolve(true)
})
},
}
export const importCsvUrlValidator = {
validator: (rule: any, value: any) => {
return new Promise((resolve, reject) => {
if (value && !/.*\.(csv)/.test(value)) {
return reject(new Error('Target file is not an accepted file type. The accepted file type is .csv!'))
}
return resolve(true)
})
},
}
export const importExcelUrlValidator = {
validator: (rule: any, value: any) => {
return new Promise((resolve, reject) => {
if (value && !/.*\.(xls|xlsx|xlsm|ods|ots)/.test(value)) {
return reject(
new Error('Target file is not an accepted file type. The accepted file types are .xls, .xlsx, .xlsm, .ods, .ots!'),
)
}
return resolve(true)
})
},
}

Loading…
Cancel
Save