Browse Source

Merge branch 'develop' into fix/gui-v2-shared-base-issues

pull/3234/head
Wing-Kam Wong 2 years ago
parent
commit
44dc459da2
  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. 2
      packages/nc-gui-v2/components/dashboard/settings/Modal.vue
  5. 137
      packages/nc-gui-v2/components/dlg/AirtableImport.vue
  6. 314
      packages/nc-gui-v2/components/dlg/QuickImport.vue
  7. 52
      packages/nc-gui-v2/components/dlg/TableCreate.vue
  8. 28
      packages/nc-gui-v2/components/general/Language.vue
  9. 2
      packages/nc-gui-v2/components/general/PreviewAs.vue
  10. 31
      packages/nc-gui-v2/components/smartsheet-header/Menu.vue
  11. 26
      packages/nc-gui-v2/components/smartsheet-toolbar/ColumnFilterMenu.vue
  12. 25
      packages/nc-gui-v2/components/smartsheet-toolbar/FieldsMenu.vue
  13. 8
      packages/nc-gui-v2/components/smartsheet/Cell.vue
  14. 48
      packages/nc-gui-v2/components/smartsheet/Form.vue
  15. 26
      packages/nc-gui-v2/components/smartsheet/Grid.vue
  16. 9
      packages/nc-gui-v2/components/smartsheet/expanded-form/index.vue
  17. 14
      packages/nc-gui-v2/components/smartsheet/sidebar/toolbar/AddRow.vue
  18. 3
      packages/nc-gui-v2/components/tabs/Smartsheet.vue
  19. 133
      packages/nc-gui-v2/components/template/Editor.vue
  20. 1
      packages/nc-gui-v2/composables/index.ts
  21. 103
      packages/nc-gui-v2/composables/useDialog/index.ts
  22. 9
      packages/nc-gui-v2/composables/useGlobal/actions.ts
  23. 11
      packages/nc-gui-v2/composables/useViewData.ts
  24. 38
      packages/nc-gui-v2/composables/useViewFilters.ts
  25. 1
      packages/nc-gui-v2/context/index.ts
  26. 32
      packages/nc-gui-v2/layouts/base.vue
  27. 7
      packages/nc-gui-v2/nuxt-shim.d.ts
  28. 38
      packages/nc-gui-v2/package-lock.json
  29. 2
      packages/nc-gui-v2/package.json
  30. 266
      packages/nc-gui-v2/pages/[projectType]/[projectId]/index/index.vue
  31. 131
      packages/nc-gui-v2/pages/[projectType]/[projectId]/index/index/index.vue
  32. 3
      packages/nc-gui-v2/pages/index/index.vue
  33. 15
      packages/nc-gui-v2/pages/signup/[[token]].vue
  34. 10
      packages/nc-gui-v2/plugins/ant.ts

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

