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
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:

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

249
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,
@ -45,6 +48,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>()
@ -86,7 +91,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: () => {
@ -127,12 +132,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)
@ -145,8 +144,6 @@ onMounted(() => {
})
})
useEventListener(document, 'click', handleClose)
watch(
() => modelValue,
() => {
@ -162,6 +159,8 @@ watch(
)
watch(isOpen, (n, _o) => {
if (!n) searchVal.value = ''
if (editAllowed.value) {
if (!n) {
aselect.value?.$el?.querySelector('input')?.blur()
@ -181,6 +180,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':
@ -194,7 +196,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
}
@ -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)
async function addIfMissingAndSave() {
@ -274,101 +281,132 @@ 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 &&
!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"
<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 &&
!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>
</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;
@ -411,6 +449,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>

177
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,
useEventListener,
useRoles,
@ -46,6 +50,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()
@ -80,7 +86,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,
@ -115,13 +121,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
}
@ -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() {
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
}
// 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
@ -187,78 +217,90 @@ 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 (aselect.value && !aselect.value.$el.contains(e.target)) {
if (isOpen.value && aselect.value && !aselect.value.$el.contains(e.target)) {
isOpen.value = false
}
}
useEventListener(document, 'click', handleClose)
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 &&
!disableOptionCreation &&
(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 &&
!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>
</a-select-option>
</a-select>
</a-select-option>
</a-select>
</div>
</template>
<style scoped lang="scss">
@ -273,4 +315,19 @@ useEventListener(document, 'click', handleClose)
: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]"

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 expandedFormRow = ref<Row>()
const expandedFormRowState = ref<Record<string, any>>()
const tbodyEl = ref<HTMLElement>()
const gridWrapper = ref<HTMLElement>()
const tableHead = ref<HTMLElement>()
@ -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"))
}
}

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

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 }

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

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

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

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

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

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;

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

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