Browse Source

chore: merge with develop

pull/4593/head
Wing-Kam Wong 2 years ago
parent
commit
87964a1643
  1. 11
      packages/nc-gui/components.d.ts
  2. 10
      packages/nc-gui/components/cell/DateTimePicker.vue
  3. 4
      packages/nc-gui/components/cell/Json.vue
  4. 2
      packages/nc-gui/components/cell/MultiSelect.vue
  5. 2
      packages/nc-gui/components/cell/TextArea.vue
  6. 4
      packages/nc-gui/components/cell/TimePicker.vue
  7. 4
      packages/nc-gui/components/cell/attachment/Carousel.vue
  8. 640
      packages/nc-gui/components/dashboard/TreeView.vue
  9. 169
      packages/nc-gui/components/dashboard/settings/AppStore.vue
  10. 426
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  11. 8
      packages/nc-gui/components/dashboard/settings/Erd.vue
  12. 11
      packages/nc-gui/components/dashboard/settings/Metadata.vue
  13. 180
      packages/nc-gui/components/dashboard/settings/Modal.vue
  14. 9
      packages/nc-gui/components/dashboard/settings/UIAcl.vue
  15. 615
      packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue
  16. 612
      packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue
  17. 7
      packages/nc-gui/components/dlg/AirtableImport.vue
  18. 2
      packages/nc-gui/components/dlg/QuickImport.vue
  19. 9
      packages/nc-gui/components/dlg/TableCreate.vue
  20. 9
      packages/nc-gui/components/dlg/TableRename.vue
  21. 19
      packages/nc-gui/components/erd/View.vue
  22. 22
      packages/nc-gui/components/general/AddBaseButton.vue
  23. 28
      packages/nc-gui/components/general/BaseLogo.vue
  24. 15
      packages/nc-gui/components/general/ShareBaseButton.vue
  25. 11
      packages/nc-gui/components/general/Tooltip.vue
  26. 9
      packages/nc-gui/components/smartsheet/ApiSnippet.vue
  27. 4
      packages/nc-gui/components/smartsheet/Cell.vue
  28. 11
      packages/nc-gui/components/smartsheet/Grid.vue
  29. 2
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  30. 6
      packages/nc-gui/components/smartsheet/column/AdvancedOptions.vue
  31. 6
      packages/nc-gui/components/smartsheet/column/CurrencyOptions.vue
  32. 2
      packages/nc-gui/components/smartsheet/column/EditOrAddProvider.vue
  33. 16
      packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue
  34. 39
      packages/nc-gui/components/smartsheet/column/LookupOptions.vue
  35. 5
      packages/nc-gui/components/smartsheet/column/QrCodeOptions.vue
  36. 38
      packages/nc-gui/components/smartsheet/column/RollupOptions.vue
  37. 4
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  38. 9
      packages/nc-gui/components/smartsheet/column/utils.ts
  39. 7
      packages/nc-gui/components/smartsheet/header/Cell.vue
  40. 5
      packages/nc-gui/components/smartsheet/header/CellIcon.ts
  41. 2
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  42. 12
      packages/nc-gui/components/smartsheet/toolbar/KanbanStackEditOrAdd.vue
  43. 4
      packages/nc-gui/components/smartsheet/toolbar/MoreActions.vue
  44. 7
      packages/nc-gui/components/smartsheet/toolbar/SearchData.vue
  45. 9
      packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue
  46. 22
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  47. 270
      packages/nc-gui/components/tabs/auth/ApiTokenManagement.vue
  48. 2
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  49. 2
      packages/nc-gui/components/tabs/auth/user-management/UsersModal.vue
  50. 41
      packages/nc-gui/components/template/Editor.vue
  51. 2
      packages/nc-gui/components/virtual-cell/Formula.vue
  52. 27
      packages/nc-gui/components/virtual-cell/components/ListChildItems.vue
  53. 2
      packages/nc-gui/components/webhook/List.vue
  54. 27
      packages/nc-gui/composables/useColumnCreateStore.ts
  55. 2
      packages/nc-gui/composables/useExpandedFormDetached/index.ts
  56. 4
      packages/nc-gui/composables/useGlobal/state.ts
  57. 2
      packages/nc-gui/composables/useLTARStore.ts
  58. 52
      packages/nc-gui/composables/useProject.ts
  59. 4
      packages/nc-gui/composables/useSharedFormViewStore.ts
  60. 9
      packages/nc-gui/composables/useSharedView.ts
  61. 5
      packages/nc-gui/composables/useSmartsheetStore.ts
  62. 9
      packages/nc-gui/composables/useTable.ts
  63. 31
      packages/nc-gui/composables/useTabs.ts
  64. 1
      packages/nc-gui/context/index.ts
  65. 10
      packages/nc-gui/lang/fr.json
  66. 9
      packages/nc-gui/lib/enums.ts
  67. 2
      packages/nc-gui/nuxt.config.ts
  68. 118
      packages/nc-gui/package-lock.json
  69. 3
      packages/nc-gui/package.json
  70. 21
      packages/nc-gui/pages/[projectType]/[projectId]/index.vue
  71. 1
      packages/nc-gui/pages/[projectType]/[projectId]/index/index.vue
  72. 5
      packages/nc-gui/pages/[projectType]/form/[viewId]/index/index.vue
  73. 2
      packages/nc-gui/pages/[projectType]/gallery/[viewId]/index.vue
  74. 2
      packages/nc-gui/pages/[projectType]/kanban/[viewId]/index.vue
  75. 2
      packages/nc-gui/pages/[projectType]/view/[viewId].vue
  76. 5
      packages/nc-gui/pages/forgot-password.vue
  77. 2
      packages/nc-gui/pages/index/apps.vue
  78. 3
      packages/nc-gui/pages/index/index/[projectId].vue
  79. 4
      packages/nc-gui/pages/index/index/create-external.vue
  80. 3
      packages/nc-gui/pages/index/index/create.vue
  81. 3
      packages/nc-gui/pages/projects/index.vue
  82. 7
      packages/nc-gui/pages/projects/index/index.vue
  83. 9
      packages/nc-gui/pages/signup/[[token]].vue
  84. 8
      packages/nc-gui/utils/projectCreateUtils.ts
  85. 2
      packages/nc-gui/utils/sortUtils.ts
  86. 8
      packages/nc-gui/utils/viewUtils.ts
  87. 4
      packages/nc-gui/web-types.json
  88. 195
      packages/nocodb-sdk/src/lib/Api.ts
  89. 2
      packages/nocodb-sdk/src/lib/columnRules/QrCodeRules.ts
  90. 47
      packages/nocodb/package-lock.json
  91. 391
      packages/nocodb/src/lib/meta/api/baseApis.ts
  92. 1
      packages/nocodb/src/lib/meta/api/dataApis/helpers.ts
  93. 1
      packages/nocodb/src/lib/meta/api/dataApis/oldDataApis.ts
  94. 2
      packages/nocodb/src/lib/meta/api/index.ts
  95. 253
      packages/nocodb/src/lib/meta/api/metaDiffApis.ts
  96. 6
      packages/nocodb/src/lib/meta/api/modelVisibilityApis.ts
  97. 40
      packages/nocodb/src/lib/meta/api/projectApis.ts
  98. 15
      packages/nocodb/src/lib/meta/api/sync/helpers/job.ts
  99. 1
      packages/nocodb/src/lib/meta/api/sync/importApis.ts
  100. 14
      packages/nocodb/src/lib/meta/api/sync/syncSourceApis.ts
  101. Some files were not shown because too many files have changed in this diff Show More

11
packages/nc-gui/components.d.ts vendored