@ -14,7 +14,7 @@ body,
#__nuxt,
.ant-layout,
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 {
@ -36,7 +36,7 @@ nav .v-list {
}
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 {
@ -194,7 +194,7 @@ h1, h2, h3, h4, h5, h6, p, label, button, textarea, select {
}
.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 {
@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']
ADrawer: typeof import('ant-design-vue/es')['Drawer']
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
ADropdownButton: typeof import('ant-design-vue/es')['DropdownButton']
AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form']
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']
ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle']
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']
CilFullscreenExit: typeof import('~icons/cil/fullscreen-exit')['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']
MdiCloseBox: typeof import('~icons/mdi/close-box')['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']
MdiCodeJson: typeof import('~icons/mdi/code-json')['default']
MdiCog: typeof import('~icons/mdi/cog')['default']
MdiContentCopy: typeof import('~icons/mdi/content-copy')['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']
MdiDelete: typeof import('~icons/mdi/delete')['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']
MdiFileExcel: typeof import('~icons/mdi/file-excel')['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']
MdiFlashOutline: typeof import('~icons/mdi/flash-outline')['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']
MdiKeyStar: typeof import('~icons/mdi/key-star')['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']
MdiLoading: typeof import('~icons/mdi/loading')['default']
MdiLogin: typeof import('~icons/mdi/login')['default']
MdiLogout: typeof import('~icons/mdi/logout')['default']
MdiMagnify: typeof import('~icons/mdi/magnify')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['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']
MdiNotebookCheckOutline: typeof import('~icons/mdi/notebook-check-outline')['default']
MdiNumeric: typeof import('~icons/mdi/numeric')['default']
MdiOpenInNew: typeof import('~icons/mdi/open-in-new')['default']
MdiPencil: typeof import('~icons/mdi/pencil')['default']
MdiPlus: typeof import('~icons/mdi/plus')['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']
MdiPlusRoundedOutline: typeof import('~icons/mdi/plus-rounded-outline')['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']
MdiWhatsapp: typeof import('~icons/mdi/whatsapp')['default']
MdiXml: typeof import('~icons/mdi/xml')['default']
PhFileCsv: typeof import('~icons/ph/file-csv')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

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

@ -139,7 +139,7 @@ const activeTable = computed(() => {
<template>
<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">
<a-input
v-model:value="filterQuery"

2
packages/nc-gui-v2/components/dashboard/settings/Modal.vue

@ -142,7 +142,7 @@ watch(
<a-typography-title class="ml-4 select-none" type="secondary" :level="5">SETTINGS</a-typography-title>
<a-button type="text" class="!rounded-md border-none -mt-1.5 -mr-1" @click="vModel = false">
<template #icon>
<MdiCloseIcon class="cursor-pointer mt-1" />
<MdiCloseIcon class="cursor-pointer mt-1 nc-modal-close" />
</template>
</a-button>
</div>

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

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

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

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

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

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

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

@ -37,22 +37,22 @@ onMounted(() => {
</script>
<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" />
<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
v-for="lang of languages"
:key="lang"
:class="lang === locale ? '!bg-primary/10 text-primary dark:(!bg-gray-700 !text-secondary)' : ''"
class="!min-h-8 group"
:class="lang === locale ? '!bg-primary/10 text-primary' : ''"
class="group"
:value="lang"
@click="changeLanguage(lang)"
>
<div
: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 }}
</div>
@ -71,3 +71,21 @@ onMounted(() => {
</template>
</a-dropdown>
</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="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 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>
import { Modal, message } from 'ant-design-vue'
import { inject } from 'vue'
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'
import { ColumnInj, IsLockedInj, MetaInj, extractSdkResponseErrorMsg, inject, useI18n, useMetas, useNuxtApp } from '#imports'
const { virtual = false } = defineProps<{ virtual?: boolean }>()
@ -34,8 +27,9 @@ const deleteColumn = () =>
async onOk() {
try {
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))
}
},
@ -44,8 +38,11 @@ const deleteColumn = () =>
const setAsPrimaryValue = async () => {
try {
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')
$e('a:column:set-primary')
} catch (e) {
message.error('Failed to update primary column')
@ -55,29 +52,31 @@ const setAsPrimaryValue = async () => {
<template>
<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>
<a-menu class="shadow bg-white">
<a-menu-item @click="emit('edit')">
<div class="nc-column-edit nc-header-menu-item">
<MdiEditIcon class="text-primary" />
<MdiPencil class="text-primary" />
<!-- Edit -->
{{ $t('general.edit') }}
</div>
</a-menu-item>
<a-menu-item v-if="!virtual" @click="setAsPrimaryValue">
<div class="nc-column-set-primary nc-header-menu-item">
<MdiStarIcon class="text-primary" />
<MdiStar class="text-primary" />
<!-- todo : tooltip -->
<!-- Set as Primary value -->
{{ $t('activity.setPrimary') }}
</div>
<!-- <span class="caption font-weight-bold">Primary value will be shown in place of primary key</span> -->
</a-menu-item>
<a-menu-item @click="deleteColumn">
<div class="nc-column-delete nc-header-menu-item">
<MdiDeleteIcon class="text-error" />
<MdiDeleteOutline class="text-error" />
<!-- Delete -->
{{ $t('general.delete') }}
</div>

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

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

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

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

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

@ -9,7 +9,9 @@ import {
IsLockedInj,
IsPublicInj,
computed,
inject,
provide,
ref,
toRef,
useColumn,
useDebounceFn,
@ -42,11 +44,11 @@ provide(EditModeInj, useVModel(props, 'editEnabled', emit))
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)

48
packages/nc-gui-v2/components/smartsheet/Form.vue

@ -144,8 +144,19 @@ function isDbRequired(column: Record<string, any>) {
return isRequired
}
function onMoveCallback(event: any) {
if (shouldSkipColumn(event.draggedContext.element)) {
return false
}
}
function onMove(event: any) {
const { newIndex, element, oldIndex } = event.added || event.moved || event.removed
console.log(event)
if (shouldSkipColumn(element)) {
console.log('SKIPPED')
return
}
if (event.added) {
element.show = true
@ -171,7 +182,7 @@ function onMove(event: any) {
}
function hideColumn(idx: number) {
if (isDbRequired(localColumns.value[idx]) || localColumns.value[idx].required) {
if (shouldSkipColumn(localColumns.value[idx])) {
message.info("Required field can't be moved")
return
}
@ -189,7 +200,7 @@ function hideColumn(idx: number) {
}
async function addAllColumns() {
for (const col of (formColumnData as Record<string, any>)?.value) {
for (const col of (localColumns as Record<string, any>)?.value) {
if (!systemFieldsIds.value.includes(col.fk_column_id)) {
col.show = true
}
@ -198,17 +209,18 @@ async function addAllColumns() {
$e('a:form-view:add-all')
}
function shouldSkipColumn(col: Record<string, any>) {
return isDbRequired(col) || !!col.required || !!col.rqd
}
async function removeAllColumns() {
for (const col of (formColumnData as Record<string, any>)?.value) {
if (isDbRequired(col) || !!col.required) {
continue
}
col.show = false
for (const col of (localColumns as Record<string, any>)?.value) {
if (!shouldSkipColumn(col)) col.show = false
}
await hideAll(
(localColumns as Record<string, any>)?.value
.filter((f: Record<string, any>) => isDbRequired(f) || !!f.required)
.map((f: Record<string, any>) => f.fk_column_id),
.filter((col: Record<string, any>) => shouldSkipColumn(col))
.map((col: Record<string, any>) => col.fk_column_id),
)
$e('a:form-view:remove-all')
}
@ -251,7 +263,12 @@ function setFormData() {
localColumns.value = col
.filter(
(f: Record<string, any>) => f.show && f.uidt !== UITypes.Rollup && f.uidt !== UITypes.Lookup && f.uidt !== UITypes.Formula,
(f: Record<string, any>) =>
f.show &&
f.uidt !== UITypes.Rollup &&
f.uidt !== UITypes.Lookup &&
f.uidt !== UITypes.Formula &&
f.uidt !== UITypes.SpecificDBType,
)
.sort((a: Record<string, any>, b: Record<string, any>) => a.order - b.order)
.map((c: Record<string, any>) => ({ ...c, required: !!(c.required || 0) }))
@ -259,7 +276,13 @@ function setFormData() {
systemFieldsIds.value = getSystemColumns(col).map((c: Record<string, any>) => c.fk_column_id)
hiddenColumns.value = col.filter(
(f: Record<string, any>) => !f.show && !systemFieldsIds.value.includes(f.fk_column_id) && f.uidt !== UITypes.Formula,
(f: Record<string, any>) =>
!f.show &&
!systemFieldsIds.value.includes(f.fk_column_id) &&
f.uidt !== UITypes.Rollup &&
f.uidt !== UITypes.Lookup &&
f.uidt !== UITypes.Formula &&
f.uidt !== UITypes.SpecificDBType,
)
}
@ -447,7 +470,7 @@ onMounted(async () => {
<!-- for future implementation of cover image -->
</div>
<a-card
class="h-full ma-0 rounded-b-0 pa-4"
class="h-full ma-0 rounded-b-0 pa-4 border-none"
:body-style="{
maxWidth: '700px',
margin: '0 auto',
@ -496,6 +519,7 @@ onMounted(async () => {
draggable=".item"
group="form-inputs"
class="h-100"
:move="onMoveCallback"
@change="onMove($event)"
@start="drag = true"
@end="drag = false"

26
packages/nc-gui-v2/components/smartsheet/Grid.vue

@ -45,6 +45,7 @@ const readOnly = inject(ReadonlyInj, false)
const isLocked = inject(IsLockedInj, false)
const reloadViewDataHook = inject(ReloadViewDataHookInj)
const openNewRecordFormHook = inject(OpenNewRecordFormHookInj)
const { isUIAllowed } = useUIPermission()
@ -87,6 +88,7 @@ const {
deleteSelectedRows,
selectedAllRecords,
loadAggCommentsCount,
removeLastEmptyRow,
} = useViewData(meta, view as any, xWhere)
const { loadGridViewColumns, updateWidth, resizingColWidth, resizingCol } = useGridViewColumnWidth(view as any)
@ -107,6 +109,17 @@ reloadViewDataHook?.on(async () => {
loadAggCommentsCount()
})
const expandForm = (row: Row, state?: Record<string, any>) => {
expandedFormRow.value = row
expandedFormRowState.value = state
expandedFormDlg.value = true
}
openNewRecordFormHook?.on(async () => {
const newRow = await addEmptyRow()
expandForm(newRow)
})
const selectCell = (row: number, col: number) => {
selected.row = row
selected.col = col
@ -293,12 +306,6 @@ const onNavigate = (dir: NavigateDir) => {
break
}
}
const expandForm = (row: Row, state: Record<string, any>) => {
expandedFormRow.value = row
expandedFormRowState.value = state
expandedFormDlg.value = true
}
</script>
<template>
@ -354,7 +361,7 @@ const expandForm = (row: Row, state: Record<string, any>) => {
>
<a-dropdown v-model:visible="addColumnDropdown" :trigger="['click']">
<div class="h-full w-[60px] flex align-center justify-center">
<MdiPlus class="text-sm" />
<MdiPlus class="text-sm nc-column-add" />
</div>
<template #overlay>
@ -377,7 +384,7 @@ const expandForm = (row: Row, state: Record<string, any>) => {
<td key="row-index" class="caption nc-grid-cell pl-5 pr-1">
<div class="align-center flex gap-1 min-w-[55px]">
<div
v-if="!readonly && !isLocked"
v-if="!readOnly && !isLocked"
class="nc-row-no text-xs text-gray-500"
:class="{ hidden: row.rowMeta.selected }"
>
@ -391,7 +398,7 @@ const expandForm = (row: Row, state: Record<string, any>) => {
<a-checkbox v-model:checked="row.rowMeta.selected" />
</div>
<span class="flex-1" />
<div v-if="!readonly && !isLocked" class="nc-expand" :class="{ 'nc-comment': row.rowMeta?.commentCount }">
<div v-if="!readOnly && !isLocked" class="nc-expand" :class="{ 'nc-comment': row.rowMeta?.commentCount }">
<span
v-if="row.rowMeta?.commentCount"
class="py-1 px-3 rounded-full text-xs cursor-pointer select-none transform hover:(scale-110)"
@ -515,6 +522,7 @@ const expandForm = (row: Row, state: Record<string, any>) => {
:row="expandedFormRow"
:state="expandedFormRowState"
:meta="meta"
@cancel="removeLastEmptyRow"
/>
</div>
</template>

9
packages/nc-gui-v2/components/smartsheet/expanded-form/index.vue

@ -36,7 +36,7 @@ interface Props {
const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue'])
const emits = defineEmits(['update:modelValue', 'cancel'])
const row = toRef(props, 'row')
@ -91,6 +91,11 @@ watch(
const isExpanded = useVModel(props, 'modelValue', emits, {
defaultValue: false,
})
const onClose = () => {
if (row.value?.rowMeta?.new) emits('cancel')
isExpanded.value = false
}
</script>
<script lang="ts">
@ -101,7 +106,7 @@ export default {
<template>
<a-modal v-model:visible="isExpanded" :footer="null" width="min(90vw,1000px)" :body-style="{ padding: 0 }" :closable="false">
<Header @cancel="isExpanded = false" />
<Header @cancel="onClose" />
<div class="!bg-gray-100 rounded">
<div class="flex h-full nc-form-wrapper items-stretch min-h-[70vh]">
<div class="flex-grow overflow-auto scrollbar-thin-primary">

14
packages/nc-gui-v2/components/smartsheet/sidebar/toolbar/AddRow.vue

@ -1,7 +1,14 @@
<script setup lang="ts">
const emits = defineEmits(['addRow'])
import { OpenNewRecordFormHookInj, inject } from '#imports'
const { isOpen } = useSidebar({ storageKey: 'nc-right-sidebar' })
const isLocked = inject(IsLockedInj)
const openNewRecordFormHook = inject(OpenNewRecordFormHookInj)!
const onClick = () => {
if (!isLocked?.value) openNewRecordFormHook.trigger()
}
</script>
<template>
@ -11,10 +18,7 @@ const isLocked = inject(IsLockedInj)
:class="{ 'hover:after:bg-primary/75 group': !isLocked, 'disabled-ring': isLocked }"
class="nc-sidebar-right-item nc-sidebar-add-row"
>
<MdiPlusOutline
:class="{ 'cursor-pointer group-hover:(!text-white)': !isLocked, 'disabled': isLocked }"
@click="!isLocked ? emits('addRow') : {}"
/>
<MdiPlusOutline :class="{ 'cursor-pointer group-hover:(!text-white)': !isLocked, 'disabled': isLocked }" @click="onClick" />
</div>
</a-tooltip>
</template>

3
packages/nc-gui-v2/components/tabs/Smartsheet.vue

@ -8,6 +8,7 @@ import {
IsFormInj,
IsLockedInj,
MetaInj,
OpenNewRecordFormHookInj,
ReloadViewDataHookInj,
TabMetaInj,
computed,
@ -42,6 +43,7 @@ watchEffect(async () => {
})
const reloadEventHook = createEventHook<void>()
const openNewRecordFormHook = createEventHook<void>()
const { isGallery, isGrid, isForm, isLocked } = useProvideSmartsheetStore(activeView as Ref<TableType>, meta)
@ -54,6 +56,7 @@ provide(TabMetaInj, tabMeta)
provide(ActiveViewInj, activeView)
provide(IsLockedInj, isLocked)
provide(ReloadViewDataHookInj, reloadEventHook)
provide(OpenNewRecordFormHookInj, openNewRecordFormHook)
provide(FieldsInj, fields)
provide(IsFormInj, isForm)

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

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

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

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

11
packages/nc-gui-v2/composables/useViewData.ts

@ -214,6 +214,16 @@ export function useViewData(
oldRow: {},
rowMeta: { new: true },
})
return formattedData.value[addAfter]
}
const removeLastEmptyRow = () => {
const lastRow = formattedData.value[formattedData.value.length - 1]
if (lastRow.rowMeta.new) {
formattedData.value.pop()
}
}
const deleteRowById = async (id: string) => {
@ -356,5 +366,6 @@ export function useViewData(
updateFormView,
aggCommentCount,
loadAggCommentsCount,
removeLastEmptyRow,
}
}

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

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

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

@ -21,6 +21,7 @@ export const CellValueInj: InjectionKey<Ref<any>> = Symbol('cell-value-injection
export const ActiveViewInj: InjectionKey<Ref<ViewType>> = Symbol('active-view-injection')
export const ReadonlyInj: InjectionKey<boolean> = Symbol('readonly-injection')
export const ReloadViewDataHookInj: InjectionKey<EventHook<void>> = Symbol('reload-view-data-injection')
export const OpenNewRecordFormHookInj: InjectionKey<EventHook<void>> = Symbol('open-new-record-form-injection')
export const FieldsInj: InjectionKey<Ref<any[]>> = Symbol('fields-injection')
export const ViewListInj: InjectionKey<Ref<ViewType[]>> = Symbol('view-list-injection')
export const EditModeInj: InjectionKey<Ref<boolean>> = Symbol('edit-mode-injection')

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

@ -50,35 +50,35 @@ const logout = () => {
<GeneralShareBaseButton v-if="!isSharedBase" />
<a-tooltip placement="bottom">
<a-tooltip placement="bottom" :mouse-enter-delay="1">
<template #title> Switch language</template>
<div class="flex pr-4 items-center">
<GeneralLanguage class="cursor-pointer text-2xl" />
<GeneralLanguage class="cursor-pointer text-2xl hover:text-pink-500" />
</div>
</a-tooltip>
<template v-if="signedIn && !isSharedBase">
<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>
<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">
<nuxt-link v-t="['c:navbar:user:email']" class="group flex items-center no-underline py-2" to="/user">
<MdiAt class="mt-1 group-hover:text-success" />&nbsp;
<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-pink-500" />&nbsp;
<span class="prose group-hover:text-black nc-user-menu-email">{{ email }}</span>
<span class="prose">{{ email }}</span>
</nuxt-link>
</a-menu-item>
<a-menu-divider class="!m-0" />
<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">
<MdiLogout class="dark:text-white group-hover:(!text-red-500)" />&nbsp;
<div v-t="['a:navbar:user:sign-out']" class="nc-project-menu-item group" @click="logout">
<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') }}
</span>
</div>
@ -112,10 +112,22 @@ const logout = () => {
@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) {
@apply m-0;
}
:deep(.ant-dropdown-menu-item) {
@apply !py-0 active:(ring ring-pink-500);
}
.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);

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']
}
}
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
public?: boolean
}
}

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

@ -32,6 +32,7 @@
},
"devDependencies": {
"@antfu/eslint-config": "^0.26.0",
"@iconify-json/bi": "^1.1.6",
"@iconify-json/cil": "^1.1.2",
"@iconify-json/clarity": "^1.1.4",
"@iconify-json/eva": "^1.1.2",
@ -40,6 +41,7 @@
"@iconify-json/material-symbols": "^1.1.8",
"@iconify-json/mdi": "^1.1.25",
"@iconify-json/mi": "^1.1.2",
"@iconify-json/ph": "^1.1.2",
"@iconify-json/ri": "^1.1.3",
"@intlify/vite-plugin-vue-i18n": "^6.0.1",
"@nuxt/image-edge": "^1.0.0-27657146.da85542",
@ -996,6 +998,15 @@
"dev": 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": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@iconify-json/cil/-/cil-1.1.2.tgz",
@ -1068,6 +1079,15 @@
"@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": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@iconify-json/ri/-/ri-1.1.3.tgz",
@ -15955,6 +15975,15 @@
"dev": 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": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@iconify-json/cil/-/cil-1.1.2.tgz",
@ -16027,6 +16056,15 @@
"@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": {
"version": "1.1.3",
"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": {
"@antfu/eslint-config": "^0.26.0",
"@iconify-json/bi": "^1.1.6",
"@iconify-json/cil": "^1.1.2",
"@iconify-json/clarity": "^1.1.4",
"@iconify-json/eva": "^1.1.2",
@ -46,6 +47,7 @@
"@iconify-json/material-symbols": "^1.1.8",
"@iconify-json/mdi": "^1.1.25",
"@iconify-json/mi": "^1.1.2",
"@iconify-json/ph": "^1.1.2",
"@iconify-json/ri": "^1.1.3",
"@intlify/vite-plugin-vue-i18n": "^6.0.1",
"@nuxt/image-edge": "^1.0.0-27657146.da85542",

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

@ -1,34 +1,24 @@
<script setup lang="ts">
import type { TabItem } from '~/composables'
import { TabMetaInj, provide, ref, useDialog, useNuxtApp, useProject, 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 { TabMetaInj, useProject, useTabs, useUIPermission } from '#imports'
import MdiAirTableIcon from '~icons/mdi/table-large'
import MdiView from '~icons/mdi/eye-circle-outline'
import MdiAccountGroup from '~icons/mdi/account-group'
const { $e } = useNuxtApp()
const { tabs, activeTabIndex, activeTab, closeTab } = useTabs()
const { isUIAllowed } = useUIPermission()
const { isSharedBase } = useProject()
const tableCreateDialog = ref(false)
const airtableImportDialog = ref(false)
const quickImportDialog = ref(false)
const importType = ref('')
const currentMenu = ref<string[]>(['addORImport'])
const { isSharedBase } = useProject
provide(TabMetaInj, activeTab)
function openQuickImportDialog(type: string) {
quickImportDialog.value = true
importType.value = type
}
const icon = (tab: TabItem) => {
switch (tab.type) {
case TabType.TABLE:
@ -39,6 +29,58 @@ const icon = (tab: TabItem) => {
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>
<template>
@ -49,121 +91,99 @@ const icon = (tab: TabItem) => {
<a-tab-pane v-for="(tab, i) in tabs" :key="i">
<template #tab>
<div class="flex align-center gap-2">
<component :is="icon(tab)" class="text-sm"></component>
<component :is="icon(tab)" class="text-sm" />
{{ tab.title }}
</div>
</template>
</a-tab-pane>
<template #leftExtra>
<a-menu
v-if="isUIAllowed('addOrImport') && !isSharedBase"
v-model:selectedKeys="currentMenu"
class="border-0"
mode="horizontal"
>
<a-sub-menu key="addORImport">
<template #title>
<div class="text-sm flex items-center gap-2 pt-[8px] pb-3">
<MdiPlusBoxOutline />
Add / Import
</div>
</template>
<a-menu-item-group v-if="isUIAllowed('addTable')">
<a-menu-item key="add-new-table" v-t="['a:actions:create-table']" @click="tableCreateDialog = true">
<span class="flex items-center gap-2">
<MdiTable class="text-primary" />
<a-dropdown v-if="isUIAllowed('addOrImport') && !isSharedBase" :trigger="['click']">
<div
class="cursor-pointer color-transition group hover:text-primary text-sm flex items-center gap-2 py-[9.5px] px-[20px]"
>
<MdiPlusBoxOutline class="group-hover:text-pink-500" />
Add / Import
</div>
<template #overlay>
<a-menu class="nc-add-project-menu !py-0 ml-6 rounded text-sm">
<a-menu-item v-if="isUIAllowed('addTable')" key="add-new-table" @click="openTableCreateDialog">
<div class="color-transition nc-project-menu-item after:(!rounded-t) group">
<MdiTable class="group-hover:text-pink-500" />
<!-- Add new table -->
{{ $t('tooltip.addTable') }}
</span>
</a-menu-item>
</a-menu-item-group>
<a-menu-item-group title="QUICK IMPORT FROM">
<a-menu-item
v-if="isUIAllowed('airtableImport')"
key="quick-import-airtable"
v-t="['a:actions:import-airtable']"
@click="airtableImportDialog = true"
>
<span class="flex items-center gap-2">
<MdiTableLarge class="text-primary" />
<!-- TODO: i18n -->
Airtable
</span>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('csvImport')"
key="quick-import-csv"
v-t="['a:actions:import-csv']"
@click="openQuickImportDialog('csv')"
>
<span class="flex items-center gap-2">
<MdiFileDocumentOutline class="text-primary" />
<!-- TODO: i18n -->
CSV file
</span>
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('jsonImport')"
key="quick-import-json"
v-t="['a:actions:import-json']"
@click="openQuickImportDialog('json')"
>
<span class="flex items-center gap-2">
<MdiCodeJson class="text-primary" />
<!-- TODO: i18n -->
JSON file
</span>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('excelImport')"
key="quick-import-excel"
v-t="['a:actions:import-excel']"
@click="openQuickImportDialog('excel')"
>
<span class="flex items-center gap-2">
<MdiFileExcel class="text-primary" />
<!-- TODO: i18n -->
Microsoft Excel
</span>
</a-menu-item>
</a-menu-item-group>
<a-menu-divider class="ma-0 mb-2" />
<a-menu-item
v-if="isUIAllowed('importRequest')"
key="add-new-table"
v-t="['e:datasource:import-request']"
class="ma-0 mt-3"
>
<a href="https://github.com/nocodb/nocodb/issues/2052" target="_blank" class="prose-sm pa-0">
<span class="flex items-center gap-2">
<MdiOpenInNew class="text-primary" />
<a-menu-item-group title="QUICK IMPORT FROM" class="!px-0 !mx-0">
<a-menu-item
v-if="isUIAllowed('airtableImport')"
key="quick-import-airtable"
@click="openAirtableImportDialog"
>
<div class="color-transition nc-project-menu-item group">
<MdiTableLarge class="group-hover:text-pink-500" />
<!-- TODO: i18n -->
Airtable
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('csvImport')" key="quick-import-csv" @click="openQuickImportDialog('csv')">
<div class="color-transition nc-project-menu-item group">
<MdiFileDocumentOutline class="group-hover:text-pink-500" />
<!-- TODO: i18n -->
CSV file
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('jsonImport')" key="quick-import-json" @click="openQuickImportDialog('json')">
<div class="color-transition nc-project-menu-item group">
<MdiCodeJson class="group-hover:text-pink-500" />
<!-- TODO: i18n -->
JSON file
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('excelImport')"
key="quick-import-excel"
@click="openQuickImportDialog('excel')"
>
<div class="color-transition nc-project-menu-item group">
<MdiFileExcel class="group-hover:text-pink-500" />
<!-- TODO: i18n -->
Microsoft Excel
</div>
</a-menu-item>
</a-menu-item-group>
<a-menu-divider class="my-0" />
<a-menu-item v-if="isUIAllowed('importRequest')" key="add-new-table" class="py-1 rounded-b">
<a
v-t="['e:datasource:import-request']"
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)"
>
<MdiOpenInNew class="group-hover:text-pink-500" />
<!-- TODO: i18n -->
Request a data source you need?
</span>
</a>
</a-menu-item>
</a-sub-menu>
</a-menu>
</a>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</a-tabs>
</div>
<div class="w-full min-h-[300px] flex-grow">
<div class="w-full min-h-[300px] flex-auto">
<NuxtPage />
</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>
</template>
@ -192,6 +212,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) {
@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>
<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>

3
packages/nc-gui-v2/pages/index/index.vue

@ -147,7 +147,8 @@ onMounted(() => {
(record) => ({
onClick: () => {
$e('a:project:open')
navigateTo(`/nc/${record.id}`)
navigateTo(`/nc/${record.id}/auth`)
},
class: ['group'],
})

15
packages/nc-gui-v2/pages/signup/[[token]].vue

@ -1,4 +1,5 @@
<script setup lang="ts">
import { validatePassword } from 'nocodb-sdk'
import {
definePageMeta,
extractSdkResponseErrorMsg,
@ -43,7 +44,7 @@ const formRules = {
{
validator: (_: unknown, v: string) => {
return new Promise((resolve, reject) => {
if (isEmail(v)) return resolve(true)
if (!v?.length || isEmail(v)) return resolve(true)
reject(new Error(t('msg.error.signUpRules.emailInvalid')))
})
},
@ -51,9 +52,15 @@ const formRules = {
},
],
password: [
// Password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
{ min: 8, message: t('msg.error.signUpRules.passwdLength') },
{
validator: (_: unknown, v: string) => {
return new Promise((resolve, reject) => {
const { error, valid } = validatePassword(v)
if (valid) return resolve(true)
reject(new Error(error))
})
},
},
],
}

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

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

Loading…
Cancel
Save