Browse Source

Merge pull request #3211 from nocodb/feat/import-dropzone

pull/3238/head
Braks 2 years ago committed by GitHub
parent
commit
fd3d1ff9ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      packages/nc-gui-v2/assets/style-v2.scss
  2. 15
      packages/nc-gui-v2/components.d.ts
  3. 2
      packages/nc-gui-v2/components/dashboard/TreeView.vue
  4. 133
      packages/nc-gui-v2/components/dlg/AirtableImport.vue
  5. 198
      packages/nc-gui-v2/components/dlg/QuickImport.vue
  6. 52
      packages/nc-gui-v2/components/dlg/TableCreate.vue
  7. 28
      packages/nc-gui-v2/components/general/Language.vue
  8. 2
      packages/nc-gui-v2/components/general/PreviewAs.vue
  9. 31
      packages/nc-gui-v2/components/smartsheet-header/Menu.vue
  10. 26
      packages/nc-gui-v2/components/smartsheet-toolbar/ColumnFilterMenu.vue
  11. 25
      packages/nc-gui-v2/components/smartsheet-toolbar/FieldsMenu.vue
  12. 8
      packages/nc-gui-v2/components/smartsheet/Cell.vue
  13. 133
      packages/nc-gui-v2/components/template/Editor.vue
  14. 1
      packages/nc-gui-v2/composables/index.ts
  15. 103
      packages/nc-gui-v2/composables/useDialog/index.ts
  16. 9
      packages/nc-gui-v2/composables/useGlobal/actions.ts
  17. 38
      packages/nc-gui-v2/composables/useViewFilters.ts
  18. 32
      packages/nc-gui-v2/layouts/base.vue
  19. 7
      packages/nc-gui-v2/nuxt-shim.d.ts
  20. 38
      packages/nc-gui-v2/package-lock.json
  21. 2
      packages/nc-gui-v2/package.json
  22. 187
      packages/nc-gui-v2/pages/[projectType]/[projectId]/index/index.vue
  23. 131
      packages/nc-gui-v2/pages/[projectType]/[projectId]/index/index/index.vue
  24. 10
      packages/nc-gui-v2/plugins/ant.ts

6
packages/nc-gui-v2/assets/style-v2.scss