@ -10,6 +10,8 @@ declare module '@vue/runtime-core' {
AAlert: typeof import('ant-design-vue/es')['Alert']
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
ABadgeRibbon: typeof import('ant-design-vue/es')['BadgeRibbon']
ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb']
ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACardMeta: typeof import('ant-design-vue/es')['CardMeta']
@ -85,6 +87,8 @@ declare module '@vue/runtime-core' {
IcTwotoneWidthFull: typeof import('~icons/ic/twotone-width-full')['default']
IcTwotoneWidthNormal: typeof import('~icons/ic/twotone-width-normal')['default']
LogosGoogleGmail: typeof import('~icons/logos/google-gmail')['default']
LogosMysqlIcon: typeof import('~icons/logos/mysql-icon')['default']
LogosPostgresql: typeof import('~icons/logos/postgresql')['default']
LogosRedditIcon: typeof import('~icons/logos/reddit-icon')['default']
LogosSwagger: typeof import('~icons/logos/swagger')['default']
MaterialSymbolsAccountTreeRounded: typeof import('~icons/material-symbols/account-tree-rounded')['default']
@ -144,7 +148,10 @@ declare module '@vue/runtime-core' {
MdiContentSave: typeof import('~icons/mdi/content-save')['default']
MdiContentSaveEdit: typeof import('~icons/mdi/content-save-edit')['default']
MdiCurrencyUsd: typeof import('~icons/mdi/currency-usd')['default']
MdiDatabaseAlert: typeof import('~icons/mdi/database-alert')['default']
MdiDatabaseLockOutline: typeof import('~icons/mdi/database-lock-outline')['default']
MdiDatabaseOutline: typeof import('~icons/mdi/database-outline')['default']
MdiDatabasePlusOutline: typeof import('~icons/mdi/database-plus-outline')['default']
MdiDatabaseSync: typeof import('~icons/mdi/database-sync')['default']
MdiDelete: typeof import('~icons/mdi/delete')['default']
MdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
@ -176,6 +183,7 @@ declare module '@vue/runtime-core' {
MdiFunction: typeof import('~icons/mdi/function')['default']
MdiGestureDoubleTap: typeof import('~icons/mdi/gesture-double-tap')['default']
MdiGithub: typeof import('~icons/mdi/github')['default']
MdiGraphOutline: typeof import('~icons/mdi/graph-outline')['default']
MdiHeart: typeof import('~icons/mdi/heart')['default']
MdiHook: typeof import('~icons/mdi/hook')['default']
MdiInformation: typeof import('~icons/mdi/information')['default']
@ -235,7 +243,10 @@ declare module '@vue/runtime-core' {
MiCircleWarning: typeof import('~icons/mi/circle-warning')['default']
PhCloudLightningDuotone: typeof import('~icons/ph/cloud-lightning-duotone')['default']
PhFileCsv: typeof import('~icons/ph/file-csv')['default']
RiTeamFill: typeof import('~icons/ri/team-fill')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SimpleIconsMicrosoftsqlserver: typeof import('~icons/simple-icons/microsoftsqlserver')['default']
VscodeIconsFileTypeSqlite: typeof import('~icons/vscode-icons/file-type-sqlite')['default']
}
}

10
packages/nc-gui/components/cell/DateTimePicker.vue

@ -31,14 +31,14 @@ const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
const columnMeta = inject(ColumnInj, null)!
const column = inject(ColumnInj)!
let isDateInvalid = $ref(false)
const dateTimeFormat = $computed(() => {
const dateFormat = columnMeta?.value?.meta?.date_format ?? dateFormats[0]
const timeFormat = columnMeta?.value?.meta?.time_format ?? timeFormats[0]
return `${dateFormat} ${timeFormat}${!isMysql ? 'Z' : ''}`
const dateFormat = column?.value?.meta?.date_format ?? dateFormats[0]
const timeFormat = column?.value?.meta?.time_format ?? timeFormats[0]
return `${dateFormat} ${timeFormat}`
})
let localState = $computed({
@ -61,7 +61,7 @@ let localState = $computed({
}
if (val.isValid()) {
emit('update:modelValue', val?.format('YYYY-MM-DD HH:mm:ss'))
emit('update:modelValue', val?.format(isMysql(column.value.base_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'))
}
},
})

4
packages/nc-gui/components/cell/Json.vue

@ -135,13 +135,13 @@ useSelectedCellKeyupListener(active, (e) => {
<a-button type="text" size="small" :onclick="clear"><div class="text-xs">Cancel</div></a-button>
<a-button type="primary" size="small" :disabled="!!error || localValue === vModel">
<div class="text-xs" :onclick="onSave">Save</div>
<div class="text-xs" @click="onSave">Save</div>
</a-button>
</div>
</div>
<LazyMonacoEditor
:model-value="localValue"
:model-value="localValue || ''"
class="min-w-full w-80"
:class="{ 'expanded-editor': isExpanded, 'editor': !isExpanded }"
:hide-minimap="true"

2
packages/nc-gui/components/cell/MultiSelect.vue

@ -105,7 +105,7 @@ const vModel = computed({
const selectedTitles = computed(() =>
modelValue
? typeof modelValue === 'string'
? isMysql
? isMysql(column.value.base_id)
? modelValue.split(',').sort((a, b) => {
const opa = options.value.find((el) => el.title === a)
const opb = options.value.find((el) => el.title === b)

2
packages/nc-gui/components/cell/TextArea.vue

@ -3,7 +3,7 @@ import type { VNodeRef } from '@vue/runtime-core'
import { EditModeInj, inject, useVModel } from '#imports'
const props = defineProps<{
modelValue?: string | null
modelValue?: string | number
}>()
const emits = defineEmits(['update:modelValue'])

4
packages/nc-gui/components/cell/TimePicker.vue

@ -19,9 +19,11 @@ const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
const column = inject(ColumnInj)!
let isTimeInvalid = $ref(false)
const dateFormat = isMysql.value ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
const dateFormat = isMysql(column.value.base_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
const localState = $computed({
get() {

4
packages/nc-gui/components/cell/attachment/Carousel.vue

@ -47,7 +47,7 @@ onClickOutside(carouselRef, () => {
</script>
<template>
<general-overlay v-model="selectedImage" :z-index="1001">
<GeneralOverlay v-model="selectedImage" :z-index="1001">
<template v-if="selectedImage">
<div class="overflow-hidden p-12 text-center relative">
<div class="text-white group absolute top-5 right-5">
@ -101,7 +101,7 @@ onClickOutside(carouselRef, () => {
</a-carousel>
</div>
</template>
</general-overlay>
</GeneralOverlay>
</template>
<style scoped>

640
packages/nc-gui/components/dashboard/TreeView.vue

@ -5,6 +5,7 @@ import Sortable from 'sortablejs'
import GithubButton from 'vue-github-button'
import type { VNodeRef } from '#imports'
import {
ClientType,
Empty,
TabType,
computed,
@ -30,7 +31,7 @@ const { addTab } = useTabs()
const { $api, $e } = useNuxtApp()
const { tables, loadTables, isSharedBase } = useProject()
const { bases, tables, loadTables, isSharedBase } = useProject()
const { activeTab } = useTabs()
@ -42,13 +43,17 @@ const route = useRoute()
const [searchActive, toggleSearchActive] = useToggle()
let key = $ref(0)
const toggleDialog = inject(ToggleDialogInj, () => {})
const menuRef = $ref<HTMLLIElement>()
const keys = $ref<Record<string, number>>({})
const activeKey = ref<string[]>([])
const menuRefs = $ref<HTMLElement[] | HTMLElement>()
let filterQuery = $ref('')
const activeTable = computed(() => ([TabType.TABLE, TabType.VIEW].includes(activeTab.value?.type) ? activeTab.value.title : null))
const activeTable = computed(() => ([TabType.TABLE, TabType.VIEW].includes(activeTab.value?.type) ? activeTab.value.id : null))
const tablesById = $computed(() =>
tables.value?.reduce<Record<string, TableType>>((acc, table) => {
@ -59,17 +64,23 @@ const tablesById = $computed(() =>
)
const filteredTables = $computed(() =>
tables.value?.filter((table) => !filterQuery || table.title.toLowerCase().includes(filterQuery.toLowerCase())),
tables.value?.filter(
(table) => !searchActive.value || !filterQuery || table.title.toLowerCase().includes(filterQuery.toLowerCase()),
),
)
let sortable: Sortable
const sortables: Record<string, Sortable> = {}
// todo: replace with vuedraggable
const initSortable = (el: Element) => {
if (sortable) sortable.destroy()
sortable = Sortable.create(el as HTMLLIElement, {
const base_id = el.getAttribute('nc-base')
if (!base_id) return
if (sortables[base_id]) sortables[base_id].destroy()
Sortable.create(el as HTMLLIElement, {
handle: '.nc-drag-icon',
onEnd: async (evt) => {
const offset = tables.value.findIndex((table) => table.base_id === base_id)
const { newIndex = 0, oldIndex = 0 } = evt
const itemEl = evt.item as HTMLLIElement
@ -99,10 +110,14 @@ const initSortable = (el: Element) => {
}
// update the order of the moved item
tables.value?.splice(newIndex, 0, ...tables.value?.splice(oldIndex, 1))
tables.value?.splice(newIndex + offset, 0, ...tables.value?.splice(oldIndex + offset, 1))
// force re-render the list
key++
if (keys[base_id]) {
keys[base_id] = keys[base_id] + 1
} else {
keys[base_id] = 1
}
// update the item order
await $api.dbTable.reorder(item.id as string, {
@ -114,8 +129,12 @@ const initSortable = (el: Element) => {
}
watchEffect(() => {
if (menuRef) {
initSortable(menuRef)
if (menuRefs) {
if (menuRefs instanceof HTMLElement) {
initSortable(menuRefs)
} else {
menuRefs.forEach((el) => initSortable(el))
}
}
})
@ -145,7 +164,7 @@ const addTableTab = (table: TableType) => {
addTab({ title: table.title, id: table.id, type: table.type as TabType })
}
function openRenameTableDialog(table: TableType, rightClick = false) {
function openRenameTableDialog(table: TableType, baseId?: string, rightClick = false) {
$e(rightClick ? 'c:table:rename:navdraw:right-click' : 'c:table:rename:navdraw:options')
const isOpen = ref(true)
@ -153,6 +172,7 @@ function openRenameTableDialog(table: TableType, rightClick = false) {
const { close } = useDialog(resolveComponent('DlgTableRename'), {
'modelValue': isOpen,
'tableMeta': table,
'baseId': baseId || bases.value[0].id,
'onUpdate:modelValue': closeDialog,
})
@ -163,7 +183,7 @@ function openRenameTableDialog(table: TableType, rightClick = false) {
}
}
function openQuickImportDialog(type: string) {
function openQuickImportDialog(type: string, baseId?: string) {
$e(`a:actions:import-${type}`)
const isOpen = ref(true)
@ -171,6 +191,7 @@ function openQuickImportDialog(type: string) {
const { close } = useDialog(resolveComponent('DlgQuickImport'), {
'modelValue': isOpen,
'importType': type,
'baseId': baseId || bases.value[0].id,
'onUpdate:modelValue': closeDialog,
})
@ -181,13 +202,14 @@ function openQuickImportDialog(type: string) {
}
}
function openAirtableImportDialog() {
function openAirtableImportDialog(baseId?: string) {
$e('a:actions:import-airtable')
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgAirtableImport'), {
'modelValue': isOpen,
'baseId': baseId || bases.value[0].id,
'onUpdate:modelValue': closeDialog,
})
@ -198,13 +220,14 @@ function openAirtableImportDialog() {
}
}
function openTableCreateDialog() {
function openTableCreateDialog(baseId?: string) {
$e('c:table:create:navdraw')
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgTableCreate'), {
'modelValue': isOpen,
'baseId': baseId || bases.value[0].id,
'onUpdate:modelValue': closeDialog,
})
@ -217,9 +240,18 @@ function openTableCreateDialog() {
const searchInputRef: VNodeRef = (vnode: typeof Input) => vnode?.$el?.focus()
const beforeSearch = ref<string[]>([])
const onSearchCloseIconClick = () => {
filterQuery = ''
toggleSearchActive(false)
activeKey.value = beforeSearch.value
}
const onSearchIconClick = () => {
beforeSearch.value = activeKey.value
toggleSearchActive(true)
activeKey.value = bases.value.filter((el) => el.enabled).map((el) => `collapse-${el.id}`)
}
const isCreateTableAllowed = computed(
@ -249,13 +281,35 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
}
}
})
watch(
activeTable,
(value, oldValue) => {
if (value) {
if (value !== oldValue) {
const fndTable = tables.value.find((el) => el.id === value)
if (fndTable) {
activeKey.value = [`collapse-${fndTable.base_id}`]
}
}
} else {
if (bases.value.filter((el) => el.enabled)[0]?.id)
activeKey.value = [`collapse-${bases.value.filter((el) => el.enabled)[0].id}`]
}
},
{ immediate: true },
)
</script>
<template>
<div class="nc-treeview-container flex flex-col">
<a-dropdown :trigger="['contextmenu']" overlay-class-name="nc-dropdown-tree-view-context-menu">
<div class="pt-2 pl-2 pb-2 flex-1 overflow-y-auto flex flex-col scrollbar-thin-dull" :class="{ 'mb-[20px]': isSharedBase }">
<div class="min-h-[36px] py-1 px-3 flex w-full items-center gap-1 cursor-pointer" @contextmenu="setMenuContext('main')">
<div
v-if="bases[0] && bases[0].enabled && !bases.slice(1).filter((el) => el.enabled)?.length"
class="min-h-[36px] py-1 px-3 flex w-full items-center gap-1 cursor-pointer"
@contextmenu="setMenuContext('main')"
>
<Transition name="slide-left" mode="out-in">
<a-input
v-if="searchActive"
@ -268,7 +322,9 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
<span v-else class="flex-1 text-bold uppercase nc-project-tree text-gray-500 font-weight-bold">
{{ $t('objects.tables') }}
<template v-if="tables?.length"> ({{ tables.length }}) </template>
<template v-if="tables.filter((table) => table.base_id === bases[0].id)?.length">
({{ tables.filter((table) => table.base_id === bases[0].id).length }})
</template>
</span>
</Transition>
@ -277,19 +333,98 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
<IcRoundSearch v-else class="text-lg text-primary mx-1 mt-0.5" @click="toggleSearchActive(true)" />
</Transition>
</div>
<div
v-else
class="min-h-[36px] py-1 px-3 flex w-full items-center gap-1 cursor-pointer"
@contextmenu="setMenuContext('main')"
>
<Transition name="slide-left" mode="out-in">
<a-input
v-if="searchActive"
:ref="searchInputRef"
v-model:value="filterQuery"
class="flex-1 rounded"
:placeholder="$t('placeholder.searchProjectTree')"
/>
<span v-else class="flex-1 text-bold uppercase nc-project-tree text-gray-500 font-weight-bold">
BASES
<template v-if="tables.filter((table) => table.base_id === bases[0].id)?.length">
({{ bases.filter((el) => el.enabled).length }})
</template>
</span>
</Transition>
<Transition name="slide-right" mode="out-in">
<MdiClose v-if="searchActive" class="text-lg mx-1 mt-0.5" @click="onSearchCloseIconClick" />
<IcRoundSearch v-else class="text-lg text-primary mx-1 mt-0.5" @click="onSearchIconClick" />
</Transition>
<a-dropdown v-if="!isSharedBase" :trigger="['click']" overlay-class-name="nc-dropdown-import-menu" @click.stop>
<Transition name="slide-right" mode="out-in">
<MdiDotsVertical v-if="!searchActive" class="hover:text-accent outline-0" />
</Transition>
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
<a-menu-item-group title="Connect to new datasource" class="!px-0 !mx-0">
<a-menu-item key="connect-new-source" @click="toggleDialog(true, 'dataSources', ClientType.MYSQL)">
<div class="color-transition nc-project-menu-item group">
<LogosMysqlIcon class="group-hover:text-accent" />
MySQL
</div>
</a-menu-item>
<a-menu-item key="connect-new-source" @click="toggleDialog(true, 'dataSources', ClientType.PG)">
<div class="color-transition nc-project-menu-item group">
<LogosPostgresql class="group-hover:text-accent" />
Postgres
</div>
</a-menu-item>
<a-menu-item key="connect-new-source" @click="toggleDialog(true, 'dataSources', ClientType.SQLITE)">
<div class="color-transition nc-project-menu-item group">
<VscodeIconsFileTypeSqlite class="group-hover:text-accent" />
SQLite
</div>
</a-menu-item>
<a-menu-item key="connect-new-source" @click="toggleDialog(true, 'dataSources', ClientType.MSSQL)">
<div class="color-transition nc-project-menu-item group">
<SimpleIconsMicrosoftsqlserver class="group-hover:text-accent" />
MSSQL
</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-e="['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-accent" />
<!-- Request a data source you need? -->
{{ $t('labels.requestDataSource') }}
</a>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div class="flex-1">
<div v-if="bases[0] && bases[0].enabled && !bases.slice(1).filter((el) => el.enabled)?.length" class="flex-1">
<div
v-if="isUIAllowed('table-create')"
class="group flex items-center gap-2 pl-5 pr-3 py-2 text-primary/70 hover:(text-primary/100) cursor-pointer select-none"
@click="openTableCreateDialog"
class="group flex items-center gap-2 pl-8 pr-3 py-2 text-primary/70 hover:(text-primary/100) cursor-pointer select-none"
@click="openTableCreateDialog(bases[0].id)"
>
<MdiPlus />
<span class="text-gray-500 group-hover:(text-primary/100) flex-1 nc-add-new-table">{{ $t('tooltip.addTable') }}</span>
<a-dropdown v-if="!isSharedBase" :trigger="['click']" overlay-class-name="nc-dropdown-import-menu" @click.stop>
<MdiDotsVertical class="transition-opacity opacity-0 group-hover:opacity-100 nc-import-menu" />
<MdiDotsVertical class="transition-opacity opacity-0 group-hover:opacity-100 nc-import-menu outline-0" />
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
@ -298,7 +433,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
<a-menu-item
v-if="isUIAllowed('airtableImport')"
key="quick-import-airtable"
@click="openAirtableImportDialog"
@click="openAirtableImportDialog(bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiTableLarge class="group-hover:text-accent" />
@ -306,14 +441,22 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('csvImport')" key="quick-import-csv" @click="openQuickImportDialog('csv')">
<a-menu-item
v-if="isUIAllowed('csvImport')"
key="quick-import-csv"
@click="openQuickImportDialog('csv', bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiFileDocumentOutline class="group-hover:text-accent" />
CSV file
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('jsonImport')" key="quick-import-json" @click="openQuickImportDialog('json')">
<a-menu-item
v-if="isUIAllowed('jsonImport')"
key="quick-import-json"
@click="openQuickImportDialog('json', bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiCodeJson class="group-hover:text-accent" />
JSON file
@ -323,7 +466,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
<a-menu-item
v-if="isUIAllowed('excelImport')"
key="quick-import-excel"
@click="openQuickImportDialog('excel')"
@click="openQuickImportDialog('excel', bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiFileExcel class="group-hover:text-accent" />
@ -334,6 +477,35 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
<a-menu-divider class="my-0" />
<a-menu-item-group title="Connect to new datasource" class="!px-0 !mx-0">
<a-menu-item key="connect-new-source" @click="toggleDialog(true, 'dataSources', ClientType.MYSQL)">
<div class="color-transition nc-project-menu-item group">
<LogosMysqlIcon class="group-hover:text-accent" />
MySQL
</div>
</a-menu-item>
<a-menu-item key="connect-new-source" @click="toggleDialog(true, 'dataSources', ClientType.PG)">
<div class="color-transition nc-project-menu-item group">
<LogosPostgresql class="group-hover:text-accent" />
Postgres
</div>
</a-menu-item>
<a-menu-item key="connect-new-source" @click="toggleDialog(true, 'dataSources', ClientType.SQLITE)">
<div class="color-transition nc-project-menu-item group">
<VscodeIconsFileTypeSqlite class="group-hover:text-accent" />
SQLite
</div>
</a-menu-item>
<a-menu-item key="connect-new-source" @click="toggleDialog(true, 'dataSources', ClientType.MSSQL)">
<div class="color-transition nc-project-menu-item group">
<SimpleIconsMicrosoftsqlserver class="group-hover:text-accent" />
MSSQL
</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-e="['e:datasource:import-request']"
@ -351,88 +523,380 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
</a-dropdown>
</div>
<div v-if="tables.length" class="transition-height duration-200 overflow-hidden">
<div :key="key" ref="menuRef" class="border-none sortable-list">
<div
v-for="table of tables"
:key="table.id"
v-e="['a:table:open']"
:class="[
{ hidden: !filteredTables?.includes(table), active: activeTable === table.title },
`nc-project-tree-tbl nc-project-tree-tbl-${table.title}`,
]"
class="nc-tree-item text-sm cursor-pointer group"
:data-order="table.order"
:data-id="table.id"
:data-testid="`tree-view-table-${table.title}`"
@click="addTableTab(table)"
<div class="transition-height duration-200">
<div class="border-none sortable-list">
<div v-if="bases[0]" :key="`base-${bases[0].id}`">
<div
v-if="bases[0] && bases[0].enabled"
ref="menuRefs"
:key="`sortable-${bases[0].id}-${bases[0].id && bases[0].id in keys ? keys[bases[0].id] : '0'}`"
:nc-base="bases[0].id"
>
<div
v-for="table of tables.filter((table) => table.base_id === bases[0].id)"
:key="table.id"
v-e="['a:table:open']"
:class="[
{ hidden: !filteredTables?.includes(table), active: activeTable === table.id },
`nc-project-tree-tbl nc-project-tree-tbl-${table.title}`,
]"
class="nc-tree-item text-sm cursor-pointer group"
:data-order="table.order"
:data-id="table.id"
:data-testid="`tree-view-table-${table.title}`"
@click="addTableTab(table)"
>
<GeneralTooltip class="pl-8 pr-3 py-2" modifier-key="Alt">
<template #title>{{ table.table_name }}</template>
<div class="flex items-center gap-2 h-full" @contextmenu="setMenuContext('table', table)">
<div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`">
<MdiDragVertical
v-if="isUIAllowed('treeview-drag-n-drop')"
:class="`nc-child-draggable-icon-${table.title}`"
class="nc-drag-icon text-xs hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 cursor-move"
@click.stop.prevent
/>
<component
:is="icon(table)"
class="nc-view-icon text-xs"
:class="{ 'group-hover:hidden group-hover:text-gray-500': isUIAllowed('treeview-drag-n-drop') }"
/>
</div>
<div class="nc-tbl-title flex-1">
<GeneralTruncateText>{{ table.title }}</GeneralTruncateText>
</div>
<a-dropdown
v-if="!isSharedBase && (isUIAllowed('table-rename') || isUIAllowed('table-delete'))"
:trigger="['click']"
@click.stop
>
<MdiDotsVertical class="transition-opacity opacity-0 group-hover:opacity-100 outline-0" />
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
<a-menu-item v-if="isUIAllowed('table-rename')" @click="openRenameTableDialog(table, bases[0].id)">
<div class="nc-project-menu-item" :data-testid="`sidebar-table-rename-${table.title}`">
{{ $t('general.rename') }}
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('table-delete')"
:data-testid="`sidebar-table-delete-${table.title}`"
@click="deleteTable(table)"
>
<div class="nc-project-menu-item">
{{ $t('general.delete') }}
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</GeneralTooltip>
</div>
</div>
</div>
</div>
</div>
<div
v-if="!tables.filter((table) => table.base_id === bases[0].id)?.length"
class="mt-0.5 pt-16 mx-3 flex flex-col items-center border-t-1 border-gray-50"
>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
</div>
<div v-else class="transition-height duration-200">
<div class="border-none sortable-list">
<div v-for="[index, base] of Object.entries(bases)" :key="`base-${base.id}`">
<a-collapse
v-if="base && base.enabled"
v-model:activeKey="activeKey"
:class="[{ hidden: searchActive && !!filterQuery && !filteredTables?.find((el) => el.base_id === base.id) }]"
expand-icon-position="right"
:bordered="false"
:accordion="!searchActive"
ghost
>
<GeneralTooltip class="pl-5 pr-3 py-2" modifier-key="Alt">
<template #title>{{ table.table_name }}</template>
<div class="flex items-center gap-2 h-full" @contextmenu="setMenuContext('table', table)">
<div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`">
<MdiDragVertical
v-if="isUIAllowed('treeview-drag-n-drop')"
:class="`nc-child-draggable-icon-${table.title}`"
class="nc-drag-icon text-xs hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 cursor-move"
@click.stop.prevent
/>
<component
:is="icon(table)"
class="nc-view-icon text-xs"
:class="{ 'group-hover:hidden group-hover:text-gray-500': isUIAllowed('treeview-drag-n-drop') }"
/>
<a-collapse-panel :key="`collapse-${base.id}`">
<template #header>
<div v-if="index === '0'" class="flex items-center gap-2 text-gray-500 font-bold">
<GeneralBaseLogo :base-type="base.type" />
Default ({{ tables.filter((table) => table.base_id === base.id).length || '0' }})
</div>
<div class="nc-tbl-title flex-1">
<GeneralTruncateText>{{ table.title }}</GeneralTruncateText>
<div v-else class="flex items-center gap-2 text-gray-500 font-bold">
<GeneralBaseLogo :base-type="base.type" />
{{ base.alias || '' }}
({{ tables.filter((table) => table.base_id === base.id).length || '0' }})
</div>
</template>
<div
v-if="index === '0' && isUIAllowed('table-create')"
class="group flex items-center gap-2 pl-8 pr-3 py-2 text-primary/70 hover:(text-primary/100) cursor-pointer select-none"
@click="openTableCreateDialog(bases[0].id)"
>
<MdiPlus />
<span class="text-gray-500 group-hover:(text-primary/100) flex-1 nc-add-new-table">{{
$t('tooltip.addTable')
}}</span>
<a-dropdown
v-if="!isSharedBase && (isUIAllowed('table-rename') || isUIAllowed('table-delete'))"
v-if="!isSharedBase"
:trigger="['click']"
overlay-class-name="nc-dropdown-import-menu"
@click.stop
>
<MdiDotsVertical class="transition-opacity opacity-0 group-hover:opacity-100" />
<MdiDotsVertical class="transition-opacity opacity-0 group-hover:opacity-100 nc-import-menu outline-0" />
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
<a-menu-item v-if="isUIAllowed('table-rename')" @click="openRenameTableDialog(table)">
<div class="nc-project-menu-item" :data-testid="`sidebar-table-rename-${table.title}`">
{{ $t('general.rename') }}
</div>
<!-- Quick Import From -->
<a-menu-item-group :title="$t('title.quickImportFrom')" class="!px-0 !mx-0">
<a-menu-item
v-if="isUIAllowed('airtableImport')"
key="quick-import-airtable"
@click="openAirtableImportDialog(bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiTableLarge class="group-hover:text-accent" />
Airtable
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('csvImport')"
key="quick-import-csv"
@click="openQuickImportDialog('csv', bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiFileDocumentOutline class="group-hover:text-accent" />
CSV file
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('jsonImport')"
key="quick-import-json"
@click="openQuickImportDialog('json', bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiCodeJson class="group-hover:text-accent" />
JSON file
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('excelImport')"
key="quick-import-excel"
@click="openQuickImportDialog('excel', bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiFileExcel class="group-hover:text-accent" />
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-e="['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-accent" />
<!-- Request a data source you need? -->
{{ $t('labels.requestDataSource') }}
</a>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div
v-else-if="isUIAllowed('table-create')"
class="group flex items-center gap-2 pl-8 pr-3 py-2 text-primary/70 hover:(text-primary/100) cursor-pointer select-none"
@click="openTableCreateDialog(base.id)"
>
<MdiPlus />
<a-menu-item
v-if="isUIAllowed('table-delete')"
:data-testid="`sidebar-table-delete-${table.title}`"
@click="deleteTable(table)"
>
<div class="nc-project-menu-item">
{{ $t('general.delete') }}
</div>
<span class="text-gray-500 group-hover:(text-primary/100) flex-1 nc-add-new-table">{{
$t('tooltip.addTable')
}}</span>
<a-dropdown
v-if="!isSharedBase"
:trigger="['click']"
overlay-class-name="nc-dropdown-import-menu"
@click.stop
>
<MdiDotsVertical class="transition-opacity opacity-0 group-hover:opacity-100 nc-import-menu outline-0" />
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
<!-- Quick Import From -->
<a-menu-item-group :title="$t('title.quickImportFrom')" class="!px-0 !mx-0">
<a-menu-item
v-if="isUIAllowed('airtableImport')"
key="quick-import-airtable"
@click="openAirtableImportDialog(base.id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiTableLarge class="group-hover:text-accent" />
Airtable
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('csvImport')"
key="quick-import-csv"
@click="openQuickImportDialog('csv', base.id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiFileDocumentOutline class="group-hover:text-accent" />
CSV file
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('jsonImport')"
key="quick-import-json"
@click="openQuickImportDialog('json', base.id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiCodeJson class="group-hover:text-accent" />
JSON file
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('excelImport')"
key="quick-import-excel"
@click="openQuickImportDialog('excel', base.id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiFileExcel class="group-hover:text-accent" />
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-e="['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-accent" />
<!-- Request a data source you need? -->
{{ $t('labels.requestDataSource') }}
</a>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</GeneralTooltip>
</div>
<div
ref="menuRefs"
:key="`sortable-${base.id}-${base.id && base.id in keys ? keys[base.id] : '0'}`"
:nc-base="base.id"
>
<div
v-for="table of tables.filter((table) => table.base_id === base.id)"
:key="table.id"
v-e="['a:table:open']"
:class="[
{ hidden: !filteredTables?.includes(table), active: activeTable === table.id },
`nc-project-tree-tbl nc-project-tree-tbl-${table.title}`,
]"
class="nc-tree-item text-sm cursor-pointer group"
:data-order="table.order"
:data-id="table.id"
:data-testid="`tree-view-table-${table.title}`"
@click="addTableTab(table)"
>
<GeneralTooltip class="pl-8 pr-3 py-2" modifier-key="Alt">
<template #title>{{ table.table_name }}</template>
<div class="flex items-center gap-2 h-full" @contextmenu="setMenuContext('table', table)">
<div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`">
<MdiDragVertical
v-if="isUIAllowed('treeview-drag-n-drop')"
:class="`nc-child-draggable-icon-${table.title}`"
class="nc-drag-icon text-xs hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 cursor-move"
@click.stop.prevent
/>
<component
:is="icon(table)"
class="nc-view-icon text-xs"
:class="{ 'group-hover:hidden group-hover:text-gray-500': isUIAllowed('treeview-drag-n-drop') }"
/>
</div>
<div class="nc-tbl-title flex-1">
<GeneralTruncateText>{{ table.title }}</GeneralTruncateText>
</div>
<a-dropdown
v-if="!isSharedBase && (isUIAllowed('table-rename') || isUIAllowed('table-delete'))"
:trigger="['click']"
@click.stop
>
<MdiDotsVertical class="transition-opacity opacity-0 group-hover:opacity-100 outline-0" />
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
<a-menu-item
v-if="isUIAllowed('table-rename')"
:data-testid="`sidebar-table-rename-${table.title}`"
@click="openRenameTableDialog(table, base.id)"
>
<div class="nc-project-menu-item">
{{ $t('general.rename') }}
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('table-delete')"
:data-testid="`sidebar-table-delete-${table.title}`"
@click="deleteTable(table)"
>
<div class="nc-project-menu-item">
{{ $t('general.delete') }}
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</GeneralTooltip>
</div>
</div>
</a-collapse-panel>
</a-collapse>
</div>
</div>
<div v-else class="mt-0.5 pt-16 mx-3 flex flex-col items-center border-t-1 border-gray-50">
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
</div>
</div>
<template v-if="!isSharedBase" #overlay>
<a-menu class="!py-0 rounded text-sm">
<template v-if="contextMenuTarget.type === 'table'">
<a-menu-item v-if="isUIAllowed('table-rename')" @click="openRenameTableDialog(contextMenuTarget.value, true)">
<a-menu-item
v-if="isUIAllowed('table-rename')"
@click="openRenameTableDialog(contextMenuTarget.value, bases[0].id, true)"
>
<div class="nc-project-menu-item">
{{ $t('general.rename') }}
</div>
@ -459,7 +923,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
<a-divider class="!my-0" />
<div class="flex items-start flex-col justify-start px-2 py-3 gap-2">
<LazyGeneralShareBaseButton
<LazyGeneralAddBaseButton
class="color-transition py-1.5 px-2 text-primary font-bold cursor-pointer select-none hover:text-accent"
/>
@ -527,7 +991,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
}
.nc-tree-item {
@apply relative cursor-pointer after:(pointer-events-none content-[''] absolute top-0 left-0 w-full h-full right-0 !bg-current transition transition-opactity duration-100 opacity-0);
@apply relative cursor-pointer after:(pointer-events-none content-[''] absolute top-0 left-0 w-full h-full right-0 !bg-current transition transition-opactity duration-100 opacity-0);
}
.nc-tree-item svg {
@ -576,4 +1040,12 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
:deep(.ant-dropdown-menu-title-content) {
@apply !p-0;
}
:deep(.ant-collapse-content-box) {
@apply !p-0;
}
:deep(.ant-collapse-header) {
@apply !border-0;
}
</style>

169
packages/nc-gui/components/dashboard/settings/AppStore.vue

@ -70,96 +70,97 @@ onMounted(async () => {
</script>
<template>
<a-modal
v-model:visible="showPluginInstallModal"
:class="{ active: showPluginInstallModal }"
:closable="false"
centered
min-height="300"
:footer="null"
wrap-class-name="nc-modal-plugin-install"
v-bind="$attrs"
>
<LazyDashboardSettingsAppInstall
v-if="pluginApp && showPluginInstallModal"
:id="pluginApp.id"
@close="showPluginInstallModal = false"
@saved="saved()"
/>
</a-modal>
<a-modal
v-model:visible="showPluginUninstallModal"
:class="{ active: showPluginUninstallModal }"
:closable="false"
width="24rem"
centered
:footer="null"
wrap-class-name="nc-modal-plugin-uninstall"
>
<div class="flex flex-col h-full">
<div class="flex flex-row justify-center mt-2 text-center w-full text-base">
{{ `Click on confirm to reset ${pluginApp && pluginApp.title}` }}
</div>
<div class="flex mt-6 justify-center space-x-2">
<a-button @click="showPluginUninstallModal = false"> {{ $t('general.cancel') }} </a-button>
<a-button type="primary" danger @click="resetPlugin"> {{ $t('general.confirm') }} </a-button>
</div>
</div>
</a-modal>
<div class="grid grid-cols-2 gap-x-2 gap-y-4 mt-4">
<a-card
v-for="(app, i) in apps"
:key="i"
:class="`relative flex overflow-x-hidden app-item-card !shadow-sm rounded-md w-full nc-app-store-card-${app.title}`"
:body-style="{ width: '100%' }"
<div>
<a-modal
v-model:visible="showPluginInstallModal"
:class="{ active: showPluginInstallModal }"
:closable="false"
centered
min-height="300"
:footer="null"
wrap-class-name="nc-modal-plugin-install"
v-bind="$attrs"
>
<div class="install-btn flex flex-row justify-end space-x-1">
<a-button v-if="app.parsedInput" size="small" type="primary" @click="showInstallPluginModal(app)">
<div class="flex flex-row justify-center items-center caption capitalize nc-app-store-card-edit">
<IcRoundEdit class="pr-0.5" :height="12" />
Edit
</div>
</a-button>
<a-button v-if="app.parsedInput" size="small" outlined @click="showResetPluginModal(app)">
<div class="flex flex-row justify-center items-center caption capitalize nc-app-store-card-reset">
<MdiCloseCircleOutline />
<div class="flex ml-0.5">Reset</div>
</div>
</a-button>
<a-button v-else size="small" type="primary" @click="showInstallPluginModal(app)">
<div class="flex flex-row justify-center items-center caption capitalize nc-app-store-card-install">
<MdiPlus />
Install
</div>
</a-button>
<LazyDashboardSettingsAppInstall
v-if="pluginApp && showPluginInstallModal"
:id="pluginApp.id"
@close="showPluginInstallModal = false"
@saved="saved()"
/>
</a-modal>
<a-modal
v-model:visible="showPluginUninstallModal"
:closable="false"
width="24rem"
centered
:footer="null"
wrap-class-name="nc-modal-plugin-uninstall"
>
<div class="flex flex-col h-full">
<div class="flex flex-row justify-center mt-2 text-center w-full text-base">
{{ `Click on confirm to reset ${pluginApp && pluginApp.title}` }}
</div>
<div class="flex mt-6 justify-center space-x-2">
<a-button @click="showPluginUninstallModal = false"> {{ $t('general.cancel') }} </a-button>
<a-button type="primary" danger @click="resetPlugin"> {{ $t('general.confirm') }} </a-button>
</div>
</div>
<div class="flex flex-row space-x-2 items-center justify-start w-full">
<div class="flex w-20 pl-3">
<img
v-if="app.title !== 'SMTP'"
class="avatar"
alt="logo"
:style="{
backgroundColor: app.title === 'SES' ? '#242f3e' : '',
}"
:src="app.logo"
/>
<div v-else />
</a-modal>
<div class="grid grid-cols-2 gap-x-2 gap-y-4 mt-4">
<a-card
v-for="(app, i) in apps"
:key="i"
:class="`relative flex overflow-x-hidden app-item-card !shadow-sm rounded-md w-full nc-app-store-card-${app.title}`"
:body-style="{ width: '100%' }"
>
<div class="install-btn flex flex-row justify-end space-x-1">
<a-button v-if="app.parsedInput" size="small" type="primary" @click="showInstallPluginModal(app)">
<div class="flex flex-row justify-center items-center caption capitalize nc-app-store-card-edit">
<IcRoundEdit class="pr-0.5" :height="12" />
Edit
</div>
</a-button>
<a-button v-if="app.parsedInput" size="small" outlined @click="showResetPluginModal(app)">
<div class="flex flex-row justify-center items-center caption capitalize nc-app-store-card-reset">
<MdiCloseCircleOutline />
<div class="flex ml-0.5">Reset</div>
</div>
</a-button>
<a-button v-else size="small" type="primary" @click="showInstallPluginModal(app)">
<div class="flex flex-row justify-center items-center caption capitalize nc-app-store-card-install">
<MdiPlus />
Install
</div>
</a-button>
</div>
<div class="flex flex-col flex-1 w-3/5 pl-3">
<a-typography-title :level="5">{{ app.title }}</a-typography-title>
<div class="flex flex-row space-x-2 items-center justify-start w-full">
<div class="flex w-20 pl-3">
<img
v-if="app.title !== 'SMTP'"
class="avatar"
alt="logo"
:style="{
backgroundColor: app.title === 'SES' ? '#242f3e' : '',
}"
:src="app.logo"
/>
<div v-else />
</div>
<div class="flex flex-col flex-1 w-3/5 pl-3">
<a-typography-title :level="5">{{ app.title }}</a-typography-title>
{{ app.description }}
{{ app.description }}
</div>
</div>
</div>
</a-card>
</a-card>
</div>
</div>
</template>

426
packages/nc-gui/components/dashboard/settings/DataSources.vue

@ -0,0 +1,426 @@
<script setup lang="ts">
import Draggable from 'vuedraggable'
import type { BaseType } from 'nocodb-sdk'
import CreateBase from './data-sources/CreateBase.vue'
import EditBase from './data-sources/EditBase.vue'
import Metadata from './Metadata.vue'
import UIAcl from './UIAcl.vue'
import Erd from './Erd.vue'
import { ClientType, DataSourcesSubTab } from '~/lib'
import { useNuxtApp, useProject } from '#imports'
interface Props {
state: string
reload: boolean
}
const props = defineProps<Props>()
const emits = defineEmits(['update:state', 'update:reload', 'awaken'])
const vState = useVModel(props, 'state', emits)
const vReload = useVModel(props, 'reload', emits)
const { $api, $e } = useNuxtApp()
const { project, loadProject } = useProject()
let sources = $ref<BaseType[]>([])
let activeBaseId = $ref('')
let metadiffbases = $ref<string[]>([])
let clientType = $ref<ClientType>(ClientType.MYSQL)
let isReloading = $ref(false)
let forceAwakened = $ref(false)
async function loadBases() {
try {
if (!project.value?.id) return
isReloading = true
vReload.value = true
const baseList = await $api.base.list(project.value?.id)
if (baseList.bases.list && baseList.bases.list.length) {
sources = baseList.bases.list
}
} catch (e) {
console.error(e)
} finally {
vReload.value = false
isReloading = false
}
}
async function loadMetaDiff() {
try {
if (!project.value?.id) return
metadiffbases = []
const metadiff = await $api.project.metaDiffGet(project.value?.id)
for (const model of metadiff) {
if (model.detectedChanges?.length > 0) {
metadiffbases.push(model.base_id)
}
}
} catch (e) {
console.error(e)
}
}
const baseAction = (baseId?: string, action?: string) => {
if (!baseId) return
activeBaseId = baseId
vState.value = action || ''
}
const deleteBase = (base: BaseType) => {
$e('c:base:delete')
Modal.confirm({
title: `Do you want to delete '${base.alias}' project?`,
wrapClassName: 'nc-modal-base-delete',
okText: 'Yes',
okType: 'danger',
cancelText: 'No',
async onOk() {
try {
await $api.base.delete(base.project_id as string, base.id as string)
$e('a:base:delete')
sources.splice(sources.indexOf(base), 1)
await loadProject()
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
},
style: 'top: 30%!important',
})
}
const toggleBase = async (base: BaseType, state: boolean) => {
try {
if (!state && sources.filter((src) => src.enabled).length < 2) {
message.info('There should be at least one enabled base!')
return
}
base.enabled = state
await $api.base.update(base.project_id as string, base.id as string, {
id: base.id,
project_id: base.project_id,
enabled: base.enabled,
})
await loadProject()
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const moveBase = async (e: any) => {
try {
if (e.oldIndex === e.newIndex) return
// sources list is mutated so we have to get the new index and mirror it to backend
const base = sources[e.newIndex]
if (base) {
if (!base.order) {
// empty update call to reorder bases (migration)
await $api.base.update(base.project_id as string, base.id as string, {
id: base.id,
project_id: base.project_id,
})
message.info('Bases are migrated. Please try again.')
} else {
await $api.base.update(base.project_id as string, base.id as string, {
id: base.id,
project_id: base.project_id,
order: e.newIndex + 1,
})
}
}
await loadProject()
await loadBases()
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const forceAwaken = () => {
forceAwakened = !forceAwakened
emits('awaken', forceAwakened)
}
onMounted(async () => {
if (sources.length === 0) {
await loadBases()
await loadMetaDiff()
}
})
watch(
() => props.reload,
async (reload) => {
if (reload && !isReloading) {
await loadBases()
await loadMetaDiff()
}
},
)
watch(
() => sources.length,
(l) => {
if (l > 1 && !forceAwakened) {
emits('awaken', false)
} else {
emits('awaken', true)
}
},
{ immediate: true },
)
watch(
vState,
async (newState) => {
if (!sources.length) {
await loadBases()
}
switch (newState) {
case ClientType.MYSQL:
clientType = ClientType.MYSQL
vState.value = DataSourcesSubTab.New
break
case ClientType.PG:
clientType = ClientType.PG
vState.value = DataSourcesSubTab.New
break
case ClientType.SQLITE:
clientType = ClientType.SQLITE
vState.value = DataSourcesSubTab.New
break
case ClientType.MSSQL:
clientType = ClientType.MSSQL
vState.value = DataSourcesSubTab.New
break
case DataSourcesSubTab.New:
if (sources.length > 1 && !forceAwakened) {
vState.value = ''
}
break
}
},
{ immediate: true },
)
</script>
<template>
<div class="flex flex-row w-full h-full">
<div class="flex flex-col w-full overflow-auto">
<div v-if="vState === ''" class="max-h-600px min-w-1200px overflow-y-auto">
<div class="ds-table-head">
<div class="ds-table-row">
<div class="ds-table-col ds-table-name">Name</div>
<div class="ds-table-col ds-table-actions">Actions</div>
<div class="ds-table-col ds-table-enabled cursor-pointer" @dblclick="forceAwaken">Show / Hide</div>
</div>
</div>
<div class="ds-table-body">
<Draggable :list="sources" item-key="id" handle=".ds-table-handle" @end="moveBase">
<template #header>
<div v-if="sources[0]" class="ds-table-row border-gray-200">
<div class="ds-table-col ds-table-name">
<div class="flex items-center gap-1">
<GeneralBaseLogo :base-type="sources[0].type" />
BASE
<span class="text-gray-400 text-xs">({{ sources[0].type }})</span>
</div>
</div>
<div class="ds-table-col ds-table-actions">
<div class="flex items-center gap-2">
<a-button
class="nc-action-btn cursor-pointer outline-0"
@click="baseAction(sources[0].id, DataSourcesSubTab.Metadata)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<a-tooltip v-if="metadiffbases.includes(sources[0].id as string)">
<template #title>Out of sync</template>
<MdiDatabaseAlert class="text-lg group-hover:text-accent text-primary" />
</a-tooltip>
<MdiDatabaseSync v-else class="text-lg group-hover:text-accent" />
Sync Metadata
</div>
</a-button>
<a-button
class="nc-action-btn cursor-pointer outline-0"
@click="baseAction(sources[0].id, DataSourcesSubTab.UIAcl)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiDatabaseLockOutline class="text-lg group-hover:text-accent" />
UI ACL
</div>
</a-button>
<a-button
class="nc-action-btn cursor-pointer outline-0"
@click="baseAction(sources[0].id, DataSourcesSubTab.ERD)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiGraphOutline class="text-lg group-hover:text-accent" />
ERD
</div>
</a-button>
<a-button
v-if="!sources[0].is_meta"
class="nc-action-btn cursor-pointer outline-0"
@click="baseAction(sources[0].id, DataSourcesSubTab.Edit)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiEditOutline class="text-lg group-hover:text-accent" />
Edit
</div>
</a-button>
</div>
</div>
<div class="ds-table-col ds-table-enabled">
<div class="flex items-center gap-1 cursor-pointer">
<a-tooltip>
<template #title>
<template v-if="sources[0].enabled">Hide in UI</template>
<template v-else>Show in UI</template>
</template>
<a-switch :checked="sources[0].enabled ? true : false" @change="toggleBase(sources[0], $event)" />
</a-tooltip>
</div>
</div>
</div>
</template>
<template #item="{ element: base, index }">
<div v-if="index !== 0" class="ds-table-row border-gray-200">
<div class="ds-table-col ds-table-name">
<MdiDragVertical v-if="sources.length > 2" small class="ds-table-handle" />
<div class="flex items-center gap-1">
<GeneralBaseLogo :base-type="base.type" />
{{ base.is_meta ? 'BASE' : base.alias }} <span class="text-gray-400 text-xs">({{ base.type }})</span>
</div>
</div>
<div class="ds-table-col ds-table-actions">
<div class="flex items-center gap-2">
<a-button
class="nc-action-btn cursor-pointer outline-0"
@click="baseAction(base.id, DataSourcesSubTab.Metadata)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<a-tooltip v-if="metadiffbases.includes(base.id as string)">
<template #title>Out of sync</template>
<MdiDatabaseAlert class="text-lg group-hover:text-accent text-primary" />
</a-tooltip>
<MdiDatabaseSync v-else class="text-lg group-hover:text-accent" />
Sync Metadata
</div>
</a-button>
<a-button
class="nc-action-btn cursor-pointer outline-0"
@click="baseAction(base.id, DataSourcesSubTab.UIAcl)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiDatabaseLockOutline class="text-lg group-hover:text-accent" />
UI ACL
</div>
</a-button>
<a-button class="nc-action-btn cursor-pointer outline-0" @click="baseAction(base.id, DataSourcesSubTab.ERD)">
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiGraphOutline class="text-lg group-hover:text-accent" />
ERD
</div>
</a-button>
<a-button
v-if="!base.is_meta"
class="nc-action-btn cursor-pointer outline-0"
@click="baseAction(base.id, DataSourcesSubTab.Edit)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiEditOutline class="text-lg group-hover:text-accent" />
Edit
</div>
</a-button>
<a-button v-if="!base.is_meta" class="nc-action-btn cursor-pointer outline-0" @click="deleteBase(base)">
<div class="flex items-center gap-2 text-red-500 font-light">
<MdiDeleteOutline class="text-lg group-hover:text-accent" />
Delete
</div>
</a-button>
</div>
</div>
<div class="ds-table-col ds-table-enabled">
<div class="flex items-center gap-1 cursor-pointer">
<a-tooltip>
<template #title>
<template v-if="base.enabled">Hide in UI</template>
<template v-else>Show in UI</template>
</template>
<a-switch :checked="base.enabled ? true : false" @change="toggleBase(base, $event)" />
</a-tooltip>
</div>
</div>
</div>
</template>
</Draggable>
</div>
</div>
<div v-else-if="vState === DataSourcesSubTab.New" class="max-h-600px overflow-y-auto">
<CreateBase :connection-type="clientType" @base-created="loadBases" />
</div>
<div v-else-if="vState === DataSourcesSubTab.Metadata" class="max-h-600px overflow-y-auto">
<Metadata :base-id="activeBaseId" @base-synced="loadBases" />
</div>
<div v-else-if="vState === DataSourcesSubTab.UIAcl" class="max-h-600px overflow-y-auto">
<UIAcl :base-id="activeBaseId" />
</div>
<div v-else-if="vState === DataSourcesSubTab.ERD" class="max-h-600px overflow-y-auto">
<Erd :base-id="activeBaseId" />
</div>
<div v-else-if="vState === DataSourcesSubTab.Edit" class="max-h-600px overflow-y-auto">
<EditBase :base-id="activeBaseId" @base-updated="loadBases" />
</div>
</div>
</div>
</template>
<style>
.ds-table-head {
@apply flex items-center border-t bg-gray-100 font-bold text-gray-500;
}
.ds-table-body {
@apply flex flex-col;
}
.ds-table-row {
@apply grid grid-cols-20 border-b w-full h-full border-l border-r;
}
.ds-table-col {
@apply flex items-center p-3 border-r-1 mr-2 h-50px;
}
.ds-table-enabled {
@apply col-span-2 flex justify-center;
}
.ds-table-name {
@apply col-span-8;
}
.ds-table-actions {
@apply col-span-10;
}
.ds-table-col:last-child {
@apply border-r-0;
}
.ds-table-handle {
@apply cursor-pointer justify-self-start mr-2;
}
</style>

8
packages/nc-gui/components/dashboard/settings/Erd.vue

@ -1,5 +1,11 @@
<script setup lang="ts">
const props = defineProps<{
baseId: string
}>()
</script>
<template>
<div class="w-full h-full !p-0 h-70vh">
<ErdView />
<ErdView :base-id="props.baseId" />
</div>
</template>

11
packages/nc-gui/components/dashboard/settings/Metadata.vue

@ -1,6 +1,12 @@
<script setup lang="ts">
import { Empty, extractSdkResponseErrorMsg, h, message, useI18n, useNuxtApp, useProject } from '#imports'
const props = defineProps<{
baseId: string
}>()
const emit = defineEmits(['baseSynced'])
const { $api } = useNuxtApp()
const { project, loadTables } = useProject()
@ -19,7 +25,7 @@ async function loadMetaDiff() {
isLoading = true
isDifferent = false
metadiff = await $api.project.metaDiffGet(project.value?.id)
metadiff = await $api.base.metaDiffGet(project.value?.id, props.baseId)
for (const model of metadiff) {
if (model.detectedChanges?.length > 0) {
model.syncState = model.detectedChanges.map((el: any) => el?.msg).join(', ')
@ -38,11 +44,12 @@ async function syncMetaDiff() {
if (!project.value?.id || !isDifferent) return
isLoading = true
await $api.project.metaDiffSync(project.value.id)
await $api.base.metaDiffSync(project.value.id, props.baseId)
// Table metadata recreated successfully
message.info(t('msg.info.metaDataRecreated'))
await loadTables()
await loadMetaDiff()
emit('baseSynced')
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {

180
packages/nc-gui/components/dashboard/settings/Modal.vue

@ -1,14 +1,19 @@
<script setup lang="ts">
import type { FunctionalComponent, SVGAttributes } from 'vue'
import { resolveComponent, useI18n, useNuxtApp, useUIPermission, useVModel, watch } from '#imports'
import AppStore from './AppStore.vue'
import DataSources from './DataSources.vue'
import Misc from './Misc.vue'
import { DataSourcesSubTab, useI18n, useNuxtApp, useUIPermission, useVModel, watch } from '#imports'
import StoreFrontOutline from '~icons/mdi/storefront-outline'
import TeamFillIcon from '~icons/ri/team-fill'
import MultipleTableIcon from '~icons/mdi/table-multiple'
import NotebookOutline from '~icons/mdi/notebook-outline'
import FolderCog from '~icons/mdi/folder-cog'
interface Props {
modelValue: boolean
openKey?: string
openKey: string
dataSourcesState: string
}
interface SubTabGroup {
@ -30,16 +35,24 @@ interface TabGroup {
const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue'])
const emits = defineEmits(['update:modelValue', 'update:openKey', 'update:dataSourcesState'])
const vModel = useVModel(props, 'modelValue', emits)
const vOpenKey = useVModel(props, 'openKey', emits)
const vDataState = useVModel(props, 'dataSourcesState', emits)
const { isUIAllowed } = useUIPermission()
const { t } = useI18n()
const { $e } = useNuxtApp()
const dataSourcesReload = ref(false)
const dataSourcesAwakened = ref(false)
const tabsInfo: TabGroup = {
teamAndAuth: {
title: t('title.teamAndAuth'),
@ -68,56 +81,33 @@ const tabsInfo: TabGroup = {
$e('c:settings:team-auth')
},
},
...(isUIAllowed('appStore')
? {
appStore: {
// App Store
title: t('title.appStore'),
icon: StoreFrontOutline,
subTabs: {
new: {
title: 'Apps',
body: resolveComponent('DashboardSettingsAppStore'),
},
},
onClick: () => {
$e('c:settings:appstore')
},
},
}
: {}),
projMetaData: {
// Project Metadata
title: t('title.projMeta'),
icon: MultipleTableIcon,
appStore: {
// App Store
title: t('title.appStore'),
icon: StoreFrontOutline,
subTabs: {
metaData: {
// Metadata
title: t('title.metadata'),
body: resolveComponent('DashboardSettingsMetadata'),
},
acl: {
// UI Access Control
title: t('title.uiACL'),
body: resolveComponent('DashboardSettingsUIAcl'),
onClick: () => {
$e('c:table:ui-acl')
},
},
erd: {
title: t('title.erdView'),
body: resolveComponent('DashboardSettingsErd'),
onClick: () => {
$e('c:settings:erd')
},
new: {
title: 'Apps',
body: AppStore,
},
misc: {
title: t('general.misc'),
body: resolveComponent('DashboardSettingsMisc'),
},
onClick: () => {
$e('c:settings:appstore')
},
},
dataSources: {
// Data Sources
title: 'Data Sources',
icon: MultipleTableIcon,
subTabs: {
dataSources: {
title: 'Data Sources',
body: DataSources,
},
},
onClick: () => {
$e('c:settings:proj-metadata')
vDataState.value = ''
$e('c:settings:data-sources')
},
},
audit: {
@ -135,29 +125,47 @@ const tabsInfo: TabGroup = {
$e('c:settings:audit')
},
},
projectSettings: {
// Project Settings
title: 'Project Settings',
icon: FolderCog,
subTabs: {
misc: {
// Misc
title: 'Misc',
body: Misc,
},
},
onClick: () => {
$e('c:settings:project-settings')
},
},
}
const firstKeyOfObject = (obj: object) => Object.keys(obj)[0]
// Array of keys of tabs which are selected. In our case will be only one.
let selectedTabKeys = $ref<string[]>([firstKeyOfObject(tabsInfo)])
const selectedTabKeys = $computed<string[]>({
get: () => [Object.keys(tabsInfo).find((key) => key === vOpenKey.value) || firstKeyOfObject(tabsInfo)],
set: (value) => {
vOpenKey.value = value[0]
},
})
const selectedTab = $computed(() => tabsInfo[selectedTabKeys[0]])
let selectedSubTabKeys = $ref<string[]>([firstKeyOfObject(selectedTab.subTabs)])
const selectedSubTab = $computed(() => selectedTab.subTabs[selectedSubTabKeys[0]])
const handleAwaken = (val: boolean) => {
dataSourcesAwakened.value = val
}
watch(
() => selectedTabKeys[0],
(newTabKey) => {
selectedSubTabKeys = [firstKeyOfObject(tabsInfo[newTabKey].subTabs)]
},
)
watch(
() => props.openKey,
(nextOpenKey) => {
selectedTabKeys = [Object.keys(tabsInfo).find((key) => key === nextOpenKey) || firstKeyOfObject(tabsInfo)]
},
)
</script>
<template>
@ -210,7 +218,12 @@ watch(
<!-- Sub Tabs -->
<a-layout-content class="h-auto px-4 scrollbar-thumb-gray-500">
<a-menu v-model:selectedKeys="selectedSubTabKeys" :open-keys="[]" mode="horizontal">
<a-menu
v-if="selectedTabKeys[0] !== 'dataSources'"
v-model:selectedKeys="selectedSubTabKeys"
:open-keys="[]"
mode="horizontal"
>
<a-menu-item
v-for="(tab, key) of selectedTab.subTabs"
:key="key"
@ -220,8 +233,59 @@ watch(
{{ tab.title }}
</a-menu-item>
</a-menu>
<div v-else>
<div class="flex items-center">
<a-breadcrumb class="w-full cursor-pointer">
<a-breadcrumb-item v-if="vDataState !== ''" @click="vDataState = ''">
<a class="!no-underline">Data Sources</a>
</a-breadcrumb-item>
<a-breadcrumb-item v-else @click="vDataState = ''">Data Sources</a-breadcrumb-item>
<a-breadcrumb-item v-if="vDataState !== ''">{{ vDataState }}</a-breadcrumb-item>
</a-breadcrumb>
<div v-if="vDataState === ''" class="flex flex-row justify-end items-center w-full gap-1">
<a-button
v-if="dataSourcesAwakened"
class="self-start nc-btn-new-datasource"
@click="vDataState = DataSourcesSubTab.New"
>
<div v-if="vDataState === ''" class="flex items-center gap-2 text-primary font-light">
<MdiDatabasePlusOutline class="text-lg group-hover:text-accent" />
New
</div>
</a-button>
<!-- Reload -->
<a-button
v-e="['a:proj-meta:data-sources:reload']"
class="self-start nc-btn-metasync-reload"
@click="dataSourcesReload = true"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiReload :class="{ 'animate-infinite animate-spin !text-success': dataSourcesReload }" />
{{ $t('general.reload') }}
</div>
</a-button>
</div>
</div>
<a-divider style="margin: 10px 0" />
</div>
<component :is="selectedSubTab?.body" class="px-2 py-6" :data-testid="`nc-settings-subtab-${selectedSubTab.title}`" />
<div class="h-[600px]">
<component
:is="selectedSubTab?.body"
v-if="selectedSubTabKeys[0] === 'dataSources'"
v-model:state="vDataState"
v-model:reload="dataSourcesReload"
class="px-2 pb-2"
:data-testid="`nc-settings-subtab-${selectedSubTab.title}`"
@awaken="handleAwaken"
/>
<component
:is="selectedSubTab?.body"
v-else
class="px-2 py-6"
:data-testid="`nc-settings-subtab-${selectedSubTab.title}`"
/>
</div>
</a-layout-content>
</a-layout>
</a-modal>

9
packages/nc-gui/components/dashboard/settings/UIAcl.vue

@ -13,6 +13,10 @@ import {
viewIcons,
} from '#imports'
const props = defineProps<{
baseId: string
}>()
const { t } = useI18n()
const { $api, $e } = useNuxtApp()
@ -32,8 +36,9 @@ const searchInput = $ref('')
const filteredTables = computed(() =>
tables.filter(
(el) =>
(typeof el?._ptn === 'string' && el._ptn.toLowerCase().includes(searchInput.toLowerCase())) ||
(typeof el?.title === 'string' && el.title.toLowerCase().includes(searchInput.toLowerCase())),
el?.base_id === props.baseId &&
((typeof el?._ptn === 'string' && el._ptn.toLowerCase().includes(searchInput.toLowerCase())) ||
(typeof el?.title === 'string' && el.title.toLowerCase().includes(searchInput.toLowerCase()))),
),
)

615
packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue

@ -0,0 +1,615 @@
<script lang="ts" setup>
import { Form, Modal, message } from 'ant-design-vue'
import type { SelectHandler } from 'ant-design-vue/es/vc-select/Select'
import type { ProjectCreateForm } from '#imports'
import {
CertTypes,
ClientType,
DefaultConnection,
SQLiteConnection,
SSLUsage,
clientTypes,
computed,
extractSdkResponseErrorMsg,
fieldRequiredValidator,
generateUniqueName,
getDefaultConnectionConfig,
getTestDatabaseName,
nextTick,
onMounted,
projectTitleValidator,
readFile,
ref,
useApi,
useI18n,
useNuxtApp,
watch,
} from '#imports'
const { connectionType } = defineProps<{ connectionType: ClientType }>()
const emit = defineEmits(['baseCreated'])
const { project, loadProject } = useProject()
const useForm = Form.useForm
const testSuccess = ref(false)
const form = ref<typeof Form>()
const { api } = useApi()
const { $e } = useNuxtApp()
const { t } = useI18n()
const toggleDialog = inject(ToggleDialogInj, () => {})
let formState = $ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
inflection: {
inflectionColumn: 'camelize',
inflectionTable: 'camelize',
},
sslUse: SSLUsage.No,
extraParameters: [],
})
const customFormState = ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
inflection: {
inflectionColumn: 'camelize',
inflectionTable: 'camelize',
},
sslUse: SSLUsage.No,
extraParameters: [],
})
const validators = computed(() => {
return {
'title': [
{
required: true,
message: 'Base name is required',
},
projectTitleValidator,
],
'extraParameters': [extraParameterValidator],
'dataSource.client': [fieldRequiredValidator()],
...(formState.dataSource.client === ClientType.SQLITE
? {
'dataSource.connection.connection.filename': [fieldRequiredValidator()],
}
: {
'dataSource.connection.host': [fieldRequiredValidator()],
'dataSource.connection.port': [fieldRequiredValidator()],
'dataSource.connection.user': [fieldRequiredValidator()],
'dataSource.connection.password': [fieldRequiredValidator()],
'dataSource.connection.database': [fieldRequiredValidator()],
...([ClientType.PG, ClientType.MSSQL].includes(formState.dataSource.client)
? {
'dataSource.searchPath.0': [fieldRequiredValidator()],
}
: {}),
}),
}
})
const { validate, validateInfos } = useForm(formState, validators)
const populateName = (v: string) => {
formState.dataSource.connection.database = `${v.trim()}_noco`
}
const onClientChange = () => {
formState.dataSource = { ...getDefaultConnectionConfig(formState.dataSource.client) }
populateName(formState.title)
}
const onSSLModeChange = ((mode: SSLUsage) => {
if (formState.dataSource.client !== ClientType.SQLITE) {
const connection = formState.dataSource.connection as DefaultConnection
switch (mode) {
case SSLUsage.No:
delete connection.ssl
break
case SSLUsage.Allowed:
connection.ssl = 'true'
break
default:
connection.ssl = {
ca: '',
cert: '',
key: '',
}
break
}
}
}) as SelectHandler
const updateSSLUse = () => {
if (formState.dataSource.client !== ClientType.SQLITE) {
const connection = formState.dataSource.connection as DefaultConnection
if (connection.ssl) {
if (typeof connection.ssl === 'string') {
formState.sslUse = SSLUsage.Allowed
} else {
formState.sslUse = SSLUsage.Preferred
}
} else {
formState.sslUse = SSLUsage.No
}
}
}
const addNewParam = () => {
formState.extraParameters.push({ key: '', value: '' })
}
const removeParam = (index: number) => {
formState.extraParameters.splice(index, 1)
}
const inflectionTypes = ['camelize', 'none']
const importURL = ref('')
const configEditDlg = ref(false)
const importURLDlg = ref(false)
const caFileInput = ref<HTMLInputElement>()
const keyFileInput = ref<HTMLInputElement>()
const certFileInput = ref<HTMLInputElement>()
const onFileSelect = (key: CertTypes, el?: HTMLInputElement) => {
if (!el) return
readFile(el, (content) => {
if ('ssl' in formState.dataSource.connection && typeof formState.dataSource.connection.ssl === 'object')
formState.dataSource.connection.ssl[key] = content ?? ''
})
}
const sslFilesRequired = computed(
() => !!formState.sslUse && formState.sslUse !== SSLUsage.No && formState.sslUse !== SSLUsage.Allowed,
)
function getConnectionConfig() {
const extraParameters = Object.fromEntries(new Map(formState.extraParameters.map((object) => [object.key, object.value])))
const connection = {
...formState.dataSource.connection,
...extraParameters,
}
if ('ssl' in connection && connection.ssl) {
if (
formState.sslUse === SSLUsage.No ||
(typeof connection.ssl === 'object' && Object.values(connection.ssl).every((v) => v === null || v === undefined))
) {
delete connection.ssl
}
}
return connection
}
const focusInvalidInput = () => {
form.value?.$el.querySelector('.ant-form-item-explain-error')?.parentNode?.parentNode?.querySelector('input')?.focus()
}
const createBase = async () => {
try {
await validate()
} catch (e) {
focusInvalidInput()
return
}
try {
if (!project.value?.id) return
const connection = getConnectionConfig()
const config = { ...formState.dataSource, connection }
await api.base.create(project.value?.id, {
alias: formState.title,
type: formState.dataSource.client,
config,
inflection_column: formState.inflection.inflectionColumn,
inflection_table: formState.inflection.inflectionTable,
})
$e('a:base:create:extdb')
await loadProject()
emit('baseCreated')
message.success('Base created!')
toggleDialog(true, 'dataSources', '')
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const testConnection = async () => {
try {
await validate()
} catch (e) {
focusInvalidInput()
return
}
$e('a:base:create:extdb:test-connection', [])
try {
if (formState.dataSource.client === ClientType.SQLITE) {
testSuccess.value = true
} else {
const connection = getConnectionConfig()
connection.database = getTestDatabaseName(formState.dataSource)!
const testConnectionConfig = {
...formState.dataSource,
connection,
}
const result = await api.utils.testConnection(testConnectionConfig)
if (result.code === 0) {
testSuccess.value = true
Modal.confirm({
title: t('msg.info.dbConnected'),
icon: null,
type: 'success',
okText: 'Ok & Add Base',
okType: 'primary',
cancelText: t('general.cancel'),
onOk: createBase,
style: 'top: 30%!important',
})
} else {
testSuccess.value = false
message.error(`${t('msg.error.dbConnectionFailed')} ${result.message}`)
}
}
} catch (e: any) {
testSuccess.value = false
message.error(await extractSdkResponseErrorMsg(e))
}
}
const handleImportURL = async () => {
if (!importURL.value || importURL.value === '') return
const connectionConfig = await api.utils.urlToConfig({ url: importURL.value })
if (connectionConfig) {
formState.dataSource.client = connectionConfig.client
formState.dataSource.connection = { ...connectionConfig.connection }
} else {
message.error(t('msg.error.invalidURL'))
}
importURLDlg.value = false
updateSSLUse()
}
const handleEditJSON = () => {
customFormState.value = { ...formState }
configEditDlg.value = true
}
const handleOk = () => {
formState = { ...customFormState.value }
configEditDlg.value = false
updateSSLUse()
}
// reset test status on config change
watch(
() => formState.dataSource,
() => (testSuccess.value = false),
{ deep: true },
)
// populate database name based on title
watch(
() => formState.title,
(v) => populateName(v),
)
// select and focus title field on load
onMounted(async () => {
formState.title = await generateUniqueName()
nextTick(() => {
// todo: replace setTimeout and follow better approach
setTimeout(() => {
const input = form.value?.$el?.querySelector('input[type=text]')
input.setSelectionRange(0, formState.title.length)
input.focus()
}, 500)
})
})
watch(
() => connectionType,
(v) => {
formState.dataSource.client = v
onClientChange()
},
{ immediate: true },
)
</script>
<template>
<div class="create-base max-w-800px mx-auto bg-white relative flex flex-col justify-center gap-2 w-full p-8">
<h1 class="prose-2xl font-bold self-center my-4">New Base</h1>
<a-form
ref="form"
:model="formState"
name="external-project-create-form"
layout="horizontal"
no-style
:label-col="{ span: 8 }"
>
<a-form-item label="Base Name" v-bind="validateInfos.title">
<a-input v-model:value="formState.title" class="nc-extdb-proj-name" />
</a-form-item>
<a-form-item :label="$t('labels.dbType')" v-bind="validateInfos['dataSource.client']">
<a-select
v-model:value="formState.dataSource.client"
class="nc-extdb-db-type"
dropdown-class-name="nc-dropdown-ext-db-type"
@change="onClientChange"
>
<a-select-option v-for="client in clientTypes" :key="client.value" :value="client.value"
>{{ client.text }}
</a-select-option>
</a-select>
</a-form-item>
<!-- SQLite File -->
<a-form-item
v-if="formState.dataSource.client === ClientType.SQLITE"
:label="$t('labels.sqliteFile')"
v-bind="validateInfos['dataSource.connection.connection.filename']"
>
<a-input v-model:value="(formState.dataSource.connection as SQLiteConnection).connection.filename" />
</a-form-item>
<template v-else>
<!-- Host Address -->
<a-form-item :label="$t('labels.hostAddress')" v-bind="validateInfos['dataSource.connection.host']">
<a-input v-model:value="(formState.dataSource.connection as DefaultConnection).host" class="nc-extdb-host-address" />
</a-form-item>
<!-- Port Number -->
<a-form-item :label="$t('labels.port')" v-bind="validateInfos['dataSource.connection.port']">
<a-input-number
v-model:value="(formState.dataSource.connection as DefaultConnection).port"
class="!w-full nc-extdb-host-port"
/>
</a-form-item>
<!-- Username -->
<a-form-item :label="$t('labels.username')" v-bind="validateInfos['dataSource.connection.user']">
<a-input v-model:value="(formState.dataSource.connection as DefaultConnection).user" class="nc-extdb-host-user" />
</a-form-item>
<!-- Password -->
<a-form-item :label="$t('labels.password')">
<a-input-password
v-model:value="(formState.dataSource.connection as DefaultConnection).password"
class="nc-extdb-host-password"
/>
</a-form-item>
<!-- Database -->
<a-form-item :label="$t('labels.database')" v-bind="validateInfos['dataSource.connection.database']">
<!-- Database : create if not exists -->
<a-input
v-model:value="formState.dataSource.connection.database"
:placeholder="$t('labels.dbCreateIfNotExists')"
class="nc-extdb-host-database"
/>
</a-form-item>
<!-- Schema name -->
<a-form-item
v-if="[ClientType.MSSQL, ClientType.PG].includes(formState.dataSource.client) && formState.dataSource.searchPath"
:label="$t('labels.schemaName')"
v-bind="validateInfos['dataSource.searchPath.0']"
>
<a-input v-model:value="formState.dataSource.searchPath[0]" />
</a-form-item>
<a-collapse ghost expand-icon-position="right" class="!mt-6">
<a-collapse-panel key="1">
<template #header>
<div class="flex items-center gap-2">
<!-- Use Connection URL -->
<a-button type="default" class="nc-extdb-btn-import-url" @click.stop="importURLDlg = true">
{{ $t('activity.useConnectionUrl') }}
</a-button>
<span>{{ $t('title.advancedParameters') }}</span>
</div>
</template>
<a-form-item label="SSL mode">
<a-select v-model:value="formState.sslUse" dropdown-class-name="nc-dropdown-ssl-mode" @select="onSSLModeChange">
<a-select-option v-for="opt in Object.values(SSLUsage)" :key="opt" :value="opt">{{ opt }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="SSL keys">
<div class="flex gap-2">
<a-tooltip placement="top">
<!-- Select .cert file -->
<template #title>
<span>{{ $t('tooltip.clientCert') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" class="shadow" @click="certFileInput?.click()">
{{ $t('labels.clientCert') }}
</a-button>
</a-tooltip>
<a-tooltip placement="top">
<!-- Select .key file -->
<template #title>
<span>{{ $t('tooltip.clientKey') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" class="shadow" @click="keyFileInput?.click()">
{{ $t('labels.clientKey') }}
</a-button>
</a-tooltip>
<a-tooltip placement="top">
<!-- Select CA file -->
<template #title>
<span>{{ $t('tooltip.clientCA') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" class="shadow" @click="caFileInput?.click()">
{{ $t('labels.serverCA') }}
</a-button>
</a-tooltip>
</div>
</a-form-item>
<input ref="caFileInput" type="file" class="!hidden" @change="onFileSelect(CertTypes.ca, caFileInput)" />
<input ref="certFileInput" type="file" class="!hidden" @change="onFileSelect(CertTypes.cert, certFileInput)" />
<input ref="keyFileInput" type="file" class="!hidden" @change="onFileSelect(CertTypes.key, keyFileInput)" />
<a-divider />
<!-- Extra connection parameters -->
<a-form-item class="mb-2" :label="$t('labels.extraConnectionParameters')" v-bind="validateInfos.extraParameters">
<a-card>
<div v-for="(item, index) of formState.extraParameters" :key="index">
<div class="flex py-1 items-center gap-1">
<a-input v-model:value="item.key" />
<span>:</span>
<a-input v-model:value="item.value" />
<MdiClose :style="{ 'font-size': '1.5em', 'color': 'red' }" @click="removeParam(index)" />
</div>
</div>
<a-button type="dashed" class="w-full caption mt-2" @click="addNewParam">
<div class="flex items-center justify-center"><MdiPlus /></div>
</a-button>
</a-card>
</a-form-item>
<a-divider />
<a-form-item :label="$t('labels.inflection.tableName')">
<a-select
v-model:value="formState.inflection.inflectionTable"
dropdown-class-name="nc-dropdown-inflection-table-name"
>
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('labels.inflection.columnName')">
<a-select
v-model:value="formState.inflection.inflectionColumn"
dropdown-class-name="nc-dropdown-inflection-column-name"
>
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }}</a-select-option>
</a-select>
</a-form-item>
<div class="flex justify-end">
<a-button class="!shadow-md" @click="handleEditJSON()">
<!-- Edit connection JSON -->
{{ $t('activity.editConnJson') }}
</a-button>
</div>
</a-collapse-panel>
</a-collapse>
</template>
<a-form-item class="flex justify-center !mt-5">
<div class="flex justify-center gap-2">
<a-button type="primary" ghost class="nc-extdb-btn-test-connection" @click="testConnection">
{{ $t('activity.testDbConn') }}
</a-button>
<a-button type="primary" :disabled="!testSuccess" class="nc-extdb-btn-submit !shadow" @click="createBase">
{{ $t('general.submit') }}
</a-button>
</div>
</a-form-item>
</a-form>
<a-modal
v-model:visible="configEditDlg"
:title="$t('activity.editConnJson')"
width="600px"
wrap-class-name="nc-modal-edit-connection-json"
@ok="handleOk"
>
<MonacoEditor v-if="configEditDlg" v-model="customFormState" class="h-[400px] w-full" />
</a-modal>
<!-- Use Connection URL -->
<a-modal
v-model:visible="importURLDlg"
:title="$t('activity.useConnectionUrl')"
width="600px"
:ok-text="$t('general.ok')"
:cancel-text="$t('general.cancel')"
wrap-class-name="nc-modal-connection-url"
@ok="handleImportURL"
>
<a-input v-model:value="importURL" />
</a-modal>
</div>
</template>
<style lang="scss" scoped>
:deep(.ant-collapse-header) {
@apply !pr-10 !-mt-4 text-right justify-end;
}
:deep(.ant-collapse-content-box) {
@apply !px-0;
}
:deep(.ant-form-item-explain-error) {
@apply !text-xs;
}
:deep(.ant-form-item) {
@apply mb-2;
}
:deep(.ant-form-item-with-help .ant-form-item-explain) {
@apply !min-h-0;
}
.create-base {
:deep(.ant-input-affix-wrapper),
:deep(.ant-input),
:deep(.ant-select) {
@apply !appearance-none border-1 border-solid rounded;
}
:deep(.ant-input-password) {
input {
@apply !border-none my-0;
}
}
}
</style>

612
packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue

@ -0,0 +1,612 @@
<script lang="ts" setup>
import type { BaseType } from 'nocodb-sdk'
import { Form, Modal, message } from 'ant-design-vue'
import type { SelectHandler } from 'ant-design-vue/es/vc-select/Select'
import type { ProjectCreateForm } from '#imports'
import {
CertTypes,
ClientType,
DefaultConnection,
SQLiteConnection,
SSLUsage,
clientTypes,
computed,
extractSdkResponseErrorMsg,
fieldRequiredValidator,
getDefaultConnectionConfig,
getTestDatabaseName,
onMounted,
projectTitleValidator,
readFile,
ref,
useApi,
useI18n,
useNuxtApp,
watch,
} from '#imports'
const props = defineProps<{
baseId: string
}>()
const emit = defineEmits(['baseUpdated'])
const { project, loadProject } = useProject()
const useForm = Form.useForm
const testSuccess = ref(false)
const form = ref<typeof Form>()
const { api } = useApi()
const { $e } = useNuxtApp()
const { t } = useI18n()
const toggleDialog = inject(ToggleDialogInj, () => {})
const formState = ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
inflection: {
inflectionColumn: 'camelize',
inflectionTable: 'camelize',
},
sslUse: SSLUsage.No,
extraParameters: [],
})
const customFormState = ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
inflection: {
inflectionColumn: 'camelize',
inflectionTable: 'camelize',
},
sslUse: SSLUsage.No,
extraParameters: [],
})
const validators = computed(() => {
return {
'title': [
{
required: true,
message: 'Base name is required',
},
projectTitleValidator,
],
'extraParameters': [extraParameterValidator],
'dataSource.client': [fieldRequiredValidator()],
...(formState.value.dataSource.client === ClientType.SQLITE
? {
'dataSource.connection.connection.filename': [fieldRequiredValidator()],
}
: {
'dataSource.connection.host': [fieldRequiredValidator()],
'dataSource.connection.port': [fieldRequiredValidator()],
'dataSource.connection.user': [fieldRequiredValidator()],
'dataSource.connection.password': [fieldRequiredValidator()],
'dataSource.connection.database': [fieldRequiredValidator()],
...([ClientType.PG, ClientType.MSSQL].includes(formState.value.dataSource.client)
? {
'dataSource.searchPath.0': [fieldRequiredValidator()],
}
: {}),
}),
}
})
const { validate, validateInfos } = useForm(formState, validators)
const onClientChange = () => {
formState.value.dataSource = { ...getDefaultConnectionConfig(formState.value.dataSource.client) }
}
const onSSLModeChange = ((mode: SSLUsage) => {
if (formState.value.dataSource.client !== ClientType.SQLITE) {
const connection = formState.value.dataSource.connection as DefaultConnection
switch (mode) {
case SSLUsage.No:
delete connection.ssl
break
case SSLUsage.Allowed:
connection.ssl = 'true'
break
default:
connection.ssl = {
ca: '',
cert: '',
key: '',
}
break
}
}
}) as SelectHandler
const updateSSLUse = () => {
if (formState.value.dataSource.client !== ClientType.SQLITE) {
const connection = formState.value.dataSource.connection as DefaultConnection
if (connection.ssl) {
if (typeof connection.ssl === 'string') {
formState.value.sslUse = SSLUsage.Allowed
} else {
formState.value.sslUse = SSLUsage.Preferred
}
} else {
formState.value.sslUse = SSLUsage.No
}
}
}
const addNewParam = () => {
formState.value.extraParameters.push({ key: '', value: '' })
}
const removeParam = (index: number) => {
formState.value.extraParameters.splice(index, 1)
}
const inflectionTypes = ['camelize', 'none']
const importURL = ref('')
const configEditDlg = ref(false)
const importURLDlg = ref(false)
const caFileInput = ref<HTMLInputElement>()
const keyFileInput = ref<HTMLInputElement>()
const certFileInput = ref<HTMLInputElement>()
const onFileSelect = (key: CertTypes, el?: HTMLInputElement) => {
if (!el) return
readFile(el, (content) => {
if ('ssl' in formState.value.dataSource.connection && typeof formState.value.dataSource.connection.ssl === 'object')
formState.value.dataSource.connection.ssl[key] = content ?? ''
})
}
const sslFilesRequired = computed(
() => !!formState.value.sslUse && formState.value.sslUse !== SSLUsage.No && formState.value.sslUse !== SSLUsage.Allowed,
)
function getConnectionConfig() {
const extraParameters = Object.fromEntries(new Map(formState.value.extraParameters.map((object) => [object.key, object.value])))
const connection = {
...formState.value.dataSource.connection,
...extraParameters,
}
if ('ssl' in connection && connection.ssl) {
if (
formState.value.sslUse === SSLUsage.No ||
(typeof connection.ssl === 'object' && Object.values(connection.ssl).every((v) => v === null || v === undefined))
) {
delete connection.ssl
}
}
return connection
}
const focusInvalidInput = () => {
form.value?.$el.querySelector('.ant-form-item-explain-error')?.parentNode?.parentNode?.querySelector('input')?.focus()
}
const editBase = async () => {
try {
await validate()
} catch (e) {
focusInvalidInput()
return
}
try {
if (!project.value?.id) return
const connection = getConnectionConfig()
const config = { ...formState.value.dataSource, connection }
await api.base.update(project.value?.id, props.baseId, {
alias: formState.value.title,
type: formState.value.dataSource.client,
config,
inflection_column: formState.value.inflection.inflectionColumn,
inflection_table: formState.value.inflection.inflectionTable,
})
$e('a:base:edit:extdb')
await loadProject()
emit('baseUpdated')
message.success('Base updated!')
toggleDialog(true, 'dataSources', '')
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const testConnection = async () => {
try {
await validate()
} catch (e) {
focusInvalidInput()
return
}
$e('a:base:edit:extdb:test-connection', [])
try {
if (formState.value.dataSource.client === ClientType.SQLITE) {
testSuccess.value = true
} else {
const connection = getConnectionConfig()
connection.database = getTestDatabaseName(formState.value.dataSource)!
const testConnectionConfig = {
...formState.value.dataSource,
connection,
}
const result = await api.utils.testConnection(testConnectionConfig)
if (result.code === 0) {
testSuccess.value = true
Modal.confirm({
title: t('msg.info.dbConnected'),
icon: null,
type: 'success',
okText: 'Ok & Edit Base',
okType: 'primary',
cancelText: t('general.cancel'),
onOk: editBase,
style: 'top: 30%!important',
})
} else {
testSuccess.value = false
message.error(`${t('msg.error.dbConnectionFailed')} ${result.message}`)
}
}
} catch (e: any) {
testSuccess.value = false
message.error(await extractSdkResponseErrorMsg(e))
}
}
const handleImportURL = async () => {
if (!importURL.value || importURL.value === '') return
const connectionConfig = await api.utils.urlToConfig({ url: importURL.value })
if (connectionConfig) {
formState.value.dataSource.client = connectionConfig.client
formState.value.dataSource.connection = { ...connectionConfig.connection }
} else {
message.error(t('msg.error.invalidURL'))
}
importURLDlg.value = false
updateSSLUse()
}
const handleEditJSON = () => {
customFormState.value = formState.value
configEditDlg.value = true
}
const handleOk = () => {
formState.value = customFormState.value
configEditDlg.value = false
updateSSLUse()
}
// reset test status on config change
watch(
() => formState.value.dataSource,
() => (testSuccess.value = false),
{ deep: true },
)
// load base config
onMounted(async () => {
if (project.value?.id) {
const definedParameters = ['host', 'port', 'user', 'password', 'database']
const activeBase = (await api.base.read(project.value?.id, props.baseId)) as BaseType
const tempParameters = Object.entries(activeBase.config.connection)
.filter(([key]) => !definedParameters.includes(key))
.map(([key, value]) => ({ key: key as string, value: value as string }))
formState.value = {
title: activeBase.alias || '',
dataSource: activeBase.config,
inflection: {
inflectionColumn: activeBase.inflection_column,
inflectionTable: activeBase.inflection_table,
},
extraParameters: tempParameters,
sslUse: SSLUsage.No,
}
updateSSLUse()
}
})
</script>
<template>
<div class="edit-base max-w-800px mx-auto bg-white relative flex flex-col justify-center gap-2 w-full p-8">
<h1 class="prose-2xl font-bold self-center my-4">Edit Base</h1>
<a-form
ref="form"
:model="formState"
name="external-project-create-form"
layout="horizontal"
no-style
:label-col="{ span: 8 }"
>
<a-form-item label="Base Name" v-bind="validateInfos.title">
<a-input v-model:value="formState.title" class="nc-extdb-proj-name" />
</a-form-item>
<a-form-item :label="$t('labels.dbType')" v-bind="validateInfos['dataSource.client']">
<a-select
v-model:value="formState.dataSource.client"
class="nc-extdb-db-type"
dropdown-class-name="nc-dropdown-ext-db-type"
@change="onClientChange"
>
<a-select-option v-for="client in clientTypes" :key="client.value" :value="client.value"
>{{ client.text }}
</a-select-option>
</a-select>
</a-form-item>
<!-- SQLite File -->
<a-form-item
v-if="formState.dataSource.client === ClientType.SQLITE"
:label="$t('labels.sqliteFile')"
v-bind="validateInfos['dataSource.connection.connection.filename']"
>
<a-input v-model:value="(formState.dataSource.connection as SQLiteConnection).connection.filename" />
</a-form-item>
<template v-else>
<!-- Host Address -->
<a-form-item :label="$t('labels.hostAddress')" v-bind="validateInfos['dataSource.connection.host']">
<a-input v-model:value="(formState.dataSource.connection as DefaultConnection).host" class="nc-extdb-host-address" />
</a-form-item>
<!-- Port Number -->
<a-form-item :label="$t('labels.port')" v-bind="validateInfos['dataSource.connection.port']">
<a-input-number
v-model:value="(formState.dataSource.connection as DefaultConnection).port"
class="!w-full nc-extdb-host-port"
/>
</a-form-item>
<!-- Username -->
<a-form-item :label="$t('labels.username')" v-bind="validateInfos['dataSource.connection.user']">
<a-input v-model:value="(formState.dataSource.connection as DefaultConnection).user" class="nc-extdb-host-user" />
</a-form-item>
<!-- Password -->
<a-form-item :label="$t('labels.password')">
<a-input-password
v-model:value="(formState.dataSource.connection as DefaultConnection).password"
class="nc-extdb-host-password"
/>
</a-form-item>
<!-- Database -->
<a-form-item :label="$t('labels.database')" v-bind="validateInfos['dataSource.connection.database']">
<!-- Database : create if not exists -->
<a-input
v-model:value="formState.dataSource.connection.database"
:placeholder="$t('labels.dbCreateIfNotExists')"
class="nc-extdb-host-database"
/>
</a-form-item>
<!-- Schema name -->
<a-form-item
v-if="[ClientType.MSSQL, ClientType.PG].includes(formState.dataSource.client) && formState.dataSource.searchPath"
:label="$t('labels.schemaName')"
v-bind="validateInfos['dataSource.searchPath.0']"
>
<a-input v-model:value="formState.dataSource.searchPath[0]" />
</a-form-item>
<a-collapse ghost expand-icon-position="right" class="!mt-6">
<a-collapse-panel key="1">
<template #header>
<div class="flex items-center gap-2">
<!-- Use Connection URL -->
<a-button type="default" class="nc-extdb-btn-import-url" @click.stop="importURLDlg = true">
{{ $t('activity.useConnectionUrl') }}
</a-button>
<span>{{ $t('title.advancedParameters') }}</span>
</div>
</template>
<a-form-item label="SSL mode">
<a-select v-model:value="formState.sslUse" dropdown-class-name="nc-dropdown-ssl-mode" @select="onSSLModeChange">
<a-select-option v-for="opt in Object.values(SSLUsage)" :key="opt" :value="opt">{{ opt }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="SSL keys">
<div class="flex gap-2">
<a-tooltip placement="top">
<!-- Select .cert file -->
<template #title>
<span>{{ $t('tooltip.clientCert') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" class="shadow" @click="certFileInput?.click()">
{{ $t('labels.clientCert') }}
</a-button>
</a-tooltip>
<a-tooltip placement="top">
<!-- Select .key file -->
<template #title>
<span>{{ $t('tooltip.clientKey') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" class="shadow" @click="keyFileInput?.click()">
{{ $t('labels.clientKey') }}
</a-button>
</a-tooltip>
<a-tooltip placement="top">
<!-- Select CA file -->
<template #title>
<span>{{ $t('tooltip.clientCA') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" class="shadow" @click="caFileInput?.click()">
{{ $t('labels.serverCA') }}
</a-button>
</a-tooltip>
</div>
</a-form-item>
<input ref="caFileInput" type="file" class="!hidden" @change="onFileSelect(CertTypes.ca, caFileInput)" />
<input ref="certFileInput" type="file" class="!hidden" @change="onFileSelect(CertTypes.cert, certFileInput)" />
<input ref="keyFileInput" type="file" class="!hidden" @change="onFileSelect(CertTypes.key, keyFileInput)" />
<a-divider />
<!-- Extra connection parameters -->
<a-form-item class="mb-2" :label="$t('labels.extraConnectionParameters')" v-bind="validateInfos.extraParameters">
<a-card>
<div v-for="(item, index) of formState.extraParameters" :key="index">
<div class="flex py-1 items-center gap-1">
<a-input v-model:value="item.key" />
<span>:</span>
<a-input v-model:value="item.value" />
<MdiClose :style="{ 'font-size': '1.5em', 'color': 'red' }" @click="removeParam(index)" />
</div>
</div>
<a-button type="dashed" class="w-full caption mt-2" @click="addNewParam">
<div class="flex items-center justify-center"><MdiPlus /></div>
</a-button>
</a-card>
</a-form-item>
<a-divider />
<a-form-item :label="$t('labels.inflection.tableName')">
<a-select
v-model:value="formState.inflection.inflectionTable"
dropdown-class-name="nc-dropdown-inflection-table-name"
>
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('labels.inflection.columnName')">
<a-select
v-model:value="formState.inflection.inflectionColumn"
dropdown-class-name="nc-dropdown-inflection-column-name"
>
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }}</a-select-option>
</a-select>
</a-form-item>
<div class="flex justify-end">
<a-button class="!shadow-md" @click="handleEditJSON()">
<!-- Edit connection JSON -->
{{ $t('activity.editConnJson') }}
</a-button>
</div>
</a-collapse-panel>
</a-collapse>
</template>
<a-form-item class="flex justify-center !mt-5">
<div class="flex justify-center gap-2">
<a-button type="primary" ghost class="nc-extdb-btn-test-connection" @click="testConnection">
{{ $t('activity.testDbConn') }}
</a-button>
<a-button type="primary" :disabled="!testSuccess" class="nc-extdb-btn-submit !shadow" @click="editBase">
{{ $t('general.submit') }}
</a-button>
</div>
</a-form-item>
<div class="w-full flex items-center mt-2 text-[#e65100]">
<MdiWarning class="mr-1" />
Please make sure database you are trying to connect is valid! This operation can cause schema loss!!
</div>
</a-form>
<a-modal
v-model:visible="configEditDlg"
:title="$t('activity.editConnJson')"
width="600px"
wrap-class-name="nc-modal-edit-connection-json"
@ok="handleOk"
>
<MonacoEditor v-if="configEditDlg" v-model="customFormState" class="h-[400px] w-full" />
</a-modal>
<!-- Use Connection URL -->
<a-modal
v-model:visible="importURLDlg"
:title="$t('activity.useConnectionUrl')"
width="600px"
:ok-text="$t('general.ok')"
:cancel-text="$t('general.cancel')"
wrap-class-name="nc-modal-connection-url"
@ok="handleImportURL"
>
<a-input v-model:value="importURL" />
</a-modal>
</div>
</template>
<style lang="scss" scoped>
:deep(.ant-collapse-header) {
@apply !pr-10 !-mt-4 text-right justify-end;
}
:deep(.ant-collapse-content-box) {
@apply !px-0;
}
:deep(.ant-form-item-explain-error) {
@apply !text-xs;
}
:deep(.ant-form-item) {
@apply mb-2;
}
:deep(.ant-form-item-with-help .ant-form-item-explain) {
@apply !min-h-0;
}
.edit-base {
:deep(.ant-input-affix-wrapper),
:deep(.ant-input),
:deep(.ant-select) {
@apply !appearance-none border-1 border-solid rounded;
}
:deep(.ant-input-password) {
input {
@apply !border-none my-0;
}
}
}
</style>

7
packages/nc-gui/components/dlg/AirtableImport.vue

@ -18,8 +18,9 @@ import {
watch,
} from '#imports'
const { modelValue } = defineProps<{
const { modelValue, baseId } = defineProps<{
modelValue: boolean
baseId: string
}>()
const emit = defineEmits(['update:modelValue'])
@ -100,7 +101,7 @@ async function createOrUpdate() {
body: payload,
})
} else {
syncSource.value = await $fetch(`/api/v1/db/meta/projects/${project.value.id}/syncs`, {
syncSource.value = await $fetch(`/api/v1/db/meta/projects/${project.value.id}/syncs/${baseId}`, {
baseURL,
method: 'POST',
headers: { 'xc-auth': $state.token.value as string },
@ -113,7 +114,7 @@ async function createOrUpdate() {
}
async function loadSyncSrc() {
const data: any = await $fetch(`/api/v1/db/meta/projects/${project.value.id}/syncs`, {
const data: any = await $fetch(`/api/v1/db/meta/projects/${project.value.id}/syncs/${baseId}`, {
baseURL,
method: 'GET',
headers: { 'xc-auth': $state.token.value as string },

2
packages/nc-gui/components/dlg/QuickImport.vue

@ -27,6 +27,7 @@ import type { importFileList, streamImportFileList } from '~/lib'
interface Props {
modelValue: boolean
importType: 'csv' | 'json' | 'excel'
baseId: string
importDataOnly?: boolean
}
@ -364,6 +365,7 @@ const beforeUpload = (file: UploadFile) => {
:import-data-only="importDataOnly"
:quick-import-type="importType"
:max-rows-to-parse="importState.parserConfig.maxRowsToParse"
:base-id="baseId"
class="nc-quick-import-template-editor"
@import="handleImport"
/>

9
packages/nc-gui/components/dlg/TableCreate.vue

@ -4,6 +4,7 @@ import { TabType } from '~/lib'
const props = defineProps<{
modelValue: boolean
baseId: string
}>()
const emit = defineEmits(['update:modelValue'])
@ -28,7 +29,7 @@ const { table, createTable, generateUniqueTitle, tables, project } = useTable(as
})
dialogShow.value = false
})
}, props.baseId)
const useForm = Form.useForm
@ -51,11 +52,11 @@ const validators = computed(() => {
validator: (rule: any, value: any) => {
return new Promise<void>((resolve, reject) => {
let tableNameLengthLimit = 255
if (isMysql) {
if (isMysql(props.baseId)) {
tableNameLengthLimit = 64
} else if (isPg) {
} else if (isPg(props.baseId)) {
tableNameLengthLimit = 63
} else if (isMssql) {
} else if (isMssql(props.baseId)) {
tableNameLengthLimit = 128
}
const projectPrefix = project?.value?.prefix || ''

9
packages/nc-gui/components/dlg/TableRename.vue

@ -21,9 +21,10 @@ import {
interface Props {
modelValue?: boolean
tableMeta: TableType
baseId: string
}
const { tableMeta, ...props } = defineProps<Props>()
const { tableMeta, baseId, ...props } = defineProps<Props>()
const emit = defineEmits(['update:modelValue', 'updated'])
@ -57,11 +58,11 @@ const validators = computed(() => {
validator: (rule: any, value: any) => {
return new Promise<void>((resolve, reject) => {
let tableNameLengthLimit = 255
if (isMysql) {
if (isMysql(baseId)) {
tableNameLengthLimit = 64
} else if (isPg) {
} else if (isPg(baseId)) {
tableNameLengthLimit = 63
} else if (isMssql) {
} else if (isMssql(baseId)) {
tableNameLengthLimit = 128
}
const projectPrefix = project?.value?.prefix || ''

19
packages/nc-gui/components/erd/View.vue

@ -4,7 +4,7 @@ import { UITypes } from 'nocodb-sdk'
import type { ERDConfig } from './utils'
import { reactive, ref, useMetas, useProject, watch } from '#imports'
const { table } = defineProps<{ table?: TableType }>()
const props = defineProps<{ table?: TableType; baseId?: string }>()
const { tables: projectTables } = useProject()
@ -18,7 +18,7 @@ const config = reactive<ERDConfig>({
showPkAndFk: true,
showViews: false,
showAllColumns: true,
singleTableMode: !!table,
singleTableMode: !!props.table,
showMMTables: false,
showJunctionTableNames: false,
})
@ -34,14 +34,13 @@ const loadMetaOfTablesNotInMetas = async (localTables: TableType[]) => {
}
const populateTables = async () => {
let localTables: TableType[]
if (table) {
let localTables: TableType[] = []
if (props.table) {
// if table is provided only get the table and its related tables
localTables = projectTables.value.filter(
(t) =>
t.id === table.id ||
table.columns?.find(
t.id === props.table.id ||
props.table.columns?.find(
(column) =>
column.uidt === UITypes.LinkToAnotherRecord &&
(column.colOptions as LinkToAnotherRecordType)?.fk_related_model_id === t.id,
@ -59,7 +58,7 @@ const populateTables = async () => {
config.showMMTables ||
(!config.showMMTables && !t.mm) ||
// Show mm table if it's the selected table
t.id === table?.id,
t.id === props.table?.id,
)
.filter((t) => config.singleTableMode || (!config.showViews && t.type !== 'view') || config.showViews)
@ -76,6 +75,8 @@ watch(config, populateTables, {
deep: true,
})
const filteredTables = computed(() => tables.value.filter((t) => !props.baseId || t.base_id === props.baseId))
watch(
() => config.showAllColumns,
() => {
@ -87,7 +88,7 @@ watch(
<template>
<div class="w-full" style="height: inherit" :class="[`nc-erd-vue-flow${config.singleTableMode ? '-single-table' : ''}`]">
<div class="relative h-full">
<LazyErdFlow :tables="tables" :config="config">
<LazyErdFlow :tables="filteredTables" :config="config">
<GeneralOverlay v-model="isLoading" inline class="bg-gray-300/50">
<div class="h-full w-full flex flex-col justify-center items-center">
<a-spin size="large" />

22
packages/nc-gui/components/general/AddBaseButton.vue

@ -0,0 +1,22 @@
<script setup lang="ts">
const { isUIAllowed } = useUIPermission()
const { t } = useI18n()
const toggleDialog = inject(ToggleDialogInj, () => {})
</script>
<template>
<div
v-if="isUIAllowed('settings')"
class="flex items-center w-full pl-3 hover:(text-primary bg-primary bg-opacity-5)"
@click="toggleDialog(true)"
>
<div>
<div class="flex items-center space-x-1">
<RiTeamFill class="mr-1 nc-new-base" />
<div>{{ t('title.teamAndSettings') }}</div>
</div>
</div>
</div>
</template>

28
packages/nc-gui/components/general/BaseLogo.vue

@ -0,0 +1,28 @@
<script setup lang="ts">
import LogosMysqlIcon from '~icons/logos/mysql-icon'
import LogosPostgresql from '~icons/logos/postgresql'
import VscodeIconsFileTypeSqlite from '~icons/vscode-icons/file-type-sqlite'
import SimpleIconsMicrosoftsqlserver from '~icons/simple-icons/microsoftsqlserver'
import MdiDatabaseOutline from '~icons/mdi/database-outline'
const { baseType } = defineProps<{ baseType?: string }>()
const baseIcon = computed(() => {
switch (baseType) {
case ClientType.MYSQL:
return LogosMysqlIcon
case ClientType.PG:
return LogosPostgresql
case ClientType.SQLITE:
return VscodeIconsFileTypeSqlite
case ClientType.MSSQL:
return SimpleIconsMicrosoftsqlserver
default:
return MdiDatabaseOutline
}
})
</script>
<template>
<component :is="baseIcon" />
</template>

15
packages/nc-gui/components/general/ShareBaseButton.vue

@ -37,13 +37,16 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
</script>
<template>
<div class="flex items-center w-full pl-3 hover:(text-primary bg-primary bg-opacity-5)" @click="showUserModal = true">
<div class="flex items-center h-full" @click="showUserModal = true">
<div v-if="isShareBaseAllowed">
<div class="flex items-center space-x-1">
<MdiAccountPlusOutline class="mr-1 nc-share-base" />
<div>{{ $t('activity.inviteTeam') }}</div>
</div>
<a-tooltip placement="left">
<template #title>
<span class="text-xs">{{ $t('activity.inviteTeam') }}</span>
</template>
<div class="flex items-center space-x-1 cursor-pointer">
<MdiAccountPlusOutline class="mr-1 nc-share-base text-gray-300 hover:text-accent" />
</div>
</a-tooltip>
</div>
<LazyTabsAuthUserManagementUsersModal :show="showUserModal" @closed="showUserModal = false" />

11
packages/nc-gui/components/general/Tooltip.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { onKeyStroke } from '@vueuse/core'
import type { CSSProperties } from '@vue/runtime-dom'
import { controlledRef, ref, useElementHover, watch } from '#imports'
import { controlledRef, ref, useAttrs, useElementHover, watch } from '#imports'
interface Props {
// Key to be pressed on hover to trigger the tooltip
@ -23,6 +23,8 @@ const showTooltip = controlledRef(false, {
const isHovering = useElementHover(() => el.value)
const attrs = useAttrs()
const isKeyPressed = ref(false)
onKeyStroke(
@ -73,6 +75,11 @@ watch([isHovering, () => modifierKey, () => disabled], ([hovering, key, isDisabl
showTooltip.value = true
}
})
const divStyles = $computed(() => ({
style: attrs.style as CSSProperties,
class: attrs.class as string,
}))
</script>
<template>
@ -81,7 +88,7 @@ watch([isHovering, () => modifierKey, () => disabled], ([hovering, key, isDisabl
<slot name="title" />
</template>
<div ref="el" class="w-full" :class="$attrs.class" :style="$attrs.style">
<div ref="el" class="w-full" v-bind="divStyles">
<slot />
</div>
</a-tooltip>

9
packages/nc-gui/components/smartsheet/ApiSnippet.vue

@ -172,13 +172,8 @@ watch($$(activeLang), (newLang) => {
hide-minimap
/>
<div v-if="activeLang.clients" class="flex flex-row w-full justify-end space-x-3 mt-4 uppercase">
<a-select
v-if="activeLang"
v-model:value="selectedClient"
style="width: 6rem"
dropdown-class-name="nc-dropdown-snippet-active-lang"
>
<div v-if="activeLang?.clients" class="flex flex-row w-full justify-end space-x-3 mt-4 uppercase">
<a-select v-model:value="selectedClient" style="width: 6rem" dropdown-class-name="nc-dropdown-snippet-active-lang">
<a-select-option v-for="(client, i) in activeLang?.clients" :key="i" class="!w-full uppercase" :value="client">
{{ client }}
</a-select-option>

4
packages/nc-gui/components/smartsheet/Cell.vue

@ -82,7 +82,9 @@ const isLocked = inject(IsLockedInj, ref(false))
const { currentRow } = useSmartsheetRowStoreOrThrow()
const { sqlUi } = useProject()
const { sqlUis } = useProject()
const sqlUi = ref(column.value?.base_id ? sqlUis.value[column.value?.base_id] : Object.values(sqlUis.value)[0])
const abstractType = computed(() => column.value && sqlUi.value.getAbstractType(column.value))

11
packages/nc-gui/components/smartsheet/Grid.vue

@ -92,6 +92,7 @@ const contextMenu = computed({
},
})
const routeQuery = $computed(() => route.query as Record<string, string>)
const contextMenuTarget = ref<{ row: number; col: number } | null>(null)
const expandedFormDlg = ref(false)
const expandedFormRow = ref<Row>()
@ -365,7 +366,7 @@ function expandForm(row: Row, state?: Record<string, any>, fromToolbar = false)
if (rowId) {
router.push({
query: {
...route.query,
...routeQuery,
rowId,
},
})
@ -569,13 +570,13 @@ onBeforeUnmount(() => {
const expandedFormOnRowIdDlg = computed({
get() {
return !!route.query.rowId
return !!routeQuery.rowId
},
set(val) {
if (!val)
router.push({
query: {
...route.query,
...routeQuery,
rowId: undefined,
},
})
@ -903,11 +904,11 @@ const closeAddColumnDropdown = () => {
<Suspense>
<LazySmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg"
:key="route.query.rowId"
:key="routeQuery.rowId"
v-model="expandedFormOnRowIdDlg"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:meta="meta"
:row-id="route.query.rowId"
:row-id="routeQuery.rowId"
:view="view"
/>
</Suspense>

2
packages/nc-gui/components/smartsheet/VirtualCell.vue

@ -24,7 +24,7 @@ import { NavigateDir } from '~/lib'
const props = defineProps<{
column: ColumnType
modelValue: any
row: Row
row?: Row
active?: boolean
}>()

6
packages/nc-gui/components/smartsheet/column/AdvancedOptions.vue

@ -1,6 +1,6 @@
<script setup lang="ts">
import { UITypes } from 'nocodb-sdk'
import { computed, useColumnCreateStoreOrThrow, useProject, useVModel } from '#imports'
import { computed, useColumnCreateStoreOrThrow, useVModel } from '#imports'
const props = defineProps<{
value: any
@ -10,9 +10,7 @@ const emit = defineEmits(['update:value'])
const vModel = useVModel(props, 'value', emit)
const { sqlUi } = useProject()
const { onAlter, onDataTypeChange, validateInfos } = useColumnCreateStoreOrThrow()
const { onAlter, onDataTypeChange, validateInfos, sqlUi } = useColumnCreateStoreOrThrow()
// todo: 2nd argument of `getDataTypeListForUiType` is missing!
const dataTypes = computed(() => sqlUi.value.getDataTypeListForUiType(vModel.value as { uidt: UITypes }, '' as any))

6
packages/nc-gui/components/smartsheet/column/CurrencyOptions.vue

@ -49,14 +49,12 @@ const validators = {
],
}
const { setAdditionalValidations, validateInfos } = useColumnCreateStoreOrThrow()
const { setAdditionalValidations, validateInfos, isPg } = useColumnCreateStoreOrThrow()
setAdditionalValidations({
...validators,
})
const { isPg } = useProject()
const currencyList = currencyCodes || []
const currencyLocaleList = currencyLocales() || []
@ -64,7 +62,7 @@ const currencyLocaleList = currencyLocales() || []
const isMoney = computed(() => vModel.value.dt === 'money')
const message = computed(() => {
if (isMoney.value && isPg) return "PostgreSQL 'money' type has own currency settings"
if (isMoney.value && isPg.value) return "PostgreSQL 'money' type has own currency settings"
return ''
})

2
packages/nc-gui/components/smartsheet/column/EditOrAddProvider.vue

@ -4,7 +4,7 @@ import type { ColumnReqType, ColumnType } from 'nocodb-sdk'
import { MetaInj, inject, ref, toRef, useProvideColumnCreateStore } from '#imports'
interface Props {
column?: ColumnType & { meta: any }
column?: ColumnType
columnPosition?: Pick<ColumnReqType, 'column_order'>
}

16
packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue

@ -14,9 +14,9 @@ const vModel = useVModel(props, 'value', emit)
const meta = $(inject(MetaInj, ref()))
const { setAdditionalValidations, validateInfos, onDataTypeChange } = useColumnCreateStoreOrThrow()
const { setAdditionalValidations, validateInfos, onDataTypeChange, sqlUi } = useColumnCreateStoreOrThrow()
const { tables, sqlUi } = $(useProject())
const { tables } = $(useProject())
setAdditionalValidations({
childId: [{ required: true, message: 'Required' }],
@ -44,8 +44,10 @@ const refTables = $computed(() => {
return []
}
return tables.filter((t) => t.type === ModelTypes.TABLE)
return tables.filter((t) => t.type === ModelTypes.TABLE && t.base_id === meta?.base_id)
})
const filterOption = (value: string, option: { key: string }) => option.key.toLowerCase().includes(value.toLowerCase())
</script>
<template>
@ -66,11 +68,11 @@ const refTables = $computed(() => {
<a-select
v-model:value="vModel.childId"
show-search
:filter-option="(value, option) => option.key.toLowerCase().includes(value.toLowerCase())"
:filter-option="filterOption"
dropdown-class-name="nc-dropdown-ltar-child-table"
@change="onDataTypeChange"
>
<a-select-option v-for="table in refTables" :key="table.title" :value="table.id">
<a-select-option v-for="table of refTables" :key="table.title" :value="table.id">
{{ table.title }}
</a-select-option>
</a-select>
@ -96,7 +98,7 @@ const refTables = $computed(() => {
dropdown-class-name="nc-dropdown-on-update"
@change="onDataTypeChange"
>
<a-select-option v-for="(option, index) in onUpdateDeleteOptions" :key="index" :value="option">
<a-select-option v-for="(option, i) of onUpdateDeleteOptions" :key="i" :value="option">
{{ option }}
</a-select-option>
</a-select>
@ -110,7 +112,7 @@ const refTables = $computed(() => {
dropdown-class-name="nc-dropdown-on-delete"
@change="onDataTypeChange"
>
<a-select-option v-for="(option, index) in onUpdateDeleteOptions" :key="index" :value="option">
<a-select-option v-for="(option, i) of onUpdateDeleteOptions" :key="i" :value="option">
{{ option }}
</a-select-option>
</a-select>

39
packages/nc-gui/components/smartsheet/column/LookupOptions.vue

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes, isSystemColumn } from 'nocodb-sdk'
import { getRelationName } from './utils'
import { MetaInj, inject, ref, useColumnCreateStoreOrThrow, useMetas, useProject, useVModel } from '#imports'
const props = defineProps<{
@ -27,36 +28,28 @@ setAdditionalValidations({
if (!vModel.value.fk_relation_column_id) vModel.value.fk_relation_column_id = null
if (!vModel.value.fk_lookup_column_id) vModel.value.fk_lookup_column_id = null
const relationNames = {
mm: 'Many To Many',
hm: 'Has Many',
bt: 'Belongs To',
}
const refTables = $computed(() => {
if (!tables || !tables.length) {
if (!tables || !tables.length || !meta || !meta.columns) {
return []
}
return meta?.columns
?.filter((c: any) => c.uidt === UITypes.LinkToAnotherRecord && !c.system)
.map((c: ColumnType) => ({
col: c.colOptions,
column: c,
...tables.find((t) => t.id === (c.colOptions as LinkToAnotherRecordType).fk_related_model_id),
const _refTables = meta.columns
.filter((column) => column.uidt === UITypes.LinkToAnotherRecord && !column.system && column.base_id === meta?.base_id)
.map((column) => ({
col: column.colOptions,
column,
...tables.find((table) => table.id === (column.colOptions as LinkToAnotherRecordType).fk_related_model_id),
}))
.filter((table: any) => table.col.fk_related_model_id === table.id && !table.mm)
.filter((table) => (table.col as LinkToAnotherRecordType)?.fk_related_model_id === table.id && !table.mm)
return _refTables as Required<TableType & { column: ColumnType; col: Required<LinkToAnotherRecordType> }>[]
})
const columns = $computed(() => {
const selectedTable = refTables?.find((t) => t.column.id === vModel.value.fk_relation_column_id)
const columns = $computed<ColumnType[]>(() => {
const selectedTable = refTables.find((t) => t.column.id === vModel.value.fk_relation_column_id)
if (!selectedTable?.id) {
return []
}
return metas[selectedTable.id].columns.filter((c: any) => {
return !(isSystemColumn(c) || c.uidt === UITypes.QrCode)
})
return metas[selectedTable.id].columns.filter((c: ColumnType) => !isSystemColumn(c))
})
</script>
@ -69,11 +62,11 @@ const columns = $computed(() => {
dropdown-class-name="!w-64 nc-dropdown-relation-table"
@change="onDataTypeChange"
>
<a-select-option v-for="(table, index) in refTables" :key="index" :value="table.col.fk_column_id">
<a-select-option v-for="(table, i) of refTables" :key="i" :value="table.col.fk_column_id">
<div class="flex flex-row space-x-0.5 h-full pb-0.5 items-center justify-between">
<div class="font-semibold text-xs">{{ table.column.title }}</div>
<div class="text-[0.65rem] text-gray-600">
{{ relationNames[table.col.type] }} {{ table.title || table.table_name }}
{{ getRelationName(table.col.type) }} {{ table.title || table.table_name }}
</div>
</div>
</a-select-option>

5
packages/nc-gui/components/smartsheet/column/QrCodeOptions.vue

@ -25,10 +25,7 @@ const { setAdditionalValidations, validateInfos, column } = useColumnCreateStore
const columnsAllowedAsQrValue = computed<SelectProps['options']>(() => {
return fields.value
?.filter(
(el) =>
el.fk_column_id &&
// AllowedColumnTypesForQrCode.map((el) => el.toString()).includes(metaColumnById.value[el.fk_column_id].uidt),
AllowedColumnTypesForQrCode.includes(metaColumnById.value[el.fk_column_id].uidt as UITypes),
(el) => el.fk_column_id && AllowedColumnTypesForQrCode.includes(metaColumnById.value[el.fk_column_id].uidt as UITypes),
)
.map((field) => {
return {

38
packages/nc-gui/components/smartsheet/column/RollupOptions.vue

@ -1,5 +1,7 @@
<script setup lang="ts">
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { getRelationName } from './utils'
import { MetaInj, inject, ref, useColumnCreateStoreOrThrow, useMetas, useProject, useVModel } from '#imports'
const props = defineProps<{
@ -24,11 +26,6 @@ setAdditionalValidations({
rollup_function: [{ required: true, message: 'Required' }],
})
const relationNames = {
mm: 'Many To Many',
hm: 'Has Many',
}
const aggrFunctionsList = [
{ text: 'count', value: 'count' },
{ text: 'min', value: 'min' },
@ -45,19 +42,24 @@ if (!vModel.value.fk_rollup_column_id) vModel.value.fk_rollup_column_id = null
if (!vModel.value.rollup_function) vModel.value.rollup_function = null
const refTables = $computed(() => {
if (!tables || !tables.length) {
if (!tables || !tables.length || !meta || !meta.columns) {
return []
}
return (
meta?.columns
?.filter((c: any) => c.uidt === UITypes.LinkToAnotherRecord && c.colOptions.type !== 'bt' && !c.system)
.map((c) => ({
col: c.colOptions,
column: c,
...tables.find((t) => t.id === (c.colOptions as any)?.fk_related_model_id),
})) ?? []
)
const _refTables = meta.columns
.filter(
(c) =>
c.uidt === UITypes.LinkToAnotherRecord &&
(c.colOptions as LinkToAnotherRecordType).type !== 'bt' &&
!c.system &&
c.base_id === meta?.base_id,
)
.map((c) => ({
col: c.colOptions,
column: c,
...tables.find((t) => t.id === (c.colOptions as any)?.fk_related_model_id),
}))
return _refTables as Required<TableType & { column: ColumnType; col: Required<LinkToAnotherRecordType> }>[]
})
const columns = $computed(() => {
@ -67,7 +69,7 @@ const columns = $computed(() => {
return []
}
return metas[selectedTable.id].columns.filter((c: any) => !isVirtualCol(c.uidt) && !isSystemColumn(c))
return metas[selectedTable.id].columns.filter((c: ColumnType) => !isVirtualCol(c.uidt as UITypes) && !isSystemColumn(c))
})
</script>
@ -80,11 +82,11 @@ const columns = $computed(() => {
dropdown-class-name="!w-64 nc-dropdown-relation-table"
@change="onDataTypeChange"
>
<a-select-option v-for="(table, index) in refTables" :key="index" :value="table.col.fk_column_id">
<a-select-option v-for="(table, i) of refTables" :key="i" :value="table.col.fk_column_id">
<div class="flex flex-row space-x-0.5 h-full pb-0.5 items-center justify-between">
<div class="font-semibold text-xs">{{ table.column.title }}</div>
<div class="text-[0.65rem] text-gray-600">
({{ relationNames[table.col.type] }} {{ table.title || table.table_name }})
({{ getRelationName(table.col.type) }} {{ table.title || table.table_name }})
</div>
</div>
</a-select-option>

4
packages/nc-gui/components/smartsheet/column/SelectOptions.vue

@ -11,9 +11,7 @@ const emit = defineEmits(['update:value'])
const vModel = useVModel(props, 'value', emit)
const { isPg, isMysql } = useProject()
const { setAdditionalValidations, validateInfos } = useColumnCreateStoreOrThrow()
const { setAdditionalValidations, validateInfos, isPg, isMysql } = useColumnCreateStoreOrThrow()
let options = $ref<any[]>([])

9
packages/nc-gui/components/smartsheet/column/utils.ts

@ -0,0 +1,9 @@
const relationNames = {
mm: 'Many To Many',
hm: 'Has Many',
bt: 'Belongs To',
} as const
export function getRelationName(type: string) {
return relationNames[type as keyof typeof relationNames]
}

7
packages/nc-gui/components/smartsheet/header/Cell.vue

@ -2,7 +2,12 @@
import type { ColumnReqType, ColumnType } from 'nocodb-sdk'
import { ColumnInj, IsFormInj, IsKanbanInj, inject, provide, ref, toRef, useUIPermission } from '#imports'
const props = defineProps<{ column: ColumnType & { meta: any }; required?: boolean | number; hideMenu?: boolean }>()
interface Props {
column: ColumnType
required?: boolean | number
hideMenu?: boolean
}
const props = defineProps<Props>()
const hideMenu = toRef(props, 'hideMenu')

5
packages/nc-gui/components/smartsheet/header/CellIcon.ts

@ -19,6 +19,7 @@ import {
isJSON,
isPercent,
isPhoneNumber,
isPrimaryKey,
isRating,
isSet,
isSingleSelect,
@ -118,7 +119,9 @@ export default defineComponent({
const column = inject(ColumnInj, columnMeta)
const { sqlUi } = useProject()
const { sqlUis } = useProject()
const sqlUi = ref(column.value?.base_id ? sqlUis.value[column.value?.base_id] : Object.values(sqlUis.value)[0])
const abstractType = computed(() => column.value && sqlUi.value.getAbstractType(column.value))

2
packages/nc-gui/components/smartsheet/header/VirtualCell.vue

@ -124,7 +124,7 @@ const closeAddColumnDropdown = () => {
<span class="name" style="white-space: nowrap" :title="column.title"> {{ column.title }}</span>
</a-tooltip>
<span v-if="isVirtualColRequired(column, meta.columns) || required" class="text-red-500">&nbsp;*</span>
<span v-if="isVirtualColRequired(column, meta?.columns || []) || required" class="text-red-500">&nbsp;*</span>
<template v-if="!hideMenu">
<div class="flex-1" />

12
packages/nc-gui/components/smartsheet/toolbar/KanbanStackEditOrAdd.vue

@ -1,5 +1,15 @@
<script setup lang="ts">
import { IsLockedInj, IsPublicInj, useKanbanViewStoreOrThrow, useMenuCloseOnEsc } from '#imports'
import {
IsKanbanInj,
IsLockedInj,
IsPublicInj,
inject,
provide,
ref,
useKanbanViewStoreOrThrow,
useMenuCloseOnEsc,
useUIPermission,
} from '#imports'
const { isUIAllowed } = useUIPermission()

4
packages/nc-gui/components/smartsheet/toolbar/MoreActions.vue

@ -14,6 +14,7 @@ import {
useI18n,
useNuxtApp,
useProject,
useSharedView,
useSmartsheetStoreOrThrow,
useUIPermission,
} from '#imports'
@ -38,6 +39,8 @@ const selectedView = inject(ActiveViewInj, ref())
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow()
const { exportFile: sharedViewExportFile } = useSharedView()
const isLocked = inject(IsLockedInj)
const showWebhookDrawer = ref(false)
@ -58,7 +61,6 @@ const exportFile = async (exportType: ExportTypes) => {
while (!isNaN(offset) && offset > -1) {
let res
if (isPublicView.value) {
const { exportFile: sharedViewExportFile } = useSharedView()
res = await sharedViewExportFile(fields.value, offset, exportType, responseType)
} else {
res = await $api.dbViewRow.export(

7
packages/nc-gui/components/smartsheet/toolbar/SearchData.vue

@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { TableType } from 'nocodb-sdk'
import {
ActiveViewInj,
ReloadViewDataHookInj,
@ -25,9 +26,9 @@ const searchDropdown = ref(null)
onClickOutside(searchDropdown, () => (isDropdownOpen.value = false))
const columns = computed(() =>
meta.value?.columns?.map((c) => ({
value: c.id,
label: c.title,
(meta.value as TableType)?.columns?.map((column) => ({
value: column.id,
label: column.title,
})),
)

9
packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue

@ -40,6 +40,11 @@ const columnByID = computed(() =>
}, {} as Record<string, ColumnType>),
)
const getColumnUidtByID = (key?: string) => {
if (!key) return ''
return columnByID.value[key]?.uidt || ''
}
watch(
() => view.value?.id,
(viewId) => {
@ -74,7 +79,7 @@ useMenuCloseOnEsc(open)
data-testid="nc-sorts-menu"
>
<div v-if="sorts?.length" class="sort-grid mb-2" @click.stop>
<template v-for="(sort, i) in sorts || []" :key="i">
<template v-for="(sort, i) of sorts" :key="i">
<MdiCloseBox class="nc-sort-item-remove-btn text-grey self-center" small @click.stop="deleteSort(sort, i)" />
<LazySmartsheetToolbarFieldListAutoCompleteDropdown
@ -95,7 +100,7 @@ useMenuCloseOnEsc(open)
@select="saveOrUpdate(sort, i)"
>
<a-select-option
v-for="(option, j) in getSortDirectionOptions(columnByID[sort.fk_column_id]?.uidt)"
v-for="(option, j) of getSortDirectionOptions(getColumnUidtByID(sort.fk_column_id))"
:key="j"
:value="option.value"
>

22
packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue

@ -4,6 +4,7 @@ import {
IsLockedInj,
IsPublicInj,
extractSdkResponseErrorMsg,
getViewIcon,
inject,
message,
ref,
@ -13,7 +14,6 @@ import {
useProject,
useSmartsheetStoreOrThrow,
useUIPermission,
viewIcons,
} from '#imports'
import { LockType } from '~/lib'
import MdiLockOutlineIcon from '~icons/mdi/lock-outline'
@ -30,9 +30,11 @@ const isView = false
const { $api, $e } = useNuxtApp()
const selectedView = inject(ActiveViewInj)
const { isSqlView } = useSmartsheetStoreOrThrow()
const selectedView = inject(ActiveViewInj, ref())
const isLocked = inject(IsLockedInj)
const isLocked = inject(IsLockedInj, ref(false))
const showWebhookDrawer = ref(false)
@ -47,7 +49,7 @@ const { isUIAllowed } = useUIPermission()
const { isSharedBase } = useProject()
const Icon = computed(() => {
switch (selectedView?.value.lock_type) {
switch (selectedView.value?.lock_type) {
case LockType.Personal:
return MdiAccountIcon
case LockType.Locked:
@ -58,10 +60,12 @@ const Icon = computed(() => {
}
})
const lockType = $computed(() => (selectedView.value?.lock_type as LockType) || LockType.Collaborative)
async function changeLockType(type: LockType) {
$e('a:grid:lockmenu', { lockType: type })
if (!selectedView?.value) return
if (!selectedView.value) return
if (type === 'personal') {
// Coming soon
@ -79,8 +83,6 @@ async function changeLockType(type: LockType) {
}
}
const { isSqlView } = useSmartsheetStoreOrThrow()
const open = ref(false)
useMenuCloseOnEsc(open)
@ -92,9 +94,9 @@ useMenuCloseOnEsc(open)
<a-button v-e="['c:actions']" class="nc-actions-menu-btn nc-toolbar-btn">
<div class="flex gap-2 items-center">
<component
:is="viewIcons[selectedView?.type].icon"
:is="getViewIcon(selectedView?.type)?.icon"
class="nc-view-icon group-hover:hidden"
:style="{ color: viewIcons[selectedView?.type].color }"
:style="{ color: getViewIcon(selectedView?.type)?.color }"
/>
<span class="!text-sm font-weight-normal">
@ -117,7 +119,7 @@ useMenuCloseOnEsc(open)
>
<template #title>
<div v-e="['c:navdraw:preview-as']" class="nc-project-menu-item group px-0 !py-0">
<LazySmartsheetToolbarLockType hide-tick :type="selectedView?.lock_type || LockType.Collaborative" />
<LazySmartsheetToolbarLockType hide-tick :type="lockType" />
<MaterialSymbolsChevronRightRounded
class="transform group-hover:(scale-115 text-accent) text-xl text-gray-400"

270
packages/nc-gui/components/tabs/auth/ApiTokenManagement.vue

@ -88,152 +88,158 @@ onMounted(() => {
</script>
<template>
<a-modal
v-model:visible="showNewTokenModal"
:class="{ active: showNewTokenModal }"
:closable="false"
width="28rem"
centered
:footer="null"
wrap-class-name="nc-modal-generate-token"
>
<div class="relative flex flex-col h-full">
<a-button type="text" class="!absolute top-0 right-0 rounded-md -mt-2 -mr-3" @click="showNewTokenModal = false">
<template #icon>
<MaterialSymbolsCloseRounded class="flex mx-auto" />
</template>
</a-button>
<!-- Generate Token -->
<div class="flex flex-row justify-center w-full -mt-1 mb-3">
<a-typography-title :level="5">{{ $t('title.generateToken') }}</a-typography-title>
</div>
<div>
<a-modal
v-model:visible="showNewTokenModal"
:class="{ active: showNewTokenModal }"
:closable="false"
width="28rem"
centered
:footer="null"
wrap-class-name="nc-modal-generate-token"
>
<div class="relative flex flex-col h-full">
<a-button type="text" class="!absolute top-0 right-0 rounded-md -mt-2 -mr-3" @click="showNewTokenModal = false">
<template #icon>
<MaterialSymbolsCloseRounded class="flex mx-auto" />
</template>
</a-button>
<!-- Description -->
<a-form
ref="form"
:model="selectedTokenData"
name="basic"
layout="vertical"
class="flex flex-col justify-center space-y-6"
no-style
autocomplete="off"
@finish="generateToken"
>
<a-input v-model:value="selectedTokenData.description" :placeholder="$t('labels.description')" />
<!-- Generate -->
<div class="flex flex-row justify-center">
<a-button type="primary" html-type="submit">
{{ $t('general.generate') }}
</a-button>
<!-- Generate Token -->
<div class="flex flex-row justify-center w-full -mt-1 mb-3">
<a-typography-title :level="5">{{ $t('title.generateToken') }}</a-typography-title>
</div>
</a-form>
</div>
</a-modal>
<a-modal
v-model:visible="showDeleteTokenModal"
:class="{ active: showDeleteTokenModal }"
:closable="false"
width="28rem"
centered
:footer="null"
wrap-class-name="nc-modal-delete-token"
>
<div class="flex flex-col h-full">
<div class="flex flex-row justify-center mt-2 text-center w-full text-base">This action will remove this API Token</div>
<div class="flex mt-6 justify-center space-x-2">
<a-button @click="showDeleteTokenModal = false"> {{ $t('general.cancel') }} </a-button>
<a-button type="primary" danger @click="deleteToken()"> {{ $t('general.confirm') }} </a-button>
</div>
</div>
</a-modal>
<div class="flex flex-col px-10 mt-6">
<div class="flex flex-row justify-end">
<div class="flex flex-row space-x-1">
<a-button size="middle" type="text" @click="loadApiTokens()">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<MdiReload class="text-gray-500" />
<div class="text-gray-500">{{ $t('general.reload') }}</div>
</div>
</a-button>
<!-- Add New Token -->
<a-button size="middle" type="primary" ghost @click="openNewTokenModal">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<MdiPlus />
<div>{{ $t('activity.newToken') }}</div>
<!-- Description -->
<a-form
ref="form"
:model="selectedTokenData"
name="basic"
layout="vertical"
class="flex flex-col justify-center space-y-6"
no-style
autocomplete="off"
@finish="generateToken"
>
<a-input v-model:value="selectedTokenData.description" :placeholder="$t('labels.description')" />
<!-- Generate -->
<div class="flex flex-row justify-center">
<a-button type="primary" html-type="submit">
{{ $t('general.generate') }}
</a-button>
</div>
</a-button>
</a-form>
</div>
</div>
<div v-if="tokensInfo" class="w-full flex flex-col mt-2 px-1">
<div class="flex flex-row border-b-1 text-gray-600 text-xs pb-2 pt-2">
<div class="flex w-4/10 pl-2">{{ $t('labels.description') }}</div>
<div class="flex w-4/10 justify-center">{{ $t('labels.token') }}</div>
<div class="flex w-2/10 justify-end pr-2">{{ $t('labels.action') }}</div>
</a-modal>
<a-modal
v-model:visible="showDeleteTokenModal"
:closable="false"
width="28rem"
centered
:footer="null"
wrap-class-name="nc-modal-delete-token"
>
<div class="flex flex-col h-full">
<div class="flex flex-row justify-center mt-2 text-center w-full text-base">This action will remove this API Token</div>
<div class="flex mt-6 justify-center space-x-2">
<a-button @click="showDeleteTokenModal = false"> {{ $t('general.cancel') }} </a-button>
<a-button type="primary" danger @click="deleteToken()"> {{ $t('general.confirm') }} </a-button>
</div>
</div>
</a-modal>
<div class="flex flex-col px-10 mt-6">
<div class="flex flex-row justify-end">
<div class="flex flex-row space-x-1">
<a-button size="middle" type="text" @click="loadApiTokens()">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<MdiReload class="text-gray-500" />
<div class="text-gray-500">{{ $t('general.reload') }}</div>
</div>
</a-button>
<div v-for="(item, index) in tokensInfo" :key="index" class="flex flex-col">
<div class="flex flex-row border-b-1 items-center px-2 py-2">
<div class="flex flex-row w-4/10 flex-wrap overflow-ellipsis">
{{ item.description }}
</div>
<!-- Add New Token -->
<a-button size="middle" type="primary" ghost @click="openNewTokenModal">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<MdiPlus />
<div>{{ $t('activity.newToken') }}</div>
</div>
</a-button>
</div>
</div>
<div class="flex w-4/10 justify-center flex-wrap overflow-ellipsis">
<span v-if="item.show">{{ item.token }}</span>
<span v-else>****************************************</span>
</div>
<div v-if="tokensInfo" class="w-full flex flex-col mt-2 px-1">
<div class="flex flex-row border-b-1 text-gray-600 text-xs pb-2 pt-2">
<div class="flex w-4/10 pl-2">{{ $t('labels.description') }}</div>
<div class="flex w-4/10 justify-center">{{ $t('labels.token') }}</div>
<div class="flex w-2/10 justify-end pr-2">{{ $t('labels.action') }}</div>
</div>
<div class="flex flex-row w-2/10 justify-end">
<a-tooltip placement="bottom">
<template #title>
<span v-if="item.show"> {{ $t('general.hide') }} </span>
<span v-else> {{ $t('general.show') }} </span>
</template>
<a-button type="text" class="!rounded-md" @click="item.show = !item.show">
<template #icon>
<MaterialSymbolsVisibilityOff v-if="item.show" class="flex mx-auto h-[1.1rem]" />
<MaterialSymbolsVisibility v-else class="flex mx-auto h-[1rem]" />
<div v-for="(item, index) in tokensInfo" :key="index" class="flex flex-col">
<div class="flex flex-row border-b-1 items-center px-2 py-2">
<div class="flex flex-row w-4/10 flex-wrap overflow-ellipsis">
{{ item.description }}
</div>
<div class="flex w-4/10 justify-center flex-wrap overflow-ellipsis">
<span v-if="item.show">{{ item.token }}</span>
<span v-else>****************************************</span>
</div>
<div class="flex flex-row w-2/10 justify-end">
<a-tooltip placement="bottom">
<template #title>
<span v-if="item.show"> {{ $t('general.hide') }} </span>
<span v-else> {{ $t('general.show') }} </span>
</template>
</a-button>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title> {{ $t('general.copy') }} </template>
<a-button type="text" class="!rounded-md" @click="item.show = !item.show">
<template #icon>
<MaterialSymbolsVisibilityOff v-if="item.show" class="flex mx-auto h-[1.1rem]" />
<MaterialSymbolsVisibility v-else class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-button type="text" class="!rounded-md" @click="copyToken(item.token)">
<template #icon>
<MdiContentCopy class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight" overlay-class-name="nc-dropdown-api-token-mgmt">
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<IcBaselineMoreVert />
</div>
<a-tooltip placement="bottom">
<template #title> {{ $t('general.copy') }} </template>
<a-button type="text" class="!rounded-md" @click="copyToken(item.token)">
<template #icon>
<MdiContentCopy class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</div>
<template #overlay>
<a-menu>
<a-menu-item>
<div class="flex flex-row items-center py-3 h-[1rem]" @click="openDeleteModal(item)">
<MdiDeleteOutline class="flex" />
<div class="text-xs pl-2">{{ $t('general.remove') }}</div>
</a-tooltip>
<a-dropdown
:trigger="['click']"
class="flex"
placement="bottomRight"
overlay-class-name="nc-dropdown-api-token-mgmt"
>
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<IcBaselineMoreVert />
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-button>
</div>
<template #overlay>
<a-menu>
<a-menu-item>
<div class="flex flex-row items-center py-3 h-[1rem]" @click="openDeleteModal(item)">
<MdiDeleteOutline class="flex" />
<div class="text-xs pl-2">{{ $t('general.remove') }}</div>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</div>
</div>

2
packages/nc-gui/components/tabs/auth/UserManagement.vue

@ -174,7 +174,7 @@ const isSuperAdmin = (user: { main_roles?: string }) => {
<div v-else class="flex flex-col w-full px-6">
<LazyTabsAuthUserManagementUsersModal
:key="showUserModal"
:key="`${showUserModal}`"
:show="showUserModal"
:selected-user="selectedUser"
@closed="showUserModal = false"

2
packages/nc-gui/components/tabs/auth/user-management/UsersModal.vue

@ -21,7 +21,7 @@ import { ProjectRole } from '~/lib'
interface Props {
show: boolean
selectedUser?: User
selectedUser?: User | null
}
interface Users {

41
packages/nc-gui/components/template/Editor.vue

@ -30,7 +30,8 @@ import {
} from '#imports'
import { TabType } from '~/lib'
const { quickImportType, projectTemplate, importData, importColumns, importDataOnly, maxRowsToParse } = defineProps<Props>()
const { quickImportType, projectTemplate, importData, importColumns, importDataOnly, maxRowsToParse, baseId } =
defineProps<Props>()
const emit = defineEmits(['import'])
@ -45,6 +46,7 @@ interface Props {
importColumns: any[]
importDataOnly: boolean
maxRowsToParse: number
baseId: string
}
interface Option {
@ -64,7 +66,9 @@ const { $api } = useNuxtApp()
const { addTab } = useTabs()
const { sqlUi, project, loadTables } = useProject()
const { sqlUis, project, loadTables } = useProject()
const sqlUi = ref(sqlUis.value[baseId] || Object.values(sqlUis.value)[0])
const hasSelectColumn = ref<boolean[]>([])
@ -395,7 +399,7 @@ async function importTemplate() {
try {
isImporting.value = true
const tableName = meta.value?.title
const tableId = meta.value?.id
const projectName = project.value.title!
await Promise.all(
@ -434,8 +438,8 @@ async function importTemplate() {
return res
}, {}),
)
await $api.dbTableRow.bulkCreate('noco', projectName, tableName!, batchData)
updateImportTips(projectName, tableName!, progress, total)
await $api.dbTableRow.bulkCreate('noco', projectName, tableId!, batchData)
updateImportTips(projectName, tableId!, progress, total)
progress += batchData.length
}
})(key),
@ -495,24 +499,24 @@ async function importTemplate() {
}
}
}
const tableMeta = await $api.dbTable.create(project?.value?.id as string, {
const createdTable = await $api.base.tableCreate(project?.value?.id as string, baseId as string, {
table_name: table.table_name,
// leave title empty to get a generated one based on table_name
title: '',
columns: table.columns || [],
})
table.title = tableMeta.title
table.id = createdTable.id
table.title = createdTable.title
// open the first table after import
if (tab.id === '' && tab.title === '') {
tab.id = tableMeta.id as string
tab.title = tableMeta.title as string
tab.id = createdTable.id as string
tab.title = createdTable.title as string
}
// set primary value
if (tableMeta?.columns?.[0]?.id) {
await $api.dbTableColumn.primaryColumnSet(tableMeta.columns[0].id as string)
if (createdTable?.columns?.[0]?.id) {
await $api.dbTableColumn.primaryColumnSet(createdTable.columns[0].id as string)
}
}
// bulk insert data
@ -532,7 +536,7 @@ async function importTemplate() {
for (let i = 0; i < data.length; i += offset) {
updateImportTips(projectName, tableMeta.title, progress, total)
const batchData = remapColNames(data.slice(i, i + offset), tableMeta.columns)
await $api.dbTableRow.bulkCreate('noco', projectName, tableMeta.title, batchData)
await $api.dbTableRow.bulkCreate('noco', projectName, tableMeta.id, batchData)
progress += batchData.length
}
updateImportTips(projectName, tableMeta.title, total, total)
@ -693,7 +697,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
size="large"
hide-details
:bordered="false"
@click="$event.stopPropagation()"
@click.stop
@blur="handleEditableTnChange(tableIdx)"
@keydown.enter="handleEditableTnChange(tableIdx)"
/>
@ -749,14 +753,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'column_name'">
<a-form-item v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.${column.key}`]">
<a-input
:ref="
(el) => {
inputRefs[record.key] = el
}
"
v-model:value="record.column_name"
/>
<a-input :ref="(el: HTMLInputElement) => (inputRefs[record.key] = el)" v-model:value="record.column_name" />
</a-form-item>
</template>

2
packages/nc-gui/components/virtual-cell/Formula.vue

@ -10,7 +10,7 @@ const cellValue = inject(CellValueInj)
const { isPg } = useProject()
const result = computed(() => (isPg.value ? handleTZ(cellValue?.value) : cellValue?.value))
const result = computed(() => (isPg(column.value.base_id) ? handleTZ(cellValue?.value) : cellValue?.value))
const urls = computed(() => replaceUrlsWithLink(result.value))

27
packages/nc-gui/components/virtual-cell/components/ListChildItems.vue

@ -1,5 +1,6 @@
<script lang="ts" setup>
import type { ColumnType } from 'nocodb-sdk'
import type { Row } from '~/lib'
import {
ColumnInj,
Empty,
@ -27,7 +28,7 @@ const isForm = inject(IsFormInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const column = inject(ColumnInj)
const column = inject(ColumnInj, ref())
const readonly = inject(ReadonlyInj, ref(false))
@ -81,6 +82,8 @@ const expandedFormDlg = ref(false)
const expandedFormRow = ref()
const colTitle = $computed(() => column.value?.title || '')
/** reload children list whenever cell value changes and list is visible */
watch(
() => props.cellValue,
@ -88,6 +91,12 @@ watch(
if (!isNew.value && vModel.value) loadChildrenList()
},
)
const onClick = (row: Row) => {
if (readonly.value) return
expandedFormRow.value = row
expandedFormDlg.value = true
}
</script>
<template>
@ -119,25 +128,19 @@ watch(
@click="emit('attachRecord')"
>
<div class="flex items-center gap-1">
<MdiLinkVariantRemove class="text-xs" type="primary" @click="unlinkRow(row)" />
<MdiLinkVariant class="text-xs" type="primary" />
Link to '{{ relatedTableMeta.title }}'
</div>
</a-button>
</div>
<template v-if="(isNew && state?.[column?.title]?.length) || childrenList?.pageInfo?.totalRows">
<template v-if="(isNew && state?.[colTitle]?.length) || childrenList?.pageInfo?.totalRows">
<div class="flex-1 overflow-auto min-h-0 scrollbar-thin-dull px-12 cursor-pointer">
<a-card
v-for="(row, i) of childrenList?.list ?? state?.[column?.title] ?? []"
v-for="(row, i) of childrenList?.list ?? state?.[colTitle] ?? []"
:key="i"
class="!my-4 hover:(!bg-gray-200/50 shadow-md)"
@click="
() => {
if (readonly) return
expandedFormRow = row
expandedFormDlg = true
}
"
@click="onClick(row)"
>
<div class="flex items-center">
<div class="flex-1 overflow-hidden min-w-0">
@ -169,7 +172,7 @@ watch(
v-model:page-size="childrenListPagination.size"
class="mt-2 mx-auto"
size="small"
:total="childrenList.pageInfo.totalRows"
:total="childrenList?.pageInfo.totalRows"
show-less-items
/>
</div>

2
packages/nc-gui/components/webhook/List.vue

@ -52,7 +52,7 @@ onMounted(() => {
<template>
<div class="">
<div class="mb-2">
<div class="float-left font-bold text-xl mt-2 mb-4">{{ meta.title }} : Webhooks</div>
<div class="float-left font-bold text-xl mt-2 mb-4">{{ meta?.title }} : Webhooks</div>
<a-button
v-e="['c:webhook:add']"

27
packages/nc-gui/composables/useColumnCreateStore.ts

@ -2,6 +2,7 @@ import clone from 'just-clone'
import type { ColumnReqType, ColumnType, TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import type { Ref } from 'vue'
import type { RuleObject } from 'ant-design-vue/es/form'
import {
Form,
computed,
@ -20,10 +21,13 @@ const useForm = Form.useForm
const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
interface ValidationsObj {
[key: string]: RuleObject[]
}
const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState(
(meta: Ref<TableType | undefined>, column: Ref<ColumnType | undefined>) => {
const { sqlUi } = useProject()
const { sqlUis, isMysql: isMysqlFunc, isPg: isPgFunc, isMssql: isMssqlFunc } = useProject()
const { $api } = useNuxtApp()
const { getMeta } = useMetas()
@ -32,14 +36,22 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const { $e } = useNuxtApp()
const isEdit = computed(() => !!column.value?.id)
const sqlUi = ref(meta.value?.base_id ? sqlUis.value[meta.value?.base_id] : Object.values(sqlUis.value)[0])
const isEdit = computed(() => !!column?.value?.id)
const isMysql = computed(() => isMysqlFunc(meta.value?.base_id ? meta.value?.base_id : Object.keys(sqlUis.value)[0]))
const isPg = computed(() => isPgFunc(meta.value?.base_id ? meta.value?.base_id : Object.keys(sqlUis.value)[0]))
const isMssql = computed(() => isMssqlFunc(meta.value?.base_id ? meta.value?.base_id : Object.keys(sqlUis.value)[0]))
const idType = null
const additionalValidations = ref<Record<string, any>>({})
const additionalValidations = ref<ValidationsObj>({})
const setAdditionalValidations = (validations: Record<string, any>) => {
additionalValidations.value = validations
const setAdditionalValidations = (validations: ValidationsObj) => {
additionalValidations.value = { ...additionalValidations.value, ...validations }
}
const formState = ref<Record<string, any>>({
@ -267,6 +279,9 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
isEdit,
column,
sqlUi,
isMssql,
isPg,
isMysql,
}
},
)

2
packages/nc-gui/composables/useExpandedFormDetached/index.ts

@ -4,7 +4,7 @@ import type { Row } from '~/lib'
interface UseExpandedFormDetachedProps {
'isOpen'?: boolean
'row': Row | null
'row': Row
'state'?: Record<string, any> | null
'meta': TableType
'loadRow'?: boolean

4
packages/nc-gui/composables/useGlobal/state.ts

@ -84,8 +84,10 @@ export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
set: (val) => (storage.value.token = val),
})
const config = useRuntimeConfig()
const appInfo = ref<AppInfo>({
ncSiteUrl: BASE_FALLBACK_URL,
ncSiteUrl: config.public.ncBackendUrl || BASE_FALLBACK_URL,
authType: 'jwt',
connectToExternalDB: false,
defaultLimit: 0,

2
packages/nc-gui/composables/useLTARStore.ts

@ -94,7 +94,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
}
const relatedTablePrimaryValueProp = computed(() => {
return (relatedTableMeta.value?.columns?.find((c) => c.pv) || relatedTableMeta?.value?.columns?.[0])?.title
return (relatedTableMeta.value?.columns?.find((c) => c.pv) || relatedTableMeta?.value?.columns?.[0])?.title || ''
})
const relatedTablePrimaryKeyProps = computed(() => {

52
packages/nc-gui/composables/useProject.ts

@ -1,4 +1,4 @@
import type { OracleUi, ProjectType, TableType } from 'nocodb-sdk'
import type { BaseType, OracleUi, ProjectType, TableType } from 'nocodb-sdk'
import { SqlUiFactory } from 'nocodb-sdk'
import { isString } from '@vueuse/core'
import {
@ -35,14 +35,16 @@ const [setup, use] = useInjectionState(() => {
const projectLoadedHook = createEventHook<ProjectType>()
const project = ref<ProjectType>({})
const bases = computed<BaseType[]>(() => project.value?.bases || [])
const tables = ref<TableType[]>([])
const projectMetaInfo = ref<ProjectMetaInfo | undefined>()
const lastOpenedViewMap = ref<Record<string, string>>({})
const projectId = computed(() => route.params.projectId as string)
let forcedProjectId: string | undefined
const projectId = computed(() => forcedProjectId || (route.params.projectId as string))
// todo: refactor path param name and variable name
const projectType = $computed(() => route.params.projectType as string)
@ -55,15 +57,35 @@ const [setup, use] = useInjectionState(() => {
}
})
const projectBaseType = $computed(() => project.value?.bases?.[0]?.type || ClientType.MYSQL)
const sqlUis = computed(() => {
const temp: Record<string, any> = {}
for (const base of bases.value) {
if (base.id) {
temp[base.id] = SqlUiFactory.create({ client: base.type }) as Exclude<
ReturnType<typeof SqlUiFactory['create']>,
typeof OracleUi
>
}
}
return temp
})
const sqlUi = computed(
() => SqlUiFactory.create({ client: projectBaseType }) as Exclude<ReturnType<typeof SqlUiFactory['create']>, typeof OracleUi>,
)
function getBaseType(baseId?: string) {
return bases.value.find((base) => base.id === baseId)?.type || ClientType.MYSQL
}
function isMysql(baseId?: string) {
return ['mysql', ClientType.MYSQL].includes(getBaseType(baseId))
}
function isMssql(baseId?: string) {
return getBaseType(baseId) === 'mssql'
}
function isPg(baseId?: string) {
return getBaseType(baseId) === 'pg'
}
const isMysql = computed(() => ['mysql', ClientType.MYSQL].includes(projectBaseType))
const isMssql = computed(() => projectBaseType === 'mssql')
const isPg = computed(() => projectBaseType === 'pg')
const isSharedBase = computed(() => projectType === 'base')
async function loadProjectMetaInfo(force?: boolean) {
@ -78,11 +100,14 @@ const [setup, use] = useInjectionState(() => {
includeM2M: includeM2M.value,
})
if (tablesResponse.list) tables.value = tablesResponse.list
if (tablesResponse.list) {
tables.value = tablesResponse.list.filter((table) => bases.value.find((base) => base.id === table.base_id)?.enabled)
}
}
}
async function loadProject(withTheme = true) {
async function loadProject(withTheme = true, forcedId?: string) {
if (forcedId) forcedProjectId = forcedId
if (projectType === 'base') {
try {
const baseData = await api.public.sharedBaseGet(route.params.projectId as string)
@ -151,6 +176,7 @@ const [setup, use] = useInjectionState(() => {
return {
project,
bases,
tables,
loadProjectRoles,
loadProject,
@ -159,7 +185,7 @@ const [setup, use] = useInjectionState(() => {
isMysql,
isMssql,
isPg,
sqlUi,
sqlUis,
isSharedBase,
loadProjectMetaInfo,
projectMetaInfo,

4
packages/nc-gui/composables/useSharedFormViewStore.ts

@ -43,6 +43,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const { metas, setMeta } = useMetas()
const { loadProject } = useProject()
const { t } = useI18n()
const formState = ref<Record<string, any>>({})
@ -84,6 +86,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
await setMeta(viewMeta.model)
await loadProject(true, viewMeta.project_id)
const relatedMetas = { ...viewMeta.relatedMetas }
Object.keys(relatedMetas).forEach((key) => setMeta(relatedMetas[key]))

9
packages/nc-gui/composables/useSharedView.ts

@ -17,6 +17,8 @@ export function useSharedView() {
const { appInfo } = $(useGlobal())
const { loadProject } = useProject()
const appInfoDefaultLimit = appInfo.defaultLimit || 25
const paginationData = useState<PaginatedType>('paginationData', () => ({ page: 1, pageSize: appInfoDefaultLimit }))
@ -71,6 +73,8 @@ export function useSharedView() {
await setMeta(viewMeta.model)
await loadProject(true, viewMeta.project_id)
const relatedMetas = { ...viewMeta.relatedMetas }
Object.keys(relatedMetas).forEach((key) => setMeta(relatedMetas[key]))
}
@ -103,7 +107,7 @@ export function useSharedView() {
const page = paginationData.value.page || 1
const pageSize = paginationData.value.pageSize || appInfoDefaultLimit
const data = await $api.public.groupedDataList(
return await $api.public.groupedDataList(
sharedView.value.uuid!,
columnId,
{
@ -118,7 +122,6 @@ export function useSharedView() {
},
},
)
return data
}
const exportFile = async (
@ -126,7 +129,7 @@ export function useSharedView() {
offset: number,
type: ExportTypes.EXCEL | ExportTypes.CSV,
responseType: 'base64' | 'blob',
{ sortsArr, filtersArr }: { sortsArr: SortType[]; filtersArr: FilterType[] },
{ sortsArr, filtersArr }: { sortsArr: SortType[]; filtersArr: FilterType[] } = { sortsArr: [], filtersArr: [] },
) => {
return await $api.public.csvExport(sharedView.value!.uuid!, type, {
format: responseType,

5
packages/nc-gui/composables/useSmartsheetStore.ts

@ -13,8 +13,9 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
initialFilters?: Ref<FilterType[]>,
) => {
const { $api } = useNuxtApp()
const { sqlUis } = useProject()
const { sqlUi } = useProject()
const sqlUi = ref(meta.value?.base_id ? sqlUis.value[meta.value?.base_id] : Object.values(sqlUis.value)[0])
const cellRefs = ref<HTMLTableDataCellElement[]>([])
@ -47,7 +48,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
})
const isSqlView = computed(() => (meta.value as TableType)?.type === 'view')
const sorts = ref<SortType[]>(unref(initialSorts) ?? [])
const sorts = ref<Required<SortType>[]>((unref(initialSorts) as Required<SortType>[]) ?? [])
const nestedFilters = ref<FilterType[]>(unref(initialFilters) ?? [])
return {

9
packages/nc-gui/composables/useTable.ts

@ -16,7 +16,7 @@ import {
} from '#imports'
import { TabType } from '~/lib'
export function useTable(onTableCreate?: (tableMeta: TableType) => void) {
export function useTable(onTableCreate?: (tableMeta: TableType) => void, baseId?: string) {
const table = reactive<{ title: string; table_name: string; columns: string[] }>({
title: '',
table_name: '',
@ -32,8 +32,9 @@ export function useTable(onTableCreate?: (tableMeta: TableType) => void) {
const { loadTables } = useProject()
const { closeTab } = useTabs()
const { sqlUis, project, tables } = useProject()
const { sqlUi, project, tables } = useProject()
const sqlUi = computed(() => (baseId && sqlUis.value[baseId] ? sqlUis.value[baseId] : Object.values(sqlUis.value)[0]))
const createTable = async () => {
if (!sqlUi?.value) return
@ -48,7 +49,7 @@ export function useTable(onTableCreate?: (tableMeta: TableType) => void) {
})
try {
const tableMeta = await $api.dbTable.create(project?.value?.id as string, {
const tableMeta = await $api.base.tableCreate(project?.value?.id as string, baseId as string, {
...table,
columns,
})
@ -62,7 +63,7 @@ export function useTable(onTableCreate?: (tableMeta: TableType) => void) {
watch(
() => table.title,
(title) => {
table.table_name = `${project?.value?.prefix || ''}${title}`
table.table_name = `${title}`
},
)

31
packages/nc-gui/composables/useTabs.ts

@ -17,7 +17,7 @@ const [setup, use] = useInjectionState(() => {
const router = useRouter()
const { tables } = useProject()
const { bases, tables } = useProject()
const projectType = $computed(() => route.params.projectType as string)
@ -29,14 +29,22 @@ const [setup, use] = useInjectionState(() => {
if (routeName.startsWith('projectType-projectId-index-index-type-title-viewTitle') && tables.value.length) {
const tab: TabItem = { type: route.params.type as TabType, title: route.params.title as string }
const currentTable = tables.value.find((t) => t.title === tab.title)
const currentTable = tables.value.find((t) => t.id === tab.title || t.title === tab.title)
if (!currentTable) return -1
const currentBase = bases.value.find((b) => b.id === currentTable.base_id)
tab.id = currentTable.id
let index = tabs.value.findIndex((t) => t.id === tab.id)
tab.title = currentTable.title
// append base alias to tab title if duplicate titles exist on other bases
if (tables.value.find((t) => t.title === currentTable?.title && t.base_id !== currentTable?.base_id))
tab.title = `${tab.title}${currentBase?.alias ? ` (${currentBase.alias})` : ``}`
if (index === -1) {
tab.sortsState = tab.sortsState || new Map()
tab.filterState = tab.filterState || new Map()
@ -81,6 +89,13 @@ const [setup, use] = useInjectionState(() => {
}
// if tab not found add it
else {
const currentTable = tables.value.find((t) => t.id === tabMeta.id || t.title === tabMeta.id)
const currentBase = bases.value.find((b) => b.id === currentTable?.base_id)
// append base alias to tab title if duplicate titles exist on other bases
if (tables.value.find((t) => t.title === currentTable?.title && t.base_id !== currentTable?.base_id))
tabMeta.title = `${tabMeta.title}${currentBase?.alias ? ` (${currentBase.alias})` : ``}`
tabs.value = [...(tabs.value || []), tabMeta]
activeTabIndex.value = tabs.value.length - 1
}
@ -111,13 +126,9 @@ const [setup, use] = useInjectionState(() => {
function navigateToTab(tab: TabItem) {
switch (tab.type) {
case TabType.TABLE:
return navigateTo(
`/${projectType}/${route.params.projectId}/table/${tab?.title}${tab.viewTitle ? `/${tab.viewTitle}` : ''}`,
)
return navigateTo(`/${projectType}/${route.params.projectId}/table/${tab?.id}${tab.viewTitle ? `/${tab.viewTitle}` : ''}`)
case TabType.VIEW:
return navigateTo(
`/${projectType}/${route.params.projectId}/view/${tab?.title}${tab.viewTitle ? `/${tab.viewTitle}` : ''}`,
)
return navigateTo(`/${projectType}/${route.params.projectId}/view/${tab?.id}${tab.viewTitle ? `/${tab.viewTitle}` : ''}`)
case TabType.AUTH:
return navigateTo(`/${projectType}/${route.params.projectId}/auth`)
}
@ -131,10 +142,10 @@ const [setup, use] = useInjectionState(() => {
Object.assign(tab, newTabItemProps)
if (isActive && tab.title)
if (isActive && tab.id)
router.replace({
params: {
title: tab.title,
title: tab.id,
},
})
}

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

@ -31,3 +31,4 @@ export const EditModeInj: InjectionKey<Ref<boolean>> = Symbol('edit-mode-injecti
export const SharedViewPasswordInj: InjectionKey<Ref<string | null>> = Symbol('shared-view-password-injection')
export const CellUrlDisableOverlayInj: InjectionKey<Ref<boolean>> = Symbol('cell-url-disable-url')
export const DropZoneRef: InjectionKey<Ref<Element | undefined>> = Symbol('drop-zone-ref')
export const ToggleDialogInj: InjectionKey<Function> = Symbol('toggle-dialog-injection')

10
packages/nc-gui/lang/fr.json

@ -248,7 +248,7 @@
"sqlOutput": "Sortie SQL",
"addOption": "Ajouter une option",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"qrCodeValueTooLong": "Trop de caractères pour un code QR",
"aggregateFunction": "Fonction agrégée",
"dbCreateIfNotExists": "Base de données : la créer si elle n'existe pas",
"clientKey": "Clé client",
@ -276,11 +276,11 @@
"childColumn": "Colonne enfant",
"onUpdate": "Mise à jour en cours",
"onDelete": "Suppression en cours",
"account": "Account",
"account": "Compte",
"language": "Language",
"primaryColor": "Primary Color",
"accentColor": "Accent Color",
"customTheme": "Custom Theme",
"customTheme": "Thème personnalisé",
"requestDataSource": "Request a data source you need?",
"apiKey": "Clé d'API",
"sharedBase": "Shared Base",
@ -304,8 +304,8 @@
"commentsOnly": "Comments only",
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"signUpWithGoogle": "S’enregistrer avec Google",
"signInWithGoogle": "Se connecter avec Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!",
"inviteOnlySignup": "Allow signup only using invite url"

9
packages/nc-gui/lib/enums.ts

@ -85,3 +85,12 @@ export enum SmartsheetStoreEvents {
FIELD_RELOAD = 'field-reload',
FIELD_ADD = 'field-add',
}
export enum DataSourcesSubTab {
New = 'New',
Metadata = 'Metadata',
ERD = 'ERD',
UIAcl = 'UI ACL',
Misc = 'Misc',
Edit = 'Edit',
}

2
packages/nc-gui/nuxt.config.ts

@ -129,6 +129,8 @@ export default defineNuxtConfig({
'ph',
'ri',
'system-uicons',
'vscode-icons',
'simple-icons',
],
}),
],

118
packages/nc-gui/package-lock.json generated

@ -9,6 +9,7 @@
"license": "AGPL-3.0-or-later",
"dependencies": {
"@ckpack/vue-color": "^1.2.0",
"@types/file-saver": "^2.0.5",
"@vue-flow/additional-components": "^1.2.0",
"@vue-flow/core": "^1.3.0",
"@vuelidate/core": "^2.0.0-alpha.44",
@ -56,7 +57,9 @@
"@iconify-json/mi": "^1.1.2",
"@iconify-json/ph": "^1.1.2",
"@iconify-json/ri": "^1.1.3",
"@iconify-json/simple-icons": "^1.1.29",
"@iconify-json/system-uicons": "^1.1.4",
"@iconify-json/vscode-icons": "^1.1.14",
"@intlify/vite-plugin-vue-i18n": "^6.0.1",
"@nuxt/image-edge": "^1.0.0-27657146.da85542",
"@types/axios": "^0.14.0",
@ -87,28 +90,6 @@
"windicss": "^3.5.6"
}
},
"../nocodb-sdk": {
"version": "0.100.2",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^0.21.1",
"jsep": "^1.3.6"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"cspell": "^4.1.0",
"eslint": "^7.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-functional": "^3.0.2",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-prettier": "^4.0.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.1.1",
"typescript": "^4.0.2"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@ -1174,6 +1155,15 @@
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/simple-icons": {
"version": "1.1.29",
"resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.1.29.tgz",
"integrity": "sha512-uP4fKlNoh9IuVTf1e1bl0TWZs+IDE19zCpKLNy+bKmJL5F9zOPwiIMqSsg4KRPXSdoZ2x7lTtt9BrO/O/3Phyg==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/system-uicons": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@iconify-json/system-uicons/-/system-uicons-1.1.4.tgz",
@ -1183,6 +1173,15 @@
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/vscode-icons": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@iconify-json/vscode-icons/-/vscode-icons-1.1.14.tgz",
"integrity": "sha512-we1er6h6WNDuV/dNSey/f4RDea5DILfDPCM7QN32EkjINr3ggAh7JhfTuQPP9A5lUQkjq5Xu2BpLyiz+E0Yd8w==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-1.1.0.tgz",
@ -3021,6 +3020,11 @@
"integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==",
"dev": true
},
"node_modules/@types/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ=="
},
"node_modules/@types/form-data": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-0.0.33.tgz",
@ -8413,7 +8417,6 @@
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==",
"devOptional": true,
"funding": [
{
"type": "individual",
@ -11769,8 +11772,21 @@
}
},
"node_modules/nocodb-sdk": {
"resolved": "../nocodb-sdk",
"link": true
"version": "0.100.2",
"resolved": "file:../nocodb-sdk",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^0.21.1",
"jsep": "^1.3.6"
}
},
"node_modules/nocodb-sdk/node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/node-abi": {
"version": "3.23.0",
@ -18408,6 +18424,15 @@
"@iconify/types": "*"
}
},
"@iconify-json/simple-icons": {
"version": "1.1.29",
"resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.1.29.tgz",
"integrity": "sha512-uP4fKlNoh9IuVTf1e1bl0TWZs+IDE19zCpKLNy+bKmJL5F9zOPwiIMqSsg4KRPXSdoZ2x7lTtt9BrO/O/3Phyg==",
"dev": true,
"requires": {
"@iconify/types": "*"
}
},
"@iconify-json/system-uicons": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@iconify-json/system-uicons/-/system-uicons-1.1.4.tgz",
@ -18417,6 +18442,15 @@
"@iconify/types": "*"
}
},
"@iconify-json/vscode-icons": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@iconify-json/vscode-icons/-/vscode-icons-1.1.14.tgz",
"integrity": "sha512-we1er6h6WNDuV/dNSey/f4RDea5DILfDPCM7QN32EkjINr3ggAh7JhfTuQPP9A5lUQkjq5Xu2BpLyiz+E0Yd8w==",
"dev": true,
"requires": {
"@iconify/types": "*"
}
},
"@iconify/types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-1.1.0.tgz",
@ -19714,6 +19748,11 @@
"integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==",
"dev": true
},
"@types/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ=="
},
"@types/form-data": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-0.0.33.tgz",
@ -23593,8 +23632,7 @@
"follow-redirects": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==",
"devOptional": true
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA=="
},
"form-data": {
"version": "4.0.0",
@ -26062,22 +26100,20 @@
}
},
"nocodb-sdk": {
"version": "file:../nocodb-sdk",
"version": "0.100.2",
"requires": {
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"axios": "^0.21.1",
"cspell": "^4.1.0",
"eslint": "^7.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-functional": "^3.0.2",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-prettier": "^4.0.0",
"jsep": "^1.3.6",
"npm-run-all": "^4.1.5",
"prettier": "^2.1.1",
"typescript": "^4.0.2"
"jsep": "^1.3.6"
},
"dependencies": {
"axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"requires": {
"follow-redirects": "^1.14.0"
}
}
}
},
"node-abi": {

3
packages/nc-gui/package.json

@ -41,6 +41,7 @@
"ant-design-vue": "^3.2.11",
"d3-scale": "^4.0.2",
"dagre": "^0.8.5",
"@types/file-saver": "^2.0.5",
"dayjs": "^1.11.3",
"file-saver": "^2.0.5",
"httpsnippet": "^2.0.0",
@ -79,7 +80,9 @@
"@iconify-json/mi": "^1.1.2",
"@iconify-json/ph": "^1.1.2",
"@iconify-json/ri": "^1.1.3",
"@iconify-json/simple-icons": "^1.1.29",
"@iconify-json/system-uicons": "^1.1.4",
"@iconify-json/vscode-icons": "^1.1.14",
"@intlify/vite-plugin-vue-i18n": "^6.0.1",
"@nuxt/image-edge": "^1.0.0-27657146.da85542",
"@types/axios": "^0.14.0",

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

@ -59,7 +59,9 @@ const { isOpen, toggle, toggleHasSidebar } = useSidebar('nc-left-sidebar', { has
const dialogOpen = ref(false)
const openDialogKey = ref<string>()
const openDialogKey = ref<string>('')
const dataSourcesState = ref<string>('')
const dropdownOpen = ref(false)
@ -73,11 +75,14 @@ const logout = () => {
navigateTo('/signin')
}
function toggleDialog(value?: boolean, key?: string) {
function toggleDialog(value?: boolean, key?: string, dsState?: string) {
dialogOpen.value = value ?? !dialogOpen.value
openDialogKey.value = key
openDialogKey.value = key || ''
dataSourcesState.value = dsState || ''
}
provide(ToggleDialogInj, toggleDialog)
const handleThemeColor = async (mode: 'swatch' | 'primary' | 'accent', color?: string) => {
switch (mode) {
case 'swatch': {
@ -234,7 +239,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
</script>
<template>
<NuxtLayout id="content">
<NuxtLayout>
<template #sidebar>
<a-layout-sider
ref="sidebar"
@ -559,12 +564,16 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
</div>
</div>
<LazyDashboardTreeView />
<LazyDashboardTreeView @create-base-dlg="toggleDialog(true, 'dataSources')" />
</a-layout-sider>
</template>
<div>
<LazyDashboardSettingsModal v-model="dialogOpen" :open-key="openDialogKey" />
<LazyDashboardSettingsModal
v-model:model-value="dialogOpen"
v-model:open-key="openDialogKey"
v-model:data-sources-state="dataSourcesState"
/>
<NuxtPage :page-key="$route.params.projectId" />

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

@ -75,6 +75,7 @@ function onEdit(targetKey: number, action: 'add' | 'remove' | string) {
</div>
</div>
<LazyGeneralShareBaseButton />
<LazyGeneralFullScreen class="nc-fullscreen-icon" />
</div>

5
packages/nc-gui/pages/[projectType]/form/[viewId]/index/index.vue

@ -84,9 +84,10 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
<div>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
:model-value="null"
class="mt-0 nc-input"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title.replaceAll(' ', '')}`"
:class="`nc-form-input-${field.title?.replaceAll(' ', '')}`"
:column="field"
/>
@ -95,7 +96,7 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
v-model="formState[field.title]"
class="nc-input"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title.replaceAll(' ', '')}`"
:class="`nc-form-input-${field.title?.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="true"
/>

2
packages/nc-gui/pages/[projectType]/gallery/[viewId]/index.vue

@ -26,7 +26,7 @@ try {
</script>
<template>
<NuxtLayout id="content" class="flex" name="shared-view">
<NuxtLayout class="flex" name="shared-view">
<div v-if="showPassword">
<LazySharedViewAskPassword v-model="showPassword" />
</div>

2
packages/nc-gui/pages/[projectType]/kanban/[viewId]/index.vue

@ -26,7 +26,7 @@ try {
</script>
<template>
<NuxtLayout id="content" class="flex" name="shared-view">
<NuxtLayout class="flex" name="shared-view">
<div v-if="showPassword">
<LazySharedViewAskPassword v-model="showPassword" />
</div>

2
packages/nc-gui/pages/[projectType]/view/[viewId].vue

@ -26,7 +26,7 @@ try {
</script>
<template>
<NuxtLayout id="content" class="flex" name="shared-view">
<NuxtLayout class="flex" name="shared-view">
<div v-if="showPassword">
<LazySharedViewAskPassword v-model="showPassword" />
</div>

5
packages/nc-gui/pages/forgot-password.vue

@ -1,4 +1,5 @@
<script setup lang="ts">
import type { RuleObject } from 'ant-design-vue/es/form'
import { definePageMeta, reactive, ref, useApi, useI18n, validateEmail } from '#imports'
definePageMeta({
@ -25,13 +26,13 @@ const formRules = {
{
validator: (_: unknown, v: string) => {
return new Promise((resolve, reject) => {
if (validateEmail(v)) return resolve(true)
if (validateEmail(v)) return resolve()
reject(new Error(t('msg.error.signUpRules.emailInvalid')))
})
},
message: t('msg.error.signUpRules.emailInvalid'),
},
],
] as RuleObject[],
}
async function resetPassword() {

2
packages/nc-gui/pages/index/apps.vue

@ -7,6 +7,8 @@ definePageMeta({
allowedRoles: [Role.Super],
title: 'title.appStore',
})
useSidebar('nc-left-sidebar', { hasSidebar: false })
</script>
<template>

3
packages/nc-gui/pages/index/index/[projectId].vue

@ -2,6 +2,7 @@
import type { Form } from 'ant-design-vue'
import type { ProjectType } from 'nocodb-sdk'
import type { VNodeRef } from '@vue/runtime-core'
import type { RuleObject } from 'ant-design-vue/es/form'
import {
extractSdkResponseErrorMsg,
message,
@ -25,7 +26,7 @@ const nameValidationRules = [
message: 'Project name is required',
},
projectTitleValidator,
]
] as RuleObject[]
const form = ref<typeof Form>()

4
packages/nc-gui/pages/index/index/create-external.vue

@ -1,7 +1,9 @@
<script lang="ts" setup>
import type { SelectHandler } from 'ant-design-vue/es/vc-select/Select'
import type { DefaultConnection, ProjectCreateForm } from '#imports'
import {
CertTypes,
ClientType,
Form,
Modal,
SSLUsage,
@ -25,8 +27,6 @@ import {
useSidebar,
watch,
} from '#imports'
import { ClientType } from '~/lib'
import type { DefaultConnection, ProjectCreateForm } from '~/utils'
const useForm = Form.useForm

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

@ -1,5 +1,6 @@
<script lang="ts" setup>
import type { Form } from 'ant-design-vue'
import type { RuleObject } from 'ant-design-vue/es/form'
import type { VNodeRef } from '@vue/runtime-core'
import {
extractSdkResponseErrorMsg,
@ -25,7 +26,7 @@ const nameValidationRules = [
message: 'Project name is required',
},
projectTitleValidator,
]
] as RuleObject[]
const form = ref<typeof Form>()

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

@ -2,8 +2,7 @@
import { Modal, message } from 'ant-design-vue'
import type { ProjectType } from 'nocodb-sdk'
import { useI18n } from 'vue-i18n'
import { navigateTo } from '#app'
import { extractSdkResponseErrorMsg } from '~/utils'
import { extractSdkResponseErrorMsg, navigateTo, useNuxtApp, useRoute } from '#imports'
import MaterialSymbolsFormatListBulletedRounded from '~icons/material-symbols/format-list-bulleted-rounded'
import MaterialSymbolsGridView from '~icons/material-symbols/grid-view'
import MdiPlus from '~icons/mdi/plus'

7
packages/nc-gui/pages/projects/index/index.vue

@ -1,7 +1,6 @@
<script lang="ts" setup>
import type { ProjectType } from 'nocodb-sdk'
import { navigateTo } from '#app'
import { useColors } from '#imports'
import { navigateTo, useColors, useNuxtApp } from '#imports'
import MdiMenuDown from '~icons/mdi/menu-down'
import MdiDeleteOutline from '~icons/mdi/delete-outline'
import MdiPlus from '~icons/mdi/plus'
@ -25,9 +24,9 @@ const openProject = async (project: ProjectType) => {
$e('a:project:open', { count: projects.length })
}
const formatTitle = (title: string) =>
const formatTitle = (title?: string) =>
title
.split(' ')
?.split(' ')
.map((w) => w[0])
.slice(0, 2)
.join('')

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

@ -1,5 +1,6 @@
<script setup lang="ts">
import { validatePassword } from 'nocodb-sdk'
import type { RuleObject } from 'ant-design-vue/es/form'
import {
definePageMeta,
navigateTo,
@ -44,24 +45,24 @@ const formRules = {
{
validator: (_: unknown, v: string) => {
return new Promise((resolve, reject) => {
if (!v?.length || validateEmail(v)) return resolve(true)
if (!v?.length || validateEmail(v)) return resolve()
reject(new Error(t('msg.error.signUpRules.emailInvalid')))
})
},
message: t('msg.error.signUpRules.emailInvalid'),
},
],
] as RuleObject[],
password: [
{
validator: (_: unknown, v: string) => {
return new Promise((resolve, reject) => {
const { error, valid } = validatePassword(v)
if (valid) return resolve(true)
if (valid) return resolve()
reject(new Error(error))
})
},
},
],
] as RuleObject[],
}
async function signUp() {

8
packages/nc-gui/utils/projectCreateUtils.ts

@ -1,6 +1,6 @@
import { ClientType } from '~/lib'
export interface ProjectCreateForm {
interface ProjectCreateForm {
title: string
dataSource: {
client: ClientType
@ -15,7 +15,7 @@ export interface ProjectCreateForm {
extraParameters: { key: string; value: string }[]
}
export interface DefaultConnection {
interface DefaultConnection {
host: string
database: string
user: string
@ -24,7 +24,7 @@ export interface DefaultConnection {
ssl?: Record<CertTypes, string> | 'no-verify' | 'true'
}
export interface SQLiteConnection {
interface SQLiteConnection {
client: ClientType.SQLITE
database: string
connection: {
@ -187,4 +187,4 @@ enum CertTypes {
key = 'key',
}
export { SSLUsage, CertTypes }
export { SSLUsage, CertTypes, ProjectCreateForm, DefaultConnection, SQLiteConnection }

2
packages/nc-gui/utils/sortUtils.ts

@ -1,6 +1,6 @@
import { UITypes } from 'nocodb-sdk'
export const getSortDirectionOptions = (uidt: UITypes) => {
export const getSortDirectionOptions = (uidt: UITypes | string) => {
switch (uidt) {
case UITypes.Year:
case UITypes.Number:

8
packages/nc-gui/utils/viewUtils.ts

@ -1,5 +1,5 @@
import { ViewTypes } from 'nocodb-sdk'
import { themeV2Colors } from '~/utils'
import { themeV2Colors } from '#imports'
import MdiGridIcon from '~icons/mdi/grid-large'
import MdiFormIcon from '~icons/mdi/form-select'
@ -41,3 +41,9 @@ export function applyLanguageDirection(dir: typeof rtl | typeof ltr) {
export function applyNonSelectable() {
document.body.classList.add('non-selectable')
}
export const getViewIcon = (key?: string | number) => {
if (!key) return
return viewIcons[key]
}

4
packages/nc-gui/web-types.json

@ -13,6 +13,10 @@
{
"name": "t",
"description": "I18n directive"
},
{
"name": "xc-ver-resize",
"description": "Resize directive"
}
]
}

195
packages/nocodb-sdk/src/lib/Api.ts

@ -84,6 +84,8 @@ export interface BaseType {
updated_at?: any;
inflection_column?: string;
inflection_table?: string;
order?: number;
enabled?: boolean;
}
export interface BaseReqType {
@ -106,8 +108,8 @@ export interface BaseListType {
export interface TableType {
id?: string;
fk_project_id?: string;
fk_base_id?: string;
project_id?: string;
base_id?: string;
table_name: string;
title: string;
type?: string;
@ -121,7 +123,6 @@ export interface TableType {
columns?: ColumnType[];
columnsById?: object;
slug?: string;
project_id?: string;
mm?: boolean | number;
}
@ -1892,6 +1893,194 @@ export class Api<
...params,
}),
};
base = {
/**
* @description Read project base details
*
* @tags Base
* @name Read
* @summary Base read
* @request GET:/api/v1/db/meta/projects/{projectId}/bases/{baseId}
* @response `200` `object` OK
*/
read: (projectId: string, baseId: string, params: RequestParams = {}) =>
this.request<object, any>({
path: `/api/v1/db/meta/projects/${projectId}/bases/${baseId}`,
method: 'GET',
format: 'json',
...params,
}),
/**
* No description
*
* @tags Base
* @name Delete
* @summary Base delete
* @request DELETE:/api/v1/db/meta/projects/{projectId}/bases/{baseId}
* @response `200` `void` OK
*/
delete: (projectId: string, baseId: string, params: RequestParams = {}) =>
this.request<void, any>({
path: `/api/v1/db/meta/projects/${projectId}/bases/${baseId}`,
method: 'DELETE',
...params,
}),
/**
* No description
*
* @tags Base
* @name Update
* @summary Base update
* @request PATCH:/api/v1/db/meta/projects/{projectId}/bases/{baseId}
* @response `200` `void` OK
*/
update: (
projectId: string,
baseId: string,
data: any,
params: RequestParams = {}
) =>
this.request<void, any>({
path: `/api/v1/db/meta/projects/${projectId}/bases/${baseId}`,
method: 'PATCH',
body: data,
type: ContentType.Json,
...params,
}),
/**
* @description Get project base list
*
* @tags Base
* @name List
* @summary Base list
* @request GET:/api/v1/db/meta/projects/{projectId}/bases/
* @response `200` `object` OK
*/
list: (projectId: string, params: RequestParams = {}) =>
this.request<object, any>({
path: `/api/v1/db/meta/projects/${projectId}/bases/`,
method: 'GET',
format: 'json',
...params,
}),
/**
* No description
*
* @tags Base
* @name Create
* @summary Base create
* @request POST:/api/v1/db/meta/projects/{projectId}/bases/
* @response `200` `BaseType` OK
*/
create: (
projectId: string,
data: BaseType & {
external?: boolean;
},
params: RequestParams = {}
) =>
this.request<BaseType, any>({
path: `/api/v1/db/meta/projects/${projectId}/bases/`,
method: 'POST',
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* No description
*
* @tags Base
* @name TableList
* @request GET:/api/v1/db/meta/projects/{projectId}/{baseId}/tables
* @response `200` `TableListType`
*/
tableList: (
projectId: string,
baseId: string,
query?: {
page?: number;
pageSize?: number;
sort?: string;
includeM2M?: boolean;
},
params: RequestParams = {}
) =>
this.request<TableListType, any>({
path: `/api/v1/db/meta/projects/${projectId}/${baseId}/tables`,
method: 'GET',
query: query,
...params,
}),
/**
* No description
*
* @tags Base
* @name TableCreate
* @request POST:/api/v1/db/meta/projects/{projectId}/{baseId}/tables
* @response `200` `TableType` OK
*/
tableCreate: (
projectId: string,
baseId: string,
data: TableReqType,
params: RequestParams = {}
) =>
this.request<TableType, any>({
path: `/api/v1/db/meta/projects/${projectId}/${baseId}/tables`,
method: 'POST',
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* No description
*
* @tags Base
* @name MetaDiffSync
* @request POST:/api/v1/db/meta/projects/{projectId}/meta-diff/{baseId}
* @response `200` `any` OK
*/
metaDiffSync: (
projectId: string,
baseId: string,
params: RequestParams = {}
) =>
this.request<any, any>({
path: `/api/v1/db/meta/projects/${projectId}/meta-diff/${baseId}`,
method: 'POST',
format: 'json',
...params,
}),
/**
* No description
*
* @tags Base
* @name MetaDiffGet
* @request GET:/api/v1/db/meta/projects/{projectId}/meta-diff/{baseId}
* @response `200` `any` OK
*/
metaDiffGet: (
projectId: string,
baseId: string,
params: RequestParams = {}
) =>
this.request<any, any>({
path: `/api/v1/db/meta/projects/${projectId}/meta-diff/${baseId}`,
method: 'GET',
format: 'json',
...params,
}),
};
dbTable = {
/**
* No description

2
packages/nocodb-sdk/src/lib/columnRules/QrCodeRules.ts

@ -7,4 +7,6 @@ export const AllowedColumnTypesForQrCode = [
UITypes.PhoneNumber,
UITypes.URL,
UITypes.Email,
UITypes.Decimal,
UITypes.Number,
];

47
packages/nocodb/package-lock.json generated

@ -151,28 +151,6 @@
"vuedraggable": "^2.24.3"
}
},
"../nocodb-sdk": {
"version": "0.100.2",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^0.21.1",
"jsep": "^1.3.6"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"cspell": "^4.1.0",
"eslint": "^7.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-functional": "^3.0.2",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-prettier": "^4.0.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.1.1",
"typescript": "^4.0.2"
}
},
"node_modules/@azure/abort-controller": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz",
@ -10656,8 +10634,13 @@
"dev": true
},
"node_modules/nocodb-sdk": {
"resolved": "../nocodb-sdk",
"link": true
"version": "0.100.2",
"resolved": "file:../nocodb-sdk",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^0.21.1",
"jsep": "^1.3.6"
}
},
"node_modules/node-abort-controller": {
"version": "3.0.1",
@ -26059,22 +26042,10 @@
"dev": true
},
"nocodb-sdk": {
"version": "file:../nocodb-sdk",
"version": "0.100.2",
"requires": {
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"axios": "^0.21.1",
"cspell": "^4.1.0",
"eslint": "^7.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-functional": "^3.0.2",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-prettier": "^4.0.0",
"jsep": "^1.3.6",
"npm-run-all": "^4.1.5",
"prettier": "^2.1.1",
"typescript": "^4.0.2"
"jsep": "^1.3.6"
}
},
"node-abort-controller": {

391
packages/nocodb/src/lib/meta/api/baseApis.ts

@ -0,0 +1,391 @@
import { Request, Response } from 'express';
import Project from '../../models/Project';
import { BaseListType, ModelTypes, UITypes } from 'nocodb-sdk';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import { syncBaseMigration } from '../helpers/syncMigration';
import { IGNORE_TABLES } from '../../utils/common/BaseApiBuilder';
import Column from '../../models/Column';
import Model from '../../models/Model';
import NcHelp from '../../utils/NcHelp';
import Base from '../../models/Base';
import NcConnectionMgrv2 from '../../utils/common/NcConnectionMgrv2';
import getTableNameAlias, { getColumnNameAlias } from '../helpers/getTableName';
import LinkToAnotherRecordColumn from '../../models/LinkToAnotherRecordColumn';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import getColumnUiType from '../helpers/getColumnUiType';
import mapDefaultPrimaryValue from '../helpers/mapDefaultPrimaryValue';
import { extractAndGenerateManyToManyRelations } from './metaDiffApis';
import { metaApiMetrics } from '../helpers/apiMetrics';
export async function baseGet(
req: Request<any, any, any>,
res: Response<Base>
) {
const base = await Base.get(req.params.baseId);
base.config = base.getConnectionConfig();
res.json(base);
}
export async function baseUpdate(
req: Request<any, any, any>,
res: Response<any>
) {
const baseBody = req.body;
const project = await Project.getWithInfo(req.params.projectId);
const base = await Base.updateBase(req.params.baseId,
{
...baseBody,
type: baseBody.config?.client,
projectId: project.id,
id: req.params.baseId,
}
);
delete base.config;
Tele.emit('evt', {
evt_type: 'base:updated'
});
res.json(base);
}
export async function baseList(
req: Request<any, any, any>,
res: Response<BaseListType>,
next
) {
try {
const bases = await Base.list({ projectId: req.params.projectId });
res // todo: pagination
.json({
bases: new PagedResponseImpl(bases, {
count: bases.length,
limit: bases.length,
})
}
);
} catch (e) {
console.log(e);
next(e);
}
}
export async function baseDelete(
req: Request<any, any, any>,
res: Response<any>
) {
const base = await Base.get(req.params.baseId);
const result = await base.delete();
Tele.emit('evt', { evt_type: 'base:deleted' });
res.json(result);
}
async function baseCreate(req: Request<any, any>, res) {
// type | base | projectId
const baseBody = req.body;
const project = await Project.getWithInfo(req.params.projectId);
const base = await Base.createBase({
...baseBody,
type: baseBody.config?.client,
projectId: project.id,
});
await syncBaseMigration(project, base);
const info = await populateMeta(base, project);
Tele.emit('evt_api_created', info);
delete base.config;
Tele.emit('evt', {
evt_type: 'base:created'
});
res.json(base);
}
async function populateMeta(base: Base, project: Project): Promise<any> {
const info = {
type: 'rest',
apiCount: 0,
tablesCount: 0,
relationsCount: 0,
viewsCount: 0,
client: base?.getConnectionConfig()?.client,
timeTaken: 0,
};
const t = process.hrtime();
const sqlClient = NcConnectionMgrv2.getSqlClient(base);
let order = 1;
const models2: { [tableName: string]: Model } = {};
const virtualColumnsInsert = [];
/* Get all relations */
const relations = (await sqlClient.relationListAll())?.data?.list;
info.relationsCount = relations.length;
let tables = (await sqlClient.tableList())?.data?.list
?.filter(({ tn }) => !IGNORE_TABLES.includes(tn))
?.map((t) => {
t.order = ++order;
t.title = getTableNameAlias(t.tn, project.prefix, base);
t.table_name = t.tn;
return t;
});
/* filter based on prefix */
if (base.is_meta && project?.prefix) {
tables = tables.filter((t) => {
return t?.tn?.startsWith(project?.prefix);
});
}
info.tablesCount = tables.length;
tables.forEach((t) => {
t.title = getTableNameAlias(t.tn, project.prefix, base);
});
relations.forEach((r) => {
r.title = getTableNameAlias(r.tn, project.prefix, base);
r.rtitle = getTableNameAlias(r.rtn, project.prefix, base);
});
// await this.syncRelations();
const tableMetasInsert = tables.map((table) => {
return async () => {
/* filter relation where this table is present */
const tableRelations = relations.filter(
(r) => r.tn === table.tn || r.rtn === table.tn
);
const columns: Array<
Omit<Column, 'column_name' | 'title'> & {
cn: string;
system?: boolean;
}
> = (await sqlClient.columnList({ tn: table.tn }))?.data?.list;
const hasMany =
table.type === 'view'
? []
: tableRelations.filter((r) => r.rtn === table.tn);
const belongsTo =
table.type === 'view'
? []
: tableRelations.filter((r) => r.tn === table.tn);
mapDefaultPrimaryValue(columns);
// add vitual columns
const virtualColumns = [
...hasMany.map((hm) => {
return {
uidt: UITypes.LinkToAnotherRecord,
type: 'hm',
hm,
title: `${hm.title} List`,
};
}),
...belongsTo.map((bt) => {
// find and mark foreign key column
const fkColumn = columns.find((c) => c.cn === bt.cn);
if (fkColumn) {
fkColumn.uidt = UITypes.ForeignKey;
fkColumn.system = true;
}
return {
uidt: UITypes.LinkToAnotherRecord,
type: 'bt',
bt,
title: `${bt.rtitle}`,
};
}),
];
// await Model.insert(project.id, base.id, meta);
/* create nc_models and its rows if it doesn't exists */
models2[table.table_name] = await Model.insert(project.id, base.id, {
table_name: table.tn || table.table_name,
title: table.title,
type: table.type || 'table',
order: table.order,
});
// table crud apis
info.apiCount += 5;
let colOrder = 1;
for (const column of columns) {
await Column.insert({
uidt: column.uidt || getColumnUiType(base, column),
fk_model_id: models2[table.tn].id,
...column,
title: getColumnNameAlias(column.cn, base),
column_name: column.cn,
order: colOrder++,
});
}
virtualColumnsInsert.push(async () => {
const columnNames = {};
for (const column of virtualColumns) {
// generate unique name if there is any duplicate column name
let c = 0;
while (`${column.title}${c || ''}` in columnNames) {
c++;
}
column.title = `${column.title}${c || ''}`;
columnNames[column.title] = true;
const rel = column.hm || column.bt;
const rel_column_id = (await models2?.[rel.tn]?.getColumns())?.find(
(c) => c.column_name === rel.cn
)?.id;
const tnId = models2?.[rel.tn]?.id;
const ref_rel_column_id = (
await models2?.[rel.rtn]?.getColumns()
)?.find((c) => c.column_name === rel.rcn)?.id;
const rtnId = models2?.[rel.rtn]?.id;
try {
await Column.insert<LinkToAnotherRecordColumn>({
project_id: project.id,
db_alias: base.id,
fk_model_id: models2[table.tn].id,
cn: column.cn,
title: column.title,
uidt: column.uidt,
type: column.hm ? 'hm' : column.mm ? 'mm' : 'bt',
// column_id,
fk_child_column_id: rel_column_id,
fk_parent_column_id: ref_rel_column_id,
fk_index_name: rel.fkn,
ur: rel.ur,
dr: rel.dr,
order: colOrder++,
fk_related_model_id: column.hm ? tnId : rtnId,
system: column.system,
});
// nested relations data apis
info.apiCount += 5;
} catch (e) {
console.log(e);
}
}
});
};
});
/* handle xc_tables update in parallel */
await NcHelp.executeOperations(tableMetasInsert, base.type);
await NcHelp.executeOperations(virtualColumnsInsert, base.type);
await extractAndGenerateManyToManyRelations(Object.values(models2));
let views: Array<{ order: number; table_name: string; title: string }> = (
await sqlClient.viewList()
)?.data?.list
// ?.filter(({ tn }) => !IGNORE_TABLES.includes(tn))
?.map((v) => {
v.order = ++order;
v.table_name = v.view_name;
v.title = getTableNameAlias(v.view_name, project.prefix, base);
return v;
});
/* filter based on prefix */
if (base.is_meta && project?.prefix) {
views = tables.filter((t) => {
return t?.tn?.startsWith(project?.prefix);
});
}
info.viewsCount = views.length;
const viewMetasInsert = views.map((table) => {
return async () => {
const columns = (await sqlClient.columnList({ tn: table.table_name }))
?.data?.list;
/* create nc_models and its rows if it doesn't exists */
models2[table.table_name] = await Model.insert(project.id, base.id, {
table_name: table.table_name,
title: getTableNameAlias(table.table_name, project.prefix, base),
// todo: sanitize
type: ModelTypes.VIEW,
order: table.order,
});
let colOrder = 1;
// view apis
info.apiCount += 2;
for (const column of columns) {
await Column.insert({
fk_model_id: models2[table.table_name].id,
...column,
title: getColumnNameAlias(column.cn, base),
order: colOrder++,
uidt: getColumnUiType(base, column),
});
}
};
});
await NcHelp.executeOperations(viewMetasInsert, base.type);
const t1 = process.hrtime(t);
const t2 = t1[0] + t1[1] / 1000000000;
(info as any).timeTaken = t2.toFixed(1);
return info;
}
export default (router) => {
router.get(
'/api/v1/db/meta/projects/:projectId/bases/:baseId',
metaApiMetrics,
ncMetaAclMw(baseGet, 'baseGet')
);
router.patch(
'/api/v1/db/meta/projects/:projectId/bases/:baseId',
metaApiMetrics,
ncMetaAclMw(baseUpdate, 'baseUpdate')
);
router.delete(
'/api/v1/db/meta/projects/:projectId/bases/:baseId',
metaApiMetrics,
ncMetaAclMw(baseDelete, 'baseDelete')
);
router.post(
'/api/v1/db/meta/projects/:projectId/bases',
metaApiMetrics,
ncMetaAclMw(baseCreate, 'baseCreate')
);
router.get(
'/api/v1/db/meta/projects/:projectId/bases',
metaApiMetrics,
ncMetaAclMw(baseList, 'baseList')
);
};

1
packages/nocodb/src/lib/meta/api/dataApis/helpers.ts

@ -24,7 +24,6 @@ export async function getViewAndModelFromRequestByAliasOrId(
const model = await Model.getByAliasOrId({
project_id: project.id,
base_id: project.bases?.[0]?.id,
aliasOrId: req.params.tableName,
});
const view =

1
packages/nocodb/src/lib/meta/api/dataApis/oldDataApis.ts

@ -108,7 +108,6 @@ async function getViewAndModelFromRequest(req) {
const project = await Project.getWithInfo(req.params.projectId);
const model = await Model.getByAliasOrId({
project_id: project.id,
base_id: project.bases?.[0]?.id,
aliasOrId: req.params.tableName,
});
const view =

2
packages/nocodb/src/lib/meta/api/index.ts

@ -3,6 +3,7 @@ import orgLicenseApis from './orgLicenseApis';
import orgTokenApis from './orgTokenApis';
import orgUserApis from './orgUserApis';
import projectApis from './projectApis';
import baseApis from './baseApis';
import tableApis from './tableApis';
import columnApis from './columnApis';
import { Router } from 'express';
@ -60,6 +61,7 @@ const jobs: { [id: string]: { last_message: any } } = {};
export default function (router: Router, server) {
initStrategies(router);
projectApis(router);
baseApis(router);
utilApis(router);
if (process.env['PLAYWRIGHT_TEST'] === 'true') {

253
packages/nocodb/src/lib/meta/api/metaDiffApis.ts

@ -42,6 +42,7 @@ const applyChangesPriorityOrder = [
type MetaDiff = {
title?: string;
table_name: string;
base_id: string;
type: ModelTypes;
detectedChanges: Array<MetaDiffChange>;
};
@ -120,7 +121,7 @@ async function getMetaDiff(
const tableList: Array<{ tn: string }> = (
await sqlClient.tableList()
)?.data?.list?.filter((t) => {
if (project?.prefix) {
if (project?.prefix && base.is_meta) {
return t.tn?.startsWith(project?.prefix);
}
return true;
@ -157,6 +158,7 @@ async function getMetaDiff(
if (oldMetaIdx === -1) {
changes.push({
table_name: table.tn,
base_id: base.id,
type: ModelTypes.TABLE,
detectedChanges: [
{
@ -175,6 +177,7 @@ async function getMetaDiff(
const tableProp: MetaDiff = {
title: oldMeta.title,
table_name: table.tn,
base_id: base.id,
type: ModelTypes.TABLE,
detectedChanges: [],
};
@ -245,6 +248,7 @@ async function getMetaDiff(
for (const model of oldTableMetas) {
changes.push({
table_name: model.table_name,
base_id: base.id,
type: ModelTypes.TABLE,
detectedChanges: [
{
@ -417,7 +421,7 @@ async function getMetaDiff(
return v;
})
.filter((t) => {
if (project?.prefix) {
if (project?.prefix && base.is_meta) {
return t.tn?.startsWith(project?.prefix);
}
return true;
@ -430,6 +434,7 @@ async function getMetaDiff(
if (oldMetaIdx === -1) {
changes.push({
table_name: view.tn,
base_id: base.id,
type: ModelTypes.VIEW,
detectedChanges: [
{
@ -448,6 +453,7 @@ async function getMetaDiff(
const tableProp: MetaDiff = {
title: oldMeta.title,
table_name: view.tn,
base_id: base.id,
type: ModelTypes.VIEW,
detectedChanges: [],
};
@ -514,6 +520,7 @@ async function getMetaDiff(
for (const model of oldViewMetas) {
changes.push({
table_name: model.table_name,
base_id: base.id,
type: ModelTypes.TABLE,
detectedChanges: [
{
@ -532,18 +539,229 @@ async function getMetaDiff(
export async function metaDiff(req, res) {
const project = await Project.getWithInfo(req.params.projectId);
const base = project.bases[0];
let changes = []
for (const base of project.bases) {
try {
// @ts-ignore
const sqlClient = NcConnectionMgrv2.getSqlClient(base);
changes = changes.concat(await getMetaDiff(sqlClient, project, base));
} catch (e) {
console.log(e);
}
}
res.json(changes);
}
export async function baseMetaDiff(req, res) {
const project = await Project.getWithInfo(req.params.projectId);
const base = await Base.get(req.params.baseId);
let changes = []
// @ts-ignore
const sqlClient = NcConnectionMgrv2.getSqlClient(base);
const changes = await getMetaDiff(sqlClient, project, base);
changes = await getMetaDiff(sqlClient, project, base);
res.json(changes);
}
export async function metaDiffSync(req, res) {
const project = await Project.getWithInfo(req.params.projectId);
const base = project.bases[0];
for (const base of project.bases) {
const virtualColumnInsert: Array<() => Promise<void>> = [];
// @ts-ignore
const sqlClient = NcConnectionMgrv2.getSqlClient(base);
const changes = await getMetaDiff(sqlClient, project, base);
/* Get all relations */
// const relations = (await sqlClient.relationListAll())?.data?.list;
for (const { table_name, detectedChanges } of changes) {
// reorder changes to apply relation remove changes
// before column remove to avoid foreign key constraint error
detectedChanges.sort((a, b) => {
return (
applyChangesPriorityOrder.indexOf(b.type) -
applyChangesPriorityOrder.indexOf(a.type)
);
});
for (const change of detectedChanges) {
switch (change.type) {
case MetaDiffType.TABLE_NEW:
{
const columns = (
await sqlClient.columnList({ tn: table_name })
)?.data?.list?.map((c) => ({ ...c, column_name: c.cn }));
mapDefaultPrimaryValue(columns);
const model = await Model.insert(project.id, base.id, {
table_name: table_name,
title: getTableNameAlias(table_name, base.is_meta ? project.prefix : '', base),
type: ModelTypes.TABLE,
});
for (const column of columns) {
await Column.insert({
uidt: getColumnUiType(base, column),
fk_model_id: model.id,
...column,
title: getColumnNameAlias(column.column_name, base),
});
}
}
break;
case MetaDiffType.VIEW_NEW:
{
const columns = (
await sqlClient.columnList({ tn: table_name })
)?.data?.list?.map((c) => ({ ...c, column_name: c.cn }));
mapDefaultPrimaryValue(columns);
const model = await Model.insert(project.id, base.id, {
table_name: table_name,
title: getTableNameAlias(table_name, project.prefix, base),
type: ModelTypes.VIEW,
});
for (const column of columns) {
await Column.insert({
uidt: getColumnUiType(base, column),
fk_model_id: model.id,
...column,
title: getColumnNameAlias(column.column_name, base),
});
}
}
break;
case MetaDiffType.TABLE_REMOVE:
case MetaDiffType.VIEW_REMOVE:
{
await change.model.delete();
}
break;
case MetaDiffType.TABLE_COLUMN_ADD:
case MetaDiffType.VIEW_COLUMN_ADD:
{
const columns = (
await sqlClient.columnList({ tn: table_name })
)?.data?.list?.map((c) => ({ ...c, column_name: c.cn }));
const column = columns.find((c) => c.cn === change.cn);
column.uidt = getColumnUiType(base, column);
//todo: inflection
column.title = getColumnNameAlias(column.cn, base);
await Column.insert({ fk_model_id: change.id, ...column });
}
// update old
// populateParams.tableNames.push({ tn });
// populateParams.oldMetas[tn] = oldMetas.find(m => m.tn === tn);
break;
case MetaDiffType.TABLE_COLUMN_TYPE_CHANGE:
case MetaDiffType.VIEW_COLUMN_TYPE_CHANGE:
{
const columns = (
await sqlClient.columnList({ tn: table_name })
)?.data?.list?.map((c) => ({ ...c, column_name: c.cn }));
const column = columns.find((c) => c.cn === change.cn);
const metaFact = ModelXcMetaFactory.create(
{ client: base.type },
{}
);
column.uidt = metaFact.getUIDataType(column);
column.title = change.column.title;
await Column.update(change.column.id, column);
}
break;
case MetaDiffType.TABLE_COLUMN_REMOVE:
case MetaDiffType.VIEW_COLUMN_REMOVE:
await change.column.delete();
break;
case MetaDiffType.TABLE_RELATION_REMOVE:
case MetaDiffType.TABLE_VIRTUAL_M2M_REMOVE:
await change.column.delete();
break;
case MetaDiffType.TABLE_RELATION_ADD:
{
virtualColumnInsert.push(async () => {
const parentModel = await Model.getByIdOrName({
project_id: base.project_id,
base_id: base.id,
table_name: change.rtn,
});
const childModel = await Model.getByIdOrName({
project_id: base.project_id,
base_id: base.id,
table_name: change.tn,
});
const parentCol = await parentModel
.getColumns()
.then((cols) => cols.find((c) => c.column_name === change.rcn));
const childCol = await childModel
.getColumns()
.then((cols) => cols.find((c) => c.column_name === change.cn));
await Column.update(childCol.id, {
...childCol,
uidt: UITypes.ForeignKey,
system: true,
});
if (change.relationType === RelationTypes.BELONGS_TO) {
const title = getUniqueColumnAliasName(
childModel.columns,
`${parentModel.title || parentModel.table_name}`
);
await Column.insert<LinkToAnotherRecordColumn>({
uidt: UITypes.LinkToAnotherRecord,
title,
fk_model_id: childModel.id,
fk_related_model_id: parentModel.id,
type: RelationTypes.BELONGS_TO,
fk_parent_column_id: parentCol.id,
fk_child_column_id: childCol.id,
virtual: false,
});
} else if (change.relationType === RelationTypes.HAS_MANY) {
const title = getUniqueColumnAliasName(
childModel.columns,
`${childModel.title || childModel.table_name} List`
);
await Column.insert<LinkToAnotherRecordColumn>({
uidt: UITypes.LinkToAnotherRecord,
title,
fk_model_id: parentModel.id,
fk_related_model_id: childModel.id,
type: RelationTypes.HAS_MANY,
fk_parent_column_id: parentCol.id,
fk_child_column_id: childCol.id,
virtual: false,
});
}
});
}
break;
}
}
}
await NcHelp.executeOperations(virtualColumnInsert, base.type);
// populate m2m relations
await extractAndGenerateManyToManyRelations(await base.getModels());
}
Tele.emit('evt', { evt_type: 'metaDiff:synced' });
res.json({ msg: 'success' });
}
export async function baseMetaDiffSync(req, res) {
const project = await Project.getWithInfo(req.params.projectId);
const base = await Base.get(req.params.baseId);
const virtualColumnInsert: Array<() => Promise<void>> = [];
// @ts-ignore
@ -554,15 +772,6 @@ export async function metaDiffSync(req, res) {
// const relations = (await sqlClient.relationListAll())?.data?.list;
for (const { table_name, detectedChanges } of changes) {
// reorder changes to apply relation remove changes
// before column remove to avoid foreign key constraint error
detectedChanges.sort((a, b) => {
return (
applyChangesPriorityOrder.indexOf(b.type) -
applyChangesPriorityOrder.indexOf(a.type)
);
});
for (const change of detectedChanges) {
switch (change.type) {
case MetaDiffType.TABLE_NEW:
@ -575,7 +784,7 @@ export async function metaDiffSync(req, res) {
const model = await Model.insert(project.id, base.id, {
table_name: table_name,
title: getTableNameAlias(table_name, project.prefix, base),
title: getTableNameAlias(table_name, base.is_meta ? project.prefix : '', base),
type: ModelTypes.TABLE,
});
@ -729,7 +938,7 @@ export async function metaDiffSync(req, res) {
// populate m2m relations
await extractAndGenerateManyToManyRelations(await base.getModels());
Tele.emit('evt', { evt_type: 'metaDiff:synced' });
Tele.emit('evt', { evt_type: 'baseMetaDiff:synced' });
res.json({ msg: 'success' });
}
@ -875,4 +1084,14 @@ router.post(
metaApiMetrics,
ncMetaAclMw(metaDiffSync, 'metaDiffSync')
);
router.get(
'/api/v1/db/meta/projects/:projectId/meta-diff/:baseId',
metaApiMetrics,
ncMetaAclMw(baseMetaDiff, 'baseMetaDiff')
);
router.post(
'/api/v1/db/meta/projects/:projectId/meta-diff/:baseId',
metaApiMetrics,
ncMetaAclMw(baseMetaDiffSync, 'baseMetaDiffSync')
);
export default router;

6
packages/nocodb/src/lib/meta/api/modelVisibilityApis.ts

@ -3,7 +3,6 @@ import ModelRoleVisibility from '../../models/ModelRoleVisibility';
import { Router } from 'express';
import { Tele } from 'nc-help';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import Project from '../../models/Project';
import { metaApiMetrics } from '../helpers/apiMetrics';
async function xcVisibilityMetaSetAll(req, res) {
Tele.emit('evt', { evt_type: 'uiAcl:updated' });
@ -49,13 +48,12 @@ export async function xcVisibilityMetaGet(
const roles = ['owner', 'creator', 'viewer', 'editor', 'commenter', 'guest'];
const defaultDisabled = roles.reduce((o, r) => ({ ...o, [r]: false }), {});
const project = await Project.getWithInfo(projectId);
let models =
_models ||
(await Model.list({
project_id: projectId,
base_id: project?.bases?.[0]?.id,
base_id: undefined,
}));
models = includeM2M ? models : (models.filter((t) => !t.mm) as Model[]);

40
packages/nocodb/src/lib/meta/api/projectApis.ts

@ -452,15 +452,12 @@ async function populateMeta(base: Base, project: Project): Promise<any> {
return info;
}
export async function projectInfoGet(req, res) {
const project = await Project.getWithInfo(req.params.projectId);
export async function projectInfoGet(_req, res) {
res.json({
Node: process.version,
Arch: process.arch,
Platform: process.platform,
Docker: isDocker(),
Database: project.bases?.[0]?.type,
ProjectOnRootDB: !!project?.is_meta,
RootDB: Noco.getConfig()?.meta?.db?.client,
PackageVersion: packageVersion,
});
@ -469,22 +466,25 @@ export async function projectInfoGet(req, res) {
export async function projectCost(req, res) {
let cost = 0;
const project = await Project.getWithInfo(req.params.projectId);
const sqlClient = NcConnectionMgrv2.getSqlClient(project.bases[0]);
const userCount = await ProjectUser.getUsersCount(req.query);
const recordCount = (await sqlClient.totalRecords())?.data.TotalRecords;
if (recordCount > 100000) {
// 36,000 or $79/user/month
cost = Math.max(36000, 948 * userCount);
} else if (recordCount > 50000) {
// $36,000 or $50/user/month
cost = Math.max(36000, 600 * userCount);
} else if (recordCount > 10000) {
// $240/user/yr
cost = Math.min(240 * userCount, 36000);
} else if (recordCount > 1000) {
// $120/user/yr
cost = Math.min(120 * userCount, 36000);
for (const base of project.bases) {
const sqlClient = NcConnectionMgrv2.getSqlClient(base);
const userCount = await ProjectUser.getUsersCount(req.query);
const recordCount = (await sqlClient.totalRecords())?.data.TotalRecords;
if (recordCount > 100000) {
// 36,000 or $79/user/month
cost = Math.max(36000, 948 * userCount);
} else if (recordCount > 50000) {
// $36,000 or $50/user/month
cost = Math.max(36000, 600 * userCount);
} else if (recordCount > 10000) {
// $240/user/yr
cost = Math.min(240 * userCount, 36000);
} else if (recordCount > 1000) {
// $120/user/yr
cost = Math.min(120 * userCount, 36000);
}
}
Tele.event({

15
packages/nocodb/src/lib/meta/api/sync/helpers/job.ts

@ -214,7 +214,7 @@ export default async (
}
function getRootDbType() {
return ncCreatedProjectSchema?.bases[0]?.type;
return ncCreatedProjectSchema?.bases.find((el) => el.id === syncDB.baseId)?.type;
}
// base mapping table
@ -312,7 +312,7 @@ export default async (
// @ts-ignore
async function nc_DumpTableSchema() {
console.log('[');
const ncTblList = await api.dbTable.list(ncCreatedProjectSchema.id);
const ncTblList = await api.base.tableList(ncCreatedProjectSchema.id, syncDB.baseId);
for (let i = 0; i < ncTblList.list.length; i++) {
const ncTbl = await api.dbTable.read(ncTblList.list[i].id);
console.log(JSON.stringify(ncTbl, null, 2));
@ -611,11 +611,12 @@ export default async (
for (let idx = 0; idx < tables.length; idx++) {
logBasic(`:: [${idx + 1}/${tables.length}] ${tables[idx].title}`);
logDetailed(`NC API: dbTable.create ${tables[idx].title}`);
logDetailed(`NC API: base.tableCreate ${tables[idx].title}`);
let _perfStart = recordPerfStart();
const table: any = await api.dbTable.create(
const table: any = await api.base.tableCreate(
ncCreatedProjectSchema.id,
syncDB.baseId,
tables[idx]
);
recordPerfStats(_perfStart, 'dbTable.create');
@ -2171,6 +2172,7 @@ export default async (
} else {
await nocoGetProject(syncDB.projectId);
syncDB.projectName = ncCreatedProjectSchema?.title;
syncDB.baseId = syncDB.baseId || ncCreatedProjectSchema.bases[0].id;
logDetailed('Getting existing project meta');
}
@ -2228,8 +2230,8 @@ export default async (
try {
// await nc_DumpTableSchema();
const _perfStart = recordPerfStart();
const ncTblList = await api.dbTable.list(ncCreatedProjectSchema.id);
recordPerfStats(_perfStart, 'dbTable.list');
const ncTblList = await api.base.tableList(ncCreatedProjectSchema.id, syncDB.baseId);
recordPerfStats(_perfStart, 'base.tableList');
logBasic('Reading Records...');
@ -2385,6 +2387,7 @@ export interface AirtableSyncConfig {
authToken: string;
projectName?: string;
projectId?: string;
baseId?: string;
apiKey: string;
shareId: string;
options: {

1
packages/nocodb/src/lib/meta/api/sync/importApis.ts

@ -111,6 +111,7 @@ export default (
id: req.params.syncId,
...(syncSource?.details || {}),
projectId: syncSource.project_id,
baseId: syncSource.base_id,
authToken: token,
baseURL,
});

14
packages/nocodb/src/lib/meta/api/sync/syncSourceApis.ts

@ -4,17 +4,21 @@ import SyncSource from '../../../models/SyncSource';
import { Tele } from 'nc-help';
import { PagedResponseImpl } from '../../helpers/PagedResponse';
import ncMetaAclMw from '../../helpers/ncMetaAclMw';
import Project from '../../../models/Project';
export async function syncSourceList(req: Request, res: Response) {
// todo: pagination
res.json(new PagedResponseImpl(await SyncSource.list(req.params.projectId)));
res.json(new PagedResponseImpl(await SyncSource.list(req.params.projectId, req.params.baseId)));
}
export async function syncCreate(req: Request, res: Response) {
Tele.emit('evt', { evt_type: 'webhooks:created' });
const project = await Project.getWithInfo(req.params.projectId);
const sync = await SyncSource.insert({
...req.body,
fk_user_id: (req as any).user.id,
base_id: req.params.baseId ? req.params.baseId : project.bases[0].id,
project_id: req.params.projectId,
});
res.json(sync);
@ -41,6 +45,14 @@ router.post(
'/api/v1/db/meta/projects/:projectId/syncs',
ncMetaAclMw(syncCreate, 'syncSourceCreate')
);
router.get(
'/api/v1/db/meta/projects/:projectId/syncs/:baseId',
ncMetaAclMw(syncSourceList, 'syncSourceList')
);
router.post(
'/api/v1/db/meta/projects/:projectId/syncs/:baseId',
ncMetaAclMw(syncCreate, 'syncSourceCreate')
);
router.delete(
'/api/v1/db/meta/syncs/:syncId',
ncMetaAclMw(syncDelete, 'syncSourceDelete')

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save