Browse Source

Merge pull request #4999 from nocodb/develop

pull/5000/head 0.104.3
github-actions[bot] 2 years ago committed by GitHub
parent
commit
4dde9b94ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      .github/ISSUE_TEMPLATE/--bug-report.yaml
  2. 12
      .github/workflows/release-nocodb.yml
  3. 2
      packages/nc-gui/assets/style.scss
  4. 4
      packages/nc-gui/components/cell/Checkbox.vue
  5. 237
      packages/nc-gui/components/cell/MultiSelect.vue
  6. 169
      packages/nc-gui/components/cell/SingleSelect.vue
  7. 2
      packages/nc-gui/components/cell/Url.vue
  8. 4
      packages/nc-gui/components/dlg/TableRename.vue
  9. 46
      packages/nc-gui/components/smartsheet/Cell.vue
  10. 14
      packages/nc-gui/components/smartsheet/Form.vue
  11. 31
      packages/nc-gui/components/smartsheet/Grid.vue
  12. 8
      packages/nc-gui/components/smartsheet/TableDataCell.vue
  13. 7
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  14. 103
      packages/nc-gui/components/smartsheet/column/AdvancedOptions.vue
  15. 7
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  16. 6
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  17. 4
      packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue
  18. 8
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  19. 41
      packages/nc-gui/components/tabs/auth/user-management/UsersModal.vue
  20. 33
      packages/nc-gui/composables/useKanbanViewStore.ts
  21. 8
      packages/nc-gui/composables/useMultiSelect/index.ts
  22. 7
      packages/nc-gui/composables/useProject.ts
  23. 38
      packages/nc-gui/composables/useSelectedCellKeyupListener/index.ts
  24. 5
      packages/nc-gui/composables/useSmartsheetStore.ts
  25. 62
      packages/nc-gui/composables/useViewData.ts
  26. 1
      packages/nc-gui/context/index.ts
  27. 6
      packages/nc-gui/lang/eu.json
  28. 2
      packages/nc-gui/lang/ru.json
  29. 274
      packages/nc-gui/lang/zh-Hans.json
  30. 52
      packages/nc-gui/package-lock.json
  31. 2
      packages/nc-gui/package.json
  32. 2
      packages/nc-gui/pages/index/index.vue
  33. 16
      packages/nc-gui/pages/index/index/create.vue
  34. 1
      packages/nc-gui/utils/columnUtils.ts
  35. 4
      packages/nocodb-sdk/package-lock.json
  36. 30
      packages/nocodb/package-lock.json
  37. 4
      packages/nocodb/package.json
  38. 2
      packages/nocodb/src/lib/Noco.ts
  39. 4
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/CustomKnex.ts
  40. 14
      packages/nocodb/src/lib/meta/api/attachmentApis.ts
  41. 5
      packages/nocodb/src/lib/models/Model.ts
  42. 2
      packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts
  43. 186
      packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts
  44. 159
      packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader_0104002.ts
  45. 43
      tests/playwright/pages/Dashboard/Grid/index.ts
  46. 10
      tests/playwright/tests/columnMultiSelect.spec.ts

4
.github/ISSUE_TEMPLATE/--bug-report.yaml

@ -53,8 +53,8 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: Attachements label: Attachments
description: Add any relevant attachemnts here description: Add any relevant attachment here
placeholder: | placeholder: |
> Drag & drop relevant image or videos > Drag & drop relevant image or videos
validations: validations:

12
.github/workflows/release-nocodb.yml

@ -103,12 +103,12 @@ jobs:
NC_GITHUB_TOKEN: "${{ secrets.NC_GITHUB_TOKEN }}" NC_GITHUB_TOKEN: "${{ secrets.NC_GITHUB_TOKEN }}"
# Close all issues with target tags 'Status: Ready for Next Release' # Close all issues with target tags 'Status: Ready for Next Release'
close-issues: # close-issues:
needs: [release-docker, process-input] # needs: [release-docker, process-input]
uses: ./.github/workflows/release-close-issue.yml # uses: ./.github/workflows/release-close-issue.yml
with: # with:
issue_label: '🚀 Status: Ready for Next Release' # issue_label: '🚀 Status: Ready for Next Release'
version: ${{ needs.process-input.outputs.target_tag }} # version: ${{ needs.process-input.outputs.target_tag }}
# Publish Docs # Publish Docs
publish-docs: publish-docs:

2
packages/nc-gui/assets/style.scss

@ -34,7 +34,7 @@ a {
} }
.nc-icon { .nc-icon {
@apply color-transition; @apply color-transition inline;
} }
// menu item styling // menu item styling

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

