Browse Source

Merge branch 'develop' into enhancement/filters

pull/5106/head
Wing-Kam Wong 2 years ago
parent
commit
064f9f17fb
  1. 4
      .github/ISSUE_TEMPLATE/--bug-report.yaml
  2. 4
      packages/nc-gui/components/cell/Checkbox.vue
  3. 249
      packages/nc-gui/components/cell/MultiSelect.vue
  4. 177
      packages/nc-gui/components/cell/SingleSelect.vue
  5. 2
      packages/nc-gui/components/cell/Url.vue
  6. 4
      packages/nc-gui/components/dlg/TableRename.vue
  7. 46
      packages/nc-gui/components/smartsheet/Cell.vue
  8. 14
      packages/nc-gui/components/smartsheet/Form.vue
  9. 27
      packages/nc-gui/components/smartsheet/Grid.vue
  10. 8
      packages/nc-gui/components/smartsheet/TableDataCell.vue
  11. 7
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  12. 103
      packages/nc-gui/components/smartsheet/column/AdvancedOptions.vue
  13. 7
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  14. 6
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  15. 41
      packages/nc-gui/components/tabs/auth/user-management/UsersModal.vue
  16. 8
      packages/nc-gui/composables/useMultiSelect/index.ts
  17. 7
      packages/nc-gui/composables/useProject.ts
  18. 38
      packages/nc-gui/composables/useSelectedCellKeyupListener/index.ts
  19. 1
      packages/nc-gui/context/index.ts
  20. 6
      packages/nc-gui/lang/eu.json
  21. 92
      packages/nc-gui/lang/zh-Hans.json
  22. 2
      packages/nc-gui/pages/index/index.vue
  23. 16
      packages/nc-gui/pages/index/index/create.vue
  24. 1
      packages/nc-gui/utils/columnUtils.ts
  25. 2
      packages/nocodb/src/lib/Noco.ts
  26. 5
      packages/nocodb/src/lib/models/Model.ts
  27. 2
      packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts
  28. 2
      packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts
  29. 159
      packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader_0104002.ts
  30. 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:

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)"

249
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,
@ -45,6 +48,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>()
@ -86,7 +91,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: () => {
@ -127,12 +132,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)
@ -145,8 +144,6 @@ onMounted(() => {
}) })
}) })
useEventListener(document, 'click', handleClose)
watch( watch(
() => modelValue, () => modelValue,
() => { () => {
@ -162,6 +159,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()
@ -181,6 +180,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':
@ -194,7 +196,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
} }
@ -202,6 +204,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() {
@ -274,101 +281,132 @@ 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 &&
!disableOptionCreation &&
(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 &&
!disableOptionCreation &&
(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;
@ -411,6 +449,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>

177
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,
useEventListener, useEventListener,
useRoles, useRoles,
@ -46,6 +50,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()
@ -80,7 +86,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,
@ -115,13 +121,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
} }
@ -129,6 +138,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
@ -179,6 +193,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
@ -187,78 +217,90 @@ 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) => { const handleClose = (e: MouseEvent) => {
if (aselect.value && !aselect.value.$el.contains(e.target)) { if (isOpen.value && aselect.value && !aselect.value.$el.contains(e.target)) {
isOpen.value = false isOpen.value = false
} }
} }
useEventListener(document, 'click', handleClose) 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 &&
!disableOptionCreation &&
(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 &&
!disableOptionCreation &&
(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">
@ -273,4 +315,19 @@ useEventListener(document, 'click', handleClose)
: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]"

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

@ -97,7 +97,6 @@ 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>()
@ -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"))
} }
} }

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')"

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 }

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",

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