@ -14,7 +14,7 @@ body,
#__nuxt, #__nuxt,
.ant-layout, .ant-layout,
main { main {
@apply m-0 h-full w-full bg-white dark:(bg-black text-white); @apply m-0 h-full w-full bg-white;
} }
html { html {
@ -36,7 +36,7 @@ nav .v-list {
} }
a { a {
@apply prose text-primary underline hover:opacity-75 dark:(text-secondary) hover:(opacity-75); @apply prose text-primary underline hover:opacity-75 dark:(text-secondary);
} }
h1, h2, h3, h4, h5, h6, p, label, button, textarea, select { h1, h2, h3, h4, h5, h6, p, label, button, textarea, select {
@ -194,7 +194,7 @@ h1, h2, h3, h4, h5, h6, p, label, button, textarea, select {
} }
.scaling-btn { .scaling-btn {
@apply z-1 relative color-transition border border-gray-300 rounded-md p-3 bg-gray-100/50 text-white bg-primary; @apply z-1 relative color-transition border border-gray-300 rounded-md p-3 bg-gray-100/50 text-white;
&::after { &::after {
@apply rounded-md absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out bg-primary; @apply rounded-md absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out bg-primary;

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

@ -22,6 +22,7 @@ declare module '@vue/runtime-core' {
ADivider: typeof import('ant-design-vue/es')['Divider'] ADivider: typeof import('ant-design-vue/es')['Divider']
ADrawer: typeof import('ant-design-vue/es')['Drawer'] ADrawer: typeof import('ant-design-vue/es')['Drawer']
ADropdown: typeof import('ant-design-vue/es')['Dropdown'] ADropdown: typeof import('ant-design-vue/es')['Dropdown']
ADropdownButton: typeof import('ant-design-vue/es')['DropdownButton']
AEmpty: typeof import('ant-design-vue/es')['Empty'] AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form'] AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem'] AFormItem: typeof import('ant-design-vue/es')['FormItem']
@ -64,6 +65,8 @@ declare module '@vue/runtime-core' {
ATypography: typeof import('ant-design-vue/es')['Typography'] ATypography: typeof import('ant-design-vue/es')['Typography']
ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle'] ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger'] AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
BiFiletypeJson: typeof import('~icons/bi/filetype-json')['default']
BiFiletypeXlsx: typeof import('~icons/bi/filetype-xlsx')['default']
CilFullscreen: typeof import('~icons/cil/fullscreen')['default'] CilFullscreen: typeof import('~icons/cil/fullscreen')['default']
CilFullscreenExit: typeof import('~icons/cil/fullscreen-exit')['default'] CilFullscreenExit: typeof import('~icons/cil/fullscreen-exit')['default']
ClaritySuccessLine: typeof import('~icons/clarity/success-line')['default'] ClaritySuccessLine: typeof import('~icons/clarity/success-line')['default']
@ -105,11 +108,14 @@ declare module '@vue/runtime-core' {
MdiClose: typeof import('~icons/mdi/close')['default'] MdiClose: typeof import('~icons/mdi/close')['default']
MdiCloseBox: typeof import('~icons/mdi/close-box')['default'] MdiCloseBox: typeof import('~icons/mdi/close-box')['default']
MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default'] MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']
MdiCloseCircleOutline: typeof import('~icons/mdi/close-circle-outline')['default']
MdiCloseThick: typeof import('~icons/mdi/close-thick')['default'] MdiCloseThick: typeof import('~icons/mdi/close-thick')['default']
MdiCodeJson: typeof import('~icons/mdi/code-json')['default'] MdiCodeJson: typeof import('~icons/mdi/code-json')['default']
MdiCog: typeof import('~icons/mdi/cog')['default'] MdiCog: typeof import('~icons/mdi/cog')['default']
MdiContentCopy: typeof import('~icons/mdi/content-copy')['default'] MdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
MdiContentSave: typeof import('~icons/mdi/content-save')['default'] MdiContentSave: typeof import('~icons/mdi/content-save')['default']
MdiCurrencyUsd: typeof import('~icons/mdi/currency-usd')['default']
MdiDatabase: typeof import('~icons/mdi/database')['default']
MdiDatabaseOutline: typeof import('~icons/mdi/database-outline')['default'] MdiDatabaseOutline: typeof import('~icons/mdi/database-outline')['default']
MdiDelete: typeof import('~icons/mdi/delete')['default'] MdiDelete: typeof import('~icons/mdi/delete')['default']
MdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default'] MdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
@ -129,6 +135,9 @@ declare module '@vue/runtime-core' {
MdiFileDocumentOutline: typeof import('~icons/mdi/file-document-outline')['default'] MdiFileDocumentOutline: typeof import('~icons/mdi/file-document-outline')['default']
MdiFileExcel: typeof import('~icons/mdi/file-excel')['default'] MdiFileExcel: typeof import('~icons/mdi/file-excel')['default']
MdiFileEyeOutline: typeof import('~icons/mdi/file-eye-outline')['default'] MdiFileEyeOutline: typeof import('~icons/mdi/file-eye-outline')['default']
MdiFilePlusOutline: typeof import('~icons/mdi/file-plus-outline')['default']
MdiFileUploadOutline: typeof import('~icons/mdi/file-upload-outline')['default']
MdiFilterOutline: typeof import('~icons/mdi/filter-outline')['default']
MdiFlag: typeof import('~icons/mdi/flag')['default'] MdiFlag: typeof import('~icons/mdi/flag')['default']
MdiFlashOutline: typeof import('~icons/mdi/flash-outline')['default'] MdiFlashOutline: typeof import('~icons/mdi/flash-outline')['default']
MdiFolder: typeof import('~icons/mdi/folder')['default'] MdiFolder: typeof import('~icons/mdi/folder')['default']
@ -143,18 +152,23 @@ declare module '@vue/runtime-core' {
MdiKeyPlus: typeof import('~icons/mdi/key-plus')['default'] MdiKeyPlus: typeof import('~icons/mdi/key-plus')['default']
MdiKeyStar: typeof import('~icons/mdi/key-star')['default'] MdiKeyStar: typeof import('~icons/mdi/key-star')['default']
MdiLink: typeof import('~icons/mdi/link')['default'] MdiLink: typeof import('~icons/mdi/link')['default']
MdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
MdiLinkVariantRemove: typeof import('~icons/mdi/link-variant-remove')['default'] MdiLinkVariantRemove: typeof import('~icons/mdi/link-variant-remove')['default']
MdiLoading: typeof import('~icons/mdi/loading')['default']
MdiLogin: typeof import('~icons/mdi/login')['default'] MdiLogin: typeof import('~icons/mdi/login')['default']
MdiLogout: typeof import('~icons/mdi/logout')['default'] MdiLogout: typeof import('~icons/mdi/logout')['default']
MdiMagnify: typeof import('~icons/mdi/magnify')['default'] MdiMagnify: typeof import('~icons/mdi/magnify')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default'] MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['default'] MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['default']
MdiMinusCircleOutline: typeof import('~icons/mdi/minus-circle-outline')['default']
MdiMoonFull: typeof import('~icons/mdi/moon-full')['default'] MdiMoonFull: typeof import('~icons/mdi/moon-full')['default']
MdiNotebookCheckOutline: typeof import('~icons/mdi/notebook-check-outline')['default'] MdiNotebookCheckOutline: typeof import('~icons/mdi/notebook-check-outline')['default']
MdiNumeric: typeof import('~icons/mdi/numeric')['default'] MdiNumeric: typeof import('~icons/mdi/numeric')['default']
MdiOpenInNew: typeof import('~icons/mdi/open-in-new')['default'] MdiOpenInNew: typeof import('~icons/mdi/open-in-new')['default']
MdiPencil: typeof import('~icons/mdi/pencil')['default']
MdiPlus: typeof import('~icons/mdi/plus')['default'] MdiPlus: typeof import('~icons/mdi/plus')['default']
MdiPlusBoxOutline: typeof import('~icons/mdi/plus-box-outline')['default'] MdiPlusBoxOutline: typeof import('~icons/mdi/plus-box-outline')['default']
MdiPlusCircleOutline: typeof import('~icons/mdi/plus-circle-outline')['default']
MdiPlusOutline: typeof import('~icons/mdi/plus-outline')['default'] MdiPlusOutline: typeof import('~icons/mdi/plus-outline')['default']
MdiPlusRoundedOutline: typeof import('~icons/mdi/plus-rounded-outline')['default'] MdiPlusRoundedOutline: typeof import('~icons/mdi/plus-rounded-outline')['default']
MdiRefresh: typeof import('~icons/mdi/refresh')['default'] MdiRefresh: typeof import('~icons/mdi/refresh')['default']
@ -180,6 +194,7 @@ declare module '@vue/runtime-core' {
MdiViewListOutline: typeof import('~icons/mdi/view-list-outline')['default'] MdiViewListOutline: typeof import('~icons/mdi/view-list-outline')['default']
MdiWhatsapp: typeof import('~icons/mdi/whatsapp')['default'] MdiWhatsapp: typeof import('~icons/mdi/whatsapp')['default']
MdiXml: typeof import('~icons/mdi/xml')['default'] MdiXml: typeof import('~icons/mdi/xml')['default']
PhFileCsv: typeof import('~icons/ph/file-csv')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }

2
packages/nc-gui-v2/components/dashboard/TreeView.vue

@ -139,7 +139,7 @@ const activeTable = computed(() => {
<template> <template>
<div class="nc-treeview-container flex flex-col"> <div class="nc-treeview-container flex flex-col">
<div class="px-6 py-[9px] border-b-1 nc-filter-input"> <div class="px-6 py-[8.5px] border-b-1 nc-filter-input">
<div class="flex items-center bg-gray-50 rounded relative"> <div class="flex items-center bg-gray-50 rounded relative">
<a-input <a-input
v-model:value="filterQuery" v-model:value="filterQuery"

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

@ -1,12 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import io from 'socket.io-client'
import type { Socket } from 'socket.io-client' import type { Socket } from 'socket.io-client'
import { Form, message } from 'ant-design-vue' import io from 'socket.io-client'
import type { Card as AntCard } from 'ant-design-vue' import type { Card as AntCard } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, fieldRequiredValidator } from '~/utils' import { Form, message } from 'ant-design-vue'
import MdiCloseCircleOutlineIcon from '~icons/mdi/close-circle-outline' import {
import MdiCurrencyUsdIcon from '~icons/mdi/currency-usd' computed,
import MdiLoadingIcon from '~icons/mdi/loading' extractSdkResponseErrorMsg,
fieldRequiredValidator,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useNuxtApp,
useProject,
watch,
} from '#imports'
interface Props { interface Props {
modelValue: boolean modelValue: boolean
@ -54,29 +62,21 @@ const syncSource = ref({
}, },
}) })
const validators = computed(() => { const validators = computed(() => ({
return {
'details.apiKey': [fieldRequiredValidator], 'details.apiKey': [fieldRequiredValidator],
'details.syncSourceUrlOrId': [fieldRequiredValidator], 'details.syncSourceUrlOrId': [fieldRequiredValidator],
} }))
})
const dialogShow = computed({ const dialogShow = computed({
get() { get: () => modelValue,
return modelValue set: (v) => emit('update:modelValue', v),
},
set(v) {
emit('update:modelValue', v)
},
}) })
const useForm = Form.useForm const useForm = Form.useForm
const { validateInfos } = useForm(syncSource, validators) const { validateInfos } = useForm(syncSource, validators)
const disableImportButton = computed(() => { const disableImportButton = computed(() => !syncSource.value.details.apiKey || !syncSource.value.details.syncSourceUrlOrId)
return !syncSource.value.details.apiKey || !syncSource.value.details.syncSourceUrlOrId
})
async function saveAndSync() { async function saveAndSync() {
await createOrUpdate() await createOrUpdate()
@ -86,6 +86,7 @@ async function saveAndSync() {
async function createOrUpdate() { async function createOrUpdate() {
try { try {
const { id, ...payload } = syncSource.value const { id, ...payload } = syncSource.value
if (id !== '') { if (id !== '') {
await $fetch(`/api/v1/db/meta/syncs/${id}`, { await $fetch(`/api/v1/db/meta/syncs/${id}`, {
baseURL, baseURL,
@ -94,13 +95,12 @@ async function createOrUpdate() {
body: payload, body: payload,
}) })
} else { } else {
const data: any = await $fetch(`/api/v1/db/meta/projects/${project.value.id}/syncs`, { syncSource.value = await $fetch(`/api/v1/db/meta/projects/${project.value.id}/syncs`, {
baseURL, baseURL,
method: 'POST', method: 'POST',
headers: { 'xc-auth': $state.token.value as string }, headers: { 'xc-auth': $state.token.value as string },
body: payload, body: payload,
}) })
syncSource.value = data
} }
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
@ -113,7 +113,9 @@ async function loadSyncSrc() {
method: 'GET', method: 'GET',
headers: { 'xc-auth': $state.token.value as string }, headers: { 'xc-auth': $state.token.value as string },
}) })
const { list: srcs } = data const { list: srcs } = data
if (srcs && srcs[0]) { if (srcs && srcs[0]) {
srcs[0].details = srcs[0].details || {} srcs[0].details = srcs[0].details || {}
syncSource.value = migrateSync(srcs[0]) syncSource.value = migrateSync(srcs[0])
@ -171,6 +173,7 @@ function migrateSync(src: any) {
src.details.options.syncViews = src.syncViews src.details.options.syncViews = src.syncViews
delete src.syncViews delete src.syncViews
} }
return src return src
} }
@ -188,6 +191,7 @@ onMounted(async () => {
socket = io(new URL(baseURL, window.location.href.split(/[?#]/)[0]).href, { socket = io(new URL(baseURL, window.location.href.split(/[?#]/)[0]).href, {
extraHeaders: { 'xc-auth': $state.token.value as string }, extraHeaders: { 'xc-auth': $state.token.value as string },
}) })
socket.on('connect_error', () => { socket.on('connect_error', () => {
socket?.disconnect() socket?.disconnect()
socket = null socket = null
@ -203,7 +207,7 @@ onMounted(async () => {
progress.value.push(d) progress.value.push(d)
// FIXME: this doesn't work // FIXME: this doesn't work
nextTick(() => { await nextTick(() => {
;(logRef.value?.$el as HTMLDivElement).scrollTo() ;(logRef.value?.$el as HTMLDivElement).scrollTo()
}) })
@ -213,6 +217,7 @@ onMounted(async () => {
// TODO: add tab of the first table // TODO: add tab of the first table
} }
}) })
await loadSyncSrc() await loadSyncSrc()
}) })
@ -224,32 +229,14 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<a-modal <a-modal v-model:visible="dialogShow" width="max(30vw, 600px)" class="pa-2" @keydown.esc="dialogShow = false">
v-model:visible="dialogShow" <div class="px-5">
width="max(30vw, 600px)" <div class="mt-5 prose-xl font-weight-bold">QUICK IMPORT - AIRTABLE</div>
:mask-closable="false"
class="pa-2"
@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"
class="nc-btn-airtable-import"
:disabled="disableImportButton"
@click="saveAndSync"
>Import
</a-button>
</div>
</template>
<span class="ml-5 mt-5 prose-xl font-weight-bold" type="secondary" :level="5">QUICK IMPORT - AIRTABLE</span>
<div class="ml-5 mr-5">
<div v-if="step === 1"> <div v-if="step === 1">
<div class="mb-4"> <div class="mb-4">
<span class="mr-3 pt-2 text-gray-500 text-xs">Credentials</span> <span class="mr-3 pt-2 text-gray-500 text-xs">Credentials</span>
<a <a
href="https://docs.nocodb.com/setup-and-usages/import-airtable-to-sql-database-within-a-minute-for-free/#get-airtable-credentials" 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 text-xs" class="prose-sm underline text-grey text-xs"
@ -257,6 +244,7 @@ onBeforeUnmount(() => {
>Where to find this? >Where to find this?
</a> </a>
</div> </div>
<a-form ref="form" :model="syncSource" name="quick-import-airtable-form" layout="horizontal" class="ma-0"> <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-form-item v-bind="validateInfos['details.apiKey']">
<a-input-password <a-input-password
@ -266,6 +254,7 @@ onBeforeUnmount(() => {
size="large" size="large"
/> />
</a-form-item> </a-form-item>
<a-form-item v-bind="validateInfos['details.syncSourceUrlOrId']"> <a-form-item v-bind="validateInfos['details.syncSourceUrlOrId']">
<a-input <a-input
v-model:value="syncSource.details.syncSourceUrlOrId" v-model:value="syncSource.details.syncSourceUrlOrId"
@ -274,23 +263,31 @@ onBeforeUnmount(() => {
size="large" size="large"
/> />
</a-form-item> </a-form-item>
<span class="prose-lg self-center my-4 text-gray-500">Advanced Settings</span>
<div class="prose-lg self-center my-4 text-gray-500">Advanced Settings</div>
<a-divider class="mt-2 mb-5" /> <a-divider class="mt-2 mb-5" />
<div class="mt-0 my-2"> <div class="mt-0 my-2">
<a-checkbox v-model:checked="syncSource.details.options.syncData">Import Data</a-checkbox> <a-checkbox v-model:checked="syncSource.details.options.syncData">Import Data</a-checkbox>
</div> </div>
<div class="my-2"> <div class="my-2">
<a-checkbox v-model:checked="syncSource.details.options.syncViews">Import Secondary Views</a-checkbox> <a-checkbox v-model:checked="syncSource.details.options.syncViews">Import Secondary Views</a-checkbox>
</div> </div>
<div class="my-2"> <div class="my-2">
<a-checkbox v-model:checked="syncSource.details.options.syncRollup">Import Rollup Columns</a-checkbox> <a-checkbox v-model:checked="syncSource.details.options.syncRollup">Import Rollup Columns</a-checkbox>
</div> </div>
<div class="my-2"> <div class="my-2">
<a-checkbox v-model:checked="syncSource.details.options.syncLookup">Import Lookup Columns</a-checkbox> <a-checkbox v-model:checked="syncSource.details.options.syncLookup">Import Lookup Columns</a-checkbox>
</div> </div>
<div class="my-2"> <div class="my-2">
<a-checkbox v-model:checked="syncSource.details.options.syncAttachment">Import Attachment Columns</a-checkbox> <a-checkbox v-model:checked="syncSource.details.options.syncAttachment">Import Attachment Columns</a-checkbox>
</div> </div>
<a-tooltip placement="top"> <a-tooltip placement="top">
<template #title> <template #title>
<span>Coming Soon!</span> <span>Coming Soon!</span>
@ -298,29 +295,39 @@ onBeforeUnmount(() => {
<a-checkbox v-model:checked="syncSource.details.options.syncFormula" disabled>Import Formula Columns</a-checkbox> <a-checkbox v-model:checked="syncSource.details.options.syncFormula" disabled>Import Formula Columns</a-checkbox>
</a-tooltip> </a-tooltip>
</a-form> </a-form>
<a-divider /> <a-divider />
<div> <div>
<a href="https://github.com/nocodb/nocodb/issues/2052" target="_blank">Questions / Help - Reach out here</a> <a href="https://github.com/nocodb/nocodb/issues/2052" target="_blank">Questions / Help - Reach out here</a>
<br /> <br />
<div> <div>
This feature is currently in beta and more information can be found 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>. <a class="prose-sm" href="https://github.com/nocodb/nocodb/discussions/2122" target="_blank">here</a>.
</div> </div>
</div> </div>
</div> </div>
<div v-if="step === 2"> <div v-if="step === 2">
<div class="mb-4 prose-xl font-bold">Logs</div> <div class="mb-4 prose-xl font-bold">Logs</div>
<a-card ref="logRef" body-style="background-color: #000000; height:400px; overflow: auto;">
<a-card ref="logRef" :body-style="{ backgroundColor: '#000000', height: '400px', overflow: 'auto' }">
<div v-for="({ msg, status }, i) in progress" :key="i"> <div v-for="({ msg, status }, i) in progress" :key="i">
<div v-if="status === 'FAILED'" class="flex items-center"> <div v-if="status === 'FAILED'" class="flex items-center">
<MdiCloseCircleOutlineIcon class="text-red-500" /> <MdiCloseCircleOutline class="text-red-500" />
<span class="text-red-500 ml-2">{{ msg }}</span> <span class="text-red-500 ml-2">{{ msg }}</span>
</div> </div>
<div v-else class="flex items-center"> <div v-else class="flex items-center">
<MdiCurrencyUsdIcon class="text-green-500" /> <MdiCurrencyUsd class="text-green-500" />
<span class="text-green-500 ml-2">{{ msg }}</span> <span class="text-green-500 ml-2">{{ msg }}</span>
</div> </div>
</div> </div>
<div <div
v-if=" v-if="
!progress || !progress ||
@ -329,18 +336,34 @@ onBeforeUnmount(() => {
" "
class="flex items-center" class="flex items-center"
> >
<MdiLoadingIcon class="text-green-500 animate-spin" /> <MdiLoading class="text-green-500 animate-spin" />
<span class="text-green-500 ml-2"> Importing</span> <span class="text-green-500 ml-2"> Importing</span>
</div> </div>
</a-card> </a-card>
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<a-button v-if="showGoToDashboardButton" class="mt-4" size="large" @click="dialogShow = false" <a-button v-if="showGoToDashboardButton" class="mt-4" size="large" @click="dialogShow = false">
>Go to Dashboard</a-button Go to Dashboard
> </a-button>
</div>
</div> </div>
</div> </div>
<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"
class="nc-btn-airtable-import"
:disabled="disableImportButton"
@click="saveAndSync"
>
Import
</a-button>
</div> </div>
</template>
</a-modal> </a-modal>
</template> </template>
<style scoped lang="scss"></style>

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

@ -1,24 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, message } from 'ant-design-vue' import { Form, message } from 'ant-design-vue'
import type { TableType } from 'nocodb-sdk' import type { TableType } from 'nocodb-sdk'
import type { UploadChangeParam } from 'ant-design-vue' import type { UploadChangeParam, UploadFile } from 'ant-design-vue'
import { useI18n } from 'vue-i18n' import {
import MdiFileIcon from '~icons/mdi/file-plus-outline' ExcelTemplateAdapter,
import MdiFileUploadOutlineIcon from '~icons/mdi/file-upload-outline' ExcelUrlTemplateAdapter,
import MdiLinkVariantIcon from '~icons/mdi/link-variant' JSONTemplateAdapter,
import MdiCodeJSONIcon from '~icons/mdi/code-json' JSONUrlTemplateAdapter,
import { fieldRequiredValidator, importCsvUrlValidator, importExcelUrlValidator, importUrlValidator } from '~/utils/validation' computed,
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils' extractSdkResponseErrorMsg,
import { ExcelTemplateAdapter, ExcelUrlTemplateAdapter, JSONTemplateAdapter, JSONUrlTemplateAdapter } from '~/utils/parsers' fieldRequiredValidator,
import { useProject } from '#imports' importCsvUrlValidator,
importExcelUrlValidator,
importUrlValidator,
reactive,
ref,
useI18n,
useProject,
useVModel,
} from '#imports'
interface Props { interface Props {
modelValue: boolean modelValue: boolean
importType: 'csv' | 'json' | 'excel' importType: 'csv' | 'json' | 'excel'
importOnly: boolean importOnly?: boolean
} }
const { importType, importOnly, ...rest } = defineProps<Props>() const { importType, importOnly = false, ...rest } = defineProps<Props>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@ -45,7 +53,7 @@ const templateEditorModal = ref(false)
const useForm = Form.useForm const useForm = Form.useForm
const importState = reactive({ const importState = reactive({
fileList: [] as Record<string, any>, fileList: [] as (UploadFile & { data: string | ArrayBuffer })[],
url: '', url: '',
jsonEditor: {}, jsonEditor: {},
parserConfig: { parserConfig: {
@ -61,12 +69,10 @@ const isImportTypeCsv = computed(() => importType === 'csv')
const IsImportTypeExcel = computed(() => importType === 'excel') const IsImportTypeExcel = computed(() => importType === 'excel')
const validators = computed(() => { const validators = computed(() => ({
return {
url: [fieldRequiredValidator, importUrlValidator, isImportTypeCsv.value ? importCsvUrlValidator : importExcelUrlValidator], url: [fieldRequiredValidator, importUrlValidator, isImportTypeCsv.value ? importCsvUrlValidator : importExcelUrlValidator],
maxRowsToParse: [fieldRequiredValidator], maxRowsToParse: [fieldRequiredValidator],
} }))
})
const { validate, validateInfos } = useForm(importState, validators) const { validate, validateInfos } = useForm(importState, validators)
@ -104,15 +110,14 @@ const disablePreImportButton = computed(() => {
return !(importState.fileList.length > 0) return !(importState.fileList.length > 0)
} else if (activeKey.value === 'urlTab') { } else if (activeKey.value === 'urlTab') {
if (!validateInfos.url.validateStatus) return true if (!validateInfos.url.validateStatus) return true
return validateInfos.url.validateStatus === 'error' return validateInfos.url.validateStatus === 'error'
} else if (activeKey.value === 'jsonEditorTab') { } else if (activeKey.value === 'jsonEditorTab') {
return !jsonEditorRef.value?.isValid return !jsonEditorRef.value?.isValid
} }
}) })
const disableImportButton = computed(() => { const disableImportButton = computed(() => !templateEditorRef.value?.isValid)
return !templateEditorRef.value?.isValid
})
const disableFormatJsonButton = computed(() => !jsonEditorRef.value?.isValid) const disableFormatJsonButton = computed(() => !jsonEditorRef.value?.isValid)
@ -120,16 +125,19 @@ const modalWidth = computed(() => {
if (importType === 'excel' && templateEditorModal.value) { if (importType === 'excel' && templateEditorModal.value) {
return 'max(90vw, 600px)' return 'max(90vw, 600px)'
} }
return 'max(60vw, 600px)' return 'max(60vw, 600px)'
}) })
async function handlePreImport() { async function handlePreImport() {
loading.value = true loading.value = true
if (activeKey.value === 'uploadTab') { if (activeKey.value === 'uploadTab') {
await parseAndExtractData(importState.fileList[0].data, importState.fileList[0].name) await parseAndExtractData(importState.fileList[0].data, importState.fileList[0].name)
} else if (activeKey.value === 'urlTab') { } else if (activeKey.value === 'urlTab') {
try { try {
await validate() await validate()
await parseAndExtractData(importState.url, '') await parseAndExtractData(importState.url, '')
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
@ -137,44 +145,53 @@ async function handlePreImport() {
} else if (activeKey.value === 'jsonEditorTab') { } else if (activeKey.value === 'jsonEditorTab') {
await parseAndExtractData(JSON.stringify(importState.jsonEditor), '') await parseAndExtractData(JSON.stringify(importState.jsonEditor), '')
} }
loading.value = false loading.value = false
} }
async function handleImport() { async function handleImport() {
try { try {
loading.value = true loading.value = true
await templateEditorRef.value.importTemplate() await templateEditorRef.value.importTemplate()
} catch (e: any) { } catch (e: any) {
return message.error(await extractSdkResponseErrorMsg(e)) return message.error(await extractSdkResponseErrorMsg(e))
} finally { } finally {
loading.value = false loading.value = false
} }
dialogShow.value = false dialogShow.value = false
} }
async function parseAndExtractData(val: any, name: string) { async function parseAndExtractData(val: string | ArrayBuffer, name: string) {
try { try {
templateData.value = null templateData.value = null
importData.value = null importData.value = null
importColumns.value = [] importColumns.value = []
const templateGenerator: any = getAdapter(name, val)
const templateGenerator = getAdapter(name, val)
if (!templateGenerator) { if (!templateGenerator) {
message.error('Template Generator cannot be found!') message.error('Template Generator cannot be found!')
return return
} }
await templateGenerator.init() await templateGenerator.init()
templateGenerator.parse() templateGenerator.parse()
templateData.value = templateGenerator.getTemplate() templateData.value = templateGenerator.getTemplate()
templateData.value.tables[0].table_name = populateUniqueTableName() templateData.value.tables[0].table_name = populateUniqueTableName()
importData.value = templateGenerator.getData() importData.value = templateGenerator.getData()
if (importOnly) importColumns.value = templateGenerator.getColumns() if (importOnly) importColumns.value = templateGenerator.getColumns()
templateEditorModal.value = true templateEditorModal.value = true
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
} }
function rejectDrop(fileList: any[]) { function rejectDrop(fileList: UploadFile[]) {
fileList.map((file) => { fileList.map((file) => {
return message.error(`Failed to upload file ${file.name}`) return message.error(`Failed to upload file ${file.name}`)
}) })
@ -182,16 +199,31 @@ function rejectDrop(fileList: any[]) {
function handleChange(info: UploadChangeParam) { function handleChange(info: UploadChangeParam) {
const status = info.file.status const status = info.file.status
if (status !== 'uploading') {
const reader: any = new FileReader() if (status !== 'uploading' && status !== 'removed') {
reader.onload = (e: any) => { const reader = new FileReader()
const target: any = importState.fileList.find((f: any) => f?.uid === info.file.uid)
reader.onload = (e: ProgressEvent<FileReader>) => {
const target = importState.fileList.find((f) => f.uid === info.file.uid)
if (e.target && e.target.result) {
/** if the file was pushed into the list by `<a-upload-dragger>` we just add the data to the file */
if (target) { if (target) {
target.data = e.target.result target.data = e.target.result
} else if (!target) {
/** if the file was added programmatically and not with d&d, we create file infos and push it into the list */
importState.fileList.push({
...info.file,
status: 'done',
data: e.target.result,
})
} }
} }
reader.readAsArrayBuffer(info.file.originFileObj)
} }
reader.readAsArrayBuffer(info.file.originFileObj!)
}
if (status === 'done') { if (status === 'done') {
message.success(`Uploaded file ${info.file.name} successfully`) message.success(`Uploaded file ${info.file.name} successfully`)
} else if (status === 'error') { } else if (status === 'error') {
@ -205,9 +237,11 @@ function formatJson() {
function populateUniqueTableName() { function populateUniqueTableName() {
let c = 1 let c = 1
while (tables.value.some((t: TableType) => t.title === `Sheet${c}`)) { while (tables.value.some((t: TableType) => t.title === `Sheet${c}`)) {
c++ c++
} }
return `Sheet${c}` return `Sheet${c}`
} }
@ -229,38 +263,21 @@ function getAdapter(name: string, val: any) {
return new JSONTemplateAdapter(name, val, importState.parserConfig) return new JSONTemplateAdapter(name, val, importState.parserConfig)
} }
} }
return null return null
} }
defineExpose({
handleChange,
})
</script> </script>
<template> <template>
<a-modal v-model:visible="dialogShow" :width="modalWidth" :mask-closable="false" @keydown.esc="dialogShow = false"> <a-modal v-model:visible="dialogShow" :width="modalWidth" @keydown.esc="dialogShow = false">
<span class="prose-xl font-weight-bold ml-5 mt-5 mb-5" type="secondary" :level="5">{{ importMeta.header }}</span> <div class="px-5">
<template #footer> <div class="prose-xl font-weight-bold my-5">{{ importMeta.header }}</div>
<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> <div class="mt-5">
<a-button
v-if="activeKey === 'jsonEditorTab' && !templateEditorModal"
key="format"
:disabled="disableFormatJsonButton"
@click="formatJson"
>Format JSON</a-button
>
<a-button
v-if="!templateEditorModal"
key="pre-import"
type="primary"
class="nc-btn-import"
: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 mt-5">
<TemplateEditor <TemplateEditor
v-if="templateEditorModal" v-if="templateEditorModal"
ref="templateEditorRef" ref="templateEditorRef"
@ -272,51 +289,59 @@ function getAdapter(name: string, val: any) {
:max-rows-to-parse="importState.parserConfig.maxRowsToParse" :max-rows-to-parse="importState.parserConfig.maxRowsToParse"
@import="handleImport" @import="handleImport"
/> />
<a-tabs v-else v-model:activeKey="activeKey" hide-add type="editable-card" :tab-position="top">
<a-tabs v-else v-model:activeKey="activeKey" hide-add type="editable-card" tab-position="top">
<a-tab-pane key="uploadTab" :closable="false"> <a-tab-pane key="uploadTab" :closable="false">
<template #tab> <template #tab>
<span class="flex items-center gap-2"> <div class="flex items-center gap-2">
<MdiFileUploadOutlineIcon /> <MdiFileUploadOutline />
Upload Upload
</span> </div>
</template> </template>
<div class="pr-10 pb-0 pt-5">
<div class="py-6">
<a-upload-dragger <a-upload-dragger
v-model:fileList="importState.fileList" v-model:fileList="importState.fileList"
name="file" name="file"
class="nc-input-import" class="nc-input-import !scrollbar-thin-dull"
:accept="importMeta.acceptTypes" :accept="importMeta.acceptTypes"
:max-count="1" :max-count="1"
list-type="picture" list-type="picture"
@change="handleChange" @change="handleChange"
@reject="rejectDrop" @reject="rejectDrop"
> >
<MdiFileIcon size="large" /> <MdiFilePlusOutline size="large" />
<p class="ant-upload-text">Click or drag file to this area to upload</p> <p class="ant-upload-text">Click or drag file to this area to upload</p>
<p class="ant-upload-hint"> <p class="ant-upload-hint">
{{ importMeta.uploadHint }} {{ importMeta.uploadHint }}
</p> </p>
</a-upload-dragger> </a-upload-dragger>
</div> </div>
</a-tab-pane> </a-tab-pane>
<a-tab-pane v-if="isImportTypeJson" key="jsonEditorTab" :closable="false"> <a-tab-pane v-if="isImportTypeJson" key="jsonEditorTab" :closable="false">
<template #tab> <template #tab>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<MdiCodeJSONIcon /> <MdiCodeJson />
JSON Editor JSON Editor
</span> </span>
</template> </template>
<div class="pb-3 pt-3"> <div class="pb-3 pt-3">
<MonacoEditor ref="jsonEditorRef" v-model="importState.jsonEditor" class="min-h-60 max-h-80" /> <MonacoEditor ref="jsonEditorRef" v-model="importState.jsonEditor" class="min-h-60 max-h-80" />
</div> </div>
</a-tab-pane> </a-tab-pane>
<a-tab-pane v-else key="urlTab" :closable="false"> <a-tab-pane v-else key="urlTab" :closable="false">
<template #tab> <template #tab>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<MdiLinkVariantIcon /> <MdiLinkVariant />
URL URL
</span> </span>
</template> </template>
<div class="pr-10 pt-5"> <div class="pr-10 pt-5">
<a-form :model="importState" name="quick-import-url-form" layout="horizontal" class="mb-0"> <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-form-item :label="importMeta.urlInputLabel" v-bind="validateInfos.url">
@ -327,28 +352,59 @@ function getAdapter(name: string, val: any) {
</a-tab-pane> </a-tab-pane>
</a-tabs> </a-tabs>
</div> </div>
<div v-if="!templateEditorModal" class="ml-5 mr-5">
<div v-if="!templateEditorModal">
<a-divider /> <a-divider />
<div class="mb-4"> <div class="mb-4">
<span class="prose-lg">Advanced Settings</span> <span class="prose-lg">Advanced Settings</span>
<a-form-item class="mt-4 mb-2" :label="t('msg.info.footMsg')" v-bind="validateInfos.maxRowsToParse"> <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-input-number v-model:value="importState.parserConfig.maxRowsToParse" :min="1" :max="50000" />
</a-form-item> </a-form-item>
<div v-if="isImportTypeJson" class="mt-3"> <div v-if="isImportTypeJson" class="mt-3">
<a-checkbox v-model:checked="importState.parserConfig.normalizeNested"> <a-checkbox v-model:checked="importState.parserConfig.normalizeNested">
<span class="caption">Flatten nested</span> <span class="caption">Flatten nested</span>
</a-checkbox> </a-checkbox>
</div> </div>
<div v-if="isImportTypeJson" class="mt-4"> <div v-if="isImportTypeJson" class="mt-4">
<a-checkbox v-model:checked="importState.parserConfig.importData">Import data</a-checkbox> <a-checkbox v-model:checked="importState.parserConfig.importData">Import data</a-checkbox>
</div> </div>
</div> </div>
</div> </div>
</div>
<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' && !templateEditorModal"
key="format"
:disabled="disableFormatJsonButton"
@click="formatJson"
>
Format JSON
</a-button>
<a-button
v-if="!templateEditorModal"
key="pre-import"
type="primary"
class="nc-btn-import"
: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>
</a-modal> </a-modal>
</template> </template>
<style scoped lang="scss">
:deep(.ant-upload-list) {
@apply max-h-80 overflow-auto;
}
</style>

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

@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form } from 'ant-design-vue' import { Form } from 'ant-design-vue'
import { onMounted, useProject, useTable, useTabs } from '#imports' import { computed, onMounted, ref, useProject, useTable, useTabs, useVModel, validateTableName } from '#imports'
import { validateTableName } from '~/utils/validation'
import { TabType } from '~/composables' import { TabType } from '~/composables'
interface Props { interface Props {
modelValue?: boolean modelValue: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@ -16,6 +15,8 @@ const dialogShow = useVModel(props, 'modelValue', emit)
const isAdvanceOptVisible = ref(false) const isAdvanceOptVisible = ref(false)
const inputEl = ref<HTMLInputElement>()
const { addTab } = useTabs() const { addTab } = useTabs()
const { loadTables } = useProject() const { loadTables } = useProject()
@ -28,16 +29,14 @@ const { table, createTable, generateUniqueTitle, tables, project } = useTable(as
title: table.title, title: table.title,
type: TabType.TABLE, type: TabType.TABLE,
}) })
dialogShow.value = false dialogShow.value = false
}) })
const validateDuplicateAlias = (v: string) => {
return (tables?.value || []).every((t) => t.title !== (v || '')) || 'Duplicate table alias'
}
const inputEl = ref<HTMLInputElement>()
const useForm = Form.useForm const useForm = Form.useForm
const validateDuplicateAlias = (v: string) => (tables.value || []).every((t) => t.title !== (v || '')) || 'Duplicate table alias'
const validators = computed(() => { const validators = computed(() => {
return { return {
title: [validateTableName, validateDuplicateAlias], title: [validateTableName, validateDuplicateAlias],
@ -48,22 +47,27 @@ const { validateInfos } = useForm(table, validators)
onMounted(() => { onMounted(() => {
generateUniqueTitle() generateUniqueTitle()
inputEl.value?.focus() inputEl.value?.focus()
}) })
</script> </script>
<template> <template>
<a-modal v-model:visible="dialogShow" width="max(30vw, 600px)" :mask-closable="false" @keydown.esc="dialogShow = false"> <a-modal v-model:visible="dialogShow" width="max(30vw, 600px)" @keydown.esc="dialogShow = false">
<template #footer> <template #footer>
<a-button key="back" size="large" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button> <a-button key="back" size="large" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button>
<a-button key="submit" size="large" type="primary" @click="createTable()">{{ $t('general.submit') }}</a-button> <a-button key="submit" size="large" type="primary" @click="createTable()">{{ $t('general.submit') }}</a-button>
</template> </template>
<div class="pl-10 pr-10 pt-5"> <div class="pl-10 pr-10 pt-5">
<a-form :model="table" name="create-new-table-form" @keydown.enter="createTable"> <a-form :model="table" name="create-new-table-form" @keydown.enter="createTable">
<!-- Create A New Table --> <!-- Create A New Table -->
<div class="prose-xl font-bold self-center my-4">{{ $t('activity.createTable') }}</div> <div class="prose-xl font-bold self-center my-4">{{ $t('activity.createTable') }}</div>
<!-- hint="Enter table name" --> <!-- hint="Enter table name" -->
<div class="mb-2">Table Name</div> <div class="mb-2">Table Name</div>
<a-form-item v-bind="validateInfos.title"> <a-form-item v-bind="validateInfos.title">
<a-input <a-input
ref="inputEl" ref="inputEl"
@ -73,25 +77,29 @@ onMounted(() => {
:placeholder="$t('msg.info.enterTableName')" :placeholder="$t('msg.info.enterTableName')"
/> />
</a-form-item> </a-form-item>
<div class="flex justify-end"> <div class="flex justify-end">
<div class="pointer" @click="isAdvanceOptVisible = !isAdvanceOptVisible"> <div class="pointer" @click="isAdvanceOptVisible = !isAdvanceOptVisible">
{{ isAdvanceOptVisible ? 'Hide' : 'Show' }} more {{ isAdvanceOptVisible ? 'Hide' : 'Show' }} more
<v-icon x-small color="grey">
{{ isAdvanceOptVisible ? 'mdi-minus-circle-outline' : 'mdi-plus-circle-outline' }} <MdiMinusCircleOutline v-if="isAdvanceOptVisible" class="text-gray-500" />
</v-icon> <MdiPlusCircleOutline v-else class="text-gray-500" />
</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" -->
<div v-if="!project.prefix" class="mb-2">{{ $t('msg.info.tableNameInDb') }}</div> <div v-if="!project.prefix" class="mb-2">{{ $t('msg.info.tableNameInDb') }}</div>
<a-form-item v-if="!project.prefix" v-bind="validateInfos.table_name"> <a-form-item v-if="!project.prefix" v-bind="validateInfos.table_name">
<a-input v-model:value="table.table_name" size="large" hide-details :placeholder="$t('msg.info.tableNameInDb')" /> <a-input v-model:value="table.table_name" size="large" hide-details :placeholder="$t('msg.info.tableNameInDb')" />
</a-form-item> </a-form-item>
<div> <div>
<div class="mb-5"> <div class="mb-5">
<!-- Add Default Columns --> <!-- Add Default Columns -->
{{ $t('msg.info.addDefaultColumns') }} {{ $t('msg.info.addDefaultColumns') }}
</div> </div>
<a-row> <a-row>
<a-col :span="6"> <a-col :span="6">
<a-tooltip placement="top"> <a-tooltip placement="top">
@ -101,12 +109,15 @@ onMounted(() => {
<a-checkbox v-model:checked="table.columns.id" disabled>ID</a-checkbox> <a-checkbox v-model:checked="table.columns.id" disabled>ID</a-checkbox>
</a-tooltip> </a-tooltip>
</a-col> </a-col>
<a-col :span="6"> <a-col :span="6">
<a-checkbox v-model:checked="table.columns.title"> title </a-checkbox> <a-checkbox v-model:checked="table.columns.title"> title </a-checkbox>
</a-col> </a-col>
<a-col :span="6"> <a-col :span="6">
<a-checkbox v-model:checked="table.columns.created_at"> created_at </a-checkbox> <a-checkbox v-model:checked="table.columns.created_at"> created_at </a-checkbox>
</a-col> </a-col>
<a-col :span="6"> <a-col :span="6">
<a-checkbox v-model:checked="table.columns.updated_at"> updated_at </a-checkbox> <a-checkbox v-model:checked="table.columns.updated_at"> updated_at </a-checkbox>
</a-col> </a-col>
@ -119,23 +130,6 @@ onMounted(() => {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
::v-deep {
.v-text-field__details {
padding: 0 2px !important;
.v-messages:not(.error--text) {
.v-messages__message {
color: grey;
font-size: 0.65rem;
}
}
}
}
.add-default-title {
font-size: 0.65rem;
}
.nc-table-advanced-options { .nc-table-advanced-options {
max-height: 0; max-height: 0;
transition: 0.3s max-height; transition: 0.3s max-height;

28
packages/nc-gui-v2/components/general/Language.vue

@ -37,22 +37,22 @@ onMounted(() => {
</script> </script>
<template> <template>
<a-dropdown class="select-none" :trigger="['click']"> <a-dropdown class="select-none color-transition" :trigger="['click']">
<MaterialSymbolsTranslate v-bind="$attrs" class="md:text-xl cursor-pointer nc-menu-translate" /> <MaterialSymbolsTranslate v-bind="$attrs" class="md:text-xl cursor-pointer nc-menu-translate" />
<template #overlay> <template #overlay>
<a-menu class="scrollbar-thin-dull min-w-50 max-h-90vh overflow-auto !py-0 dark:(!bg-gray-800 !text-white)"> <a-menu class="scrollbar-thin-dull min-w-50 max-h-90vh overflow-auto !py-0 rounded">
<a-menu-item <a-menu-item
v-for="lang of languages" v-for="lang of languages"
:key="lang" :key="lang"
:class="lang === locale ? '!bg-primary/10 text-primary dark:(!bg-gray-700 !text-secondary)' : ''" :class="lang === locale ? '!bg-primary/10 text-primary' : ''"
class="!min-h-8 group" class="group"
:value="lang" :value="lang"
@click="changeLanguage(lang)" @click="changeLanguage(lang)"
> >
<div <div
:class="lang === locale ? '!font-semibold !text-primary' : ''" :class="lang === locale ? '!font-semibold !text-primary' : ''"
class="capitalize md:(!leading-8) group-hover:(text-primary font-semibold) dark:(group-hover:text-secondary)" class="nc-project-menu-item capitalize group-hover:text-pink-500"
> >
{{ Language[lang] || lang }} {{ Language[lang] || lang }}
</div> </div>
@ -71,3 +71,21 @@ onMounted(() => {
</template> </template>
</a-dropdown> </a-dropdown>
</template> </template>
<style scoped>
:deep(.ant-dropdown-menu-item-group-list) {
@apply !mx-0;
}
:deep(.ant-dropdown-menu-item-group-title) {
@apply border-b-1;
}
:deep(.ant-dropdown-menu-item-group-list) {
@apply m-0;
}
:deep(.ant-dropdown-menu-item) {
@apply !py-0 active:(ring ring-pink-500);
}
</style>

2
packages/nc-gui-v2/components/general/PreviewAs.vue

@ -58,7 +58,7 @@ watch(previewAs, () => window.location.reload())
<div class="divider" /> <div class="divider" />
<div class="pointer flex items-center gap-4"> <div class="pointer flex items-center gap-4">
<span>Preview as :</span> <span>Preview as:</span>
<a-radio-group v-model:value="previewAs" name="radioGroup"> <a-radio-group v-model:value="previewAs" name="radioGroup">
<a-radio v-for="role of roleList" :key="role.title" class="capitalize !text-white" :value="role.title" <a-radio v-for="role of roleList" :key="role.title" class="capitalize !text-white" :value="role.title"

31
packages/nc-gui-v2/components/smartsheet-header/Menu.vue

@ -1,13 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Modal, message } from 'ant-design-vue' import { Modal, message } from 'ant-design-vue'
import { inject } from 'vue' import { ColumnInj, IsLockedInj, MetaInj, extractSdkResponseErrorMsg, inject, useI18n, useMetas, useNuxtApp } from '#imports'
import { useI18n } from 'vue-i18n'
import { useNuxtApp } from '#app'
import { ColumnInj, IsLockedInj, MetaInj, extractSdkResponseErrorMsg, useMetas } from '#imports'
import MdiEditIcon from '~icons/mdi/pencil'
import MdiStarIcon from '~icons/mdi/star'
import MdiDeleteIcon from '~icons/mdi/delete-outline'
import MdiMenuDownIcon from '~icons/mdi/menu-down'
const { virtual = false } = defineProps<{ virtual?: boolean }>() const { virtual = false } = defineProps<{ virtual?: boolean }>()
@ -34,8 +27,9 @@ const deleteColumn = () =>
async onOk() { async onOk() {
try { try {
await $api.dbTableColumn.delete(column?.value?.id as string) await $api.dbTableColumn.delete(column?.value?.id as string)
getMeta(meta?.value?.id as string, true)
} catch (e) { await getMeta(meta?.value?.id as string, true)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
}, },
@ -44,8 +38,11 @@ const deleteColumn = () =>
const setAsPrimaryValue = async () => { const setAsPrimaryValue = async () => {
try { try {
await $api.dbTableColumn.primaryColumnSet(column?.value?.id as string) await $api.dbTableColumn.primaryColumnSet(column?.value?.id as string)
getMeta(meta?.value?.id as string, true)
await getMeta(meta?.value?.id as string, true)
message.success('Successfully updated as primary column') message.success('Successfully updated as primary column')
$e('a:column:set-primary') $e('a:column:set-primary')
} catch (e) { } catch (e) {
message.error('Failed to update primary column') message.error('Failed to update primary column')
@ -55,29 +52,31 @@ const setAsPrimaryValue = async () => {
<template> <template>
<a-dropdown v-if="!isLocked" placement="bottomRight" :trigger="['click']"> <a-dropdown v-if="!isLocked" placement="bottomRight" :trigger="['click']">
<MdiMenuDownIcon class="h-full text-grey nc-ui-dt-dropdown cursor-pointer outline-0" /> <MdiMenuDown class="h-full text-grey nc-ui-dt-dropdown cursor-pointer outline-0" />
<template #overlay> <template #overlay>
<a-menu class="shadow bg-white"> <a-menu class="shadow bg-white">
<a-menu-item @click="emit('edit')"> <a-menu-item @click="emit('edit')">
<div class="nc-column-edit nc-header-menu-item"> <div class="nc-column-edit nc-header-menu-item">
<MdiEditIcon class="text-primary" /> <MdiPencil class="text-primary" />
<!-- Edit --> <!-- Edit -->
{{ $t('general.edit') }} {{ $t('general.edit') }}
</div> </div>
</a-menu-item> </a-menu-item>
<a-menu-item v-if="!virtual" @click="setAsPrimaryValue"> <a-menu-item v-if="!virtual" @click="setAsPrimaryValue">
<div class="nc-column-set-primary nc-header-menu-item"> <div class="nc-column-set-primary nc-header-menu-item">
<MdiStarIcon class="text-primary" /> <MdiStar class="text-primary" />
<!-- todo : tooltip --> <!-- todo : tooltip -->
<!-- Set as Primary value --> <!-- Set as Primary value -->
{{ $t('activity.setPrimary') }} {{ $t('activity.setPrimary') }}
</div> </div>
<!-- <span class="caption font-weight-bold">Primary value will be shown in place of primary key</span> -->
</a-menu-item> </a-menu-item>
<a-menu-item @click="deleteColumn"> <a-menu-item @click="deleteColumn">
<div class="nc-column-delete nc-header-menu-item"> <div class="nc-column-delete nc-header-menu-item">
<MdiDeleteIcon class="text-error" /> <MdiDeleteOutline class="text-error" />
<!-- Delete --> <!-- Delete -->
{{ $t('general.delete') }} {{ $t('general.delete') }}
</div> </div>

26
packages/nc-gui-v2/components/smartsheet-toolbar/ColumnFilterMenu.vue

@ -1,16 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { watchEffect } from '@vue/runtime-core'
import type ColumnFilter from './ColumnFilter.vue' import type ColumnFilter from './ColumnFilter.vue'
import { ActiveViewInj, IsLockedInj, IsPublicInj } from '~/context' import { ActiveViewInj, IsLockedInj, IsPublicInj, computed, inject, ref, useGlobal, useViewFilters, watchEffect } from '#imports'
import MdiFilterIcon from '~icons/mdi/filter-outline'
import MdiMenuDownIcon from '~icons/mdi/menu-down' const isLocked = inject(IsLockedInj, ref(false))
const isLocked = inject(IsLockedInj)
const activeView = inject(ActiveViewInj) const activeView = inject(ActiveViewInj)
const isPublic = inject(IsPublicInj)
const isPublic = inject(IsPublicInj, ref(false))
const { filterAutoSave } = useGlobal() const { filterAutoSave } = useGlobal()
const filterComp = ref<typeof ColumnFilter>()
// todo: avoid duplicate api call by keeping a filter store // todo: avoid duplicate api call by keeping a filter store
const { filters, loadFilters } = useViewFilters( const { filters, loadFilters } = useViewFilters(
activeView, activeView,
@ -19,17 +20,16 @@ const { filters, loadFilters } = useViewFilters(
) )
const filtersLength = ref(0) const filtersLength = ref(0)
watchEffect(async () => { watchEffect(async () => {
if (activeView?.value) { if (activeView?.value) {
await loadFilters() await loadFilters()
filtersLength.value = filters?.value?.length ?? 0
filtersLength.value = filters.value.length || 0
} }
}) })
const filterComp = ref<typeof ColumnFilter>()
const applyChanges = async () => { const applyChanges = async () => await filterComp.value?.applyChanges()
await filterComp?.value?.applyChanges()
}
</script> </script>
<template> <template>
@ -37,10 +37,10 @@ const applyChanges = async () => {
<div :class="{ 'nc-badge nc-active-btn': filtersLength }"> <div :class="{ 'nc-badge nc-active-btn': filtersLength }">
<a-button v-t="['c:filter']" class="nc-filter-menu-btn nc-toolbar-btn txt-sm" :disabled="isLocked"> <a-button v-t="['c:filter']" class="nc-filter-menu-btn nc-toolbar-btn txt-sm" :disabled="isLocked">
<div class="flex align-center gap-1"> <div class="flex align-center gap-1">
<MdiFilterIcon /> <MdiFilterOutline />
<!-- Filter --> <!-- Filter -->
<span class="text-capitalize !text-sm font-weight-medium">{{ $t('activity.filter') }}</span> <span class="text-capitalize !text-sm font-weight-medium">{{ $t('activity.filter') }}</span>
<MdiMenuDownIcon class="text-grey" /> <MdiMenuDown class="text-grey" />
</div> </div>
</a-button> </a-button>
</div> </div>

25
packages/nc-gui-v2/components/smartsheet-toolbar/FieldsMenu.vue

@ -1,14 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { ActiveViewInj, FieldsInj, IsLockedInj, IsPublicInj, MetaInj, ReloadViewDataHookInj } from '~/context' import {
import { computed, inject, useNuxtApp, useViewColumns, watch } from '#imports' ActiveViewInj,
FieldsInj,
IsLockedInj,
IsPublicInj,
MetaInj,
ReloadViewDataHookInj,
computed,
inject,
ref,
useNuxtApp,
useViewColumns,
watch,
} from '#imports'
const meta = inject(MetaInj)! const meta = inject(MetaInj)!
const activeView = inject(ActiveViewInj)! const activeView = inject(ActiveViewInj)!
const reloadDataHook = inject(ReloadViewDataHookInj)! const reloadDataHook = inject(ReloadViewDataHookInj)!
const rootFields = inject(FieldsInj) const rootFields = inject(FieldsInj)
const isLocked = inject(IsLockedInj)
const isPublic = inject(IsPublicInj) const isLocked = inject(IsLockedInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const { $e } = useNuxtApp() const { $e } = useNuxtApp()

8
packages/nc-gui-v2/components/smartsheet/Cell.vue

@ -9,7 +9,9 @@ import {
IsLockedInj, IsLockedInj,
IsPublicInj, IsPublicInj,
computed, computed,
inject,
provide, provide,
ref,
toRef, toRef,
useColumn, useColumn,
useDebounceFn, useDebounceFn,
@ -42,11 +44,11 @@ provide(EditModeInj, useVModel(props, 'editEnabled', emit))
provide(ActiveCellInj, active) provide(ActiveCellInj, active)
const isForm = inject(IsFormInj) const isForm = inject(IsFormInj, ref(false))
const isPublic = inject(IsPublicInj) const isPublic = inject(IsPublicInj, ref(false))
const isLocked = inject(IsLockedInj) const isLocked = inject(IsLockedInj, ref(false))
let changed = $ref(false) let changed = $ref(false)

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

@ -7,13 +7,16 @@ import {
MetaInj, MetaInj,
ReloadViewDataHookInj, ReloadViewDataHookInj,
computed, computed,
createEventHook,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
fieldRequiredValidator, fieldRequiredValidator,
getUIDTIcon, getUIDTIcon,
inject,
nextTick, nextTick,
onMounted, onMounted,
reactive, reactive,
ref, ref,
useNuxtApp,
useProject, useProject,
useTabs, useTabs,
useTemplateRefsList, useTemplateRefsList,
@ -38,16 +41,20 @@ const { quickImportType, projectTemplate, importData, importColumns, importOnly,
const emit = defineEmits(['import']) const emit = defineEmits(['import'])
const meta = inject(MetaInj) const meta = inject(MetaInj, ref({} as TableType))
const columns = computed(() => meta?.value?.columns || []) const columns = computed(() => meta.value?.columns || [])
const reloadHook = inject(ReloadViewDataHookInj)! const reloadHook = inject(ReloadViewDataHookInj, createEventHook())
const useForm = Form.useForm const useForm = Form.useForm
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { addTab } = useTabs()
const { sqlUi, project, loadTables } = useProject()
const hasSelectColumn = ref<boolean[]>([]) const hasSelectColumn = ref<boolean[]>([])
const expansionPanel = ref<number[]>([]) const expansionPanel = ref<number[]>([])
@ -75,24 +82,14 @@ const uiTypeOptions = ref<Option[]>(
})), })),
) )
const data = reactive<{ title: string | null; name: string; tables: TableType[] }>({ const srcDestMapping = ref<Record<string, any>[]>([])
const data = reactive<{ title: string | null; name: string; tables: (TableType & { ref_table_name: string })[] }>({
title: null, title: null,
name: 'Project Name', name: 'Project Name',
tables: [], tables: [],
}) })
const { addTab } = useTabs()
const { sqlUi, project, loadTables } = useProject()
onMounted(() => {
parseAndLoadTemplate()
nextTick(() => {
inputRefs.value[0]?.focus()
})
})
const validators = computed(() => const validators = computed(() =>
data.tables.reduce<Record<string, [typeof fieldRequiredValidator]>>((acc, table, tableIdx) => { data.tables.reduce<Record<string, [typeof fieldRequiredValidator]>>((acc, table, tableIdx) => {
acc[`tables.${tableIdx}.table_name`] = [fieldRequiredValidator] acc[`tables.${tableIdx}.table_name`] = [fieldRequiredValidator]
@ -110,10 +107,35 @@ const validators = computed(() =>
}, {}), }, {}),
) )
const srcDestMapping = ref<Record<string, any>[]>([])
const { validate, validateInfos } = useForm(data, validators) const { validate, validateInfos } = useForm(data, validators)
const isValid = computed(() => {
if (importOnly) {
for (const record of srcDestMapping.value) {
if (!fieldsValidation(record)) {
return false
}
}
} else {
for (const [_, o] of Object.entries(validateInfos)) {
if (o?.validateStatus) {
if (o.validateStatus === 'error') {
return false
}
}
}
}
return true
})
onMounted(() => {
parseAndLoadTemplate()
nextTick(() => {
inputRefs.value[0]?.focus()
})
})
function filterOption(input: string, option: Option) { function filterOption(input: string, option: Option) {
return option.value.toUpperCase().includes(input.toUpperCase()) return option.value.toUpperCase().includes(input.toUpperCase())
} }
@ -121,7 +143,9 @@ function filterOption(input: string, option: Option) {
function parseAndLoadTemplate() { function parseAndLoadTemplate() {
if (projectTemplate) { if (projectTemplate) {
parseTemplate(projectTemplate) parseTemplate(projectTemplate)
expansionPanel.value = Array.from({ length: data.tables.length || 0 }, (_, i) => i) expansionPanel.value = Array.from({ length: data.tables.length || 0 }, (_, i) => i)
hasSelectColumn.value = Array.from({ length: data.tables.length || 0 }, () => false) hasSelectColumn.value = Array.from({ length: data.tables.length || 0 }, () => false)
} }
} }
@ -145,6 +169,7 @@ function parseTemplate({ tables = [], ...rest }: Props['projectTemplate']) {
], ],
})), })),
} }
Object.assign(data, parsedTemplate) Object.assign(data, parsedTemplate)
} }
@ -166,8 +191,10 @@ function addNewColumnRow(table: Record<string, any>, uidt?: string) {
column_name: `title${table.columns.length + 1}`, column_name: `title${table.columns.length + 1}`,
uidt, uidt,
}) })
nextTick(() => { nextTick(() => {
const input = inputRefs.value[table.columns.length - 1] const input = inputRefs.value[table.columns.length - 1]
input.focus() input.focus()
input.select() input.select()
}) })
@ -194,10 +221,12 @@ function missingRequiredColumnsValidation() {
(c: Record<string, any>) => (c: Record<string, any>) =>
(c.pk ? !c.ai && !c.cdf : !c.cdf && c.rqd) && !srcDestMapping.value.some((r) => r.destCn === c.title), (c.pk ? !c.ai && !c.cdf : !c.cdf && c.rqd) && !srcDestMapping.value.some((r) => r.destCn === c.title),
) )
if (missingRequiredColumns.length) { if (missingRequiredColumns.length) {
message.error(`Following columns are required : ${missingRequiredColumns.map((c) => c.title).join(', ')}`) message.error(`Following columns are required : ${missingRequiredColumns.map((c) => c.title).join(', ')}`)
return false return false
} }
return true return true
} }
@ -206,6 +235,7 @@ function atLeastOneEnabledValidation() {
message.error('At least one column has to be selected') message.error('At least one column has to be selected')
return false return false
} }
return true return true
} }
@ -252,6 +282,7 @@ function fieldsValidation(record: Record<string, any>) {
message.error('Source data contains some invalid numbers') message.error('Source data contains some invalid numbers')
return false return false
} }
break break
case UITypes.Checkbox: case UITypes.Checkbox:
if ( if (
@ -271,16 +302,20 @@ function fieldsValidation(record: Record<string, any>) {
input === '1' input === '1'
) )
} }
return input !== 1 && input !== 0 && input !== true && input !== false return input !== 1 && input !== 0 && input !== true && input !== false
} }
return false return false
}) })
) { ) {
message.error('Source data contains some invalid boolean values') message.error('Source data contains some invalid boolean values')
return false return false
} }
break break
} }
return true return true
} }
@ -288,24 +323,33 @@ async function importTemplate() {
if (importOnly) { if (importOnly) {
// validate required columns // validate required columns
if (!missingRequiredColumnsValidation()) return if (!missingRequiredColumnsValidation()) return
// validate at least one column needs to be selected // validate at least one column needs to be selected
if (!atLeastOneEnabledValidation()) return if (!atLeastOneEnabledValidation()) return
try { try {
isImporting.value = true isImporting.value = true
const tableName = meta?.value.title as string
const tableName = meta.value.title
const data = importData[tableName] const data = importData[tableName]
const projectName = project.value.title as string
const projectName = project.value.title!
const total = data.length const total = data.length
for (let i = 0, progress = 0; i < total; i += maxRowsToParse) { for (let i = 0, progress = 0; i < total; i += maxRowsToParse) {
const batchData = data.slice(i, i + maxRowsToParse).map((row: Record<string, any>) => const batchData = data.slice(i, i + maxRowsToParse).map((row: Record<string, any>) =>
srcDestMapping.value.reduce((res: Record<string, any>, col: Record<string, any>) => { srcDestMapping.value.reduce((res: Record<string, any>, col: Record<string, any>) => {
if (col.enabled && col.destCn) { if (col.enabled && col.destCn) {
const v = columns.value.find((c: Record<string, any>) => c.title === col.destCn) as Record<string, any> const v = columns.value.find((c: Record<string, any>) => c.title === col.destCn) as Record<string, any>
let input = row[col.srcCn] let input = row[col.srcCn]
// parse potential boolean values // parse potential boolean values
if (v.uidt === UITypes.Checkbox) { if (v.uidt === UITypes.Checkbox) {
input = input.replace(/["']/g, '').toLowerCase().trim() input = input.replace(/["']/g, '').toLowerCase().trim()
if (input === 'false' || input === 'no' || input === 'n') { if (input === 'false' || input === 'no' || input === 'n') {
input = '0' input = '0'
} else if (input === 'true' || input === 'yes' || input === 'y') { } else if (input === 'true' || input === 'yes' || input === 'y') {
@ -325,8 +369,11 @@ async function importTemplate() {
return res return res
}, {}), }, {}),
) )
await $api.dbTableRow.bulkCreate('noco', projectName, tableName, batchData) await $api.dbTableRow.bulkCreate('noco', projectName, tableName, batchData)
importingTip.value = `Importing data to ${projectName}: ${progress}/${total} records` importingTip.value = `Importing data to ${projectName}: ${progress}/${total} records`
progress += batchData.length progress += batchData.length
} }
@ -421,6 +468,7 @@ async function importTemplate() {
} }
// reload table list // reload table list
await loadTables() await loadTables()
addTab({ addTab({
...tab, ...tab,
type: TabType.TABLE, type: TabType.TABLE,
@ -433,25 +481,6 @@ async function importTemplate() {
} }
} }
const isValid = computed(() => {
if (importOnly) {
for (const record of srcDestMapping.value) {
if (!fieldsValidation(record)) {
return false
}
}
} else {
for (const [_, o] of Object.entries(validateInfos)) {
if (o?.validateStatus) {
if (o.validateStatus === 'error') {
return false
}
}
}
}
return true
})
function mapDefaultColumns() { function mapDefaultColumns() {
srcDestMapping.value = [] srcDestMapping.value = []
for (const col of importColumns[0]) { for (const col of importColumns[0]) {
@ -645,6 +674,7 @@ onMounted(() => {
<mdi-key-star class="text-lg" /> <mdi-key-star class="text-lg" />
</div> </div>
</a-tooltip> </a-tooltip>
<a-tooltip v-else> <a-tooltip v-else>
<template #title> <template #title>
<!-- TODO: i18n --> <!-- TODO: i18n -->
@ -660,16 +690,17 @@ onMounted(() => {
</template> </template>
</template> </template>
</a-table> </a-table>
<div class="text-center mt-5">
<div class="mt-5 flex gap-2 justify-center">
<a-tooltip bottom> <a-tooltip bottom>
<template #title> <template #title>
<!-- TODO: i18n --> <!-- TODO: i18n -->
<span>Add Number Column</span> <span>Add Number Column</span>
</template> </template>
<a-button @click="addNewColumnRow(table, 'Number')"> <a-button class="group" @click="addNewColumnRow(table, 'Number')">
<div class="flex items-center"> <div class="flex items-center">
<mdi-numeric class="text-lg" /> <mdi-numeric class="group-hover:!text-pink-500 flex text-lg" />
</div> </div>
</a-button> </a-button>
</a-tooltip> </a-tooltip>
@ -679,9 +710,10 @@ onMounted(() => {
<!-- TODO: i18n --> <!-- TODO: i18n -->
<span>Add SingleLineText Column</span> <span>Add SingleLineText Column</span>
</template> </template>
<a-button @click="addNewColumnRow(table, 'SingleLineText')">
<a-button class="group" @click="addNewColumnRow(table, 'SingleLineText')">
<div class="flex items-center"> <div class="flex items-center">
<mdi-alpha-a class="text-lg" /> <mdi-alpha-a class="group-hover:!text-pink-500 text-lg" />
</div> </div>
</a-button> </a-button>
</a-tooltip> </a-tooltip>
@ -691,9 +723,10 @@ onMounted(() => {
<!-- TODO: i18n --> <!-- TODO: i18n -->
<span>Add LongText Column</span> <span>Add LongText Column</span>
</template> </template>
<a-button @click="addNewColumnRow(table, 'LongText')">
<a-button class="group" @click="addNewColumnRow(table, 'LongText')">
<div class="flex items-center"> <div class="flex items-center">
<mdi-text class="text-lg" /> <mdi-text class="group-hover:!text-pink-500 text-lg" />
</div> </div>
</a-button> </a-button>
</a-tooltip> </a-tooltip>
@ -703,10 +736,10 @@ onMounted(() => {
<!-- TODO: i18n --> <!-- TODO: i18n -->
<span>Add Other Column</span> <span>Add Other Column</span>
</template> </template>
<a-button @click="addNewColumnRow(table, 'SingleLineText')">
<div class="flex items-center"> <a-button class="group" @click="addNewColumnRow(table, 'SingleLineText')">
<mdi-plus class="text-lg" /> <div class="flex items-center gap-1">
Column <mdi-plus class="group-hover:!text-pink-500 text-lg" />
</div> </div>
</a-button> </a-button>
</a-tooltip> </a-tooltip>

1
packages/nc-gui-v2/composables/index.ts

@ -1,4 +1,5 @@
export * from './useApi' export * from './useApi'
export * from './useDialog'
export * from './useGlobal' export * from './useGlobal'
export * from './useInjectionState' export * from './useInjectionState'
export * from './useSidebar' export * from './useSidebar'

103
packages/nc-gui-v2/composables/useDialog/index.ts

@ -0,0 +1,103 @@
import type { DefineComponent, VNode } from '@vue/runtime-dom'
import { isVNode, render } from '@vue/runtime-dom'
import type { ComponentPublicInstance } from '@vue/runtime-core'
import { isClient } from '@vueuse/core'
import { createEventHook, h, ref, toReactive, tryOnScopeDispose, useNuxtApp, watch } from '#imports'
/**
* Programmatically create a component and attach it to the body (or a specific mount target), like a dialog or modal.
* This composable is not SSR friendly - it should be used only on the client.
*
* @param componentOrVNode The component to create and attach. Can be a VNode or a component definition.
* @param props The props to pass to the component.
* @param mountTarget The target to attach the component to. Defaults to the document body
*
* @example
* import { useDialog } from '#imports'
* import DlgQuickImport from '~/components/dlg/QuickImport.vue'
*
* function openQuickImportDialog(type: string) {
* // create a ref for showing/hiding the modal
* const isOpen = ref(true)
*
* const { close, vNode } = useDialog(DlgQuickImport, {
* 'modelValue': isOpen,
* 'importType': type,
* 'onUpdate:modelValue': closeDialog,
* })
*
* function closeDialog() {
* // hide the modal
* isOpen.value = false
*
* // debounce destroying the component, so the modal transition can finish
* close(1000)
* }
* }
*/
export function useDialog(
componentOrVNode: DefineComponent<any, any, any> | VNode,
props: NonNullable<Parameters<typeof h>[1]> = {},
mountTarget?: Element | ComponentPublicInstance,
) {
if (typeof document === 'undefined' || !isClient) {
console.warn('[useDialog]: Cannot use outside of browser!')
}
const closeHook = createEventHook<void>()
const mountedHook = createEventHook<void>()
const isMounted = $ref(false)
const domNode = document.createElement('div')
const vNodeRef = ref<VNode>()
mountTarget = mountTarget ? ('$el' in mountTarget ? (mountTarget.$el as HTMLElement) : mountTarget) : document.body
/** if specified, append vnode to mount target instead of document.body */
mountTarget.appendChild(domNode)
/** When props change, we want to re-render the element with the new prop values */
const stop = watch(
toReactive(props),
(reactiveProps) => {
const vNode = isVNode(componentOrVNode) ? componentOrVNode : h(componentOrVNode, reactiveProps)
vNode.appContext = useNuxtApp().vueApp._context
vNodeRef.value = vNode
render(vNode, domNode)
if (!isMounted) mountedHook.trigger()
},
{ deep: true, immediate: true, flush: 'post' },
)
/** When calling scope is disposed, destroy component */
tryOnScopeDispose(close)
/** destroy component, can be debounced */
function close(debounce = 0) {
setTimeout(() => {
stop()
render(null, domNode)
setTimeout(() => {
;(mountTarget as HTMLElement)!.removeChild(domNode)
}, 100)
closeHook.trigger()
}, debounce)
}
return {
close,
onClose: closeHook.on,
onMounted: mountedHook.on,
domNode,
vNode: vNodeRef,
}
}

9
packages/nc-gui-v2/composables/useGlobal/actions.ts

@ -1,11 +1,10 @@
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { Api } from 'nocodb-sdk'
import type { Actions, State } from './types' import type { Actions, State } from './types'
import { useNuxtApp } from '#imports'
export function useGlobalActions(state: State): Actions { export function useGlobalActions(state: State): Actions {
// todo replace with just `new Api()`? Would solve recursion issues /** detached api instance, will not trigger global loading */
/** we have to use the globally injected api instance, otherwise we run into recursion as `useApi` calls `useGlobal` */ const api = new Api()
const { $api } = useNuxtApp()
/** Sign out by deleting the token from localStorage */ /** Sign out by deleting the token from localStorage */
const signOut: Actions['signOut'] = () => { const signOut: Actions['signOut'] = () => {
@ -30,7 +29,7 @@ export function useGlobalActions(state: State): Actions {
/** manually try to refresh token */ /** manually try to refresh token */
const refreshToken = async () => { const refreshToken = async () => {
$api.instance api.instance
.post('/auth/refresh-token', null, { .post('/auth/refresh-token', null, {
withCredentials: true, withCredentials: true,
}) })

38
packages/nc-gui-v2/composables/useViewFilters.ts

@ -1,6 +1,17 @@
import type { ViewType } from 'nocodb-sdk' import type { ViewType } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import { IsPublicInj, ReloadViewDataHookInj, useMetas, useNuxtApp, useUIPermission } from '#imports' import {
IsPublicInj,
ReloadViewDataHookInj,
computed,
inject,
ref,
useMetas,
useNuxtApp,
useSharedView,
useUIPermission,
watch,
} from '#imports'
import type { Filter } from '~/lib' import type { Filter } from '~/lib'
export function useViewFilters( export function useViewFilters(
@ -17,12 +28,15 @@ export function useViewFilters(
const _filters = ref<Filter[]>([]) const _filters = ref<Filter[]>([])
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { metas } = useMetas() const { metas } = useMetas()
const filters = computed({ const filters = computed({
get: () => (isPublic.value ? siblingFilters || nestedFilters.value : _filters.value), get: () => (isPublic.value ? siblingFilters || nestedFilters.value : _filters.value) ?? [],
set: (value) => { set: (value) => {
if (isPublic.value) { if (isPublic.value) {
if (siblingFilters) { if (siblingFilters) {
@ -94,6 +108,7 @@ export function useViewFilters(
// if shared or sync permission not allowed simply remove it from array // if shared or sync permission not allowed simply remove it from array
if (isPublic.value || !isUIAllowed('filterSync')) { if (isPublic.value || !isUIAllowed('filterSync')) {
filters.value.splice(i, 1) filters.value.splice(i, 1)
reloadData?.() reloadData?.()
} else { } else {
if (filter.id) { if (filter.id) {
@ -103,7 +118,9 @@ export function useViewFilters(
// if auto-apply enabled invoke delete api and remove from array // if auto-apply enabled invoke delete api and remove from array
} else { } else {
await $api.dbTableFilter.delete(filter.id) await $api.dbTableFilter.delete(filter.id)
reloadData?.() reloadData?.()
filters.value.splice(i, 1) filters.value.splice(i, 1)
} }
// if not synced yet remove it from array // if not synced yet remove it from array
@ -115,11 +132,14 @@ export function useViewFilters(
const saveOrUpdate = async (filter: Filter, i: number, force = false) => { const saveOrUpdate = async (filter: Filter, i: number, force = false) => {
if (isPublic.value) { if (isPublic.value) {
filters.value[i] = { ...filter } as any filters.value[i] = { ...filter }
filters.value = [...filters.value] filters.value = [...filters.value]
return return
} }
if (!view?.value) return if (!view?.value) return
if (!isUIAllowed('filterSync')) { if (!isUIAllowed('filterSync')) {
// skip // skip
} else if (!autoApply?.value && !force) { } else if (!autoApply?.value && !force) {
@ -136,12 +156,11 @@ export function useViewFilters(
fk_parent_id: parentId, fk_parent_id: parentId,
})) as any })) as any
} }
reloadData?.() reloadData?.()
} }
const addFilter = () => { const addFilter = () => filters.value.push(placeholderFilter)
filters.value.push(placeholderFilter)
}
const addFilterGroup = async () => { const addFilterGroup = async () => {
const child = placeholderFilter const child = placeholderFilter
@ -150,10 +169,13 @@ export function useViewFilters(
status: 'create', status: 'create',
logical_op: 'and', logical_op: 'and',
} }
if (isPublic.value) placeHolderGroupFilter.children = [child] if (isPublic.value) placeHolderGroupFilter.children = [child]
filters.value.push(placeHolderGroupFilter) filters.value.push(placeHolderGroupFilter)
const index = filters.value.length - 1 const index = filters.value.length - 1
await saveOrUpdate(filters.value[index], index, true) await saveOrUpdate(filters.value[index], index, true)
} }
@ -167,9 +189,7 @@ export function useViewFilters(
return metas?.value?.[view?.value?.fk_model_id as string]?.columns?.length || 0 return metas?.value?.[view?.value?.fk_model_id as string]?.columns?.length || 0
}, },
async (nextColsLength, oldColsLength) => { async (nextColsLength, oldColsLength) => {
if (nextColsLength < oldColsLength) { if (nextColsLength < oldColsLength) await loadFilters()
await loadFilters()
}
}, },
) )

32
packages/nc-gui-v2/layouts/base.vue

@ -50,35 +50,35 @@ const logout = () => {
<GeneralShareBaseButton /> <GeneralShareBaseButton />
<a-tooltip placement="bottom"> <a-tooltip placement="bottom" :mouse-enter-delay="1">
<template #title> Switch language</template> <template #title> Switch language</template>
<div class="flex pr-4 items-center"> <div class="flex pr-4 items-center">
<GeneralLanguage class="cursor-pointer text-2xl" /> <GeneralLanguage class="cursor-pointer text-2xl hover:text-pink-500" />
</div> </div>
</a-tooltip> </a-tooltip>
<template v-if="signedIn && !isSharedBase"> <template v-if="signedIn && !isSharedBase">
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<MdiDotsVertical class="md:text-xl cursor-pointer nc-user-menu" @click.prevent /> <MdiDotsVertical class="md:text-xl cursor-pointer hover:text-pink-500" @click.prevent />
<template #overlay> <template #overlay>
<a-menu class="!py-0 nc-user-menu dark:(!bg-gray-800) leading-8 !rounded"> <a-menu class="!py-0 dark:(!bg-gray-800) leading-8 !rounded">
<a-menu-item key="0" class="!rounded-t"> <a-menu-item key="0" class="!rounded-t">
<nuxt-link v-t="['c:navbar:user:email']" class="group flex items-center no-underline py-2" to="/user"> <nuxt-link v-t="['c:navbar:user:email']" class="nc-project-menu-item group no-underline" to="/user">
<MdiAt class="mt-1 group-hover:text-success" />&nbsp; <MdiAt class="mt-1 group-hover:text-pink-500" />&nbsp;
<span class="prose group-hover:text-black nc-user-menu-email">{{ email }}</span> <span class="prose">{{ email }}</span>
</nuxt-link> </nuxt-link>
</a-menu-item> </a-menu-item>
<a-menu-divider class="!m-0" /> <a-menu-divider class="!m-0" />
<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="logout"> <div v-t="['a:navbar:user:sign-out']" class="nc-project-menu-item group" @click="logout">
<MdiLogout class="dark:text-white group-hover:(!text-red-500)" />&nbsp; <MdiLogout class="group-hover:(!text-pink-500)" />&nbsp;
<span class="prose font-semibold text-gray-500 group-hover:text-black nc-user-menu-signout"> <span class="prose">
{{ $t('general.signOut') }} {{ $t('general.signOut') }}
</span> </span>
</div> </div>
@ -112,10 +112,22 @@ const logout = () => {
@apply border-b-1; @apply border-b-1;
} }
:deep(.ant-dropdown-menu-item-group-list) {
@apply !mx-0;
}
:deep(.ant-dropdown-menu-item-group-title) {
@apply border-b-1;
}
:deep(.ant-dropdown-menu-item-group-list) { :deep(.ant-dropdown-menu-item-group-list) {
@apply m-0; @apply m-0;
} }
:deep(.ant-dropdown-menu-item) {
@apply !py-0 active:(ring ring-pink-500);
}
.nc-lang-btn { .nc-lang-btn {
@apply color-transition flex items-center justify-center fixed bottom-10 right-10 z-99 w-12 h-12 rounded-full shadow-md shadow-gray-500 p-2 !bg-primary text-white active:(ring ring-pink-500) hover:(ring ring-pink-500); @apply color-transition flex items-center justify-center fixed bottom-10 right-10 z-99 w-12 h-12 rounded-full shadow-md shadow-gray-500 p-2 !bg-primary text-white active:(ring ring-pink-500) hover:(ring ring-pink-500);

7
packages/nc-gui-v2/nuxt-shim.d.ts vendored

@ -24,3 +24,10 @@ declare module '@vue/runtime-core' {
i18n: I18n<MessageSchema, unknown, unknown, false>['global'] i18n: I18n<MessageSchema, unknown, unknown, false>['global']
} }
} }
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
public?: boolean
}
}

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

@ -32,6 +32,7 @@
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^0.26.0", "@antfu/eslint-config": "^0.26.0",
"@iconify-json/bi": "^1.1.6",
"@iconify-json/cil": "^1.1.2", "@iconify-json/cil": "^1.1.2",
"@iconify-json/clarity": "^1.1.4", "@iconify-json/clarity": "^1.1.4",
"@iconify-json/eva": "^1.1.2", "@iconify-json/eva": "^1.1.2",
@ -40,6 +41,7 @@
"@iconify-json/material-symbols": "^1.1.8", "@iconify-json/material-symbols": "^1.1.8",
"@iconify-json/mdi": "^1.1.25", "@iconify-json/mdi": "^1.1.25",
"@iconify-json/mi": "^1.1.2", "@iconify-json/mi": "^1.1.2",
"@iconify-json/ph": "^1.1.2",
"@iconify-json/ri": "^1.1.3", "@iconify-json/ri": "^1.1.3",
"@intlify/vite-plugin-vue-i18n": "^6.0.1", "@intlify/vite-plugin-vue-i18n": "^6.0.1",
"@nuxt/image-edge": "^1.0.0-27657146.da85542", "@nuxt/image-edge": "^1.0.0-27657146.da85542",
@ -996,6 +998,15 @@
"dev": true, "dev": true,
"peer": true "peer": true
}, },
"node_modules/@iconify-json/bi": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@iconify-json/bi/-/bi-1.1.6.tgz",
"integrity": "sha512-q80o/IJN/mEwhzQG/LjmpA4S5Zk3XzHegmhseWEvu6XF/N3pc8d7a1Fv/PVE2kij06J6ugb8DTdt30BCt5Dplw==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/cil": { "node_modules/@iconify-json/cil": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@iconify-json/cil/-/cil-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@iconify-json/cil/-/cil-1.1.2.tgz",
@ -1068,6 +1079,15 @@
"@iconify/types": "*" "@iconify/types": "*"
} }
}, },
"node_modules/@iconify-json/ph": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@iconify-json/ph/-/ph-1.1.2.tgz",
"integrity": "sha512-NuTdtt/UmuxIHS4hfdyv3BP5JiWikNkr81hFHXDScXlH0GUMdRSY/B5T9vDvbXDY/esMLFnIAXoFVDLsGinhpw==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/ri": { "node_modules/@iconify-json/ri": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@iconify-json/ri/-/ri-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@iconify-json/ri/-/ri-1.1.3.tgz",
@ -15955,6 +15975,15 @@
"dev": true, "dev": true,
"peer": true "peer": true
}, },
"@iconify-json/bi": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@iconify-json/bi/-/bi-1.1.6.tgz",
"integrity": "sha512-q80o/IJN/mEwhzQG/LjmpA4S5Zk3XzHegmhseWEvu6XF/N3pc8d7a1Fv/PVE2kij06J6ugb8DTdt30BCt5Dplw==",
"dev": true,
"requires": {
"@iconify/types": "*"
}
},
"@iconify-json/cil": { "@iconify-json/cil": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@iconify-json/cil/-/cil-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@iconify-json/cil/-/cil-1.1.2.tgz",
@ -16027,6 +16056,15 @@
"@iconify/types": "*" "@iconify/types": "*"
} }
}, },
"@iconify-json/ph": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@iconify-json/ph/-/ph-1.1.2.tgz",
"integrity": "sha512-NuTdtt/UmuxIHS4hfdyv3BP5JiWikNkr81hFHXDScXlH0GUMdRSY/B5T9vDvbXDY/esMLFnIAXoFVDLsGinhpw==",
"dev": true,
"requires": {
"@iconify/types": "*"
}
},
"@iconify-json/ri": { "@iconify-json/ri": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@iconify-json/ri/-/ri-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@iconify-json/ri/-/ri-1.1.3.tgz",

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

@ -38,6 +38,7 @@
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^0.26.0", "@antfu/eslint-config": "^0.26.0",
"@iconify-json/bi": "^1.1.6",
"@iconify-json/cil": "^1.1.2", "@iconify-json/cil": "^1.1.2",
"@iconify-json/clarity": "^1.1.4", "@iconify-json/clarity": "^1.1.4",
"@iconify-json/eva": "^1.1.2", "@iconify-json/eva": "^1.1.2",
@ -46,6 +47,7 @@
"@iconify-json/material-symbols": "^1.1.8", "@iconify-json/material-symbols": "^1.1.8",
"@iconify-json/mdi": "^1.1.25", "@iconify-json/mdi": "^1.1.25",
"@iconify-json/mi": "^1.1.2", "@iconify-json/mi": "^1.1.2",
"@iconify-json/ph": "^1.1.2",
"@iconify-json/ri": "^1.1.3", "@iconify-json/ri": "^1.1.3",
"@intlify/vite-plugin-vue-i18n": "^6.0.1", "@intlify/vite-plugin-vue-i18n": "^6.0.1",
"@nuxt/image-edge": "^1.0.0-27657146.da85542", "@nuxt/image-edge": "^1.0.0-27657146.da85542",

187
packages/nc-gui-v2/pages/[projectType]/[projectId]/index/index.vue

@ -1,28 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TabItem } from '~/composables' import type { TabItem } from '~/composables'
import { TabMetaInj, provide, ref, useDialog, useNuxtApp, useTabs, useUIPermission } from '#imports'
import DlgTableCreate from '~/components/dlg/TableCreate.vue'
import DlgAirtableImport from '~/components/dlg/AirtableImport.vue'
import DlgQuickImport from '~/components/dlg/QuickImport.vue'
import { TabType } from '~/composables' import { TabType } from '~/composables'
import { TabMetaInj, useTabs, useUIPermission } from '#imports'
import MdiAirTableIcon from '~icons/mdi/table-large' import MdiAirTableIcon from '~icons/mdi/table-large'
import MdiView from '~icons/mdi/eye-circle-outline' import MdiView from '~icons/mdi/eye-circle-outline'
import MdiAccountGroup from '~icons/mdi/account-group' import MdiAccountGroup from '~icons/mdi/account-group'
const { $e } = useNuxtApp()
const { tabs, activeTabIndex, activeTab, closeTab } = useTabs() const { tabs, activeTabIndex, activeTab, closeTab } = useTabs()
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const tableCreateDialog = ref(false)
const airtableImportDialog = ref(false)
const quickImportDialog = ref(false)
const importType = ref('')
const currentMenu = ref<string[]>(['addORImport'])
provide(TabMetaInj, activeTab) provide(TabMetaInj, activeTab)
function openQuickImportDialog(type: string) {
quickImportDialog.value = true
importType.value = type
}
const icon = (tab: TabItem) => { const icon = (tab: TabItem) => {
switch (tab.type) { switch (tab.type) {
case TabType.TABLE: case TabType.TABLE:
@ -33,6 +27,58 @@ const icon = (tab: TabItem) => {
return MdiAccountGroup return MdiAccountGroup
} }
} }
function openQuickImportDialog(type: string) {
$e(`a:actions:import-${type}`)
const isOpen = ref(true)
const { close } = useDialog(DlgQuickImport, {
'modelValue': isOpen,
'importType': type,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
function openTableCreateDialog() {
$e('a:actions:create-table')
const isOpen = ref(true)
const { close } = useDialog(DlgTableCreate, {
'modelValue': isOpen,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
function openAirtableImportDialog() {
$e('a:actions:import-airtable')
const isOpen = ref(true)
const { close } = useDialog(DlgAirtableImport, {
'modelValue': isOpen,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
</script> </script>
<template> <template>
@ -43,116 +89,99 @@ const icon = (tab: TabItem) => {
<a-tab-pane v-for="(tab, i) in tabs" :key="i"> <a-tab-pane v-for="(tab, i) in tabs" :key="i">
<template #tab> <template #tab>
<div class="flex align-center gap-2"> <div class="flex align-center gap-2">
<component :is="icon(tab)" class="text-sm"></component> <component :is="icon(tab)" class="text-sm" />
{{ tab.title }} {{ tab.title }}
</div> </div>
</template> </template>
</a-tab-pane> </a-tab-pane>
<template #leftExtra> <template #leftExtra>
<a-menu v-if="isUIAllowed('addOrImport')" v-model:selectedKeys="currentMenu" class="border-0" mode="horizontal"> <a-dropdown v-if="isUIAllowed('addOrImport')" :trigger="['click']">
<a-sub-menu key="addORImport"> <div
<template #title> class="cursor-pointer color-transition group hover:text-primary text-sm flex items-center gap-2 py-[9.5px] px-[20px]"
<div class="text-sm flex items-center gap-2 pt-[8px] pb-3"> >
<MdiPlusBoxOutline /> <MdiPlusBoxOutline class="group-hover:text-pink-500" />
Add / Import Add / Import
</div> </div>
</template>
<a-menu-item-group v-if="isUIAllowed('addTable')"> <template #overlay>
<a-menu-item key="add-new-table" v-t="['a:actions:create-table']" @click="tableCreateDialog = true"> <a-menu class="nc-add-project-menu !py-0 ml-6 rounded text-sm">
<span class="flex items-center gap-2"> <a-menu-item v-if="isUIAllowed('addTable')" key="add-new-table" @click="openTableCreateDialog">
<MdiTable class="text-primary" /> <div class="color-transition nc-project-menu-item after:(!rounded-t) group">
<MdiTable class="group-hover:text-pink-500" />
<!-- Add new table --> <!-- Add new table -->
{{ $t('tooltip.addTable') }} {{ $t('tooltip.addTable') }}
</span> </div>
</a-menu-item> </a-menu-item>
</a-menu-item-group>
<a-menu-item-group title="QUICK IMPORT FROM"> <a-menu-item-group title="QUICK IMPORT FROM" class="!px-0 !mx-0">
<a-menu-item <a-menu-item
v-if="isUIAllowed('airtableImport')" v-if="isUIAllowed('airtableImport')"
key="quick-import-airtable" key="quick-import-airtable"
v-t="['a:actions:import-airtable']" @click="openAirtableImportDialog"
@click="airtableImportDialog = true"
> >
<span class="flex items-center gap-2"> <div class="color-transition nc-project-menu-item group">
<MdiTableLarge class="text-primary" /> <MdiTableLarge class="group-hover:text-pink-500" />
<!-- TODO: i18n --> <!-- TODO: i18n -->
Airtable Airtable
</span> </div>
</a-menu-item> </a-menu-item>
<a-menu-item
v-if="isUIAllowed('csvImport')" <a-menu-item v-if="isUIAllowed('csvImport')" key="quick-import-csv" @click="openQuickImportDialog('csv')">
key="quick-import-csv" <div class="color-transition nc-project-menu-item group">
v-t="['a:actions:import-csv']" <MdiFileDocumentOutline class="group-hover:text-pink-500" />
@click="openQuickImportDialog('csv')"
>
<span class="flex items-center gap-2">
<MdiFileDocumentOutline class="text-primary" />
<!-- TODO: i18n --> <!-- TODO: i18n -->
CSV file CSV file
</span> </div>
</a-menu-item> </a-menu-item>
<a-menu-item <a-menu-item v-if="isUIAllowed('jsonImport')" key="quick-import-json" @click="openQuickImportDialog('json')">
v-if="isUIAllowed('jsonImport')" <div class="color-transition nc-project-menu-item group">
key="quick-import-json" <MdiCodeJson class="group-hover:text-pink-500" />
v-t="['a:actions:import-json']"
@click="openQuickImportDialog('json')"
>
<span class="flex items-center gap-2">
<MdiCodeJson class="text-primary" />
<!-- TODO: i18n --> <!-- TODO: i18n -->
JSON file JSON file
</span> </div>
</a-menu-item> </a-menu-item>
<a-menu-item <a-menu-item
v-if="isUIAllowed('excelImport')" v-if="isUIAllowed('excelImport')"
key="quick-import-excel" key="quick-import-excel"
v-t="['a:actions:import-excel']"
@click="openQuickImportDialog('excel')" @click="openQuickImportDialog('excel')"
> >
<span class="flex items-center gap-2"> <div class="color-transition nc-project-menu-item group">
<MdiFileExcel class="text-primary" /> <MdiFileExcel class="group-hover:text-pink-500" />
<!-- TODO: i18n --> <!-- TODO: i18n -->
Microsoft Excel Microsoft Excel
</span> </div>
</a-menu-item> </a-menu-item>
</a-menu-item-group> </a-menu-item-group>
<a-menu-divider class="ma-0 mb-2" /> <a-menu-divider class="my-0" />
<a-menu-item <a-menu-item v-if="isUIAllowed('importRequest')" key="add-new-table" class="py-1 rounded-b">
v-if="isUIAllowed('importRequest')" <a
key="add-new-table"
v-t="['e:datasource:import-request']" v-t="['e:datasource:import-request']"
class="ma-0 mt-3" href="https://github.com/nocodb/nocodb/issues/2052"
target="_blank"
class="prose-sm hover:(!text-primary !opacity-100) color-transition nc-project-menu-item group after:(!rounded-b)"
> >
<a href="https://github.com/nocodb/nocodb/issues/2052" target="_blank" class="prose-sm pa-0"> <MdiOpenInNew class="group-hover:text-pink-500" />
<span class="flex items-center gap-2">
<MdiOpenInNew class="text-primary" />
<!-- TODO: i18n --> <!-- TODO: i18n -->
Request a data source you need? Request a data source you need?
</span>
</a> </a>
</a-menu-item> </a-menu-item>
</a-sub-menu>
</a-menu> </a-menu>
</template> </template>
</a-dropdown>
</template>
</a-tabs> </a-tabs>
</div> </div>
<div class="w-full min-h-[300px] flex-grow">
<div class="w-full min-h-[300px] flex-auto">
<NuxtPage /> <NuxtPage />
</div> </div>
</div> </div>
<DlgTableCreate v-if="tableCreateDialog" v-model="tableCreateDialog" />
<DlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" :import-type="importType" />
<DlgAirtableImport v-if="airtableImportDialog" v-model="airtableImportDialog" />
</div> </div>
</template> </template>
@ -181,6 +210,24 @@ const icon = (tab: TabItem) => {
} }
} }
.nc-add-project-menu {
:deep(.ant-dropdown-menu-item-group-list) {
@apply !mx-0;
}
:deep(.ant-dropdown-menu-item-group-title) {
@apply border-b-1;
}
:deep(.ant-dropdown-menu-item-group-list) {
@apply m-0;
}
:deep(.ant-dropdown-menu-item) {
@apply !py-0 active:(ring ring-pink-500);
}
}
:deep(.ant-menu-item-selected) { :deep(.ant-menu-item-selected) {
@apply text-inherit !bg-inherit; @apply text-inherit !bg-inherit;
} }

131
packages/nc-gui-v2/pages/[projectType]/[projectId]/index/index/index.vue

@ -1,3 +1,132 @@
<script lang="ts" setup>
import type { UploadChangeParam, UploadFile } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { ref, useDialog, useDropZone, useFileDialog, useNuxtApp, watch } from '#imports'
import DlgQuickImport from '~/components/dlg/QuickImport.vue'
const dropZone = ref<HTMLDivElement>()
const { isOverDropZone } = useDropZone(dropZone, onDrop)
const { files, open, reset } = useFileDialog()
const { $e } = useNuxtApp()
type QuickImportTypes = 'excel' | 'json' | 'csv'
const allowedQuickImportTypes = [
// Excel
'.xls, .xlsx, .xlsm, .ods, .ots',
// CSV
'.csv',
// JSON
'.json',
]
watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles), { flush: 'post' })
function onFileSelect(fileList: FileList | null) {
if (!fileList) return
const files = Array.from(fileList).map((file) => file)
onDrop(files)
}
function onDrop(droppedFiles: File[] | null) {
if (!droppedFiles) return
/** we can only handle one file per drop */
if (droppedFiles.length > 1) {
return message.error({
content: `Only one file can be imported at a time.`,
duration: 2,
})
}
let fileType: QuickImportTypes | null = null
const isValid = allowedQuickImportTypes.some((type) => {
const isAllowed = droppedFiles[0].type.replace('/', '.').endsWith(type)
if (isAllowed) {
fileType = type.replace('.', '') as QuickImportTypes
}
return isAllowed
})
/** Invalid file type was dropped */
if (!isValid) {
return message.error({
content: 'Invalid file type',
duration: 2,
})
}
if (fileType && isValid) {
openQuickImportDialog(fileType, droppedFiles[0])
}
}
function openQuickImportDialog(type: QuickImportTypes, file: File) {
$e(`a:actions:import-${type}`)
const isOpen = ref(true)
const { close, vNode } = useDialog(DlgQuickImport, {
'modelValue': isOpen,
'importType': type,
'onUpdate:modelValue': closeDialog,
})
vNode.value?.component?.exposed?.handleChange({
file: {
uid: `${type}-${file.name}-${Math.random().toString(36).substring(2)}`,
name: file.name,
type: file.type,
status: 'done',
fileName: file.name,
lastModified: file.lastModified,
size: file.size,
originFileObj: file,
},
event: { percent: 100 },
} as UploadChangeParam<UploadFile<File>>)
function closeDialog() {
isOpen.value = false
close(1000)
reset()
}
}
</script>
<template> <template>
<div class="h-full w-full prose text-3xl text-gray-400 flex items-center justify-center">Welcome to NocoDB!</div> <div ref="dropZone" class="h-full w-full text-gray-600 flex items-center justify-center relative">
<general-overlay
:model-value="true"
:class="[isOverDropZone ? 'bg-gray-300/75 border-primary shadow' : 'bg-gray-100/25 border-gray-500 cursor-pointer']"
inline
style="top: 20%; left: 20%; right: 20%; bottom: 20%"
class="text-3xl flex items-center justify-center gap-2 border-1 border-dashed rounded hover:border-primary"
@click="open"
>
<template v-if="isOverDropZone"> <MaterialSymbolsFileCopyOutline class="text-pink-500" /> Drop here </template>
</general-overlay>
<div class="flex flex-col gap-6 items-center justify-center md:w-1/2 mx-auto text-center">
<div class="text-3xl">Welcome to NocoDB!</div>
<div class="flex items-center flex-wrap justify-center gap-2 prose-lg leading-8">
To get started, either drop a <span class="flex items-center gap-2"><PhFileCsv /> CSV</span>,
<span class="flex items-center gap-2"><BiFiletypeJson /> JSON</span> or
<span class="flex items-center gap-2"><BiFiletypeXlsx /> Excel</span> file here or click the button in the top-left of
this page.
</div>
</div>
</div>
</template> </template>

10
packages/nc-gui-v2/plugins/ant.ts

@ -1,6 +1,12 @@
import { Menu as AntMenu } from 'ant-design-vue' import { Menu as AntMenu, ConfigProvider } from 'ant-design-vue'
import { defineNuxtPlugin } from '#imports' import { defineNuxtPlugin, themeColors } from '#imports'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
ConfigProvider.config({
theme: {
primaryColor: themeColors.primary,
},
})
nuxtApp.vueApp.component(AntMenu.name, AntMenu) nuxtApp.vueApp.component(AntMenu.name, AntMenu)
}) })

Loading…
Cancel
Save