@ -63,7 +63,7 @@ useSelectedCellKeyupListener(active, (e) => {
<template> <template>
<div <div
class="flex cursor-pointer" class="flex cursor-pointer w-full h-full"
:class="{ :class="{
'justify-center': !isForm, 'justify-center': !isForm,
'w-full': isForm, 'w-full': isForm,
@ -72,7 +72,7 @@ useSelectedCellKeyupListener(active, (e) => {
}" }"
@click="onClick(false, $event)" @click="onClick(false, $event)"
> >
<div class="px-1 pt-1 rounded-full items-center" :class="{ 'bg-gray-100': !vModel, '!ml-[-8px]': readOnly }"> <div class="p-1 rounded-full items-center" :class="{ 'bg-gray-100': !vModel, '!ml-[-8px]': readOnly }">
<Transition name="layout" mode="out-in" :duration="100"> <Transition name="layout" mode="out-in" :duration="100">
<component <component
:is="getMdiIcon(vModel ? checkboxMeta.icon.checked : checkboxMeta.icon.unchecked)" :is="getMdiIcon(vModel ? checkboxMeta.icon.checked : checkboxMeta.icon.unchecked)"

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

@ -1,10 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue' import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType, SelectOptionsType } from 'nocodb-sdk' import type { SelectOptionType, SelectOptionsType } from 'nocodb-sdk'
import { import {
ActiveCellInj, ActiveCellInj,
CellClickHookInj,
ColumnInj, ColumnInj,
IsKanbanInj, IsKanbanInj,
ReadonlyInj, ReadonlyInj,
@ -13,6 +15,7 @@ import {
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
h, h,
inject, inject,
isDrawerOrModalExist,
onMounted, onMounted,
reactive, reactive,
ref, ref,
@ -44,6 +47,8 @@ const editable = inject(EditModeInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const selectedIds = ref<string[]>([]) const selectedIds = ref<string[]>([])
const aselect = ref<typeof AntSelect>() const aselect = ref<typeof AntSelect>()
@ -85,7 +90,7 @@ const isOptionMissing = computed(() => {
const hasEditRoles = computed(() => hasRole('owner', true) || hasRole('creator', true) || hasRole('editor', true)) const hasEditRoles = computed(() => hasRole('owner', true) || hasRole('creator', true) || hasRole('editor', true))
const editAllowed = computed(() => hasEditRoles.value && (active.value || editable.value)) const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && (active.value || editable.value))
const vModel = computed({ const vModel = computed({
get: () => { get: () => {
@ -126,12 +131,6 @@ const selectedTitles = computed(() =>
: [], : [],
) )
const handleClose = (e: MouseEvent) => {
if (aselect.value && !aselect.value.$el.contains(e.target)) {
isOpen.value = false
}
}
onMounted(() => { onMounted(() => {
selectedIds.value = selectedTitles.value.flatMap((el) => { selectedIds.value = selectedTitles.value.flatMap((el) => {
const item = options.value.find((op) => op.title === el) const item = options.value.find((op) => op.title === el)
@ -144,8 +143,6 @@ onMounted(() => {
}) })
}) })
useEventListener(document, 'click', handleClose)
watch( watch(
() => modelValue, () => modelValue,
() => { () => {
@ -161,6 +158,8 @@ watch(
) )
watch(isOpen, (n, _o) => { watch(isOpen, (n, _o) => {
if (!n) searchVal.value = ''
if (editAllowed.value) { if (editAllowed.value) {
if (!n) { if (!n) {
aselect.value?.$el?.querySelector('input')?.blur() aselect.value?.$el?.querySelector('input')?.blur()
@ -180,6 +179,9 @@ useSelectedCellKeyupListener(active, (e) => {
isOpen.value = true isOpen.value = true
} }
break break
// skip space bar key press since it's used for expand row
case ' ':
break
case 'ArrowUp': case 'ArrowUp':
case 'ArrowDown': case 'ArrowDown':
case 'ArrowRight': case 'ArrowRight':
@ -193,7 +195,7 @@ useSelectedCellKeyupListener(active, (e) => {
break break
} }
// toggle only if char key pressed // toggle only if char key pressed
if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1) { if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1 && !isDrawerOrModalExist()) {
e.stopPropagation() e.stopPropagation()
isOpen.value = true isOpen.value = true
} }
@ -201,6 +203,11 @@ useSelectedCellKeyupListener(active, (e) => {
} }
}) })
// close dropdown list on escape
useSelectedCellKeyupListener(isOpen, (e) => {
if (e.key === 'Escape') isOpen.value = false
})
const activeOptCreateInProgress = ref(0) const activeOptCreateInProgress = ref(0)
async function addIfMissingAndSave() { async function addIfMissingAndSave() {
@ -273,95 +280,126 @@ const onTagClick = (e: Event, onClose: Function) => {
onClose() onClose()
} }
} }
const cellClickHook = inject(CellClickHookInj)
const toggleMenu = () => {
if (cellClickHook) return
isOpen.value = editAllowed.value && !isOpen.value
}
const cellClickHookHandler = () => {
isOpen.value = editAllowed.value && !isOpen.value
}
onMounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
onUnmounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
const handleClose = (e: MouseEvent) => {
// close dropdown if clicked outside of dropdown
if (
isOpen.value &&
aselect.value &&
!aselect.value.$el.contains(e.target) &&
!document.querySelector('.nc-dropdown-multi-select-cell.active')?.contains(e.target as Node)
) {
isOpen.value = false
}
}
useEventListener(document, 'click', handleClose, true)
</script> </script>
<template> <template>
<a-select <div class="nc-multi-select h-full w-full flex items-center" :class="{ 'read-only': readOnly }" @click="toggleMenu">
ref="aselect" <a-select
v-model:value="vModel" ref="aselect"
v-model:open="isOpen" v-model:value="vModel"
mode="multiple" mode="multiple"
class="w-full overflow-hidden" class="w-full overflow-hidden"
:bordered="false" :bordered="false"
clear-icon clear-icon
show-search show-search
:show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel.length === 0))" :show-arrow="editAllowed && !readOnly"
:open="isOpen && (active || editable)" :open="isOpen && editAllowed"
:disabled="readOnly" :disabled="readOnly || !editAllowed"
:class="{ '!ml-[-8px]': readOnly, 'caret-transparent': !hasEditRoles }" :class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-multi-select-cell ${isOpen ? 'active' : ''}`" :dropdown-class-name="`nc-dropdown-multi-select-cell ${isOpen ? 'active' : ''}`"
@search="search" @search="search"
@keydown.stop @keydown.stop
@click="isOpen = editAllowed && !isOpen"
>
<a-select-option
v-for="op of options"
:key="op.id || op.title"
:value="op.title"
:data-testid="`select-option-${column.title}-${rowIndex}`"
@click.stop
> >
<a-tag class="rounded-tag" :color="op.color"> <a-select-option
<span v-for="op of options"
:style="{ :key="op.id || op.title"
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' }) :value="op.title"
? '#fff' :data-testid="`select-option-${column.title}-${rowIndex}`"
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(), @click.stop
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.title }}
</span>
</a-tag>
</a-select-option>
<a-select-option
v-if="searchVal && isOptionMissing && !isPublic && (hasRole('owner', true) || hasRole('creator', true))"
:key="searchVal"
:value="searchVal"
>
<div class="flex gap-2 text-gray-500 items-center h-full">
<MdiPlusThick class="min-w-4" />
<div class="text-xs whitespace-normal">
Create new option named <strong>{{ searchVal }}</strong>
</div>
</div>
</a-select-option>
<template #tagRender="{ value: val, onClose }">
<a-tag
v-if="options.find((el) => el.title === val)"
class="rounded-tag nc-selected-option"
:style="{ display: 'flex', alignItems: 'center' }"
:color="options.find((el) => el.title === val)?.color"
:closable="editAllowed && (active || editable) && (vModel.length > 1 || !column?.rqd)"
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })"
@click="onTagClick($event, onClose)"
@close="onClose"
> >
<span <a-tag class="rounded-tag" :color="op.color">
:style="{ <span
'color': tinycolor.isReadable(options.find((el) => el.title === val)?.color || '#ccc', '#fff', { :style="{
level: 'AA', 'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
size: 'large', ? '#fff'
}) : tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
? '#fff' 'font-size': '13px',
: tinycolor }"
.mostReadable(options.find((el) => el.title === val)?.color || '#ccc', ['#0b1d05', '#fff']) :class="{ 'text-sm': isKanban }"
.toHex8String(), >
'font-size': '13px', {{ op.title }}
}" </span>
:class="{ 'text-sm': isKanban }" </a-tag>
</a-select-option>
<a-select-option
v-if="searchVal && isOptionMissing && !isPublic && (hasRole('owner', true) || hasRole('creator', true))"
:key="searchVal"
:value="searchVal"
>
<div class="flex gap-2 text-gray-500 items-center h-full">
<MdiPlusThick class="min-w-4" />
<div class="text-xs whitespace-normal">
Create new option named <strong>{{ searchVal }}</strong>
</div>
</div>
</a-select-option>
<template #tagRender="{ value: val, onClose }">
<a-tag
v-if="options.find((el) => el.title === val)"
class="rounded-tag nc-selected-option"
:style="{ display: 'flex', alignItems: 'center' }"
:color="options.find((el) => el.title === val)?.color"
:closable="editAllowed && (vModel.length > 1 || !column?.rqd)"
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })"
@click="onTagClick($event, onClose)"
@close="onClose"
> >
{{ val }} <span
</span> :style="{
</a-tag> 'color': tinycolor.isReadable(options.find((el) => el.title === val)?.color || '#ccc', '#fff', {
</template> level: 'AA',
</a-select> size: 'large',
})
? '#fff'
: tinycolor
.mostReadable(options.find((el) => el.title === val)?.color || '#ccc', ['#0b1d05', '#fff'])
.toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ val }}
</span>
</a-tag>
</template>
</a-select>
</div>
</template> </template>
<style scoped> <style scoped lang="scss">
.ms-close-icon { .ms-close-icon {
color: rgba(0, 0, 0, 0.25); color: rgba(0, 0, 0, 0.25);
cursor: pointer; cursor: pointer;
@ -404,6 +442,21 @@ const onTagClick = (e: Event, onClose: Function) => {
} }
:deep(.ant-select-selection-overflow) { :deep(.ant-select-selection-overflow) {
@apply flex-nowrap; @apply flex-nowrap overflow-hidden;
}
.nc-multi-select:not(.read-only) {
:deep(.ant-select-selector),
:deep(.ant-select-selector input) {
@apply "!cursor-pointer";
}
}
:deep(.ant-select-selector) {
@apply !px-0;
}
:deep(.ant-select-selection-search-input) {
@apply !text-xs;
} }
</style> </style>

169
packages/nc-gui/components/cell/SingleSelect.vue

@ -1,18 +1,22 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue' import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType } from 'nocodb-sdk' import type { SelectOptionType } from 'nocodb-sdk'
import { import {
ActiveCellInj, ActiveCellInj,
CellClickHookInj,
ColumnInj, ColumnInj,
EditModeInj, EditModeInj,
IsFormInj,
IsKanbanInj, IsKanbanInj,
ReadonlyInj, ReadonlyInj,
computed, computed,
enumColor, enumColor,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
inject, inject,
isDrawerOrModalExist,
ref, ref,
useRoles, useRoles,
useSelectedCellKeyupListener, useSelectedCellKeyupListener,
@ -44,6 +48,8 @@ const isKanban = inject(IsKanbanInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const searchVal = ref() const searchVal = ref()
@ -78,7 +84,7 @@ const isOptionMissing = computed(() => {
const hasEditRoles = computed(() => hasRole('owner', true) || hasRole('creator', true) || hasRole('editor', true)) const hasEditRoles = computed(() => hasRole('owner', true) || hasRole('creator', true) || hasRole('editor', true))
const editAllowed = computed(() => hasEditRoles.value && (active.value || editable.value)) const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && (active.value || editable.value))
const vModel = computed({ const vModel = computed({
get: () => tempSelectedOptState.value ?? modelValue, get: () => tempSelectedOptState.value ?? modelValue,
@ -113,13 +119,16 @@ useSelectedCellKeyupListener(active, (e) => {
isOpen.value = true isOpen.value = true
} }
break break
// skip space bar key press since it's used for expand row
case ' ':
break
default: default:
if (!editAllowed.value) { if (!editAllowed.value) {
e.preventDefault() e.preventDefault()
break break
} }
// toggle only if char key pressed // toggle only if char key pressed
if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1) { if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1 && !isDrawerOrModalExist()) {
e.stopPropagation() e.stopPropagation()
isOpen.value = true isOpen.value = true
} }
@ -127,6 +136,11 @@ useSelectedCellKeyupListener(active, (e) => {
} }
}) })
// close dropdown list on escape
useSelectedCellKeyupListener(isOpen, (e) => {
if (e.key === 'Escape') isOpen.value = false
})
async function addIfMissingAndSave() { async function addIfMissingAndSave() {
if (!searchVal.value || isPublic.value) return false if (!searchVal.value || isPublic.value) return false
@ -177,6 +191,22 @@ const search = () => {
searchVal.value = aselect.value?.$el?.querySelector('.ant-select-selection-search-input')?.value searchVal.value = aselect.value?.$el?.querySelector('.ant-select-selection-search-input')?.value
} }
// prevent propagation of keydown event if select is open
const onKeydown = (e: KeyboardEvent) => {
if (isOpen.value && (active.value || editable.value)) {
e.stopPropagation()
}
if (e.key === 'Enter') {
e.stopPropagation()
}
}
const onSelect = () => {
isOpen.value = false
}
const cellClickHook = inject(CellClickHookInj)
const toggleMenu = (e: Event) => { const toggleMenu = (e: Event) => {
// todo: refactor // todo: refactor
// check clicked element is clear icon // check clicked element is clear icon
@ -185,64 +215,84 @@ const toggleMenu = (e: Event) => {
(e.target as HTMLElement)?.closest('.ant-select-clear') (e.target as HTMLElement)?.closest('.ant-select-clear')
) { ) {
vModel.value = '' vModel.value = ''
return return e.stopPropagation()
} }
if (cellClickHook) return
isOpen.value = editAllowed.value && !isOpen.value
}
const cellClickHookHandler = () => {
isOpen.value = editAllowed.value && !isOpen.value isOpen.value = editAllowed.value && !isOpen.value
} }
onMounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
onUnmounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
const handleClose = (e: MouseEvent) => {
if (isOpen.value && aselect.value && !aselect.value.$el.contains(e.target)) {
isOpen.value = false
}
}
useEventListener(document, 'click', handleClose, true)
</script> </script>
<template> <template>
<a-select <div class="h-full w-full flex items-center nc-single-select" :class="{ 'read-only': readOnly }" @click="toggleMenu">
ref="aselect" <a-select
v-model:value="vModel" ref="aselect"
class="w-full" v-model:value="vModel"
:class="{ 'caret-transparent': !hasEditRoles }" class="w-full"
:allow-clear="!column.rqd && editAllowed" :class="{ 'caret-transparent': !hasEditRoles }"
:bordered="false" :allow-clear="!column.rqd && editAllowed"
:open="isOpen" :bordered="false"
:disabled="readOnly" :open="isOpen && (active || editable)"
:show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel === null))" :disabled="readOnly || !(active || editable)"
:dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen ? 'active' : ''}`" :show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel === null))"
show-search :dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen && (active || editable) ? 'active' : ''}`"
@select="isOpen = false" :show-search="isOpen && (active || editable)"
@keydown.stop @select="onSelect"
@search="search" @keydown="onKeydown($event)"
@click="toggleMenu" @search="search"
>
<a-select-option
v-for="op of options"
:key="op.title"
:value="op.title"
:data-testid="`select-option-${column.title}-${rowIndex}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.title }}
</span>
</a-tag>
</a-select-option>
<a-select-option
v-if="searchVal && isOptionMissing && !isPublic && (hasRole('owner', true) || hasRole('creator', true))"
:key="searchVal"
:value="searchVal"
> >
<div class="flex gap-2 text-gray-500 items-center h-full"> <a-select-option
<MdiPlusThick class="min-w-4" /> v-for="op of options"
<div class="text-xs whitespace-normal"> :key="op.title"
Create new option named <strong>{{ searchVal }}</strong> :value="op.title"
:data-testid="`select-option-${column.title}-${rowIndex}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.title }}
</span>
</a-tag>
</a-select-option>
<a-select-option
v-if="searchVal && isOptionMissing && !isPublic && (hasRole('owner', true) || hasRole('creator', true))"
:key="searchVal"
:value="searchVal"
>
<div class="flex gap-2 text-gray-500 items-center h-full">
<MdiPlusThick class="min-w-4" />
<div class="text-xs whitespace-normal">
Create new option named <strong>{{ searchVal }}</strong>
</div>
</div> </div>
</div> </a-select-option>
</a-select-option> </a-select>
</a-select> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@ -257,4 +307,19 @@ const toggleMenu = (e: Event) => {
:deep(.ant-select-clear) { :deep(.ant-select-clear) {
opacity: 1; opacity: 1;
} }
.nc-single-select:not(.read-only) {
:deep(.ant-select-selector),
:deep(.ant-select-selector input) {
@apply !cursor-pointer;
}
}
:deep(.ant-select-selector) {
@apply !px-0;
}
:deep(.ant-select-selection-search-input) {
@apply !text-xs;
}
</style> </style>

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

@ -79,7 +79,7 @@ watch(
v-if="editEnabled" v-if="editEnabled"
:ref="focus" :ref="focus"
v-model="vModel" v-model="vModel"
class="outline-none text-sm w-full px-2" class="outline-none text-sm w-full px-2 bg-transparent h-full"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.down.stop @keydown.down.stop
@keydown.left.stop @keydown.left.stop

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

@ -80,9 +80,7 @@ const validators = computed(() => {
return reject(new Error('Leading or trailing whitespace not allowed in table name')) return reject(new Error('Leading or trailing whitespace not allowed in table name'))
} }
if ( if (
!(tables?.value || []).every( !(tables?.value || []).every((t) => t.id === tableMeta.id || t.title.toLowerCase() !== (value || '').toLowerCase())
(t) => t.id === tableMeta.id || t.table_name.toLowerCase() !== (value || '').toLowerCase(),
)
) { ) {
return reject(new Error('Duplicate table alias')) return reject(new Error('Duplicate table alias'))
} }

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

@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType, GridType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import { isSystemColumn } from 'nocodb-sdk' import { isSystemColumn } from 'nocodb-sdk'
import { import {
ActiveCellInj, ActiveCellInj,
ActiveViewInj,
ColumnInj, ColumnInj,
EditModeInj, EditModeInj,
IsFormInj, IsFormInj,
@ -61,8 +60,6 @@ const props = defineProps<Props>()
const emit = defineEmits(['update:modelValue', 'save', 'navigate', 'update:editEnabled']) const emit = defineEmits(['update:modelValue', 'save', 'navigate', 'update:editEnabled'])
const view = inject(ActiveViewInj, ref())
const column = toRef(props, 'column') const column = toRef(props, 'column')
const active = toRef(props, 'active', false) const active = toRef(props, 'active', false)
@ -79,6 +76,8 @@ provide(ReadonlyInj, readOnly)
const isForm = inject(IsFormInj, ref(false)) const isForm = inject(IsFormInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
const isLocked = inject(IsLockedInj, ref(false)) const isLocked = inject(IsLockedInj, ref(false))
@ -110,7 +109,6 @@ const vModel = computed({
syncValue() syncValue()
} else if (!isManualSaved(column.value)) { } else if (!isManualSaved(column.value)) {
emit('save') emit('save')
currentRow.value.rowMeta.changed = true
} }
} }
}, },
@ -128,32 +126,25 @@ const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
if (!isForm.value) e.stopImmediatePropagation() if (!isForm.value) e.stopImmediatePropagation()
} }
const rowHeight = computed(() => { const isNumericField = computed(() => {
if ((view.value?.view as GridType)?.row_height !== undefined) { return (
switch ((view.value?.view as GridType)?.row_height) { isInt(column.value, abstractType.value) ||
case 0: isFloat(column.value, abstractType.value) ||
return 1 isDecimal(column.value) ||
case 1: isCurrency(column.value) ||
return 2 isPercent(column.value) ||
case 2: isDuration(column.value)
return 4 )
case 3:
return 6
default:
return 1
}
}
}) })
</script> </script>
<template> <template>
<div <div
class="nc-cell w-full" class="nc-cell w-full h-full"
:class="[ :class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`, `nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
{ 'text-blue-600': isPrimary(column) && !props.virtual && !isForm }, { 'text-blue-600': isPrimary(column) && !props.virtual && !isForm },
{ 'm-y-auto !h-auto': !rowHeight || rowHeight === 1 }, { 'nc-grid-numeric-cell': isGrid && !isForm && isNumericField },
{ '!h-full': rowHeight && rowHeight !== 1 },
]" ]"
@keydown.enter.exact="syncAndNavigate(NavigateDir.NEXT, $event)" @keydown.enter.exact="syncAndNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="syncAndNavigate(NavigateDir.PREV, $event)" @keydown.shift.enter.exact="syncAndNavigate(NavigateDir.PREV, $event)"
@ -190,3 +181,12 @@ const rowHeight = computed(() => {
</template> </template>
</div> </div>
</template> </template>
<style scoped lang="scss">
.nc-grid-numeric-cell {
@apply text-right;
:deep(input) {
@apply text-right;
}
}
</style>

14
packages/nc-gui/components/smartsheet/Form.vue

@ -662,7 +662,12 @@ watch(view, (nextView) => {
v-if="isVirtualCol(element)" v-if="isVirtualCol(element)"
:name="element.title" :name="element.title"
class="!mb-0" class="!mb-0"
:rules="[{ required: isRequired(element, element.required), message: `${element.title} is required` }]" :rules="[
{
required: isRequired(element, element.required),
message: `${element.label || element.title} is required`,
},
]"
> >
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell
v-model="formState[element.title]" v-model="formState[element.title]"
@ -679,7 +684,12 @@ watch(view, (nextView) => {
v-else v-else
:name="element.title" :name="element.title"
class="!mb-0" class="!mb-0"
:rules="[{ required: isRequired(element, element.required), message: `${element.title} is required` }]" :rules="[
{
required: isRequired(element, element.required),
message: `${element.label || element.title} is required`,
},
]"
> >
<LazySmartsheetCell <LazySmartsheetCell
v-model="formState[element.title]" v-model="formState[element.title]"

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

@ -97,13 +97,12 @@ const contextMenuTarget = ref<{ row: number; col: number } | null>(null)
const expandedFormDlg = ref(false) const expandedFormDlg = ref(false)
const expandedFormRow = ref<Row>() const expandedFormRow = ref<Row>()
const expandedFormRowState = ref<Record<string, any>>() const expandedFormRowState = ref<Record<string, any>>()
const tbodyEl = ref<HTMLElement>()
const gridWrapper = ref<HTMLElement>() const gridWrapper = ref<HTMLElement>()
const tableHead = ref<HTMLElement>() const tableHead = ref<HTMLElement>()
const isAddingColumnAllowed = !readOnly.value && !isLocked.value && isUIAllowed('add-column') && !isSqlView.value const isAddingColumnAllowed = $computed(() => !readOnly.value && !isLocked.value && isUIAllowed('add-column') && !isSqlView.value)
const isAddingEmptyRowAllowed = !isView && !isLocked.value && hasEditPermission && !isSqlView.value const isAddingEmptyRowAllowed = $computed(() => !isView && !isLocked.value && hasEditPermission && !isSqlView.value)
const { const {
isLoading, isLoading,
@ -182,6 +181,8 @@ const {
clearSelectedRange, clearSelectedRange,
copyValue, copyValue,
isCellActive, isCellActive,
tbodyEl,
resetSelectedRange,
} = useMultiSelect( } = useMultiSelect(
meta, meta,
fields, fields,
@ -279,6 +280,17 @@ const {
if (isAddingEmptyRowAllowed) { if (isAddingEmptyRowAllowed) {
$e('c:shortcut', { key: 'ALT + R' }) $e('c:shortcut', { key: 'ALT + R' })
addEmptyRow() addEmptyRow()
activeCell.row = data.value.length - 1
activeCell.col = 0
resetSelectedRange()
makeEditable(data.value[activeCell.row], fields.value[activeCell.col])
nextTick(() => {
;(
document.querySelector('td.cell.active')?.querySelector('input,textarea') as
| HTMLInputElement
| HTMLTextAreaElement
)?.focus()
})
} }
break break
} }
@ -483,7 +495,7 @@ useEventListener(document, 'keyup', async (e: KeyboardEvent) => {
/** On clicking outside of table reset active cell */ /** On clicking outside of table reset active cell */
const smartTable = ref(null) const smartTable = ref(null)
onClickOutside(smartTable, (e) => { onClickOutside(tbodyEl, (e) => {
// do nothing if context menu was open // do nothing if context menu was open
if (contextMenu.value) return if (contextMenu.value) return
@ -558,11 +570,14 @@ const saveOrUpdateRecords = async (args: { metaValue?: TableType; viewMetaValue?
currentRow.rowMeta.changed = false currentRow.rowMeta.changed = false
continue continue
} }
/** if existing row check updated cell and invoke update method */ /** if existing row check updated cell and invoke update method */
if (currentRow.rowMeta.changed) { if (currentRow.rowMeta.changed) {
currentRow.rowMeta.changed = false currentRow.rowMeta.changed = false
for (const field of (args.metaValue || meta.value)?.columns ?? []) { for (const field of (args.metaValue || meta.value)?.columns ?? []) {
if (isVirtualCol(field)) continue // `url` would be enriched in attachment during listing
// hence it would consider as a change while it is not necessary to update
if (isVirtualCol(field) || field.uidt === UITypes.Attachment) continue
if (field.title! in currentRow.row && currentRow.row[field.title!] !== currentRow.oldRow[field.title!]) { if (field.title! in currentRow.row && currentRow.row[field.title!] !== currentRow.oldRow[field.title!]) {
await updateOrSaveRow(currentRow, field.title!, {}, args) await updateOrSaveRow(currentRow, field.title!, {}, args)
} }
@ -699,7 +714,7 @@ const rowHeight = computed(() => {
@contextmenu="showContextMenu" @contextmenu="showContextMenu"
> >
<thead ref="tableHead"> <thead ref="tableHead">
<tr class="nc-grid-header border-1 bg-gray-100 sticky top[-1px]"> <tr class="nc-grid-header border-1 bg-gray-100 sticky top[-1px] !z-4">
<th data-testid="grid-id-column"> <th data-testid="grid-id-column">
<div class="w-full h-full bg-gray-100 flex min-w-[70px] pl-5 pr-1 items-center" data-testid="nc-check-all"> <div class="w-full h-full bg-gray-100 flex min-w-[70px] pl-5 pr-1 items-center" data-testid="nc-check-all">
<template v-if="!readOnly"> <template v-if="!readOnly">
@ -830,6 +845,8 @@ const rowHeight = computed(() => {
:class="{ :class="{
'active': hasEditPermission && isCellSelected(rowIndex, colIndex), 'active': hasEditPermission && isCellSelected(rowIndex, colIndex),
'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row), 'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row),
'align-middle': !rowHeight || rowHeight === 1,
'align-top': rowHeight && rowHeight !== 1,
}" }"
:data-testid="`cell-${columnObj.title}-${rowIndex}`" :data-testid="`cell-${columnObj.title}-${rowIndex}`"
:data-key="rowIndex + columnObj.id" :data-key="rowIndex + columnObj.id"
@ -982,7 +999,7 @@ const rowHeight = computed(() => {
td:not(:first-child) > div { td:not(:first-child) > div {
overflow: hidden; overflow: hidden;
@apply flex px-1; @apply flex px-1 h-auto;
} }
table, table,

8
packages/nc-gui/components/smartsheet/TableDataCell.vue

@ -1,10 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, useSmartsheetStoreOrThrow } from '#imports' import { CellClickHookInj, createEventHook, onBeforeUnmount, onMounted, ref, useSmartsheetStoreOrThrow } from '#imports'
const { cellRefs } = useSmartsheetStoreOrThrow() const { cellRefs } = useSmartsheetStoreOrThrow()
const el = ref<HTMLTableDataCellElement>() const el = ref<HTMLTableDataCellElement>()
const cellClickHook = createEventHook()
provide(CellClickHookInj, cellClickHook)
onMounted(() => { onMounted(() => {
cellRefs.value.push(el.value!) cellRefs.value.push(el.value!)
}) })
@ -18,7 +22,7 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<td ref="el"> <td ref="el" @click="cellClickHook.trigger($event)">
<slot /> <slot />
</td> </td>
</template> </template>

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

@ -5,6 +5,7 @@ import {
CellValueInj, CellValueInj,
ColumnInj, ColumnInj,
IsFormInj, IsFormInj,
IsGridInj,
RowInj, RowInj,
inject, inject,
isBarcode, isBarcode,
@ -40,7 +41,10 @@ provide(ActiveCellInj, active)
provide(RowInj, row) provide(RowInj, row)
provide(CellValueInj, toRef(props, 'modelValue')) provide(CellValueInj, toRef(props, 'modelValue'))
const isGrid = inject(IsGridInj, ref(false))
const isForm = inject(IsFormInj, ref(false)) const isForm = inject(IsFormInj, ref(false))
function onNavigate(dir: NavigateDir, e: KeyboardEvent) { function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
emit('navigate', dir) emit('navigate', dir)
@ -50,7 +54,8 @@ function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
<template> <template>
<div <div
class="nc-virtual-cell w-full" class="nc-virtual-cell w-full flex items-center"
:class="{ 'text-right justify-end': isGrid && !isForm && isRollup(column) }"
@keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)" @keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)" @keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)"
> >

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

@ -4,6 +4,7 @@ import { computed, useColumnCreateStoreOrThrow, useVModel } from '#imports'
const props = defineProps<{ const props = defineProps<{
value: any value: any
advancedDbOptions: boolean
}>() }>()
const emit = defineEmits(['update:value']) const emit = defineEmits(['update:value'])
@ -40,62 +41,64 @@ vModel.value.au = !!vModel.value.au
<template> <template>
<div class="p-4 border-[2px] radius-1 border-grey w-full flex flex-col gap-2"> <div class="p-4 border-[2px] radius-1 border-grey w-full flex flex-col gap-2">
<div class="flex justify-between w-full gap-1"> <template v-if="props.advancedDbOptions">
<a-form-item label="NN"> <div class="flex justify-between w-full gap-1">
<a-checkbox <a-form-item label="NN">
v-model:checked="vModel.rqd" <a-checkbox
:disabled="vModel.pk || !sqlUi.columnEditable(vModel)" v-model:checked="vModel.rqd"
class="nc-column-checkbox-NN" :disabled="vModel.pk || !sqlUi.columnEditable(vModel)"
@change="onAlter" class="nc-column-checkbox-NN"
/> @change="onAlter"
</a-form-item> />
</a-form-item>
<a-form-item label="PK">
<a-checkbox <a-form-item label="PK">
v-model:checked="vModel.pk" <a-checkbox
:disabled="!sqlUi.columnEditable(vModel)" v-model:checked="vModel.pk"
class="nc-column-checkbox-PK" :disabled="!sqlUi.columnEditable(vModel)"
@change="onAlter" class="nc-column-checkbox-PK"
/> @change="onAlter"
/>
</a-form-item>
<a-form-item label="AI">
<a-checkbox
v-model:checked="vModel.ai"
:disabled="sqlUi.colPropUNDisabled(vModel) || !sqlUi.columnEditable(vModel)"
class="nc-column-checkbox-AI"
@change="onAlter"
/>
</a-form-item>
<a-form-item label="UN" :disabled="sqlUi.colPropUNDisabled(vModel) || !sqlUi.columnEditable(vModel)" @change="onAlter">
<a-checkbox v-model:checked="vModel.un" class="nc-column-checkbox-UN" />
</a-form-item>
<a-form-item label="AU" :disabled="sqlUi.colPropAuDisabled(vModel) || !sqlUi.columnEditable(vModel)" @change="onAlter">
<a-checkbox v-model:checked="vModel.au" class="nc-column-checkbox-AU" />
</a-form-item>
</div>
<a-form-item :label="$t('labels.databaseType')" v-bind="validateInfos.dt">
<a-select v-model:value="vModel.dt" dropdown-class-name="nc-dropdown-db-type" @change="onDataTypeChange">
<a-select-option v-for="type in dataTypes" :key="type" :value="type">
{{ type }}
</a-select-option>
</a-select>
</a-form-item> </a-form-item>
<a-form-item label="AI"> <a-form-item v-if="!hideLength" :label="$t('labels.lengthValue')">
<a-checkbox <a-input
v-model:checked="vModel.ai" v-model:value="vModel.dtxp"
:disabled="sqlUi.colPropUNDisabled(vModel) || !sqlUi.columnEditable(vModel)" :disabled="sqlUi.getDefaultLengthIsDisabled(vModel.dt) || !sqlUi.columnEditable(vModel)"
class="nc-column-checkbox-AI" @input="onAlter"
@change="onAlter"
/> />
</a-form-item> </a-form-item>
<a-form-item label="UN" :disabled="sqlUi.colPropUNDisabled(vModel) || !sqlUi.columnEditable(vModel)" @change="onAlter"> <a-form-item v-if="sqlUi.showScale(vModel)" label="Scale">
<a-checkbox v-model:checked="vModel.un" class="nc-column-checkbox-UN" /> <a-input v-model:value="vModel.dtxs" :disabled="!sqlUi.columnEditable(vModel)" @input="onAlter" />
</a-form-item>
<a-form-item label="AU" :disabled="sqlUi.colPropAuDisabled(vModel) || !sqlUi.columnEditable(vModel)" @change="onAlter">
<a-checkbox v-model:checked="vModel.au" class="nc-column-checkbox-AU" />
</a-form-item> </a-form-item>
</div> </template>
<a-form-item :label="$t('labels.databaseType')" v-bind="validateInfos.dt">
<a-select v-model:value="vModel.dt" dropdown-class-name="nc-dropdown-db-type" @change="onDataTypeChange">
<a-select-option v-for="type in dataTypes" :key="type" :value="type">
{{ type }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="!hideLength" :label="$t('labels.lengthValue')">
<a-input
v-model:value="vModel.dtxp"
:disabled="sqlUi.getDefaultLengthIsDisabled(vModel.dt) || !sqlUi.columnEditable(vModel)"
@input="onAlter"
/>
</a-form-item>
<a-form-item v-if="sqlUi.showScale(vModel)" label="Scale">
<a-input v-model:value="vModel.dtxs" :disabled="!sqlUi.columnEditable(vModel)" @input="onAlter" />
</a-form-item>
<a-form-item :label="$t('placeholder.defaultValue')"> <a-form-item :label="$t('placeholder.defaultValue')">
<a-textarea v-model:value="vModel.cdf" auto-size @input="onAlter(2, true)" /> <a-textarea v-model:value="vModel.cdf" auto-size @input="onAlter(2, true)" />

7
packages/nc-gui/components/smartsheet/column/EditOrAdd.vue

@ -51,6 +51,8 @@ const reloadDataTrigger = inject(ReloadViewDataHookInj)
const advancedOptions = ref(false) const advancedOptions = ref(false)
const advancedDbOptions = ref(false)
const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber] const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
const onlyNameUpdateOnEditColumns = [UITypes.LinkToAnotherRecord, UITypes.Lookup, UITypes.Rollup] const onlyNameUpdateOnEditColumns = [UITypes.LinkToAnotherRecord, UITypes.Lookup, UITypes.Rollup]
@ -196,8 +198,9 @@ useEventListener('keydown', (e: KeyboardEvent) => {
<div <div
v-if="!isVirtualCol(formState.uidt)" v-if="!isVirtualCol(formState.uidt)"
class="text-xs cursor-pointer text-grey nc-more-options mb-1 mt-4 flex items-center gap-1 justify-end" class="text-xs cursor-pointer text-gray-400 nc-more-options mb-1 mt-4 flex items-center gap-1 justify-end"
@click="advancedOptions = !advancedOptions" @click="advancedOptions = !advancedOptions"
@dblclick="advancedDbOptions = !advancedDbOptions"
> >
{{ advancedOptions ? $t('general.hideAll') : $t('general.showMore') }} {{ advancedOptions ? $t('general.hideAll') : $t('general.showMore') }}
<component :is="advancedOptions ? MdiMinusIcon : MdiPlusIcon" /> <component :is="advancedOptions ? MdiMinusIcon : MdiPlusIcon" />
@ -220,7 +223,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
v-model:value="formState" v-model:value="formState"
/> />
<LazySmartsheetColumnAdvancedOptions v-model:value="formState" /> <LazySmartsheetColumnAdvancedOptions v-model:value="formState" :advanced-db-options="advancedDbOptions" />
</div> </div>
</Transition> </Transition>

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

@ -21,7 +21,7 @@ const vModel = useVModel(props, 'value', emit)
const { setAdditionalValidations, validateInfos, isPg, isMysql } = useColumnCreateStoreOrThrow() const { setAdditionalValidations, validateInfos, isPg, isMysql } = useColumnCreateStoreOrThrow()
let options = $ref<Option[]>([]) let options = $ref<(Option & { status?: 'remove' })[]>([])
let renderedOptions = $ref<(Option & { status?: 'remove' })[]>([]) let renderedOptions = $ref<(Option & { status?: 'remove' })[]>([])
let savedDefaultOption = $ref<Option | null>(null) let savedDefaultOption = $ref<Option | null>(null)
let savedCdf = $ref<string | null>(null) let savedCdf = $ref<string | null>(null)
@ -41,13 +41,15 @@ const validators = {
validator: (_: any, _opt: any) => { validator: (_: any, _opt: any) => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
for (const opt of options) { for (const opt of options) {
if ((opt as any).status === 'remove') continue
if (!opt.title.length) { if (!opt.title.length) {
return reject(new Error("Select options can't be null")) return reject(new Error("Select options can't be null"))
} }
if (vModel.value.uidt === UITypes.MultiSelect && opt.title.includes(',')) { if (vModel.value.uidt === UITypes.MultiSelect && opt.title.includes(',')) {
return reject(new Error("MultiSelect columns can't have commas(',')")) return reject(new Error("MultiSelect columns can't have commas(',')"))
} }
if (options.filter((el) => el.title === opt.title).length !== 1) { if (options.filter((el) => el.title === opt.title && (el as any).status !== 'remove').length > 1) {
return reject(new Error("Select options can't have duplicates")) return reject(new Error("Select options can't have duplicates"))
} }
} }

4
packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue

@ -200,7 +200,7 @@ function onStopEdit() {
{{ $t('activity.copyView') }} {{ $t('activity.copyView') }}
</template> </template>
<MdiContentCopy class="hidden group-hover:block text-gray-500 nc-view-copy-icon" @click.stop="onDuplicate" /> <MdiContentCopy class="!hidden !group-hover:block text-gray-500 nc-view-copy-icon" @click.stop="onDuplicate" />
</a-tooltip> </a-tooltip>
<template v-if="!vModel.is_default"> <template v-if="!vModel.is_default">
@ -209,7 +209,7 @@ function onStopEdit() {
{{ $t('activity.deleteView') }} {{ $t('activity.deleteView') }}
</template> </template>
<MdiTrashCan class="hidden group-hover:block text-red-500 nc-view-delete-icon" @click.stop="onDelete" /> <MdiTrashCan class="!hidden !group-hover:block text-red-500 nc-view-delete-icon" @click.stop="onDelete" />
</a-tooltip> </a-tooltip>
</template> </template>
</div> </div>

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

@ -320,7 +320,13 @@ const isSuperAdmin = (user: { main_roles?: string }) => {
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight" overlay-class-name="nc-dropdown-user-mgmt"> <a-dropdown
v-if="user.invite_token"
:trigger="['click']"
class="flex"
placement="bottomRight"
overlay-class-name="nc-dropdown-user-mgmt"
>
<div class="flex flex-row items-center"> <div class="flex flex-row items-center">
<a-button type="text" class="!px-0"> <a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]"> <div class="flex flex-row items-center h-[1.2rem]">

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

@ -9,6 +9,7 @@ import {
projectRoleTagColors, projectRoleTagColors,
projectRoles, projectRoles,
ref, ref,
useActiveKeyupListener,
useCopy, useCopy,
useDashboard, useDashboard,
useI18n, useI18n,
@ -44,7 +45,7 @@ const { copy } = useCopy()
const { dashboardUrl } = $(useDashboard()) const { dashboardUrl } = $(useDashboard())
const usersData = $ref<Users>({ emails: undefined, role: ProjectRole.Viewer, invitationToken: undefined }) let usersData = $ref<Users>({ emails: undefined, role: ProjectRole.Viewer, invitationToken: undefined })
const formRef = ref() const formRef = ref()
@ -80,6 +81,11 @@ onMounted(() => {
} }
}) })
const close = () => {
emit('closed')
usersData = { role: ProjectRole.Viewer }
}
const saveUser = async () => { const saveUser = async () => {
$e('a:user:invite', { role: usersData.role }) $e('a:user:invite', { role: usersData.role })
@ -95,7 +101,7 @@ const saveUser = async () => {
project_id: project.value.id, project_id: project.value.id,
projectName: project.value.title, projectName: project.value.title,
}) })
emit('closed') close()
} else { } else {
const res = await $api.auth.projectUserAdd(project.value.id, { const res = await $api.auth.projectUserAdd(project.value.id, {
roles: usersData.role, roles: usersData.role,
@ -135,9 +141,28 @@ const clickInviteMore = () => {
usersData.emails = undefined usersData.emails = undefined
} }
const emailField = (inputEl: typeof Input) => { const emailField = ref<typeof Input>()
inputEl?.$el?.focus()
} useActiveKeyupListener(
computed(() => show),
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
close()
}
},
{ immediate: true },
)
watch(
() => show,
async (val) => {
if (val) {
await nextTick()
emailField.value?.$el?.focus()
}
},
{ immediate: true },
)
</script> </script>
<template> <template>
@ -149,7 +174,7 @@ const emailField = (inputEl: typeof Input) => {
:closable="false" :closable="false"
width="max(50vw, 44rem)" width="max(50vw, 44rem)"
wrap-class-name="nc-modal-invite-user-and-share-base" wrap-class-name="nc-modal-invite-user-and-share-base"
@cancel="emit('closed')" @cancel="close"
> >
<div class="flex flex-col" data-testid="invite-user-and-share-base-modal"> <div class="flex flex-col" data-testid="invite-user-and-share-base-modal">
<div class="flex flex-row justify-between items-center pb-1.5 mb-2 border-b-1 w-full"> <div class="flex flex-row justify-between items-center pb-1.5 mb-2 border-b-1 w-full">
@ -159,7 +184,7 @@ const emailField = (inputEl: typeof Input) => {
type="text" type="text"
class="!rounded-md mr-1 -mt-1.5" class="!rounded-md mr-1 -mt-1.5"
data-testid="invite-user-and-share-base-modal-close-btn" data-testid="invite-user-and-share-base-modal-close-btn"
@click="emit('closed')" @click="close"
> >
<template #icon> <template #icon>
<MaterialSymbolsCloseRounded class="flex mx-auto" /> <MaterialSymbolsCloseRounded class="flex mx-auto" />
@ -233,7 +258,7 @@ const emailField = (inputEl: typeof Input) => {
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('datatype.Email') }}:</div> <div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('datatype.Email') }}:</div>
<a-input <a-input
:ref="emailField" ref="emailField"
v-model:value="usersData.emails" v-model:value="usersData.emails"
validate-trigger="onBlur" validate-trigger="onBlur"
:placeholder="$t('labels.email')" :placeholder="$t('labels.email')"

33
packages/nc-gui/composables/useKanbanViewStore.ts

@ -24,6 +24,7 @@ import {
provide, provide,
ref, ref,
useApi, useApi,
useFieldQuery,
useGlobal, useGlobal,
useI18n, useI18n,
useInjectionState, useInjectionState,
@ -66,6 +67,30 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const password = ref<string | null>(null) const password = ref<string | null>(null)
const { search } = useFieldQuery()
const { sqlUis } = useProject()
const sqlUi = ref(
(meta.value as TableType)?.base_id ? sqlUis.value[(meta.value as TableType).base_id!] : Object.values(sqlUis.value)[0],
)
const xWhere = computed(() => {
let where
const col =
(meta.value as TableType)?.columns?.find(({ id }) => id === search.value.field) ||
(meta.value as TableType)?.columns?.find((v) => v.pv)
if (!col) return
if (!search.value.query.trim()) return
if (['text', 'string'].includes(sqlUi.value.getAbstractType(col)) && col.dt !== 'bigint') {
where = `(${col.title},like,%${search.value.query.trim()}%)`
} else {
where = `(${col.title},eq,${search.value.query.trim()})`
}
return where
})
const attachmentColumns = computed(() => const attachmentColumns = computed(() =>
(meta.value?.columns as ColumnType[])?.filter((c) => c.uidt === UITypes.Attachment).map((c) => c.title), (meta.value?.columns as ColumnType[])?.filter((c) => c.uidt === UITypes.Attachment).map((c) => c.title),
) )
@ -141,7 +166,8 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
} }
async function loadKanbanData() { async function loadKanbanData() {
if ((!project?.value?.id || !meta.value?.id || !viewMeta?.value?.id) && !isPublic.value) return if ((!project?.value?.id || !meta.value?.id || !viewMeta?.value?.id || !groupingFieldColumn?.value?.id) && !isPublic.value)
return
// reset formattedData & countByStack to avoid storing previous data after changing grouping field // reset formattedData & countByStack to avoid storing previous data after changing grouping field
formattedData.value = new Map<string | null, Row[]>() formattedData.value = new Map<string | null, Row[]>()
@ -161,7 +187,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
meta.value!.id!, meta.value!.id!,
viewMeta.value!.id!, viewMeta.value!.id!,
groupingFieldColumn!.value!.id!, groupingFieldColumn!.value!.id!,
{}, { where: xWhere.value },
{}, {},
) )
} }
@ -174,6 +200,8 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
// See /packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts for the details // See /packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts for the details
for (const record of data.value.list) { for (const record of data.value.list) {
for (const attachmentColumn of attachmentColumns.value) { for (const attachmentColumn of attachmentColumns.value) {
// attachment column can be hidden
if (!record[attachmentColumn!]) continue
const oldAttachment = JSON.parse(record[attachmentColumn!]) const oldAttachment = JSON.parse(record[attachmentColumn!])
const newAttachment = [] const newAttachment = []
for (const attachmentObj of oldAttachment) { for (const attachmentObj of oldAttachment) {
@ -200,6 +228,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const response = !isPublic.value const response = !isPublic.value
? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, { ? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, {
...{ where: xWhere.value },
...params, ...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }), ...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }), ...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),

8
packages/nc-gui/composables/useMultiSelect/index.ts

@ -42,6 +42,8 @@ export function useMultiSelect(
) { ) {
const meta = ref(_meta) const meta = ref(_meta)
const tbodyEl = ref<HTMLElement>()
const { t } = useI18n() const { t } = useI18n()
const { copy } = useCopy() const { copy } = useCopy()
@ -372,10 +374,12 @@ export function useMultiSelect(
} }
} }
const resetSelectedRange = () => selectedRange.clear()
const clearSelectedRange = selectedRange.clear.bind(selectedRange) const clearSelectedRange = selectedRange.clear.bind(selectedRange)
useEventListener(document, 'keydown', handleKeyDown) useEventListener(document, 'keydown', handleKeyDown)
useEventListener(document, 'mouseup', handleMouseUp) useEventListener(tbodyEl, 'mouseup', handleMouseUp)
return { return {
isCellActive, isCellActive,
@ -386,5 +390,7 @@ export function useMultiSelect(
isCellSelected, isCellSelected,
activeCell, activeCell,
handleCellClick, handleCellClick,
tbodyEl,
resetSelectedRange,
} }
} }

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

@ -1,7 +1,6 @@
import type { BaseType, OracleUi, ProjectType, TableType } from 'nocodb-sdk' import type { BaseType, OracleUi, ProjectType, TableType } from 'nocodb-sdk'
import { SqlUiFactory } from 'nocodb-sdk' import { SqlUiFactory } from 'nocodb-sdk'
import { isString } from '@vueuse/core' import { isString } from '@vueuse/core'
import { useRoute } from 'vue-router'
import { import {
ClientType, ClientType,
computed, computed,
@ -12,6 +11,7 @@ import {
useInjectionState, useInjectionState,
useNuxtApp, useNuxtApp,
useRoles, useRoles,
useRoute,
useRouter, useRouter,
useTheme, useTheme,
} from '#imports' } from '#imports'
@ -86,6 +86,10 @@ const [setup, use] = useInjectionState(() => {
return getBaseType(baseId) === 'pg' return getBaseType(baseId) === 'pg'
} }
function isXcdbBase(baseId?: string) {
return bases.value.find((base) => base.id === baseId)?.is_meta
}
const isSharedBase = computed(() => projectType === 'base') const isSharedBase = computed(() => projectType === 'base')
async function loadProjectMetaInfo(force?: boolean) { async function loadProjectMetaInfo(force?: boolean) {
@ -195,6 +199,7 @@ const [setup, use] = useInjectionState(() => {
reset, reset,
isLoading, isLoading,
lastOpenedViewMap, lastOpenedViewMap,
isXcdbBase,
} }
}, 'useProject') }, 'useProject')

38
packages/nc-gui/composables/useSelectedCellKeyupListener/index.ts

@ -1,21 +1,31 @@
import { isClient } from '@vueuse/core' import { isClient } from '@vueuse/core'
import type { Ref } from 'vue' import type { Ref } from 'vue'
export function useSelectedCellKeyupListener(selected: Ref<boolean>, handler: (e: KeyboardEvent) => void) { function useSelectedCellKeyupListener(
selected: Ref<boolean>,
handler: (e: KeyboardEvent) => void,
{ immediate = false }: { immediate?: boolean } = {},
) {
if (isClient) { if (isClient) {
watch(selected, (nextVal, _, cleanup) => { watch(
// bind listener when `selected` is truthy selected,
if (nextVal) { (nextVal: boolean, _: boolean, cleanup) => {
document.addEventListener('keydown', handler, true) // bind listener when `selected` is truthy
// if `selected` is falsy then remove the event handler if (nextVal) {
} else { document.addEventListener('keydown', handler, true)
document.removeEventListener('keydown', handler, true) // if `selected` is falsy then remove the event handler
} } else {
document.removeEventListener('keydown', handler, true)
}
// cleanup is called whenever the watcher is re-evaluated or stopped // cleanup is called whenever the watcher is re-evaluated or stopped
cleanup(() => { cleanup(() => {
document.removeEventListener('keydown', handler, true) document.removeEventListener('keydown', handler, true)
}) })
}) },
{ immediate },
)
} }
} }
export { useSelectedCellKeyupListener, useSelectedCellKeyupListener as useActiveKeyupListener }

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

@ -13,9 +13,12 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
initialFilters?: Ref<FilterType[]>, initialFilters?: Ref<FilterType[]>,
) => { ) => {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { sqlUis } = useProject() const { sqlUis } = useProject()
const sqlUi = ref(meta.value?.base_id ? sqlUis.value[meta.value?.base_id] : Object.values(sqlUis.value)[0]) const sqlUi = ref(
(meta.value as TableType)?.base_id ? sqlUis.value[(meta.value as TableType).base_id!] : Object.values(sqlUis.value)[0],
)
const cellRefs = ref<HTMLTableDataCellElement[]>([]) const cellRefs = ref<HTMLTableDataCellElement[]>([])

62
packages/nc-gui/composables/useViewData.ts

@ -1,5 +1,15 @@
import { UITypes, ViewTypes } from 'nocodb-sdk' import { UITypes, ViewTypes } from 'nocodb-sdk'
import type { Api, ColumnType, FormColumnType, FormType, GalleryType, PaginatedType, TableType, ViewType } from 'nocodb-sdk' import type {
Api,
AttachmentType,
ColumnType,
FormColumnType,
FormType,
GalleryType,
PaginatedType,
TableType,
ViewType,
} from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import { import {
IsPublicInj, IsPublicInj,
@ -81,6 +91,10 @@ export function useViewData(
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const attachmentColumns = computed(() =>
(meta.value?.columns as ColumnType[])?.filter((c) => c.uidt === UITypes.Attachment).map((c) => c.title),
)
const routeQuery = $computed(() => route.query as Record<string, string>) const routeQuery = $computed(() => route.query as Record<string, string>)
const paginationData = computed({ const paginationData = computed({
@ -187,6 +201,28 @@ export function useViewData(
} }
} }
// TODO: refactor
async function getAttachmentUrl(item: AttachmentType) {
const path = item?.path
// if path doesn't exist, use `item.url`
if (path) {
// try ${appInfo.value.ncSiteUrl}/${item.path} first
const url = `${appInfo.ncSiteUrl}/${item.path}`
try {
const res = await fetch(url)
if (res.ok) {
// use `url` if it is accessible
return Promise.resolve(url)
}
} catch {
// for some cases, `url` is not accessible as expected
// do nothing here
}
}
// if it fails, use the original url
return Promise.resolve(item.url)
}
async function loadData(params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) { async function loadData(params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) {
if ((!project?.value?.id || !meta.value?.id || !viewMeta.value?.id) && !isPublic.value) return if ((!project?.value?.id || !meta.value?.id || !viewMeta.value?.id) && !isPublic.value) return
const response = !isPublic.value const response = !isPublic.value
@ -198,7 +234,27 @@ export function useViewData(
where: where?.value, where: where?.value,
}) })
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value }) : await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value })
formattedData.value = formatData(response.list) // reconstruct the url
// See /packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts for the details
const records = []
for (const record of response.list) {
for (const attachmentColumn of attachmentColumns.value) {
// attachment column can be hidden
if (!record[attachmentColumn!]) continue
const oldAttachment =
typeof record[attachmentColumn!] === 'string' ? JSON.parse(record[attachmentColumn!]) : record[attachmentColumn!]
const newAttachment = []
for (const attachmentObj of oldAttachment) {
newAttachment.push({
...attachmentObj,
url: await getAttachmentUrl(attachmentObj),
})
}
record[attachmentColumn!] = newAttachment
}
records.push(record)
}
formattedData.value = formatData(records)
paginationData.value = response.pageInfo paginationData.value = response.pageInfo
// to cater the case like when querying with a non-zero offset // to cater the case like when querying with a non-zero offset
@ -455,7 +511,7 @@ export function useViewData(
order: (fieldById[c.id] && fieldById[c.id].order) || order++, order: (fieldById[c.id] && fieldById[c.id].order) || order++,
id: fieldById[c.id] && fieldById[c.id].id, id: fieldById[c.id] && fieldById[c.id].id,
})) }))
.sort((a: Record<string, any>, b: Record<string, any>) => a.order - b.order) as Record<string, any> .sort((a: Record<string, any>, b: Record<string, any>) => a.order - b.order) as Record<string, any>[]
} catch (e: any) { } catch (e: any) {
return message.error(`${t('msg.error.setFormDataFailed')}: ${await extractSdkResponseErrorMsg(e)}`) return message.error(`${t('msg.error.setFormDataFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
} }

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

@ -32,3 +32,4 @@ export const SharedViewPasswordInj: InjectionKey<Ref<string | null>> = Symbol('s
export const CellUrlDisableOverlayInj: InjectionKey<Ref<boolean>> = Symbol('cell-url-disable-url') export const CellUrlDisableOverlayInj: InjectionKey<Ref<boolean>> = Symbol('cell-url-disable-url')
export const DropZoneRef: InjectionKey<Ref<Element | undefined>> = Symbol('drop-zone-ref') export const DropZoneRef: InjectionKey<Ref<Element | undefined>> = Symbol('drop-zone-ref')
export const ToggleDialogInj: InjectionKey<Function> = Symbol('toggle-dialog-injection') export const ToggleDialogInj: InjectionKey<Function> = Symbol('toggle-dialog-injection')
export const CellClickHookInj: InjectionKey<EventHook<MouseEvent> | undefined> = Symbol('cell-click-injection')

6
packages/nc-gui/lang/eu.json

@ -62,7 +62,7 @@
"misc": "Miszelanea", "misc": "Miszelanea",
"lock": "Blokeatu", "lock": "Blokeatu",
"unlock": "Desblokeatu", "unlock": "Desblokeatu",
"credentials": "Credentials", "credentials": "Egiaztagiriak",
"help": "Laguntza", "help": "Laguntza",
"questions": "Galderak", "questions": "Galderak",
"reachOut": "Reach out here", "reachOut": "Reach out here",
@ -103,7 +103,7 @@
"user": "Erabiltzailea", "user": "Erabiltzailea",
"users": "Erabiltzaileak", "users": "Erabiltzaileak",
"role": "Rola", "role": "Rola",
"roles": "Roles", "roles": "Rolak",
"roleType": { "roleType": {
"owner": "Jabea", "owner": "Jabea",
"creator": "Sortzailea", "creator": "Sortzailea",
@ -182,7 +182,7 @@
"rolesMgmt": "Roles Management", "rolesMgmt": "Roles Management",
"projMeta": "Project Metadata", "projMeta": "Project Metadata",
"metaMgmt": "Meta Management", "metaMgmt": "Meta Management",
"metadata": "Metadata", "metadata": "Metadatuak",
"exportImportMeta": "Export / Import Metadata", "exportImportMeta": "Export / Import Metadata",
"uiACL": "UI Access Control", "uiACL": "UI Access Control",
"metaOperations": "Metadata Operations", "metaOperations": "Metadata Operations",

2
packages/nc-gui/lang/ru.json

@ -206,7 +206,7 @@
"advancedSettings": "Расширенные настройки", "advancedSettings": "Расширенные настройки",
"codeSnippet": "Сниппет кода", "codeSnippet": "Сниппет кода",
"keyboardShortcut": "Горячие клавиши", "keyboardShortcut": "Горячие клавиши",
"generateRandomName": "Generate Random Name" "generateRandomName": "Сгенерировать случайное имя"
}, },
"labels": { "labels": {
"createdBy": "Автор", "createdBy": "Автор",

274
packages/nc-gui/lang/zh-Hans.json

@ -70,11 +70,11 @@
"moreInfo": "点击此处了解更多信息。", "moreInfo": "点击此处了解更多信息。",
"logs": "日志", "logs": "日志",
"groupingField": "分组字段", "groupingField": "分组字段",
"insertAfter": "Insert After", "insertAfter": "在右侧插入列",
"insertBefore": "Insert Before", "insertBefore": "在左侧插入列",
"hideField": "Hide Field", "hideField": "隐藏字段",
"sortAsc": "Sort Ascending", "sortAsc": "升序",
"sortDesc": "Sort Descending" "sortDesc": "降序"
}, },
"objects": { "objects": {
"project": "项目", "project": "项目",
@ -86,7 +86,7 @@
"column": "列", "column": "列",
"columns": "列", "columns": "列",
"page": "页", "page": "页",
"pages": "页", "pages": "页",
"record": "记录", "record": "记录",
"records": "记录", "records": "记录",
"webhook": "Webhook.", "webhook": "Webhook.",
@ -110,8 +110,8 @@
"editor": "编辑者", "editor": "编辑者",
"commenter": "评论者", "commenter": "评论者",
"viewer": "浏览者", "viewer": "浏览者",
"orgLevelCreator": "Organization Level Creator", "orgLevelCreator": "创始人",
"orgLevelViewer": "Organization Level Viewer" "orgLevelViewer": "游客"
}, },
"sqlVIew": "SQL 视图" "sqlVIew": "SQL 视图"
}, },
@ -123,7 +123,7 @@
"Attachment": "附件", "Attachment": "附件",
"Checkbox": "复选框", "Checkbox": "复选框",
"MultiSelect": "多选", "MultiSelect": "多选",
"SingleSelect": "单", "SingleSelect": "单选",
"Collaborator": "合作者", "Collaborator": "合作者",
"Date": "日期", "Date": "日期",
"Year": "年", "Year": "年",
@ -131,64 +131,64 @@
"PhoneNumber": "电话号码", "PhoneNumber": "电话号码",
"Email": "电子邮件", "Email": "电子邮件",
"URL": "URL.", "URL": "URL.",
"Number": "数", "Number": "数",
"Decimal": "十进制", "Decimal": "小数",
"Currency": "货币", "Currency": "货币",
"Percent": "百分", "Percent": "百分",
"Duration": "期间", "Duration": "时长",
"Rating": "评分", "Rating": "评分",
"Formula": "公式", "Formula": "公式",
"Rollup": "卷起", "Rollup": "聚合",
"Count": "计数", "Count": "计数",
"Lookup": "查找", "Lookup": "查找",
"DateTime": "日期时间", "DateTime": "日期时间",
"CreateTime": "创建时间", "CreateTime": "创建时间",
"LastModifiedTime": "最后修改时间", "LastModifiedTime": "最后修改时间",
"AutoNumber": "自动编号", "AutoNumber": "自动编号",
"Barcode": "条码", "Barcode": "条码",
"Button": "按钮", "Button": "按钮",
"Password": "密码", "Password": "密码",
"relationProperties": { "relationProperties": {
"noAction": "没有任何行动", "noAction": "没有任何行动",
"cascade": "级联", "cascade": "级联",
"restrict": "严格", "restrict": "严格",
"setNull": "设置null.", "setNull": "设为空(NULL)",
"setDefault": "默认设置" "setDefault": "设为默认值(Default)"
} }
}, },
"filterOperation": { "filterOperation": {
"isEqual": "完全一致", "isEqual": "等于",
"isNotEqual": "完全不一致", "isNotEqual": "不等于",
"isLike": "部分一致", "isLike": "部分一致",
"isNot like": "部分不一致", "isNot like": "部分不一致",
"isEmpty": "是空的", "isEmpty": "内容为空",
"isNotEmpty": "不是空的", "isNotEmpty": "内容不为空",
"isNull": "一片空白", "isNull": "无值(Null)",
"isNotNull": "不是空虚" "isNotNull": "有值(Not Null)"
}, },
"title": { "title": {
"erdView": "实体关系图", "erdView": "实体关系图",
"newProj": "创建新项目", "newProj": "创建新项目",
"myProject": "我的项目", "myProject": "我的项目",
"formTitle": "表标题", "formTitle": "表标题",
"collabView": "合作视图", "collabView": "合作视图",
"lockedView": "锁定视图", "lockedView": "锁定视图",
"personalView": "个人视图", "personalView": "个人视图",
"appStore": "软件商店", "appStore": "软件商店",
"teamAndAuth": "团队和认证", "teamAndAuth": "团队和认证",
"rolesUserMgmt": "角色和用户管理", "rolesUserMgmt": "角色和用户管理",
"userMgmt": "用户账号管理", "userMgmt": "用户管理",
"apiTokenMgmt": "API Tokens 管理", "apiTokenMgmt": "API 令牌管理",
"rolesMgmt": "角色管理", "rolesMgmt": "角色管理",
"projMeta": "项目基础信息", "projMeta": "项目基础信息",
"metaMgmt": "项目基础信息管理", "metaMgmt": "项目基础信息管理",
"metadata": "元数据", "metadata": "元数据",
"exportImportMeta": "导出/导入元数据", "exportImportMeta": "导出/导入元数据",
"uiACL": "UI访问控制", "uiACL": "UI 访问控制",
"metaOperations": "元数据操作", "metaOperations": "元数据操作",
"audit": "审计", "audit": "审计",
"auditLogs": "审计日志", "auditLogs": "审计日志",
"sqlMigrations": "SQL迁移", "sqlMigrations": "SQL 迁移",
"dbCredentials": "数据库链接凭证", "dbCredentials": "数据库链接凭证",
"advancedParameters": "SSL 和高级参数", "advancedParameters": "SSL 和高级参数",
"headCreateProject": "新建项目 | NocoDB", "headCreateProject": "新建项目 | NocoDB",
@ -201,25 +201,25 @@
"APIsAndSupport": "API与支持", "APIsAndSupport": "API与支持",
"helpCenter": "帮助中心", "helpCenter": "帮助中心",
"swaggerDocumentation": "Swagger 文档", "swaggerDocumentation": "Swagger 文档",
"quickImportFrom": "快速导入", "quickImportFrom": "快速导入",
"quickImport": "快速导入", "quickImport": "快速导入",
"advancedSettings": "高级设置", "advancedSettings": "高级设置",
"codeSnippet": "代码片段", "codeSnippet": "代码片段",
"keyboardShortcut": "Keyboard Shortcuts", "keyboardShortcut": "快捷键",
"generateRandomName": "Generate Random Name" "generateRandomName": "随机名称"
}, },
"labels": { "labels": {
"createdBy": "Created By", "createdBy": "创建自",
"notifyVia": "通知通过", "notifyVia": "通知方式",
"projName": "项目名", "projName": "项目名",
"tableName": "表名", "tableName": "表名",
"viewName": "查看名称", "viewName": "查看名称",
"viewLink": "查看链接", "viewLink": "查看链接",
"columnName": "列名", "columnName": "列名",
"columnType": "列类型", "columnType": "列类型",
"roleName": "角色名称", "roleName": "权限组",
"roleDescription": "角色描述", "roleDescription": "权限描述",
"databaseType": "键入数据库", "databaseType": "数据库中的类型",
"lengthValue": "长度/值", "lengthValue": "长度/值",
"dbType": "数据库类型", "dbType": "数据库类型",
"sqliteFile": "SQLite 文件", "sqliteFile": "SQLite 文件",
@ -227,36 +227,36 @@
"port": "端口号", "port": "端口号",
"username": "用户名", "username": "用户名",
"password": "密码", "password": "密码",
"schemaName": "架构名称", "schemaName": "模式名",
"database": "数据库", "database": "数据库",
"action": "行动", "action": "操作",
"actions": "操作", "actions": "操作",
"operation": "操作", "operation": "操作",
"operationType": "操作类型", "operationType": "操作类型",
"operationSubType": "操作类型", "operationSubType": "操作类型",
"description": "描述", "description": "描述",
"authentication": "证", "authentication": "身份认证",
"token": "令牌", "token": "令牌",
"where": "在哪里", "where": "在哪里",
"cache": "缓存", "cache": "缓存",
"chat": "聊天", "chat": "聊天",
"email": "电子邮件", "email": "电子邮件",
"storage": "存", "storage": "存",
"uiAcl": "UI-ACL", "uiAcl": "UI-ACL",
"models": "模型", "models": "模型",
"syncState": "同步状态", "syncState": "同步状态",
"created": "创了", "created": "创了",
"sqlOutput": "SQL输出", "sqlOutput": "输出 SQL",
"addOption": "添加选项", "addOption": "添加选项",
"qrCodeValueColumn": "Column with QR code value", "qrCodeValueColumn": "二维码要显示的数据",
"barcodeValueColumn": "Column with Barcode value", "barcodeValueColumn": "条形码要显示的数据",
"barcodeFormat": "Barcode format", "barcodeFormat": "条形码码制",
"qrCodeValueTooLong": "Too many characters for a QR code", "qrCodeValueTooLong": "字数超出二维码容量",
"barcodeValueTooLong": "Too many characters for a barcode", "barcodeValueTooLong": "字数超出条形码容量",
"aggregateFunction": "汇总功能", "aggregateFunction": "汇总功能",
"dbCreateIfNotExists": "数据库 : 如果不存在则创建", "dbCreateIfNotExists": "自动创建数据库",
"clientKey": "客户端 Key", "clientKey": "客户端密钥",
"clientCert": "客户端 Cert", "clientCert": "客户端证书",
"serverCA": "服务器 CA", "serverCA": "服务器 CA",
"requriedCa": "必填项-CA", "requriedCa": "必填项-CA",
"requriedIdentity": "必填项-IDENTITY", "requriedIdentity": "必填项-IDENTITY",
@ -278,9 +278,9 @@
"selectUserRole": "选择用户角色", "selectUserRole": "选择用户角色",
"childTable": "子表", "childTable": "子表",
"childColumn": "子列", "childColumn": "子列",
"linkToAnotherRecord": "Link to another record", "linkToAnotherRecord": "关联到其他表",
"onUpdate": "更新", "onUpdate": "更新",
"onDelete": "删除", "onDelete": "删除",
"account": "帐户", "account": "帐户",
"language": "语言", "language": "语言",
"primaryColor": "主色调", "primaryColor": "主色调",
@ -288,15 +288,15 @@
"customTheme": "定制样式", "customTheme": "定制样式",
"requestDataSource": "请求您需要的数据源?", "requestDataSource": "请求您需要的数据源?",
"apiKey": "API 密钥", "apiKey": "API 密钥",
"sharedBase": "Shared Base", "sharedBase": "分享项目",
"importData": "导入数据", "importData": "导入数据",
"importSecondaryViews": "导入次要视图", "importSecondaryViews": "导入次要视图",
"importRollupColumns": "Import Rollup Columns", "importRollupColumns": "导入聚合列",
"importLookupColumns": "Import Lookup Columns", "importLookupColumns": "导入查询列",
"importAttachmentColumns": "导入附件列", "importAttachmentColumns": "导入附件列",
"importFormulaColumns": "导入公式列", "importFormulaColumns": "导入公式列",
"noData": "暂无数据", "noData": "暂无数据",
"goToDashboard": "前往仪表板", "goToDashboard": "转到仪表板",
"importing": "导入中", "importing": "导入中",
"flattenNested": "Flatten Nested", "flattenNested": "Flatten Nested",
"downloadAllowed": "允许下载", "downloadAllowed": "允许下载",
@ -306,16 +306,16 @@
"belongsTo": "属于", "belongsTo": "属于",
"manyToMany": "多对多关系", "manyToMany": "多对多关系",
"extraConnectionParameters": "额外连接参数", "extraConnectionParameters": "额外连接参数",
"commentsOnly": "仅注释", "commentsOnly": "仅评论",
"documentation": "文档", "documentation": "文档",
"subscribeNewsletter": "订阅我们的每周新闻", "subscribeNewsletter": "订阅我们的每周新闻",
"signUpWithGoogle": "使用 Google 注册", "signUpWithGoogle": "使用 Google 注册",
"signInWithGoogle": "使用 Google 登录", "signInWithGoogle": "使用 Google 登录",
"agreeToTos": "注册即表明您同意服务条款", "agreeToTos": "注册即表明您同意服务条款",
"welcomeToNc": "欢迎来到NocoDB!", "welcomeToNc": "欢迎来到NocoDB!",
"inviteOnlySignup": "Allow signup only using invite url", "inviteOnlySignup": "只能通过邀请链接注册账户",
"nextRow": "Next Row", "nextRow": "下一行",
"prevRow": "Previous Row" "prevRow": "上一行"
}, },
"activity": { "activity": {
"createProject": "创建项目", "createProject": "创建项目",
@ -328,21 +328,21 @@
"deleteProject": "删除项目", "deleteProject": "删除项目",
"refreshProject": "刷新项目", "refreshProject": "刷新项目",
"saveProject": "保存项目", "saveProject": "保存项目",
"deleteKanbanStack": "Delete stack?", "deleteKanbanStack": "是否删除此类别?",
"createProjectExtended": { "createProjectExtended": {
"extDB": "通过连接新建 <br>连接到外部数据库", "extDB": "新建 <br>从外部数据库",
"excel": "从Excel创建项目", "excel": "从 Excel 创建项目",
"template": "从模板创建项目" "template": "从模板创建项目"
}, },
"OkSaveProject": "确认并保存项目", "OkSaveProject": "确认并保存项目",
"upgrade": { "upgrade": {
"available": "升级可用", "available": "有可用的升级",
"releaseNote": "发行说明", "releaseNote": "发行说明",
"howTo": "如何升级?" "howTo": "如何升级?"
}, },
"translate": "帮助翻译", "translate": "帮助翻译",
"account": { "account": {
"authToken": "复制auth令牌", "authToken": "复制 Auth 令牌",
"swagger": "Swagger: REST APIs", "swagger": "Swagger: REST APIs",
"projInfo": "复制项目信息", "projInfo": "复制项目信息",
"themes": "主题" "themes": "主题"
@ -353,46 +353,46 @@
"addFilter": "添加过滤器", "addFilter": "添加过滤器",
"share": "分享", "share": "分享",
"shareBase": { "shareBase": {
"disable": "禁用共享基础", "disable": "停止分享项目",
"enable": "任何有链接的人", "enable": "任何有链接的人",
"link": "共享基本链接" "link": "分享项目链接"
}, },
"invite": "邀请", "invite": "邀请",
"inviteMore": "邀请更多", "inviteMore": "邀请更多",
"inviteTeam": "邀请团队", "inviteTeam": "邀请团队",
"inviteUser": "Invite User", "inviteUser": "邀请用户",
"inviteToken": "邀请令牌", "inviteToken": "邀请令牌",
"newUser": "用户", "newUser": "创建用户",
"editUser": "编辑用户", "editUser": "编辑用户",
"deleteUser": "从项目中删除用户", "deleteUser": "从项目中踢出用户",
"resendInvite": "重新发送邀请电子邮件", "resendInvite": "重新发送邀请邮件",
"copyInviteURL": "复制邀请链接", "copyInviteURL": "复制邀请链接",
"copyPasswordResetURL": "Copy password reset URL", "copyPasswordResetURL": "复制密码重置网址",
"newRole": "新角色", "newRole": "新建权限组",
"reloadRoles": "重新加载角色", "reloadRoles": "重新加载权限组",
"nextPage": "下一页", "nextPage": "下一页",
"prevPage": "上一页", "prevPage": "上一页",
"nextRecord": "下一记录", "nextRecord": "下一记录",
"previousRecord": "之前的纪录", "previousRecord": "上一条纪录",
"copyApiURL": "复制 API 链接", "copyApiURL": "复制 API 链接",
"createTable": "表创造", "createTable": "创建表格",
"refreshTable": "刷新", "refreshTable": "刷新表格",
"renameTable": "重命名", "renameTable": "重命名表格",
"deleteTable": "删除", "deleteTable": "删除表格",
"addField": "将新字段添加到此表", "addField": "添加新字段",
"setPrimary": "设置为主要值", "setPrimary": "设置为主要值",
"addRow": "添加新行", "addRow": "添加新行",
"saveRow": "保存行", "saveRow": "保存行",
"saveAndExit": "保存并退出", "saveAndExit": "保存并退出",
"saveAndStay": "Save & Stay", "saveAndStay": "保存并留在此页",
"insertRow": "插入新行", "insertRow": "插入新行",
"deleteRow": "删除行", "deleteRow": "删除行",
"deleteSelectedRow": "删除所选行", "deleteSelectedRow": "删除所选行",
"importExcel": "导入Excel", "importExcel": "导入 Excel",
"importCSV": "导入 CSV", "importCSV": "导入 CSV",
"downloadCSV": "下载为CSV", "downloadCSV": "下载为 CSV",
"downloadExcel": "下载为XLSX", "downloadExcel": "下载为 XLSX",
"uploadCSV": "上传CSV", "uploadCSV": "上传 CSV",
"import": "导入", "import": "导入",
"importMetadata": "导入元数据", "importMetadata": "导入元数据",
"exportMetadata": "导出元数据", "exportMetadata": "导出元数据",
@ -414,25 +414,25 @@
"showSystemFields": "显示系统字段", "showSystemFields": "显示系统字段",
"copyUrl": "复制链接", "copyUrl": "复制链接",
"openTab": "打开新标签", "openTab": "打开新标签",
"iFrame": "复制嵌入HTML代码", "iFrame": "复制嵌入HTML 代码",
"addWebhook": "添加新的webhook.", "addWebhook": "添加新的 Webhook",
"newToken": "添加新 Token", "newToken": "添加新 Token",
"exportZip": "导出为zip格式", "exportZip": "导出为 zip 格式",
"importZip": "导入zip格式", "importZip": "导入 zip 格式",
"metaSync": "立即同步", "metaSync": "立即同步",
"settings": "设置", "settings": "设置",
"previewAs": "预览", "previewAs": "预览",
"resetReview": "重置预览", "resetReview": "重置预览",
"testDbConn": "测试数据库链接", "testDbConn": "测试数据库链接",
"removeDbFromEnv": "从环境中删除数据库", "removeDbFromEnv": "从环境中删除数据库",
"editConnJson": "编辑链接JSON", "editConnJson": "编辑链接 JSON",
"sponsorUs": "赞助我们", "sponsorUs": "赞助我们",
"sendEmail": "发送邮件", "sendEmail": "发送邮件",
"addUserToProject": "添加用户到项目", "addUserToProject": "添加用户到项目",
"getApiSnippet": "生成代码", "getApiSnippet": "生成代码",
"clearCell": "清除单元格内容", "clearCell": "清除单元格内容",
"addFilterGroup": "添加筛选器组", "addFilterGroup": "添加筛选器组",
"linkRecord": "链接记录", "linkRecord": "关联到其他记录",
"addNewRecord": "新增记录", "addNewRecord": "新增记录",
"useConnectionUrl": "使用连接 URL", "useConnectionUrl": "使用连接 URL",
"toggleCommentsDraw": "切换评论视图", "toggleCommentsDraw": "切换评论视图",
@ -446,11 +446,11 @@
"showJunctionTableNames": "显示关联表名称" "showJunctionTableNames": "显示关联表名称"
}, },
"kanban": { "kanban": {
"collapseStack": "Collapse Stack", "collapseStack": "折叠此类别",
"deleteStack": "Delete Stack", "deleteStack": "删除此类别",
"stackedBy": "Stacked By", "stackedBy": "分类依据为",
"chooseGroupingField": "选择分组字段", "chooseGroupingField": "选择分组字段",
"addOrEditStack": "Add / Edit Stack" "addOrEditStack": "添加/编辑分类标签"
} }
}, },
"tooltip": { "tooltip": {
@ -467,7 +467,7 @@
"inviteMore": "邀请更多用户", "inviteMore": "邀请更多用户",
"toggleNavDraw": "切换导航抽屉", "toggleNavDraw": "切换导航抽屉",
"reloadApiToken": "重新加载API令牌", "reloadApiToken": "重新加载API令牌",
"generateNewApiToken": "生成新的API令牌", "generateNewApiToken": "生成新的 API 令牌",
"addRole": "添加新角色", "addRole": "添加新角色",
"reloadList": "重新加载列表", "reloadList": "重新加载列表",
"metaSync": "同步元数据", "metaSync": "同步元数据",
@ -504,7 +504,7 @@
"msg": { "msg": {
"warning": { "warning": {
"barcode": { "barcode": {
"renderError": "Barcode error - please check compatibility between input and barcode type" "renderError": "条形码错误 - 请注意输入数据和条形码类型之间的兼容性"
}, },
"nonEditableFields": { "nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text", "computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
@ -514,8 +514,8 @@
"info": { "info": {
"pasteNotSupported": "Paste operation is not supported on the active cell", "pasteNotSupported": "Paste operation is not supported on the active cell",
"roles": { "roles": {
"orgCreator": "Creator can create new projects and access any invited project.", "orgCreator": "创始人可以创建新项目,访问受邀项目。",
"orgViewer": "Viewer is not allowed to create new projects but they can access any invited project." "orgViewer": "游客不能创建新项目,仅允许访问受邀项目。"
}, },
"footerInfo": "每页行驶", "footerInfo": "每页行驶",
"upload": "选择文件以上传", "upload": "选择文件以上传",
@ -535,40 +535,40 @@
"deleteProject": "你想删除这个项目吗?", "deleteProject": "你想删除这个项目吗?",
"shareBasePrivate": "产生公开共享的只读基础", "shareBasePrivate": "产生公开共享的只读基础",
"shareBasePublic": "互联网上的任何人都可以查看", "shareBasePublic": "互联网上的任何人都可以查看",
"userInviteNoSMTP": "看起来你还没有配置邮件!请复制上面的邀请链接并将其发送给", "userInviteNoSMTP": "你还没有配置邮箱!请复制上面的邀请链接并将其发送给",
"dragDropHide": "在此处拖放字段以隐藏", "dragDropHide": "在此处拖放字段以隐藏",
"formInput": "输入表单输入标签", "formInput": "输入表单输入标签",
"formHelpText": "添加一些帮助文本", "formHelpText": "添加一些帮助文本",
"onlyCreator": "仅创人可见", "onlyCreator": "仅创人可见",
"formDesc": "添加表单描述", "formDesc": "添加表单描述",
"beforeEnablePwd": "使用密码限制访问", "beforeEnablePwd": "使用密码限制访问",
"afterEnablePwd": "访问受密码限制", "afterEnablePwd": "访问受密码限制",
"privateLink": "此视图是通过私人链接共享的", "privateLink": "此视图通过私有链接共享",
"privateLinkAdditionalInfo": "拥有私人链接的人只能看到此视图中可见的单元格", "privateLinkAdditionalInfo": "拥有私人链接的人只能看到此视图中可见的单元格",
"afterFormSubmitted": "表提交后", "afterFormSubmitted": "表提交后",
"apiOptions": "访问项目通过", "apiOptions": "访问项目通过",
"submitAnotherForm": "显示“提交另一个表单”按钮", "submitAnotherForm": "显示“提交另一个表单”按钮",
"showBlankForm": "5秒后显示空白表格", "showBlankForm": "5 秒后显示空表单",
"emailForm": "发电子邮件给我", "emailForm": "发电子邮件给我",
"showSysFields": "显示系统字段", "showSysFields": "显示系统字段",
"filterAutoApply": "自动应用", "filterAutoApply": "自动应用",
"showMessage": "显示此消息", "showMessage": "显示此消息",
"viewNotShared": "当前视图不共享!", "viewNotShared": "当前视图不共享!",
"showAllViews": "显示此表的所有共享视图", "showAllViews": "显示此表的所有共享视图",
"collabView": "具有编辑权限或更高的合作者可以更改视图配置。", "collabView": "具有编辑及更高权限的合作者可以更改视图配置。",
"lockedView": "没有人可以编辑视图配置,直到它锁。", "lockedView": "视图配置被锁。",
"personalView": "只有您可以编辑视图配置。默认情况下,其他合作者的个人视图隐藏。", "personalView": "只有您可以编辑视图配置,其他合作者的个人视图默认不显示。",
"ownerDesc": "可以添加/删除创建者。和完整编辑数据库结构和字段。", "ownerDesc": "可以添加/删除创始人。能编辑数据库结构和字段。",
"creatorDesc": "可以完全编辑数据库结构和值。", "creatorDesc": "可以自由编辑数据库结构和内容。",
"editorDesc": "可以编辑记录但无法更改数据库/字段的结构。", "editorDesc": "可以编辑记录但无法更改数据库/字段的结构。",
"commenterDesc": "可以查看和评论记录,但无法编辑任何内容", "commenterDesc": "可以查看和评论,但无法编辑任何内容",
"viewerDesc": "可以查看记录但无法编辑任何内容", "viewerDesc": "可以查看记录但无法编辑任何内容",
"addUser": "添加新用户", "addUser": "添加新用户",
"staticRoleInfo": "无法编辑系统定义的角色", "staticRoleInfo": "不允许修改系统权限",
"exportZip": "导出项目数据为 zip 格式并下载。", "exportZip": "以 zip 格式下载项目元数据。",
"importZip": "导入项目 zip 格式的元数据并重新启动。", "importZip": "导入 zip 格式的项目元数据并重新启动。",
"importText": "通过上传元数据 zip 文件导入 NocoDB 项目", "importText": "通过上传项目元数据 zip 文件导入 NocoDB 项目",
"metaNoChange": "没有确定更改", "metaNoChange": "没有发现变化",
"sqlMigration": "将自动创建架构迁移。创建一个表并刷新此页面。", "sqlMigration": "将自动创建架构迁移。创建一个表并刷新此页面。",
"dbConnectionStatus": "环境验证", "dbConnectionStatus": "环境验证",
"dbConnected": "连接成功", "dbConnected": "连接成功",
@ -602,7 +602,7 @@
"calendar": "添加日历视图" "calendar": "添加日历视图"
}, },
"tablesMetadataInSync": "表元数据同步", "tablesMetadataInSync": "表元数据同步",
"addMultipleUsers": "您可以添加多个逗号(,)分隔的电子邮件", "addMultipleUsers": "可以使用多个英文逗号 (,) 分隔邮箱地址",
"enterTableName": "输入表名", "enterTableName": "输入表名",
"addDefaultColumns": "添加默认列", "addDefaultColumns": "添加默认列",
"tableNameInDb": "数据库中保存的表名", "tableNameInDb": "数据库中保存的表名",
@ -629,18 +629,18 @@
"generatePublicShareableReadonlyBase": "Generate publicly shareable readonly base", "generatePublicShareableReadonlyBase": "Generate publicly shareable readonly base",
"deleteViewConfirmation": "您确定要删除此视图?", "deleteViewConfirmation": "您确定要删除此视图?",
"deleteTableConfirmation": "您想要删除该表吗?", "deleteTableConfirmation": "您想要删除该表吗?",
"showM2mTables": "Show M2M Tables", "showM2mTables": "显示中间表",
"deleteKanbanStackConfirmation": "Deleting this stack will also remove the select option `{stackToBeDeleted}` from the `{groupingField}`. The records will move to the uncategorized stack.", "deleteKanbanStackConfirmation": "删除这个类别标签也将从 \"{groupingField}\"中删除选择选项 \"{stackToBeDeleted}\"。这类记录将移到未分类的类别中。",
"computedFieldEditWarning": "Computed field: contents are read-only. Use column edit menu to reconfigure", "computedFieldEditWarning": "Computed field: contents are read-only. Use column edit menu to reconfigure",
"computedFieldDeleteWarning": "Computed field: contents are read-only. Unable to clear content.", "computedFieldDeleteWarning": "Computed field: contents are read-only. Unable to clear content.",
"noMoreRecords": "No more records" "noMoreRecords": "暂无数据"
}, },
"error": { "error": {
"searchProject": "搜索: {search} 没有发现匹配的结果", "searchProject": "搜索: {search} 没有发现匹配的结果",
"invalidChar": "文件夹路径中的字符无效。", "invalidChar": "文件夹路径中的字符无效。",
"invalidDbCredentials": "无效的数据库凭据。", "invalidDbCredentials": "无效的数据库凭据。",
"unableToConnectToDb": "无法连接到数据库,请检查您的数据库是否已启动。", "unableToConnectToDb": "无法连接到数据库,请检查您的数据库是否已启动。",
"userDoesntHaveSufficientPermission": "用户不存在或具有创建架构的足够权限。", "userDoesntHaveSufficientPermission": "用户不存在或无权创建模式。",
"dbConnectionStatus": "数据库参数无效", "dbConnectionStatus": "数据库参数无效",
"dbConnectionFailed": "连接失败:", "dbConnectionFailed": "连接失败:",
"signUpRules": { "signUpRules": {
@ -694,8 +694,8 @@
"parameterKeyCannotBeEmpty": "参数键不能为空", "parameterKeyCannotBeEmpty": "参数键不能为空",
"duplicateParameterKeysAreNotAllowed": "不允许重复的参数键", "duplicateParameterKeysAreNotAllowed": "不允许重复的参数键",
"fieldRequired": "{value} 不能为空。", "fieldRequired": "{value} 不能为空。",
"projectNotAccessible": "Project not accessible", "projectNotAccessible": "无权访问此项目",
"copyToClipboardError": "Failed to copy to clipboard" "copyToClipboardError": "未能复制到剪贴板"
}, },
"toast": { "toast": {
"exportMetadata": "项目元数据成功导出", "exportMetadata": "项目元数据成功导出",
@ -715,7 +715,7 @@
"futureRelease": "即将推出!" "futureRelease": "即将推出!"
}, },
"success": { "success": {
"columnDuplicated": "Column duplicated successfully", "columnDuplicated": "此列的副本创建成功",
"updatedUIACL": "已成功更新表的 UI ACL", "updatedUIACL": "已成功更新表的 UI ACL",
"pluginUninstalled": "插件卸载成功", "pluginUninstalled": "插件卸载成功",
"pluginSettingsSaved": "插件设置保存成功", "pluginSettingsSaved": "插件设置保存成功",
@ -726,19 +726,19 @@
"tableDataExported": "成功导出所有表内数据", "tableDataExported": "成功导出所有表内数据",
"updated": "更新成功", "updated": "更新成功",
"sharedViewDeleted": "共享视图删除成功", "sharedViewDeleted": "共享视图删除成功",
"userDeleted": "User deleted successfully", "userDeleted": "删除用户成功",
"viewRenamed": "视图重命名成功", "viewRenamed": "视图重命名成功",
"tokenGenerated": "令牌生成成功", "tokenGenerated": "令牌生成成功",
"tokenDeleted": "Token deleted successfully", "tokenDeleted": "成功删除令牌",
"userAddedToProject": "添加用户到项目成功", "userAddedToProject": "添加用户成功",
"userAdded": "Successfully added user", "userAdded": "添加用户成功",
"userDeletedFromProject": "从项目中删除用户成功", "userDeletedFromProject": "踢出用户成功",
"inviteEmailSent": "邀请邮件发送成功", "inviteEmailSent": "邀请邮件发送成功",
"inviteURLCopied": "邀请URL已复制到剪贴板", "inviteURLCopied": "邀请URL已复制到剪贴板",
"passwordResetURLCopied": "Password reset URL copied to clipboard", "passwordResetURLCopied": "密码重置网址已复制到剪贴板",
"shareableURLCopied": "已将可共享的基础URL复制到剪贴板!", "shareableURLCopied": "已将可共享的基础URL复制到剪贴板!",
"embeddableHTMLCodeCopied": "已复制可嵌入的 HTML 代码!", "embeddableHTMLCodeCopied": "已复制可嵌入的 HTML 代码!",
"userDetailsUpdated": "成功更新用户详细信息", "userDetailsUpdated": "成功更新用户详",
"tableDataImported": "成功导入表数据", "tableDataImported": "成功导入表数据",
"webhookUpdated": "Webhook 详细信息更新成功", "webhookUpdated": "Webhook 详细信息更新成功",
"webhookDeleted": "Hook 删除成功", "webhookDeleted": "Hook 删除成功",
@ -746,8 +746,8 @@
"columnUpdated": "列已更新", "columnUpdated": "列已更新",
"columnCreated": "已创建列", "columnCreated": "已创建列",
"passwordChanged": "密码修改成功。请重新登录。", "passwordChanged": "密码修改成功。请重新登录。",
"settingsSaved": "Settings saved successfully", "settingsSaved": "设置保存成功",
"roleUpdated": "Role updated successfully" "roleUpdated": "权限已更新"
} }
} }
} }

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

@ -29,7 +29,7 @@
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"locale-codes": "^1.3.1", "locale-codes": "^1.3.1",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"nocodb-sdk": "0.104.2", "nocodb-sdk": "file:../nocodb-sdk",
"papaparse": "^5.3.2", "papaparse": "^5.3.2",
"qrcode": "^1.5.1", "qrcode": "^1.5.1",
"socket.io-client": "^4.5.1", "socket.io-client": "^4.5.1",
@ -97,7 +97,6 @@
}, },
"../nocodb-sdk": { "../nocodb-sdk": {
"version": "0.104.2", "version": "0.104.2",
"extraneous": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"axios": "^0.21.1", "axios": "^0.21.1",
@ -8545,6 +8544,7 @@
"version": "1.15.1", "version": "1.15.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==",
"devOptional": true,
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@ -11960,21 +11960,8 @@
} }
}, },
"node_modules/nocodb-sdk": { "node_modules/nocodb-sdk": {
"version": "0.104.2", "resolved": "../nocodb-sdk",
"resolved": "https://registry.npmjs.org/nocodb-sdk/-/nocodb-sdk-0.104.2.tgz", "link": true
"integrity": "sha512-D5uONEIcx4gFGLBo42AOr4u0ejEG4BvDvRY0pY6EaoEMSbpvWQIia2XTgiAg9JPYuz2AKxSwWYR2AP6H52Nzjg==",
"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": { "node_modules/node-abi": {
"version": "3.23.0", "version": "3.23.0",
@ -23956,7 +23943,8 @@
"follow-redirects": { "follow-redirects": {
"version": "1.15.1", "version": "1.15.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==" "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==",
"devOptional": true
}, },
"form-data": { "form-data": {
"version": "4.0.0", "version": "4.0.0",
@ -26429,22 +26417,22 @@
} }
}, },
"nocodb-sdk": { "nocodb-sdk": {
"version": "0.104.2", "version": "file:../nocodb-sdk",
"resolved": "https://registry.npmjs.org/nocodb-sdk/-/nocodb-sdk-0.104.2.tgz",
"integrity": "sha512-D5uONEIcx4gFGLBo42AOr4u0ejEG4BvDvRY0pY6EaoEMSbpvWQIia2XTgiAg9JPYuz2AKxSwWYR2AP6H52Nzjg==",
"requires": { "requires": {
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"axios": "^0.21.1", "axios": "^0.21.1",
"jsep": "^1.3.6" "cspell": "^4.1.0",
}, "eslint": "^7.8.0",
"dependencies": { "eslint-config-prettier": "^6.11.0",
"axios": { "eslint-plugin-eslint-comments": "^3.2.0",
"version": "0.21.4", "eslint-plugin-functional": "^3.0.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", "eslint-plugin-import": "^2.22.0",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", "eslint-plugin-prettier": "^4.0.0",
"requires": { "jsep": "^1.3.6",
"follow-redirects": "^1.14.0" "npm-run-all": "^4.1.5",
} "prettier": "^2.1.1",
} "typescript": "^4.0.2"
} }
}, },
"node-abi": { "node-abi": {

2
packages/nc-gui/package.json

@ -52,7 +52,7 @@
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"locale-codes": "^1.3.1", "locale-codes": "^1.3.1",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"nocodb-sdk": "0.104.2", "nocodb-sdk": "file:../nocodb-sdk",
"papaparse": "^5.3.2", "papaparse": "^5.3.2",
"qrcode": "^1.5.1", "qrcode": "^1.5.1",
"socket.io-client": "^4.5.1", "socket.io-client": "^4.5.1",

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

@ -18,7 +18,7 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
</div> </div>
<div class="min-w-2/4 xl:max-w-2/4 w-full mx-auto"> <div class="min-w-2/4 xl:max-w-2/4 w-full mx-auto">
<NuxtPage /> <NuxtPage :transition="false" />
</div> </div>
<div <div

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

@ -1,11 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Form } from 'ant-design-vue' import type { Form, Input } from 'ant-design-vue'
import type { RuleObject } from 'ant-design-vue/es/form' import type { RuleObject } from 'ant-design-vue/es/form'
import type { VNodeRef } from '@vue/runtime-core' import type { VNodeRef } from '@vue/runtime-core'
import { import {
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
generateUniqueName,
message, message,
navigateTo, navigateTo,
nextTick,
onMounted,
projectTitleValidator, projectTitleValidator,
reactive, reactive,
ref, ref,
@ -47,7 +50,14 @@ const createProject = async () => {
} }
} }
const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus() const input: VNodeRef = ref<typeof Input>()
onMounted(async () => {
formState.title = await generateUniqueName()
await nextTick()
input.value?.$el?.focus()
input.value?.$el?.select()
})
</script> </script>
<template> <template>
@ -76,7 +86,7 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@finish="createProject" @finish="createProject"
> >
<a-form-item :label="$t('labels.projName')" name="title" :rules="nameValidationRules" class="m-10"> <a-form-item :label="$t('labels.projName')" name="title" :rules="nameValidationRules" class="m-10">
<a-input :ref="focus" v-model:value="formState.title" name="title" class="nc-metadb-project-name" /> <a-input ref="input" v-model:value="formState.title" name="title" class="nc-metadb-project-name" />
</a-form-item> </a-form-item>
<div class="text-center"> <div class="text-center">

1
packages/nc-gui/utils/columnUtils.ts

@ -214,6 +214,7 @@ const isTypableInputColumn = (colOrUidt: ColumnType | UITypes) => {
UITypes.Percent, UITypes.Percent,
UITypes.Duration, UITypes.Duration,
UITypes.JSON, UITypes.JSON,
UITypes.URL,
].includes(uidt) ].includes(uidt)
} }

4
packages/nocodb-sdk/package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "nocodb-sdk", "name": "nocodb-sdk",
"version": "0.104.1", "version": "0.104.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nocodb-sdk", "name": "nocodb-sdk",
"version": "0.104.1", "version": "0.104.2",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"axios": "^0.21.1", "axios": "^0.21.1",

30
packages/nocodb/package-lock.json generated

@ -68,7 +68,7 @@
"nc-lib-gui": "0.104.2", "nc-lib-gui": "0.104.2",
"nc-plugin": "0.1.2", "nc-plugin": "0.1.2",
"ncp": "^2.0.0", "ncp": "^2.0.0",
"nocodb-sdk": "0.104.2", "nocodb-sdk": "file:../nocodb-sdk",
"nodemailer": "^6.4.10", "nodemailer": "^6.4.10",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"os-locale": "^5.0.0", "os-locale": "^5.0.0",
@ -154,7 +154,6 @@
}, },
"../nocodb-sdk": { "../nocodb-sdk": {
"version": "0.104.2", "version": "0.104.2",
"extraneous": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"axios": "^0.21.1", "axios": "^0.21.1",
@ -11223,13 +11222,8 @@
"dev": true "dev": true
}, },
"node_modules/nocodb-sdk": { "node_modules/nocodb-sdk": {
"version": "0.104.2", "resolved": "../nocodb-sdk",
"resolved": "https://registry.npmjs.org/nocodb-sdk/-/nocodb-sdk-0.104.2.tgz", "link": true
"integrity": "sha512-D5uONEIcx4gFGLBo42AOr4u0ejEG4BvDvRY0pY6EaoEMSbpvWQIia2XTgiAg9JPYuz2AKxSwWYR2AP6H52Nzjg==",
"dependencies": {
"axios": "^0.21.1",
"jsep": "^1.3.6"
}
}, },
"node_modules/node-abort-controller": { "node_modules/node-abort-controller": {
"version": "3.0.1", "version": "3.0.1",
@ -27742,12 +27736,22 @@
"dev": true "dev": true
}, },
"nocodb-sdk": { "nocodb-sdk": {
"version": "0.104.2", "version": "file:../nocodb-sdk",
"resolved": "https://registry.npmjs.org/nocodb-sdk/-/nocodb-sdk-0.104.2.tgz",
"integrity": "sha512-D5uONEIcx4gFGLBo42AOr4u0ejEG4BvDvRY0pY6EaoEMSbpvWQIia2XTgiAg9JPYuz2AKxSwWYR2AP6H52Nzjg==",
"requires": { "requires": {
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"axios": "^0.21.1", "axios": "^0.21.1",
"jsep": "^1.3.6" "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"
} }
}, },
"node-abort-controller": { "node-abort-controller": {

4
packages/nocodb/package.json

@ -108,7 +108,7 @@
"nc-lib-gui": "0.104.2", "nc-lib-gui": "0.104.2",
"nc-plugin": "0.1.2", "nc-plugin": "0.1.2",
"ncp": "^2.0.0", "ncp": "^2.0.0",
"nocodb-sdk": "0.104.2", "nocodb-sdk": "file:../nocodb-sdk",
"nodemailer": "^6.4.10", "nodemailer": "^6.4.10",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"os-locale": "^5.0.0", "os-locale": "^5.0.0",
@ -183,4 +183,4 @@
"prettier": { "prettier": {
"singleQuote": true "singleQuote": true
} }
} }

2
packages/nocodb/src/lib/Noco.ts

@ -104,7 +104,7 @@ export default class Noco {
constructor() { constructor() {
process.env.PORT = process.env.PORT || '8080'; process.env.PORT = process.env.PORT || '8080';
// todo: move // todo: move
process.env.NC_VERSION = '0101002'; process.env.NC_VERSION = '0104002';
// if env variable NC_MINIMAL_DBS is set, then disable project creation with external sources // if env variable NC_MINIMAL_DBS is set, then disable project creation with external sources
if (process.env.NC_MINIMAL_DBS) { if (process.env.NC_MINIMAL_DBS) {

4
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/CustomKnex.ts

@ -548,7 +548,9 @@ knex.QueryBuilder.extend(
knex.QueryBuilder.extend('concat', function (cn: any) { knex.QueryBuilder.extend('concat', function (cn: any) {
switch (this?.client?.config?.client) { switch (this?.client?.config?.client) {
case 'pg': case 'pg':
this.select(this.client.raw(`STRING_AGG(?? , ',')`, [cn])); this.select(
this.client.raw(`STRING_AGG(??::character varying , ',')`, [cn])
);
break; break;
case 'mysql': case 'mysql':
case 'mysql2': case 'mysql2':

14
packages/nocodb/src/lib/meta/api/attachmentApis.ts

@ -96,21 +96,25 @@ export async function uploadViaURL(req: Request, res: Response) {
req.body?.map?.(async (urlMeta) => { req.body?.map?.(async (urlMeta) => {
const { url, fileName: _fileName } = urlMeta; const { url, fileName: _fileName } = urlMeta;
const fileName = `${nanoid(6)}${_fileName || url.split('/').pop()}`; const fileName = `${nanoid(18)}${_fileName || url.split('/').pop()}`;
let attachmentUrl = await (storageAdapter as any).fileCreateByUrl( let attachmentUrl = await (storageAdapter as any).fileCreateByUrl(
slash(path.join(destPath, fileName)), slash(path.join(destPath, fileName)),
url url
); );
let attachmentPath;
// if `attachmentUrl` is null, then it is local attachment
if (!attachmentUrl) { if (!attachmentUrl) {
attachmentUrl = `${(req as any).ncSiteUrl}/download/${filePath.join( // then store the attachement path only
'/' // url will be constructued in `useAttachmentCell`
)}/${fileName}`; attachmentPath = `download/${filePath.join('/')}/${fileName}`;
} }
return { return {
url: attachmentUrl, ...(attachmentUrl ? { url: attachmentUrl } : {}),
...(attachmentPath ? { path: attachmentPath } : {}),
title: fileName, title: fileName,
mimetype: urlMeta.mimetype, mimetype: urlMeta.mimetype,
size: urlMeta.size, size: urlMeta.size,

5
packages/nocodb/src/lib/models/Model.ts

@ -441,11 +441,14 @@ export default class Model implements TableType {
const insertObj = {}; const insertObj = {};
for (const col of await this.getColumns()) { for (const col of await this.getColumns()) {
if (isVirtualCol(col)) continue; if (isVirtualCol(col)) continue;
const val = let val =
data?.[col.column_name] !== undefined data?.[col.column_name] !== undefined
? data?.[col.column_name] ? data?.[col.column_name]
: data?.[col.title]; : data?.[col.title];
if (val !== undefined) { if (val !== undefined) {
if (col.uidt === UITypes.Attachment && typeof val !== 'string') {
val = JSON.stringify(val);
}
insertObj[sanitize(col.column_name)] = val; insertObj[sanitize(col.column_name)] = val;
} }
} }

2
packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts

@ -10,6 +10,7 @@ import ncDataTypesUpgrader from './ncDataTypesUpgrader';
import ncProjectRolesUpgrader from './ncProjectRolesUpgrader'; import ncProjectRolesUpgrader from './ncProjectRolesUpgrader';
import ncFilterUpgrader from './ncFilterUpgrader'; import ncFilterUpgrader from './ncFilterUpgrader';
import ncAttachmentUpgrader from './ncAttachmentUpgrader'; import ncAttachmentUpgrader from './ncAttachmentUpgrader';
import ncAttachmentUpgrader_0104002 from './ncAttachmentUpgrader_0104002';
const log = debug('nc:version-upgrader'); const log = debug('nc:version-upgrader');
import boxen from 'boxen'; import boxen from 'boxen';
@ -39,6 +40,7 @@ export default class NcUpgrader {
{ name: '0098005', handler: ncProjectRolesUpgrader }, { name: '0098005', handler: ncProjectRolesUpgrader },
{ name: '0100002', handler: ncFilterUpgrader }, { name: '0100002', handler: ncFilterUpgrader },
{ name: '0101002', handler: ncAttachmentUpgrader }, { name: '0101002', handler: ncAttachmentUpgrader },
{ name: '0104002', handler: ncAttachmentUpgrader_0104002 },
]; ];
if (!(await ctx.ncMeta.knexConnection?.schema?.hasTable?.('nc_store'))) { if (!(await ctx.ncMeta.knexConnection?.schema?.hasTable?.('nc_store'))) {
return; return;

186
packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts

@ -1,10 +1,11 @@
import { Knex } from 'knex';
import { NcUpgraderCtx } from './NcUpgrader'; import { NcUpgraderCtx } from './NcUpgrader';
import { MetaTable } from '../utils/globals'; import { MetaTable } from '../utils/globals';
import Base from '../models/Base'; import Base from '../models/Base';
import Model from '../models/Model'; import Model from '../models/Model';
import { XKnex } from '../db/sql-data-mapper/index'; import { XKnex } from '../db/sql-data-mapper/index';
import NcConnectionMgrv2 from '../utils/common/NcConnectionMgrv2'; import NcConnectionMgrv2 from '../utils/common/NcConnectionMgrv2';
import { BaseType, UITypes } from 'nocodb-sdk' import { BaseType, UITypes } from 'nocodb-sdk';
// before 0.103.0, an attachment object was like // before 0.103.0, an attachment object was like
// [{ // [{
@ -29,7 +30,7 @@ function getTnPath(knex: XKnex, tb: Model) {
const schema = (knex as any).searchPath?.(); const schema = (knex as any).searchPath?.();
const clientType = knex.clientType(); const clientType = knex.clientType();
if (clientType === 'mssql' && schema) { if (clientType === 'mssql' && schema) {
return knex.raw('??.??', [schema, tb.table_name]); return knex.raw('??.??', [schema, tb.table_name]).toQuery();
} else if (clientType === 'snowflake') { } else if (clientType === 'snowflake') {
return [ return [
knex.client.config.connection.database, knex.client.config.connection.database,
@ -45,82 +46,133 @@ export default async function ({ ncMeta }: NcUpgraderCtx) {
const bases: BaseType[] = await ncMeta.metaList2(null, null, MetaTable.BASES); const bases: BaseType[] = await ncMeta.metaList2(null, null, MetaTable.BASES);
for (const _base of bases) { for (const _base of bases) {
const base = new Base(_base); const base = new Base(_base);
const knex: XKnex = base.is_meta
// skip if the project_id is missing
if (!base.project_id) {
continue;
}
const project = await ncMeta.metaGet2(null, null, MetaTable.PROJECT, {
id: base.project_id,
});
// skip if the project is missing
if (!project) {
continue;
}
const isProjectDeleted = project.deleted;
const knex: Knex = base.is_meta
? ncMeta.knexConnection ? ncMeta.knexConnection
: NcConnectionMgrv2.get(base); : NcConnectionMgrv2.get(base);
const models = await base.getModels(ncMeta); const models = await base.getModels(ncMeta);
for (const model of models) { for (const model of models) {
const updateRecords = []; try {
const columns = await ( // if the table is missing in database, skip
await Model.get(model.id, ncMeta) if (!(await knex.schema.hasTable(getTnPath(knex, model)))) {
).getColumns(ncMeta); continue;
const attachmentColumns = columns }
.filter((c) => c.uidt === UITypes.Attachment)
.map((c) => c.column_name);
if (attachmentColumns.length === 0) {
continue;
}
const primaryKeys = columns.filter((c) => c.pk).map((c) => c.column_name);
const records = await knex(getTnPath(knex, model)).select([
...primaryKeys,
...attachmentColumns,
]);
for (const record of records) {
for (const attachmentColumn of attachmentColumns) {
let attachmentMeta: Array<{
url: string;
}>;
// if parsing failed ignore the cell
try {
attachmentMeta =
typeof record[attachmentColumn] === 'string'
? JSON.parse(record[attachmentColumn])
: record[attachmentColumn];
} catch {}
// if cell data is not an array, ignore it
if (!Array.isArray(attachmentMeta)) {
continue;
}
if (attachmentMeta) { const updateRecords = [];
const newAttachmentMeta = [];
for (const attachment of attachmentMeta) { // get all attachment & primary key columns
if ('url' in attachment && typeof attachment.url === 'string') { // and filter out the columns that are missing in database
const match = attachment.url.match(/^(.*)\/download\/(.*)$/); const columns = await (await Model.get(model.id, ncMeta))
if (match) { .getColumns(ncMeta)
// e.g. http://localhost:8080/download/noco/xcdb/Sheet-1/title5/ee2G8p_nute_gunray.png .then(async (columns) => {
// match[1] = http://localhost:8080 const filteredColumns = [];
// match[2] = download/noco/xcdb/Sheet-1/title5/ee2G8p_nute_gunray.png
const path = `download/${match[2]}`; for (const column of columns) {
if (column.uidt !== UITypes.Attachment && !column.pk) continue;
newAttachmentMeta.push({ if (
...attachment, !(await knex.schema.hasColumn(
path, getTnPath(knex, model),
}); column.column_name
} else { ))
// keep it as it is )
newAttachmentMeta.push(attachment); continue;
filteredColumns.push(column);
}
return filteredColumns;
});
const attachmentColumns = columns
.filter((c) => c.uidt === UITypes.Attachment)
.map((c) => c.column_name);
if (attachmentColumns.length === 0) {
continue;
}
const primaryKeys = columns
.filter((c) => c.pk)
.map((c) => c.column_name);
const records = await knex(getTnPath(knex, model)).select();
for (const record of records) {
for (const attachmentColumn of attachmentColumns) {
let attachmentMeta: Array<{
url: string;
}>;
// if parsing failed ignore the cell
try {
attachmentMeta =
typeof record[attachmentColumn] === 'string'
? JSON.parse(record[attachmentColumn])
: record[attachmentColumn];
} catch {}
// if cell data is not an array, ignore it
if (!Array.isArray(attachmentMeta)) {
continue;
}
if (attachmentMeta) {
const newAttachmentMeta = [];
for (const attachment of attachmentMeta) {
if ('url' in attachment && typeof attachment.url === 'string') {
const match = attachment.url.match(/^(.*)\/download\/(.*)$/);
if (match) {
// e.g. http://localhost:8080/download/noco/xcdb/Sheet-1/title5/ee2G8p_nute_gunray.png
// match[1] = http://localhost:8080
// match[2] = download/noco/xcdb/Sheet-1/title5/ee2G8p_nute_gunray.png
const path = `download/${match[2]}`;
newAttachmentMeta.push({
...attachment,
path,
});
} else {
// keep it as it is
newAttachmentMeta.push(attachment);
}
} }
} }
} const where = primaryKeys
const where = primaryKeys .map((key) => {
.map((key) => { return { [key]: record[key] };
return { [key]: record[key] };
})
.reduce((acc, val) => Object.assign(acc, val), {});
updateRecords.push(
await knex(getTnPath(knex, model))
.update({
[attachmentColumn]: JSON.stringify(newAttachmentMeta),
}) })
.where(where) .reduce((acc, val) => Object.assign(acc, val), {});
); updateRecords.push(
await knex(getTnPath(knex, model))
.update({
[attachmentColumn]: JSON.stringify(newAttachmentMeta),
})
.where(where)
);
}
} }
} }
await Promise.all(updateRecords);
} catch (e) {
// ignore the error related to deleted project
if (!isProjectDeleted) {
throw e;
}
} }
await Promise.all(updateRecords);
} }
} }
} }

159
packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader_0104002.ts

@ -0,0 +1,159 @@
import { Knex } from 'knex';
import { NcUpgraderCtx } from './NcUpgrader';
import { MetaTable } from '../utils/globals';
import Base from '../models/Base';
import Model from '../models/Model';
import { XKnex } from '../db/sql-data-mapper/index';
import NcConnectionMgrv2 from '../utils/common/NcConnectionMgrv2';
import { BaseType, UITypes } from 'nocodb-sdk';
// after 0101002 upgrader, the attachment object would become broken when
// (1) switching views after updating a singleSelect field
// since `url` will be enriched the attachment cell, and `saveOrUpdateRecords` in Grid.vue will be triggered
// in this way, the attachment value will be corrupted like
// {"{\"path\":\"download/noco/xcdb2/attachment2/a/JRubxMQgPlcumdm3jL.jpeg\",\"title\":\"haha.jpeg\",\"mimetype\":\"image/jpeg\",\"size\":6494,\"url\":\"http://localhost:8080/download/noco/xcdb2/attachment2/a/JRubxMQgPlcumdm3jL.jpeg\"}"}
// while the expected one is
// [{"path":"download/noco/xcdb2/attachment2/a/JRubxMQgPlcumdm3jL.jpeg","title":"haha.jpeg","mimetype":"image/jpeg","size":6494}]
// (2) or reordering attachments
// since the incoming value is not string, the value will be broken
// hence, this upgrader is to revert back these corrupted values
function getTnPath(knex: XKnex, tb: Model) {
const schema = (knex as any).searchPath?.();
const clientType = knex.clientType();
if (clientType === 'mssql' && schema) {
return knex.raw('??.??', [schema, tb.table_name]).toQuery();
} else if (clientType === 'snowflake') {
return [
knex.client.config.connection.database,
knex.client.config.connection.schema,
tb.table_name,
].join('.');
} else {
return tb.table_name;
}
}
export default async function ({ ncMeta }: NcUpgraderCtx) {
const bases: BaseType[] = await ncMeta.metaList2(null, null, MetaTable.BASES);
for (const _base of bases) {
const base = new Base(_base);
// skip if the project_id is missing
if (!base.project_id) {
continue;
}
const project = await ncMeta.metaGet2(null, null, MetaTable.PROJECT, {
id: base.project_id,
});
// skip if the project is missing
if (!project) {
continue;
}
const isProjectDeleted = project.deleted;
const knex: Knex = base.is_meta
? ncMeta.knexConnection
: NcConnectionMgrv2.get(base);
const models = await base.getModels(ncMeta);
for (const model of models) {
try {
// if the table is missing in database, skip
if (!(await knex.schema.hasTable(getTnPath(knex, model)))) {
continue;
}
const updateRecords = [];
// get all attachment & primary key columns
// and filter out the columns that are missing in database
const columns = await (await Model.get(model.id, ncMeta))
.getColumns(ncMeta)
.then(async (columns) => {
const filteredColumns = [];
for (const column of columns) {
if (column.uidt !== UITypes.Attachment && !column.pk) continue;
if (
!(await knex.schema.hasColumn(
getTnPath(knex, model),
column.column_name
))
)
continue;
filteredColumns.push(column);
}
return filteredColumns;
});
const attachmentColumns = columns
.filter((c) => c.uidt === UITypes.Attachment)
.map((c) => c.column_name);
if (attachmentColumns.length === 0) {
continue;
}
const primaryKeys = columns
.filter((c) => c.pk)
.map((c) => c.column_name);
const records = await knex(getTnPath(knex, model)).select();
for (const record of records) {
const where = primaryKeys
.map((key) => {
return { [key]: record[key] };
})
.reduce((acc, val) => Object.assign(acc, val), {});
for (const attachmentColumn of attachmentColumns) {
if (typeof record[attachmentColumn] === 'string') {
// potentially corrupted
try {
JSON.parse(record[attachmentColumn]);
// it works fine - skip
continue;
} catch {
try {
// corrupted
let corruptedAttachment = record[attachmentColumn];
// replace the first and last character with `[` and `]`
// and parse it again
corruptedAttachment = JSON.parse(
`[${corruptedAttachment.slice(
1,
corruptedAttachment.length - 1
)}]`
);
let newAttachmentMeta = [];
for (const attachment of corruptedAttachment) {
newAttachmentMeta.push(JSON.parse(attachment));
}
updateRecords.push(
await knex(getTnPath(knex, model))
.update({
[attachmentColumn]: JSON.stringify(newAttachmentMeta),
})
.where(where)
);
} catch {
// if parsing failed ignore the cell
continue;
}
}
}
}
}
await Promise.all(updateRecords);
} catch (e) {
// ignore the error related to deleted project
if (!isProjectDeleted) {
throw e;
}
}
}
}
}

43
tests/playwright/pages/Dashboard/Grid/index.ts

@ -308,18 +308,41 @@ export class GridPage extends BasePage {
} }
async copyWithKeyboard() { async copyWithKeyboard() {
await this.get().press((await this.isMacOs()) ? 'Meta+C' : 'Control+C'); // retry to avoid flakiness, until text is copied to clipboard
await this.verifyToast({ message: 'Copied to clipboard' }); //
let text = '';
return this.getClipboardText(); let retryCount = 5;
while (text === '') {
await this.get().press((await this.isMacOs()) ? 'Meta+C' : 'Control+C');
await this.verifyToast({ message: 'Copied to clipboard' });
text = await this.getClipboardText();
// retry if text is empty till count is reached
retryCount--;
if (0 === retryCount) {
break;
}
}
return text;
} }
async copyWithMouse({ index, columnHeader }: CellProps) { async copyWithMouse({ index, columnHeader }: CellProps) {
await this.cell.get({ index, columnHeader }).click({ button: 'right' }); // retry to avoid flakiness, until text is copied to clipboard
await this.get().page().getByTestId('context-menu-item-copy').click(); //
let text = '';
await this.verifyToast({ message: 'Copied to clipboard' }); let retryCount = 5;
while (text === '') {
return this.getClipboardText(); await this.cell.get({ index, columnHeader }).click({ button: 'right' });
await this.get().page().getByTestId('context-menu-item-copy').click();
await this.verifyToast({ message: 'Copied to clipboard' });
text = await this.getClipboardText();
// retry if text is empty till count is reached
retryCount--;
if (0 === retryCount) {
break;
}
}
return text;
} }
} }

10
tests/playwright/tests/columnMultiSelect.spec.ts

@ -76,18 +76,22 @@ test.describe('Multi select', () => {
multiSelect: true, multiSelect: true,
}); });
await grid.column.selectOption.editOption({ index: 2, columnTitle: 'MultiSelect', newOption: 'New Option 3' }); await grid.column.selectOption.editOption({
index: 2,
columnTitle: 'MultiSelect',
newOption: 'MultiSelect New Option 3',
});
await grid.cell.selectOption.verify({ await grid.cell.selectOption.verify({
index: 0, index: 0,
columnHeader: 'MultiSelect', columnHeader: 'MultiSelect',
option: 'New Option 3', option: 'MultiSelect New Option 3',
multiSelect: true, multiSelect: true,
}); });
await grid.cell.selectOption.verifyOptions({ await grid.cell.selectOption.verifyOptions({
index: 0, index: 0,
columnHeader: 'MultiSelect', columnHeader: 'MultiSelect',
options: ['Option 1', 'Option 2', 'New Option 3'], options: ['Option 1', 'Option 2', 'MultiSelect New Option 3'],
}); });
await grid.deleteRow(0); await grid.deleteRow(0);

Loading…
Cancel
Save