@ -70,8 +70,8 @@
"moreInfo": "点击此处了解更多信息。", "moreInfo": "点击此处了解更多信息。",
"logs": "日志", "logs": "日志",
"groupingField": "分组字段", "groupingField": "分组字段",
"insertAfter": "Insert After", "insertAfter": "在右侧插入列",
"insertBefore": "Insert Before", "insertBefore": "在左侧插入列",
"hideField": "隐藏字段", "hideField": "隐藏字段",
"sortAsc": "升序", "sortAsc": "升序",
"sortDesc": "降序" "sortDesc": "降序"
@ -86,7 +86,7 @@
"column": "列", "column": "列",
"columns": "列", "columns": "列",
"page": "页", "page": "页",
"pages": "页", "pages": "页",
"record": "记录", "record": "记录",
"records": "记录", "records": "记录",
"webhook": "Webhook.", "webhook": "Webhook.",
@ -176,8 +176,8 @@
"personalView": "个人视图", "personalView": "个人视图",
"appStore": "软件商店", "appStore": "软件商店",
"teamAndAuth": "团队和认证", "teamAndAuth": "团队和认证",
"rolesUserMgmt": "权限和成员管理", "rolesUserMgmt": "角色和用户管理",
"userMgmt": "成员管理", "userMgmt": "用户管理",
"apiTokenMgmt": "API 令牌管理", "apiTokenMgmt": "API 令牌管理",
"rolesMgmt": "角色管理", "rolesMgmt": "角色管理",
"projMeta": "项目基础信息", "projMeta": "项目基础信息",
@ -275,10 +275,10 @@
"followNocodb": "关注 NocoDB" "followNocodb": "关注 NocoDB"
}, },
"docReference": "参考文档", "docReference": "参考文档",
"selectUserRole": "选择成员权限", "selectUserRole": "选择用户角色",
"childTable": "子表", "childTable": "子表",
"childColumn": "子列", "childColumn": "子列",
"linkToAnotherRecord": "Link to another record", "linkToAnotherRecord": "关联到其他表",
"onUpdate": "更新时", "onUpdate": "更新时",
"onDelete": "删除时", "onDelete": "删除时",
"account": "帐户", "account": "帐户",
@ -291,8 +291,8 @@
"sharedBase": "分享项目", "sharedBase": "分享项目",
"importData": "导入数据", "importData": "导入数据",
"importSecondaryViews": "导入次要视图", "importSecondaryViews": "导入次要视图",
"importRollupColumns": "Import Rollup Columns", "importRollupColumns": "导入聚合列",
"importLookupColumns": "Import Lookup Columns", "importLookupColumns": "导入查询列",
"importAttachmentColumns": "导入附件列", "importAttachmentColumns": "导入附件列",
"importFormulaColumns": "导入公式列", "importFormulaColumns": "导入公式列",
"noData": "暂无数据", "noData": "暂无数据",
@ -314,8 +314,8 @@
"agreeToTos": "注册即表明您同意服务条款", "agreeToTos": "注册即表明您同意服务条款",
"welcomeToNc": "欢迎来到NocoDB!", "welcomeToNc": "欢迎来到NocoDB!",
"inviteOnlySignup": "只能通过邀请链接注册账户", "inviteOnlySignup": "只能通过邀请链接注册账户",
"nextRow": "Next Row", "nextRow": "下一行",
"prevRow": "Previous Row" "prevRow": "上一行"
}, },
"activity": { "activity": {
"createProject": "创建项目", "createProject": "创建项目",
@ -328,7 +328,7 @@
"deleteProject": "删除项目", "deleteProject": "删除项目",
"refreshProject": "刷新项目", "refreshProject": "刷新项目",
"saveProject": "保存项目", "saveProject": "保存项目",
"deleteKanbanStack": "Delete stack?", "deleteKanbanStack": "是否删除此类别?",
"createProjectExtended": { "createProjectExtended": {
"extDB": "新建 <br>从外部数据库", "extDB": "新建 <br>从外部数据库",
"excel": "从 Excel 创建项目", "excel": "从 Excel 创建项目",
@ -360,14 +360,14 @@
"invite": "邀请", "invite": "邀请",
"inviteMore": "邀请更多", "inviteMore": "邀请更多",
"inviteTeam": "邀请团队", "inviteTeam": "邀请团队",
"inviteUser": "邀请新成员", "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": "下一页",
@ -384,7 +384,7 @@
"addRow": "添加新行", "addRow": "添加新行",
"saveRow": "保存行", "saveRow": "保存行",
"saveAndExit": "保存并退出", "saveAndExit": "保存并退出",
"saveAndStay": "Save & Stay", "saveAndStay": "保存并留在此页",
"insertRow": "插入新行", "insertRow": "插入新行",
"deleteRow": "删除行", "deleteRow": "删除行",
"deleteSelectedRow": "删除所选行", "deleteSelectedRow": "删除所选行",
@ -428,11 +428,11 @@
"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": {
@ -464,7 +464,7 @@
"light": "它是黑色吗? (^⇧b)" "light": "它是黑色吗? (^⇧b)"
}, },
"addTable": "添加新表", "addTable": "添加新表",
"inviteMore": "邀请更多成员", "inviteMore": "邀请更多用户",
"toggleNavDraw": "切换导航抽屉", "toggleNavDraw": "切换导航抽屉",
"reloadApiToken": "重新加载API令牌", "reloadApiToken": "重新加载API令牌",
"generateNewApiToken": "生成新的 API 令牌", "generateNewApiToken": "生成新的 API 令牌",
@ -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,7 +535,7 @@
"deleteProject": "你想删除这个项目吗?", "deleteProject": "你想删除这个项目吗?",
"shareBasePrivate": "产生公开共享的只读基础", "shareBasePrivate": "产生公开共享的只读基础",
"shareBasePublic": "互联网上的任何人都可以查看", "shareBasePublic": "互联网上的任何人都可以查看",
"userInviteNoSMTP": "看起来你还没有配置邮件!请复制上面的邀请链接并将其发送给", "userInviteNoSMTP": "你还没有配置邮箱!请复制上面的邀请链接并将其发送给",
"dragDropHide": "在此处拖放字段以隐藏", "dragDropHide": "在此处拖放字段以隐藏",
"formInput": "输入表单输入标签", "formInput": "输入表单输入标签",
"formHelpText": "添加一些帮助文本", "formHelpText": "添加一些帮助文本",
@ -563,7 +563,7 @@
"editorDesc": "可以编辑记录但无法更改数据库/字段的结构。", "editorDesc": "可以编辑记录但无法更改数据库/字段的结构。",
"commenterDesc": "可以查看和评论,但无法编辑任何内容", "commenterDesc": "可以查看和评论,但无法编辑任何内容",
"viewerDesc": "可以查看记录但无法编辑任何内容", "viewerDesc": "可以查看记录但无法编辑任何内容",
"addUser": "添加新成员", "addUser": "添加新用户",
"staticRoleInfo": "不允许修改系统权限", "staticRoleInfo": "不允许修改系统权限",
"exportZip": "以 zip 格式下载项目元数据。", "exportZip": "以 zip 格式下载项目元数据。",
"importZip": "导入 zip 格式的项目元数据并重新启动。", "importZip": "导入 zip 格式的项目元数据并重新启动。",
@ -602,7 +602,7 @@
"calendar": "添加日历视图" "calendar": "添加日历视图"
}, },
"tablesMetadataInSync": "表元数据同步", "tablesMetadataInSync": "表元数据同步",
"addMultipleUsers": "您可以添加多个逗号(,)分隔的电子邮件", "addMultipleUsers": "可以使用多个英文逗号 (,) 分隔邮箱地址",
"enterTableName": "输入表名", "enterTableName": "输入表名",
"addDefaultColumns": "添加默认列", "addDefaultColumns": "添加默认列",
"tableNameInDb": "数据库中保存的表名", "tableNameInDb": "数据库中保存的表名",
@ -630,17 +630,17 @@
"deleteViewConfirmation": "您确定要删除此视图?", "deleteViewConfirmation": "您确定要删除此视图?",
"deleteTableConfirmation": "您想要删除该表吗?", "deleteTableConfirmation": "您想要删除该表吗?",
"showM2mTables": "显示中间表", "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": "权限已更新"
} }
} }
} }

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)
} }

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) {

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;

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

@ -47,7 +47,7 @@ export default async function ({ ncMeta }: NcUpgraderCtx) {
for (const _base of bases) { for (const _base of bases) {
const base = new Base(_base); const base = new Base(_base);
// skip if the prodect_id is missing // skip if the project_id is missing
if (!base.project_id) { if (!base.project_id) {
continue; continue;
} }

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;
}
}
}
}
}

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