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
attributes:
label: Attachements
description: Add any relevant attachemnts here
label: Attachments
description: Add any relevant attachment here
placeholder: |
> Drag & drop relevant image or videos
validations:

12
.github/workflows/release-nocodb.yml

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

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

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

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

@ -63,7 +63,7 @@ useSelectedCellKeyupListener(active, (e) => {
<template>
<div
class="flex cursor-pointer"
class="flex cursor-pointer w-full h-full"
:class="{
'justify-center': !isForm,
'w-full': isForm,
@ -72,7 +72,7 @@ useSelectedCellKeyupListener(active, (e) => {
}"
@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">
<component
: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>
import { onUnmounted } from '@vue/runtime-core'
import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType, SelectOptionsType } from 'nocodb-sdk'
import {
ActiveCellInj,
CellClickHookInj,
ColumnInj,
IsKanbanInj,
ReadonlyInj,
@ -13,6 +15,7 @@ import {
extractSdkResponseErrorMsg,
h,
inject,
isDrawerOrModalExist,
onMounted,
reactive,
ref,
@ -44,6 +47,8 @@ const editable = inject(EditModeInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const selectedIds = ref<string[]>([])
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 editAllowed = computed(() => hasEditRoles.value && (active.value || editable.value))
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && (active.value || editable.value))
const vModel = computed({
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(() => {
selectedIds.value = selectedTitles.value.flatMap((el) => {
const item = options.value.find((op) => op.title === el)
@ -144,8 +143,6 @@ onMounted(() => {
})
})
useEventListener(document, 'click', handleClose)
watch(
() => modelValue,
() => {
@ -161,6 +158,8 @@ watch(
)
watch(isOpen, (n, _o) => {
if (!n) searchVal.value = ''
if (editAllowed.value) {
if (!n) {
aselect.value?.$el?.querySelector('input')?.blur()
@ -180,6 +179,9 @@ useSelectedCellKeyupListener(active, (e) => {
isOpen.value = true
}
break
// skip space bar key press since it's used for expand row
case ' ':
break
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowRight':
@ -193,7 +195,7 @@ useSelectedCellKeyupListener(active, (e) => {
break
}
// 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()
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)
async function addIfMissingAndSave() {
@ -273,95 +280,126 @@ const onTagClick = (e: Event, onClose: Function) => {
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>
<template>
<a-select
ref="aselect"
v-model:value="vModel"
v-model:open="isOpen"
mode="multiple"
class="w-full overflow-hidden"
:bordered="false"
clear-icon
show-search
:show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel.length === 0))"
:open="isOpen && (active || editable)"
:disabled="readOnly"
:class="{ '!ml-[-8px]': readOnly, 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-multi-select-cell ${isOpen ? 'active' : ''}`"
@search="search"
@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
<div class="nc-multi-select h-full w-full flex items-center" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<a-select
ref="aselect"
v-model:value="vModel"
mode="multiple"
class="w-full overflow-hidden"
:bordered="false"
clear-icon
show-search
:show-arrow="editAllowed && !readOnly"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-multi-select-cell ${isOpen ? 'active' : ''}`"
@search="search"
@keydown.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>
</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"
<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
>
<span
:style="{
'color': tinycolor.isReadable(options.find((el) => el.title === val)?.color || '#ccc', '#fff', {
level: 'AA',
size: 'large',
})
? '#fff'
: tinycolor
.mostReadable(options.find((el) => el.title === val)?.color || '#ccc', ['#0b1d05', '#fff'])
.toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
<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>
</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>
</a-tag>
</template>
</a-select>
<span
:style="{
'color': tinycolor.isReadable(options.find((el) => el.title === val)?.color || '#ccc', '#fff', {
level: 'AA',
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>
<style scoped>
<style scoped lang="scss">
.ms-close-icon {
color: rgba(0, 0, 0, 0.25);
cursor: pointer;
@ -404,6 +442,21 @@ const onTagClick = (e: Event, onClose: Function) => {
}
: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>

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

@ -1,18 +1,22 @@
<script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType } from 'nocodb-sdk'
import {
ActiveCellInj,
CellClickHookInj,
ColumnInj,
EditModeInj,
IsFormInj,
IsKanbanInj,
ReadonlyInj,
computed,
enumColor,
extractSdkResponseErrorMsg,
inject,
isDrawerOrModalExist,
ref,
useRoles,
useSelectedCellKeyupListener,
@ -44,6 +48,8 @@ const isKanban = inject(IsKanbanInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const { $api } = useNuxtApp()
const searchVal = ref()
@ -78,7 +84,7 @@ const isOptionMissing = computed(() => {
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({
get: () => tempSelectedOptState.value ?? modelValue,
@ -113,13 +119,16 @@ useSelectedCellKeyupListener(active, (e) => {
isOpen.value = true
}
break
// skip space bar key press since it's used for expand row
case ' ':
break
default:
if (!editAllowed.value) {
e.preventDefault()
break
}
// 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()
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() {
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
}
// 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) => {
// todo: refactor
// check clicked element is clear icon
@ -185,64 +215,84 @@ const toggleMenu = (e: Event) => {
(e.target as HTMLElement)?.closest('.ant-select-clear')
) {
vModel.value = ''
return
return e.stopPropagation()
}
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) => {
if (isOpen.value && aselect.value && !aselect.value.$el.contains(e.target)) {
isOpen.value = false
}
}
useEventListener(document, 'click', handleClose, true)
</script>
<template>
<a-select
ref="aselect"
v-model:value="vModel"
class="w-full"
:class="{ 'caret-transparent': !hasEditRoles }"
:allow-clear="!column.rqd && editAllowed"
:bordered="false"
:open="isOpen"
:disabled="readOnly"
:show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel === null))"
:dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen ? 'active' : ''}`"
show-search
@select="isOpen = false"
@keydown.stop
@search="search"
@click="toggleMenu"
>
<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="h-full w-full flex items-center nc-single-select" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<a-select
ref="aselect"
v-model:value="vModel"
class="w-full"
:class="{ 'caret-transparent': !hasEditRoles }"
:allow-clear="!column.rqd && editAllowed"
:bordered="false"
:open="isOpen && (active || editable)"
:disabled="readOnly || !(active || editable)"
:show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel === null))"
:dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen && (active || editable) ? 'active' : ''}`"
:show-search="isOpen && (active || editable)"
@select="onSelect"
@keydown="onKeydown($event)"
@search="search"
>
<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>
<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">
<MdiPlusThick class="min-w-4" />
<div class="text-xs whitespace-normal">
Create new option named <strong>{{ searchVal }}</strong>
</div>
</div>
</div>
</a-select-option>
</a-select>
</a-select-option>
</a-select>
</div>
</template>
<style scoped lang="scss">
@ -257,4 +307,19 @@ const toggleMenu = (e: Event) => {
:deep(.ant-select-clear) {
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>

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

@ -79,7 +79,7 @@ watch(
v-if="editEnabled"
:ref="focus"
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"
@keydown.down.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'))
}
if (
!(tables?.value || []).every(
(t) => t.id === tableMeta.id || t.table_name.toLowerCase() !== (value || '').toLowerCase(),
)
!(tables?.value || []).every((t) => t.id === tableMeta.id || t.title.toLowerCase() !== (value || '').toLowerCase())
) {
return reject(new Error('Duplicate table alias'))
}

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

@ -1,9 +1,8 @@
<script setup lang="ts">
import type { ColumnType, GridType } from 'nocodb-sdk'
import type { ColumnType } from 'nocodb-sdk'
import { isSystemColumn } from 'nocodb-sdk'
import {
ActiveCellInj,
ActiveViewInj,
ColumnInj,
EditModeInj,
IsFormInj,
@ -61,8 +60,6 @@ const props = defineProps<Props>()
const emit = defineEmits(['update:modelValue', 'save', 'navigate', 'update:editEnabled'])
const view = inject(ActiveViewInj, ref())
const column = toRef(props, 'column')
const active = toRef(props, 'active', false)
@ -79,6 +76,8 @@ provide(ReadonlyInj, readOnly)
const isForm = inject(IsFormInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const isLocked = inject(IsLockedInj, ref(false))
@ -110,7 +109,6 @@ const vModel = computed({
syncValue()
} else if (!isManualSaved(column.value)) {
emit('save')
currentRow.value.rowMeta.changed = true
}
}
},
@ -128,32 +126,25 @@ const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
if (!isForm.value) e.stopImmediatePropagation()
}
const rowHeight = computed(() => {
if ((view.value?.view as GridType)?.row_height !== undefined) {
switch ((view.value?.view as GridType)?.row_height) {
case 0:
return 1
case 1:
return 2
case 2:
return 4
case 3:
return 6
default:
return 1
}
}
const isNumericField = computed(() => {
return (
isInt(column.value, abstractType.value) ||
isFloat(column.value, abstractType.value) ||
isDecimal(column.value) ||
isCurrency(column.value) ||
isPercent(column.value) ||
isDuration(column.value)
)
})
</script>
<template>
<div
class="nc-cell w-full"
class="nc-cell w-full h-full"
:class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
{ 'text-blue-600': isPrimary(column) && !props.virtual && !isForm },
{ 'm-y-auto !h-auto': !rowHeight || rowHeight === 1 },
{ '!h-full': rowHeight && rowHeight !== 1 },
{ 'nc-grid-numeric-cell': isGrid && !isForm && isNumericField },
]"
@keydown.enter.exact="syncAndNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="syncAndNavigate(NavigateDir.PREV, $event)"
@ -190,3 +181,12 @@ const rowHeight = computed(() => {
</template>
</div>
</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)"
:name="element.title"
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
v-model="formState[element.title]"
@ -679,7 +684,12 @@ watch(view, (nextView) => {
v-else
:name="element.title"
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
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 expandedFormRow = ref<Row>()
const expandedFormRowState = ref<Record<string, any>>()
const tbodyEl = ref<HTMLElement>()
const gridWrapper = 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 {
isLoading,
@ -182,6 +181,8 @@ const {
clearSelectedRange,
copyValue,
isCellActive,
tbodyEl,
resetSelectedRange,
} = useMultiSelect(
meta,
fields,
@ -279,6 +280,17 @@ const {
if (isAddingEmptyRowAllowed) {
$e('c:shortcut', { key: 'ALT + R' })
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
}
@ -483,7 +495,7 @@ useEventListener(document, 'keyup', async (e: KeyboardEvent) => {
/** On clicking outside of table reset active cell */
const smartTable = ref(null)
onClickOutside(smartTable, (e) => {
onClickOutside(tbodyEl, (e) => {
// do nothing if context menu was open
if (contextMenu.value) return
@ -558,11 +570,14 @@ const saveOrUpdateRecords = async (args: { metaValue?: TableType; viewMetaValue?
currentRow.rowMeta.changed = false
continue
}
/** if existing row check updated cell and invoke update method */
if (currentRow.rowMeta.changed) {
currentRow.rowMeta.changed = false
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!]) {
await updateOrSaveRow(currentRow, field.title!, {}, args)
}
@ -699,7 +714,7 @@ const rowHeight = computed(() => {
@contextmenu="showContextMenu"
>
<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">
<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">
@ -830,6 +845,8 @@ const rowHeight = computed(() => {
:class="{
'active': hasEditPermission && isCellSelected(rowIndex, colIndex),
'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row),
'align-middle': !rowHeight || rowHeight === 1,
'align-top': rowHeight && rowHeight !== 1,
}"
:data-testid="`cell-${columnObj.title}-${rowIndex}`"
:data-key="rowIndex + columnObj.id"
@ -982,7 +999,7 @@ const rowHeight = computed(() => {
td:not(:first-child) > div {
overflow: hidden;
@apply flex px-1;
@apply flex px-1 h-auto;
}
table,

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

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

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

@ -5,6 +5,7 @@ import {
CellValueInj,
ColumnInj,
IsFormInj,
IsGridInj,
RowInj,
inject,
isBarcode,
@ -40,7 +41,10 @@ provide(ActiveCellInj, active)
provide(RowInj, row)
provide(CellValueInj, toRef(props, 'modelValue'))
const isGrid = inject(IsGridInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
emit('navigate', dir)
@ -50,7 +54,8 @@ function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
<template>
<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.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<{
value: any
advancedDbOptions: boolean
}>()
const emit = defineEmits(['update:value'])
@ -40,62 +41,64 @@ vModel.value.au = !!vModel.value.au
<template>
<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">
<a-form-item label="NN">
<a-checkbox
v-model:checked="vModel.rqd"
:disabled="vModel.pk || !sqlUi.columnEditable(vModel)"
class="nc-column-checkbox-NN"
@change="onAlter"
/>
</a-form-item>
<a-form-item label="PK">
<a-checkbox
v-model:checked="vModel.pk"
:disabled="!sqlUi.columnEditable(vModel)"
class="nc-column-checkbox-PK"
@change="onAlter"
/>
<template v-if="props.advancedDbOptions">
<div class="flex justify-between w-full gap-1">
<a-form-item label="NN">
<a-checkbox
v-model:checked="vModel.rqd"
:disabled="vModel.pk || !sqlUi.columnEditable(vModel)"
class="nc-column-checkbox-NN"
@change="onAlter"
/>
</a-form-item>
<a-form-item label="PK">
<a-checkbox
v-model:checked="vModel.pk"
:disabled="!sqlUi.columnEditable(vModel)"
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 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 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 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 v-if="sqlUi.showScale(vModel)" label="Scale">
<a-input v-model:value="vModel.dtxs" :disabled="!sqlUi.columnEditable(vModel)" @input="onAlter" />
</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 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>
</template>
<a-form-item :label="$t('placeholder.defaultValue')">
<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 advancedDbOptions = ref(false)
const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
const onlyNameUpdateOnEditColumns = [UITypes.LinkToAnotherRecord, UITypes.Lookup, UITypes.Rollup]
@ -196,8 +198,9 @@ useEventListener('keydown', (e: KeyboardEvent) => {
<div
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"
@dblclick="advancedDbOptions = !advancedDbOptions"
>
{{ advancedOptions ? $t('general.hideAll') : $t('general.showMore') }}
<component :is="advancedOptions ? MdiMinusIcon : MdiPlusIcon" />
@ -220,7 +223,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
v-model:value="formState"
/>
<LazySmartsheetColumnAdvancedOptions v-model:value="formState" />
<LazySmartsheetColumnAdvancedOptions v-model:value="formState" :advanced-db-options="advancedDbOptions" />
</div>
</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()
let options = $ref<Option[]>([])
let options = $ref<(Option & { status?: 'remove' })[]>([])
let renderedOptions = $ref<(Option & { status?: 'remove' })[]>([])
let savedDefaultOption = $ref<Option | null>(null)
let savedCdf = $ref<string | null>(null)
@ -41,13 +41,15 @@ const validators = {
validator: (_: any, _opt: any) => {
return new Promise<void>((resolve, reject) => {
for (const opt of options) {
if ((opt as any).status === 'remove') continue
if (!opt.title.length) {
return reject(new Error("Select options can't be null"))
}
if (vModel.value.uidt === UITypes.MultiSelect && opt.title.includes(',')) {
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"))
}
}

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

@ -200,7 +200,7 @@ function onStopEdit() {
{{ $t('activity.copyView') }}
</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>
<template v-if="!vModel.is_default">
@ -209,7 +209,7 @@ function onStopEdit() {
{{ $t('activity.deleteView') }}
</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>
</template>
</div>

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

@ -320,7 +320,13 @@ const isSuperAdmin = (user: { main_roles?: string }) => {
</a-button>
</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">
<a-button type="text" class="!px-0">
<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,
projectRoles,
ref,
useActiveKeyupListener,
useCopy,
useDashboard,
useI18n,
@ -44,7 +45,7 @@ const { copy } = useCopy()
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()
@ -80,6 +81,11 @@ onMounted(() => {
}
})
const close = () => {
emit('closed')
usersData = { role: ProjectRole.Viewer }
}
const saveUser = async () => {
$e('a:user:invite', { role: usersData.role })
@ -95,7 +101,7 @@ const saveUser = async () => {
project_id: project.value.id,
projectName: project.value.title,
})
emit('closed')
close()
} else {
const res = await $api.auth.projectUserAdd(project.value.id, {
roles: usersData.role,
@ -135,9 +141,28 @@ const clickInviteMore = () => {
usersData.emails = undefined
}
const emailField = (inputEl: typeof Input) => {
inputEl?.$el?.focus()
}
const emailField = ref<typeof Input>()
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>
<template>
@ -149,7 +174,7 @@ const emailField = (inputEl: typeof Input) => {
:closable="false"
width="max(50vw, 44rem)"
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-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"
class="!rounded-md mr-1 -mt-1.5"
data-testid="invite-user-and-share-base-modal-close-btn"
@click="emit('closed')"
@click="close"
>
<template #icon>
<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>
<a-input
:ref="emailField"
ref="emailField"
v-model:value="usersData.emails"
validate-trigger="onBlur"
:placeholder="$t('labels.email')"

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

@ -24,6 +24,7 @@ import {
provide,
ref,
useApi,
useFieldQuery,
useGlobal,
useI18n,
useInjectionState,
@ -66,6 +67,30 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
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(() =>
(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() {
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
formattedData.value = new Map<string | null, Row[]>()
@ -161,7 +187,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
meta.value!.id!,
viewMeta.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
for (const record of data.value.list) {
for (const attachmentColumn of attachmentColumns.value) {
// attachment column can be hidden
if (!record[attachmentColumn!]) continue
const oldAttachment = JSON.parse(record[attachmentColumn!])
const newAttachment = []
for (const attachmentObj of oldAttachment) {
@ -200,6 +228,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const response = !isPublic.value
? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, {
...{ where: xWhere.value },
...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.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 tbodyEl = ref<HTMLElement>()
const { t } = useI18n()
const { copy } = useCopy()
@ -372,10 +374,12 @@ export function useMultiSelect(
}
}
const resetSelectedRange = () => selectedRange.clear()
const clearSelectedRange = selectedRange.clear.bind(selectedRange)
useEventListener(document, 'keydown', handleKeyDown)
useEventListener(document, 'mouseup', handleMouseUp)
useEventListener(tbodyEl, 'mouseup', handleMouseUp)
return {
isCellActive,
@ -386,5 +390,7 @@ export function useMultiSelect(
isCellSelected,
activeCell,
handleCellClick,
tbodyEl,
resetSelectedRange,
}
}

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

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

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

@ -1,21 +1,31 @@
import { isClient } from '@vueuse/core'
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) {
watch(selected, (nextVal, _, cleanup) => {
// bind listener when `selected` is truthy
if (nextVal) {
document.addEventListener('keydown', handler, true)
// if `selected` is falsy then remove the event handler
} else {
document.removeEventListener('keydown', handler, true)
}
watch(
selected,
(nextVal: boolean, _: boolean, cleanup) => {
// bind listener when `selected` is truthy
if (nextVal) {
document.addEventListener('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(() => {
document.removeEventListener('keydown', handler, true)
})
})
// cleanup is called whenever the watcher is re-evaluated or stopped
cleanup(() => {
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[]>,
) => {
const { $api } = useNuxtApp()
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[]>([])

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

@ -1,5 +1,15 @@
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 {
IsPublicInj,
@ -81,6 +91,10 @@ export function useViewData(
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 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] = {}) {
if ((!project?.value?.id || !meta.value?.id || !viewMeta.value?.id) && !isPublic.value) return
const response = !isPublic.value
@ -198,7 +234,27 @@ export function useViewData(
where: where?.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
// 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++,
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) {
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 DropZoneRef: InjectionKey<Ref<Element | undefined>> = Symbol('drop-zone-ref')
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",
"lock": "Blokeatu",
"unlock": "Desblokeatu",
"credentials": "Credentials",
"credentials": "Egiaztagiriak",
"help": "Laguntza",
"questions": "Galderak",
"reachOut": "Reach out here",
@ -103,7 +103,7 @@
"user": "Erabiltzailea",
"users": "Erabiltzaileak",
"role": "Rola",
"roles": "Roles",
"roles": "Rolak",
"roleType": {
"owner": "Jabea",
"creator": "Sortzailea",
@ -182,7 +182,7 @@
"rolesMgmt": "Roles Management",
"projMeta": "Project Metadata",
"metaMgmt": "Meta Management",
"metadata": "Metadata",
"metadata": "Metadatuak",
"exportImportMeta": "Export / Import Metadata",
"uiACL": "UI Access Control",
"metaOperations": "Metadata Operations",

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

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

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

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

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

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

2
packages/nc-gui/package.json

@ -52,7 +52,7 @@
"jwt-decode": "^3.1.2",
"locale-codes": "^1.3.1",
"monaco-editor": "^0.33.0",
"nocodb-sdk": "0.104.2",
"nocodb-sdk": "file:../nocodb-sdk",
"papaparse": "^5.3.2",
"qrcode": "^1.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 class="min-w-2/4 xl:max-w-2/4 w-full mx-auto">
<NuxtPage />
<NuxtPage :transition="false" />
</div>
<div

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

@ -1,11 +1,14 @@
<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 { VNodeRef } from '@vue/runtime-core'
import {
extractSdkResponseErrorMsg,
generateUniqueName,
message,
navigateTo,
nextTick,
onMounted,
projectTitleValidator,
reactive,
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>
<template>
@ -76,7 +86,7 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@finish="createProject"
>
<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>
<div class="text-center">

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

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

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

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

30
packages/nocodb/package-lock.json generated

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

4
packages/nocodb/package.json

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

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

@ -104,7 +104,7 @@ export default class Noco {
constructor() {
process.env.PORT = process.env.PORT || '8080';
// 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 (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) {
switch (this?.client?.config?.client) {
case 'pg':
this.select(this.client.raw(`STRING_AGG(?? , ',')`, [cn]));
this.select(
this.client.raw(`STRING_AGG(??::character varying , ',')`, [cn])
);
break;
case 'mysql':
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) => {
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(
slash(path.join(destPath, fileName)),
url
);
let attachmentPath;
// if `attachmentUrl` is null, then it is local attachment
if (!attachmentUrl) {
attachmentUrl = `${(req as any).ncSiteUrl}/download/${filePath.join(
'/'
)}/${fileName}`;
// then store the attachement path only
// url will be constructued in `useAttachmentCell`
attachmentPath = `download/${filePath.join('/')}/${fileName}`;
}
return {
url: attachmentUrl,
...(attachmentUrl ? { url: attachmentUrl } : {}),
...(attachmentPath ? { path: attachmentPath } : {}),
title: fileName,
mimetype: urlMeta.mimetype,
size: urlMeta.size,

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

@ -441,11 +441,14 @@ export default class Model implements TableType {
const insertObj = {};
for (const col of await this.getColumns()) {
if (isVirtualCol(col)) continue;
const val =
let val =
data?.[col.column_name] !== undefined
? data?.[col.column_name]
: data?.[col.title];
if (val !== undefined) {
if (col.uidt === UITypes.Attachment && typeof val !== 'string') {
val = JSON.stringify(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 ncFilterUpgrader from './ncFilterUpgrader';
import ncAttachmentUpgrader from './ncAttachmentUpgrader';
import ncAttachmentUpgrader_0104002 from './ncAttachmentUpgrader_0104002';
const log = debug('nc:version-upgrader');
import boxen from 'boxen';
@ -39,6 +40,7 @@ export default class NcUpgrader {
{ name: '0098005', handler: ncProjectRolesUpgrader },
{ name: '0100002', handler: ncFilterUpgrader },
{ name: '0101002', handler: ncAttachmentUpgrader },
{ name: '0104002', handler: ncAttachmentUpgrader_0104002 },
];
if (!(await ctx.ncMeta.knexConnection?.schema?.hasTable?.('nc_store'))) {
return;

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

@ -1,10 +1,11 @@
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'
import { BaseType, UITypes } from 'nocodb-sdk';
// 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 clientType = knex.clientType();
if (clientType === 'mssql' && schema) {
return knex.raw('??.??', [schema, tb.table_name]);
return knex.raw('??.??', [schema, tb.table_name]).toQuery();
} else if (clientType === 'snowflake') {
return [
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);
for (const _base of bases) {
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
: NcConnectionMgrv2.get(base);
const models = await base.getModels(ncMeta);
for (const model of models) {
const updateRecords = [];
const columns = await (
await Model.get(model.id, ncMeta)
).getColumns(ncMeta);
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;
}
try {
// if the table is missing in database, skip
if (!(await knex.schema.hasTable(getTnPath(knex, model)))) {
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 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) {
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
.map((key) => {
return { [key]: record[key] };
})
.reduce((acc, val) => Object.assign(acc, val), {});
updateRecords.push(
await knex(getTnPath(knex, model))
.update({
[attachmentColumn]: JSON.stringify(newAttachmentMeta),
const where = primaryKeys
.map((key) => {
return { [key]: record[key] };
})
.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() {
await this.get().press((await this.isMacOs()) ? 'Meta+C' : 'Control+C');
await this.verifyToast({ message: 'Copied to clipboard' });
return this.getClipboardText();
// retry to avoid flakiness, until text is copied to clipboard
//
let text = '';
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) {
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' });
return this.getClipboardText();
// retry to avoid flakiness, until text is copied to clipboard
//
let text = '';
let retryCount = 5;
while (text === '') {
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,
});
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({
index: 0,
columnHeader: 'MultiSelect',
option: 'New Option 3',
option: 'MultiSelect New Option 3',
multiSelect: true,
});
await grid.cell.selectOption.verifyOptions({
index: 0,
columnHeader: 'MultiSelect',
options: ['Option 1', 'Option 2', 'New Option 3'],
options: ['Option 1', 'Option 2', 'MultiSelect New Option 3'],
});
await grid.deleteRow(0);

Loading…
Cancel
Save