Browse Source

Merge branch 'develop' into master

pull/7932/head
Pranav C 8 months ago committed by GitHub
parent
commit
070d5cbeee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      .github/workflows/playwright-test-workflow.yml
  2. 6
      packages/nc-gui/assets/nc-icons/onetoone.svg
  3. 19
      packages/nc-gui/components/cell/Checkbox.vue
  4. 11
      packages/nc-gui/components/cell/Currency.vue
  5. 21
      packages/nc-gui/components/cell/Email.vue
  6. 15
      packages/nc-gui/components/cell/Integer.vue
  7. 5
      packages/nc-gui/components/cell/MultiSelect.vue
  8. 38
      packages/nc-gui/components/cell/Percent.vue
  9. 22
      packages/nc-gui/components/cell/SingleSelect.vue
  10. 21
      packages/nc-gui/components/cell/Url.vue
  11. 17
      packages/nc-gui/components/cell/attachment/index.vue
  12. 4
      packages/nc-gui/components/dashboard/Sidebar/TopSection.vue
  13. 4
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  14. 4
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  15. 4
      packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue
  16. 10
      packages/nc-gui/components/general/FormBanner.vue
  17. 2
      packages/nc-gui/components/general/ImageCropper.vue
  18. 4
      packages/nc-gui/components/project/View.vue
  19. 2
      packages/nc-gui/components/smartsheet/Cell.vue
  20. 75
      packages/nc-gui/components/smartsheet/Form.vue
  21. 2
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  22. 13
      packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue
  23. 4
      packages/nc-gui/components/smartsheet/column/RollupOptions.vue
  24. 9
      packages/nc-gui/components/smartsheet/grid/Table.vue
  25. 6
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  26. 2
      packages/nc-gui/components/smartsheet/header/VirtualCellIcon.ts
  27. 7
      packages/nc-gui/components/smartsheet/toolbar/CreateGroupBy.vue
  28. 7
      packages/nc-gui/components/smartsheet/toolbar/CreateSort.vue
  29. 5
      packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue
  30. 18
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  31. 12
      packages/nc-gui/components/virtual-cell/Lookup.vue
  32. 150
      packages/nc-gui/components/virtual-cell/OneToOne.vue
  33. 11
      packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue
  34. 43
      packages/nc-gui/components/webhook/Editor.vue
  35. 32
      packages/nc-gui/composables/useLTARStore.ts
  36. 4
      packages/nc-gui/composables/useMultiSelect/convertCellData.ts
  37. 7
      packages/nc-gui/composables/useMultiSelect/index.ts
  38. 44
      packages/nc-gui/composables/useSharedFormViewStore.ts
  39. 11
      packages/nc-gui/composables/useSmartsheetRowStore.ts
  40. 1
      packages/nc-gui/lang/ar.json
  41. 1
      packages/nc-gui/lang/bn_IN.json
  42. 1
      packages/nc-gui/lang/cs.json
  43. 1
      packages/nc-gui/lang/da.json
  44. 1
      packages/nc-gui/lang/de.json
  45. 3
      packages/nc-gui/lang/en.json
  46. 1
      packages/nc-gui/lang/es.json
  47. 1
      packages/nc-gui/lang/eu.json
  48. 1
      packages/nc-gui/lang/fa.json
  49. 1
      packages/nc-gui/lang/fi.json
  50. 1
      packages/nc-gui/lang/fr.json
  51. 3
      packages/nc-gui/lang/he.json
  52. 1
      packages/nc-gui/lang/hi.json
  53. 1
      packages/nc-gui/lang/hr.json
  54. 1
      packages/nc-gui/lang/id.json
  55. 1
      packages/nc-gui/lang/it.json
  56. 1
      packages/nc-gui/lang/ja.json
  57. 1
      packages/nc-gui/lang/ko.json
  58. 1
      packages/nc-gui/lang/lv.json
  59. 1
      packages/nc-gui/lang/nl.json
  60. 1
      packages/nc-gui/lang/no.json
  61. 15
      packages/nc-gui/lang/pl.json
  62. 1
      packages/nc-gui/lang/pt.json
  63. 1
      packages/nc-gui/lang/pt_BR.json
  64. 1
      packages/nc-gui/lang/ru.json
  65. 1
      packages/nc-gui/lang/sk.json
  66. 1
      packages/nc-gui/lang/sl.json
  67. 1
      packages/nc-gui/lang/sv.json
  68. 1
      packages/nc-gui/lang/th.json
  69. 1
      packages/nc-gui/lang/tr.json
  70. 69
      packages/nc-gui/lang/uk.json
  71. 1
      packages/nc-gui/lang/vi.json
  72. 1
      packages/nc-gui/lang/zh-Hans.json
  73. 1
      packages/nc-gui/lang/zh-Hant.json
  74. 2
      packages/nc-gui/package.json
  75. 19
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index.vue
  76. 10
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue
  77. 149
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue
  78. 4
      packages/nc-gui/store/views.ts
  79. 2
      packages/nc-gui/utils/dataUtils.ts
  80. 2
      packages/nc-gui/utils/iconUtils.ts
  81. 3
      packages/nc-gui/utils/virtualCell.ts
  82. 12
      packages/nocodb-sdk/src/lib/Api.ts
  83. 27
      packages/nocodb-sdk/src/lib/globals.ts
  84. 10
      packages/nocodb/package.json
  85. 20
      packages/nocodb/src/controllers/data-alias-nested.controller.ts
  86. 2
      packages/nocodb/src/controllers/old-datas/old-datas.service.ts
  87. 10
      packages/nocodb/src/controllers/public-datas-export.controller.ts
  88. 522
      packages/nocodb/src/db/BaseModelSqlv2.ts
  89. 33
      packages/nocodb/src/db/conditionV2.ts
  90. 95
      packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts
  91. 21
      packages/nocodb/src/db/genRollupSelectv2.ts
  92. 39
      packages/nocodb/src/db/generateLookupSelectQuery.ts
  93. 2
      packages/nocodb/src/db/sortV2.ts
  94. 1
      packages/nocodb/src/db/sql-client/lib/mysql/MysqlClient.ts
  95. 2
      packages/nocodb/src/db/sql-client/lib/pg/PgClient.ts
  96. 5
      packages/nocodb/src/db/sql-client/lib/sqlite/SqliteClient.ts
  97. 20
      packages/nocodb/src/filters/global-exception/global-exception.filter.ts
  98. 6
      packages/nocodb/src/helpers/NcPluginMgrv2.ts
  99. 2
      packages/nocodb/src/helpers/PagedResponse.ts
  100. 298
      packages/nocodb/src/helpers/catchError.ts
  101. Some files were not shown because too many files have changed in this diff Show More

2
.github/workflows/playwright-test-workflow.yml

@ -131,7 +131,7 @@ jobs:
sleep 2
done
echo "Backend is up"
timeout-minutes: 2
timeout-minutes: 3
- name: Run Playwright Tests
working-directory: ./tests/playwright
run: E2E_DB_TYPE=${{ inputs.db }} npm run ci:test:shard:${{ inputs.shard }}

6
packages/nc-gui/assets/nc-icons/onetoone.svg

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 10C11.8954 10 11 9.10457 11 8C11 6.89543 11.8954 6 13 6C14.1046 6 15 6.89543 15 8C15 9.10457 14.1046 10 13 10Z" stroke="#F3ECFA" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 10C1.89543 10 1 9.10457 1 8C1 6.89543 1.89543 6 3 6C4.10457 6 5 6.89543 5 8C5 9.10457 4.10457 10 3 10Z" stroke="#F3ECFA" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 8L11 8" stroke="#F3ECFA" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 10C11.8954 10 11 9.10457 11 8C11 6.89543 11.8954 6 13 6C14.1046 6 15 6.89543 15 8C15 9.10457 14.1046 10 13 10Z" stroke="purple" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 10C1.89543 10 1 9.10457 1 8C1 6.89543 1.89543 6 3 6C4.10457 6 5 6.89543 5 8C5 9.10457 4.10457 10 3 10Z" stroke="purple" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 8L11 8" stroke="purple" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 631 B

After

Width:  |  Height:  |  Size: 628 B

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

@ -63,7 +63,7 @@ const vModel = computed<boolean | number>({
set: (val: any) => emits('update:modelValue', isMssql(column?.value?.source_id) ? +val : val),
})
function onClick(force?: boolean, event?: MouseEvent) {
function onClick(force?: boolean, event?: MouseEvent | KeyboardEvent) {
if (
(event?.target as HTMLElement)?.classList?.contains('nc-checkbox') ||
(event?.target as HTMLElement)?.closest('.nc-checkbox')
@ -75,6 +75,19 @@ function onClick(force?: boolean, event?: MouseEvent) {
}
}
const keydownEnter = (e: KeyboardEvent) => {
if (!isSurveyForm.value) {
onClick(true, e)
e.stopPropagation()
}
}
const keydownSpace = (e: KeyboardEvent) => {
if (isSurveyForm.value) {
onClick(true, e)
e.stopPropagation()
}
}
useSelectedCellKeyupListener(active, (e) => {
switch (e.key) {
case 'Enter':
@ -101,8 +114,8 @@ useSelectedCellKeyupListener(active, (e) => {
}"
:tabindex="readOnly ? -1 : 0"
@click="onClick(false, $event)"
@keydown.enter.stop="!isSurveyForm ? onClick(true, $event) : undefined"
@keydown.space.stop="isSurveyForm ? onClick(true, $event) : undefined"
@keydown.enter="keydownEnter"
@keydown.space="keydownSpace($event)"
>
<div
class="flex items-center"

11
packages/nc-gui/components/cell/Currency.vue

@ -103,12 +103,21 @@ onMounted(() => {
</script>
<template>
<div
v-if="isForm && !isEditColumn"
class="nc-currency-code h-full !bg-gray-100 border-r border-gray-200 px-3 mr-1 flex items-center"
>
<span>
{{ currencyMeta.currency_code }}
</span>
</div>
<input
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="vModel"
type="number"
class="nc-cell-field w-full h-full text-sm border-none rounded-md py-1 outline-none focus:outline-none focus:ring-0"
class="nc-cell-field h-full text-sm border-none rounded-md py-1 outline-none focus:outline-none focus:ring-0"
:class="isForm && !isEditColumn ? 'flex flex-1' : 'w-full'"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="onBlur"
@keydown.enter="onKeydownEnter"

21
packages/nc-gui/components/cell/Email.vue

@ -5,7 +5,6 @@ import {
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
IsSurveyFormInj,
ReadonlyInj,
computed,
inject,
@ -31,12 +30,14 @@ const editEnabled = inject(EditModeInj)!
const column = inject(ColumnInj)!
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
// Used in the logic of when to display error since we are not storing the email if it's not valid
const localState = ref(value)
@ -44,7 +45,7 @@ const vModel = computed({
get: () => value,
set: (val) => {
localState.value = val
if (!parseProp(column.value.meta)?.validate || (val && validateEmail(val)) || !val || isSurveyForm.value) {
if (!parseProp(column.value.meta)?.validate || (val && validateEmail(val)) || !val || isForm.value) {
emit('update:modelValue', val)
}
},
@ -52,17 +53,19 @@ const vModel = computed({
const validEmail = computed(() => vModel.value && validateEmail(vModel.value))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
watch(
() => editEnabled.value,
() => {
if (parseProp(column.value.meta)?.validate && !editEnabled.value && localState.value && !validateEmail(localState.value)) {
if (
!isForm.value &&
parseProp(column.value.meta)?.validate &&
!editEnabled.value &&
localState.value &&
!validateEmail(localState.value)
) {
message.error(t('msg.error.invalidEmail'))
localState.value = undefined
return

15
packages/nc-gui/components/cell/Integer.vue

@ -25,6 +25,10 @@ const isEditColumn = inject(EditColumnInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
const _vModel = useVModel(props, 'modelValue', emits)
const displayValue = computed(() => {
@ -42,15 +46,15 @@ const vModel = computed({
// if we clear / empty a cell in sqlite,
// the value is considered as ''
_vModel.value = null
} else if (isForm.value && !isEditColumn.value) {
_vModel.value = isNaN(Number(value)) ? value : Number(value)
} else {
_vModel.value = value
}
},
})
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
const inputType = computed(() => (isForm.value && !isEditColumn.value ? 'text' : 'number'))
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
@ -91,7 +95,7 @@ function onKeyDown(e: any) {
:ref="focus"
v-model="vModel"
class="nc-cell-field outline-none py-1 border-none w-full h-full text-sm"
type="number"
:type="inputType"
style="letter-spacing: 0.06rem"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false"
@ -109,7 +113,8 @@ function onKeyDown(e: any) {
</template>
<style scoped lang="scss">
input[type='number']:focus {
input[type='number']:focus,
input[type='text']:focus {
@apply ring-transparent;
}

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

@ -371,6 +371,9 @@ const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
isOpen.value = false
return
} else if (e.key === 'Escape' && isForm.value) {
isOpen.value = false
return
}
e.stopPropagation()
@ -394,7 +397,7 @@ const onFocus = () => {
@click="toggleMenu"
>
<div v-if="!isEditColumn && isForm && parseProp(column.meta)?.isList" class="w-full max-w-full">
<a-checkbox-group v-model:value="vModel" :disabled="readOnly || !editAllowed" class="nc-field-layout-list">
<a-checkbox-group v-model:value="vModel" :disabled="readOnly || !editAllowed" class="nc-field-layout-list" @click.stop>
<a-checkbox
v-for="op of options"
:key="op.title"

38
packages/nc-gui/components/cell/Percent.vue

@ -24,17 +24,6 @@ const _vModel = useVModel(props, 'modelValue', emits)
const wrapperRef = ref<HTMLElement>()
const vModel = computed({
get: () => _vModel.value,
set: (value) => {
if (value === '') {
_vModel.value = null
} else {
_vModel.value = value
}
},
})
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
@ -46,6 +35,23 @@ const cellFocused = ref(false)
const expandedEditEnabled = ref(false)
const vModel = computed({
get: () => {
return isForm.value && !isEditColumn.value && _vModel.value && !cellFocused.value && !isNaN(Number(_vModel.value))
? `${_vModel.value}%`
: _vModel.value
},
set: (value) => {
if (value === '') {
_vModel.value = null
} else if (isForm.value && !isEditColumn.value) {
_vModel.value = isNaN(Number(value)) ? value : Number(value)
} else {
_vModel.value = value
}
},
})
const percentMeta = computed(() => {
return {
is_progress: false,
@ -53,6 +59,8 @@ const percentMeta = computed(() => {
}
})
const inputType = computed(() => (isForm.value && !isEditColumn.value ? 'text' : 'number'))
const onBlur = () => {
if (editEnabled) {
editEnabled.value = false
@ -106,7 +114,11 @@ const onTabPress = (e: KeyboardEvent) => {
)
for (let i = focusesNcCellIndex - 1; i >= 0; i--) {
const lastFormItem = nodes[i].querySelector('[tabindex="0"]') as HTMLElement
const node = nodes[i]
const lastFormItem = (node.querySelector('[tabindex="0"]') ??
node.querySelector('input') ??
node.querySelector('textarea') ??
node.querySelector('button')) as HTMLElement
if (lastFormItem) {
lastFormItem.focus()
break
@ -132,7 +144,7 @@ const onTabPress = (e: KeyboardEvent) => {
:ref="focus"
v-model="vModel"
class="nc-cell-field w-full !text-sm !border-none !outline-none focus:ring-0 text-base py-1"
type="number"
:type="inputType"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="onBlur"
@focus="onFocus"

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

@ -257,12 +257,26 @@ const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
isOpen.value = false
if (isForm.value) return
setTimeout(() => {
aselect.value?.$el.querySelector('.ant-select-selection-search > input').focus()
}, 100)
}
}
const handleKeyDownList = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowRight':
case 'ArrowLeft':
// skip
e.stopPropagation()
break
}
}
const onSelect = () => {
isOpen.value = false
isEditable.value = false
@ -315,7 +329,13 @@ const onFocus = () => {
@keydown.enter.stop.prevent="toggleMenu"
>
<div v-if="!isEditColumn && isForm && parseProp(column.meta)?.isList" class="w-full max-w-full">
<a-radio-group v-model:value="vModel" :disabled="readOnly || !editAllowed" class="nc-field-layout-list">
<a-radio-group
v-model:value="vModel"
:disabled="readOnly || !editAllowed"
class="nc-field-layout-list"
@keydown="handleKeyDownList"
@click.stop
>
<a-radio
v-for="op of options"
:key="op.title"

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

@ -7,7 +7,6 @@ import {
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
IsSurveyFormInj,
ReadonlyInj,
computed,
inject,
@ -42,10 +41,12 @@ const disableOverlay = inject(CellUrlDisableOverlayInj, ref(false))
const rowHeight = inject(RowHeightInj, ref(undefined))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
// Used in the logic of when to display error since we are not storing the url if it's not valid
const localState = ref(value)
@ -53,7 +54,7 @@ const vModel = computed({
get: () => value,
set: (val) => {
localState.value = val
if (!parseProp(column.value.meta)?.validate || (val && isValidURL(val)) || !val || isSurveyForm.value) {
if (!parseProp(column.value.meta)?.validate || (val && isValidURL(val)) || !val || isForm.value) {
emit('update:modelValue', val)
}
},
@ -72,17 +73,19 @@ const url = computed(() => {
const { cellUrlOptions } = useCellUrlConfig(url)
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
watch(
() => editEnabled.value,
() => {
if (parseProp(column.value.meta)?.validate && !editEnabled.value && localState.value && !isValidURL(localState.value)) {
if (
!isForm.value &&
parseProp(column.value.meta)?.validate &&
!editEnabled.value &&
localState.value &&
!isValidURL(localState.value)
) {
message.error(t('msg.error.invalidURL'))
localState.value = undefined
return

17
packages/nc-gui/components/cell/attachment/index.vue

@ -178,6 +178,19 @@ const onImageClick = (item: any) => {
selectedImage.value = item
}
const keydownEnter = (e: KeyboardEvent) => {
if (!isSurveyForm.value) {
open(e)
e.stopPropagation()
}
}
const keydownSpace = (e: KeyboardEvent) => {
if (isSurveyForm.value) {
open(e)
e.stopPropagation()
}
}
</script>
<template>
@ -211,8 +224,8 @@ const onImageClick = (item: any) => {
data-testid="attachment-cell-file-picker-button"
tabindex="0"
@click="open"
@keydown.enter="!isSurveyForm ? open($event) : undefined"
@keydown.space="isSurveyForm ? open($event) : undefined"
@keydown.enter="keydownEnter"
@keydown.space="keydownSpace"
>
<component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />

4
packages/nc-gui/components/dashboard/Sidebar/TopSection.vue

@ -6,7 +6,7 @@ const { isUIAllowed } = useRoles()
const { appInfo } = useGlobal()
const { meta: metaKey, ctrlKey } = useMagicKeys()
const { meta: metaKey, control } = useMagicKeys()
const { isWorkspaceLoading, isWorkspaceSettingsPageOpened } = storeToRefs(workspaceStore)
@ -17,7 +17,7 @@ const { isSharedBase } = storeToRefs(baseStore)
const isCreateProjectOpen = ref(false)
const navigateToSettings = () => {
const cmdOrCtrl = isMac() ? metaKey.value : ctrlKey.value
const cmdOrCtrl = isMac() ? metaKey.value : control.value
// TODO: Handle cloud case properly
navigateToWorkspaceSettings('', cmdOrCtrl)

4
packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue

@ -73,7 +73,7 @@ const { orgRoles, isUIAllowed } = useRoles()
useTabs()
const { meta: metaKey, ctrlKey } = useMagicKeys()
const { meta: metaKey, control } = useMagicKeys()
const { refreshCommandPalette } = useCommandPalette()
@ -261,7 +261,7 @@ const onProjectClick = async (base: NcProject, ignoreNavigation?: boolean, toggl
if (!base) {
return
}
const cmdOrCtrl = isMac() ? metaKey.value : ctrlKey.value
const cmdOrCtrl = isMac() ? metaKey.value : control.value
if (!toggleIsExpanded && !cmdOrCtrl) $e('c:base:open')

4
packages/nc-gui/components/dashboard/TreeView/TableNode.vue

@ -39,7 +39,7 @@ useTableNew({
baseId: base.value.id!,
})
const { meta: metaKey, ctrlKey } = useMagicKeys()
const { meta: metaKey, control } = useMagicKeys()
const baseRole = inject(ProjectRoleInj)
provide(SidebarTableInj, table)
@ -108,7 +108,7 @@ const onExpand = async () => {
}
const onOpenTable = async () => {
if (isMac() ? metaKey.value : ctrlKey.value) {
if (isMac() ? metaKey.value : control.value) {
await _openTable(table.value, true)
return
}

4
packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue

@ -53,7 +53,7 @@ const { activeView } = storeToRefs(useViewsStore())
const { getMeta } = useMetas()
const { meta: metaKey, ctrlKey } = useMagicKeys()
const { meta: metaKey, control } = useMagicKeys()
const table = computed(() => props.table)
const injectedTable = ref(table.value)
@ -89,7 +89,7 @@ const onClick = useDebounceFn(() => {
const handleOnClick = () => {
if (isEditing.value || isStopped.value) return
const cmdOrCtrl = isMac() ? metaKey.value : ctrlKey.value
const cmdOrCtrl = isMac() ? metaKey.value : control.value
if (cmdOrCtrl) {
emits('changeView', vModel.value)

10
packages/nc-gui/components/general/FormBanner.vue

@ -7,6 +7,10 @@ interface Props {
const { bannerImageUrl } = defineProps<Props>()
const { getPossibleAttachmentSrc } = useAttachment()
const getBannerImageSrc = computed(() => {
return getPossibleAttachmentSrc(parseProp(bannerImageUrl))
})
</script>
<template>
@ -15,11 +19,7 @@ const { getPossibleAttachmentSrc } = useAttachment()
:class="!bannerImageUrl ? 'shadow-sm' : ''"
:style="{ aspectRatio: 4 / 1 }"
>
<LazyCellAttachmentImage
v-if="bannerImageUrl"
:srcs="getPossibleAttachmentSrc(parseProp(bannerImageUrl))"
class="nc-form-banner-image object-cover w-full"
/>
<LazyCellAttachmentImage v-if="bannerImageUrl" :srcs="getBannerImageSrc" class="nc-form-banner-image object-cover w-full" />
<div v-else class="h-full flex items-stretch justify-between bg-white">
<div class="flex -mt-1">
<img src="~assets/img/form-banner-left.png" alt="form-banner-left'" />

2
packages/nc-gui/components/general/ImageCropper.vue

@ -53,6 +53,7 @@ const handleUploadImage = async (fileToUpload: AttachmentReqType[]) => {
if (uploadResult?.[0]) {
emit('submit', {
...uploadResult[0],
data: fileToUpload[0].data,
})
} else {
emit('submit', fileToUpload[0])
@ -78,6 +79,7 @@ const handleSaveImage = async () => {
mimetype: imageConfig.type,
size: blob.size,
url: previewImage.value.src,
data: previewImage.value.src,
},
])
}, imageConfig.type)

4
packages/nc-gui/components/project/View.vue

@ -20,7 +20,7 @@ const { $e } = useNuxtApp()
return openedProject.value?.sources?.[0]
}) */
const { isUIAllowed } = useRoles()
const { isUIAllowed, baseRoles } = useRoles()
const { base } = storeToRefs(useBase())
@ -121,7 +121,7 @@ watch(
<!-- <a-tab-pane v-if="defaultBase" key="erd" tab="Base ERD" force-render class="pt-4 pb-12">
<ErdView :source-id="defaultBase!.id" class="!h-full" />
</a-tab-pane> -->
<a-tab-pane v-if="isUIAllowed('newUser')" key="collaborator">
<a-tab-pane v-if="isUIAllowed('newUser', { roles: baseRoles })" key="collaborator">
<template #tab>
<div class="tab-title" data-testid="proj-view-tab__access-settings">
<GeneralIcon icon="users" class="!h-3.5 !w-3.5" />

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

@ -196,7 +196,7 @@ onUnmounted(() => {
{
'text-brand-500': isPrimary(column) && !props.virtual && !isForm && !isCalendar,
'nc-grid-numeric-cell-right': isGrid && isNumericField && !isEditColumnMenu && !isForm && !isExpandedFormOpen,
'h-10': isForm && !isSurveyForm && !isAttachment(column) && !isTextArea(column) && !isJSON(column) && !props.virtual,
'h-10': !isEditColumnMenu && isForm && !isAttachment(column) && !isTextArea(column) && !isJSON(column) && !props.virtual,
'nc-grid-numeric-cell-left': (isForm && isNumericField && isExpandedFormOpen) || isEditColumnMenu,
'!min-h-30': isTextArea(column) && (isForm || isSurveyForm),
},

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

@ -3,6 +3,8 @@ import Draggable from 'vuedraggable'
import tinycolor from 'tinycolor2'
import { Pane, Splitpanes } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css'
import type { FormItemProps } from 'ant-design-vue'
import {
type AttachmentResType,
ProjectRoles,
@ -186,6 +188,8 @@ const { open, onChange: onChangeFile } = useFileDialog({
const visibleColumns = computed(() => localColumns.value.filter((f) => f.show).sort((a, b) => a.order - b.order))
const getFormLogoSrc = computed(() => getPossibleAttachmentSrc(parseProp(formViewData.value.logo_url)))
const updateView = useDebounceFn(
() => {
updateFormView(formViewData.value)
@ -546,6 +550,50 @@ const handleOnUploadImage = (data: AttachmentResType = null) => {
updateView()
}
const validateFormEmail = async (_rule, value) => {
if (!value) {
return Promise.resolve()
} else if (!validateEmail(value)) {
return Promise.reject(t('msg.error.invalidEmail'))
}
}
const validateFormURL = async (_rule, value) => {
if (!value) {
return Promise.resolve()
} else if (!isValidURL(value)) {
return Promise.reject(t('msg.error.invalidURL'))
}
}
const formElementValidationRules = (element) => {
const rules: FormItemProps['rules'][] = [
{
required: isRequired(element, element.required),
message: t('msg.error.fieldRequired', { value: 'This field' }),
},
]
if (parseProp(element.meta).validate && element.uidt === UITypes.URL) {
rules.push({
validator: validateFormURL,
})
} else if (parseProp(element.meta).validate && element.uidt === UITypes.Email) {
rules.push({
validator: validateFormEmail,
})
}
if ([UITypes.Number, UITypes.Currency, UITypes.Percent].includes(element.uidt)) {
rules.push({
type: 'number',
message: t('msg.plsEnterANumber'),
})
}
return rules
}
onClickOutside(draggableRef, (e) => {
if (
(e.target as HTMLElement)?.closest(
@ -736,7 +784,7 @@ useEventListener(
<div class="flex justify-center">
<div class="w-full">
<a-alert class="!my-4 !py-4 text-left !rounded-lg" type="success" outlined>
<a-alert class="nc-form-success-msg !my-4 !py-4 text-left !rounded-lg" type="success" outlined>
<template #message>
<LazyCellRichText
v-if="formViewData?.success_msg?.trim()"
@ -806,7 +854,7 @@ useEventListener(
></GeneralImageCropper>
<!-- cover image -->
<div v-if="!parseProp(formViewData?.meta).hide_banner" class="group relative max-w-[max(33%,688px)] mx-auto">
<GeneralFormBanner :banner-image-url="formViewData.banner_image_url" />
<GeneralFormBanner :key="formViewData.banner_image_url?.path" :banner-image-url="formViewData.banner_image_url" />
<div class="absolute bottom-0 right-0 hidden group-hover:block">
<div class="flex items-center space-x-1 m-2">
<NcTooltip :disabled="isEeUI">
@ -880,7 +928,8 @@ useEventListener(
>
<LazyCellAttachmentImage
v-if="formViewData.logo_url"
:srcs="getPossibleAttachmentSrc(parseProp(formViewData.logo_url))"
:key="formViewData.logo_url?.path"
:srcs="getFormLogoSrc"
class="flex-none nc-form-logo !object-contain object-left max-h-full max-w-full !m-0"
/>
<div
@ -973,8 +1022,7 @@ useEventListener(
:bordered="false"
:data-testid="NcForm.heading"
:data-title="NcForm.heading"
@blur="updateView"
@keydown.enter="updateView"
@update:value="updateView"
/>
</a-form-item>
@ -1199,12 +1247,7 @@ useEventListener(
<a-form-item
:name="element.title"
class="!my-0 nc-input-required-error nc-form-input-item"
:rules="[
{
required: isRequired(element, element.required),
message: `${$t('msg.error.fieldRequired', { value: 'This field' })}`,
},
]"
:rules="formElementValidationRules(element)"
>
<LazySmartsheetDivDataCell
class="relative"
@ -1725,6 +1768,9 @@ useEventListener(
&.nc-cell-geodata {
@apply !py-1;
}
&.nc-cell-currency {
@apply !py-0 !pl-0 flex items-stretch;
}
:deep(input) {
@apply !px-1;
@ -1732,8 +1778,11 @@ useEventListener(
&.nc-cell-longtext {
@apply p-0 h-auto;
}
&:not(.nc-cell-longtext) {
@apply px-2 py-2;
&.nc-cell:not(.nc-cell-longtext) {
@apply p-2;
}
&.nc-virtual-cell {
@apply px-2 py-1;
}
&.nc-cell-json {

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

@ -20,6 +20,7 @@ import {
isLink,
isLookup,
isMm,
isOo,
isPrimary,
isQrCode,
isRollup,
@ -111,6 +112,7 @@ onUnmounted(() => {
<LazyVirtualCellHasMany v-else-if="isHm(column)" />
<LazyVirtualCellManyToMany v-else-if="isMm(column)" />
<LazyVirtualCellBelongsTo v-else-if="isBt(column)" />
<LazyVirtualCellOneToOne v-else-if="isOo(column)" />
<LazyVirtualCellRollup v-else-if="isRollup(column)" />
<LazyVirtualCellFormula v-else-if="isFormula(column)" />
<LazyVirtualCellQrCode v-else-if="isQrCode(column)" />

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

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ModelTypes, MssqlUi, SqliteUi, UITypes } from 'nocodb-sdk'
import { ModelTypes, MssqlUi, RelationTypes, SqliteUi, UITypes } from 'nocodb-sdk'
import { MetaInj, inject, ref, storeToRefs, useBase, useVModel } from '#imports'
import MdiPlusIcon from '~icons/mdi/plus-circle-outline'
import MdiMinusIcon from '~icons/mdi/minus-circle-outline'
@ -52,16 +52,19 @@ const refTables = computed(() => {
const filterOption = (value: string, option: { key: string }) => option.key.toLowerCase().includes(value.toLowerCase())
const isLinks = computed(() => vModel.value.uidt === UITypes.Links)
const isLinks = computed(() => vModel.value.uidt === UITypes.Links && vModel.value.type !== RelationTypes.ONE_TO_ONE)
const oneToOneEnabled = ref(false)
</script>
<template>
<div class="w-full flex flex-col mb-2 mt-4">
<div class="border-2 p-6">
<a-form-item v-bind="validateInfos.type" class="nc-ltar-relation-type">
<a-radio-group v-model:value="vModel.type" name="type" v-bind="validateInfos.type">
<a-radio value="hm">{{ $t('title.hasMany') }}</a-radio>
<a-radio-group v-model:value="vModel.type" name="type" v-bind="validateInfos.type" class="!flex flex-col gap-2">
<a-radio value="hm" @dblclick="oneToOneEnabled = !oneToOneEnabled">{{ $t('title.hasMany') }}</a-radio>
<a-radio value="mm">{{ $t('title.manyToMany') }}</a-radio>
<a-radio v-if="oneToOneEnabled" value="oo">{{ $t('title.oneToOne') }}</a-radio>
</a-radio-group>
</a-form-item>
@ -102,7 +105,7 @@ const isLinks = computed(() => vModel.value.uidt === UITypes.Links)
</div>
<div v-if="advancedOptions" class="flex flex-col p-6 gap-4 border-2 mt-2">
<LazySmartsheetColumnLinkOptions v-model:value="vModel" class="-my-2" />
<LazySmartsheetColumnLinkOptions v-if="isLinks" v-model:value="vModel" class="-my-2" />
<template v-if="!isXcdbBase">
<div class="flex flex-row space-x-2">
<a-form-item class="flex w-1/2" :label="$t('labels.onUpdate')">

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

@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted } from '@vue/runtime-core'
import type { ColumnType, LinkToAnotherRecordType, TableType, UITypes } from 'nocodb-sdk'
import { type ColumnType, type LinkToAnotherRecordType, RelationTypes, type TableType, type UITypes } from 'nocodb-sdk'
import { getAvailableRollupForUiType, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from '#imports'
import {
@ -58,7 +58,7 @@ const refTables = computed(() => {
.filter(
(c) =>
isLinksOrLTAR(c) &&
(c.colOptions as LinkToAnotherRecordType).type !== 'bt' &&
![RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes((c.colOptions as LinkToAnotherRecordType).type) &&
!c.system &&
c.source_id === meta.value?.source_id,
)

9
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -35,6 +35,7 @@ import {
isEeUI,
isMac,
isMm,
isOo,
message,
onClickOutside,
onMounted,
@ -294,7 +295,7 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
columnObj.id === col.id
) {
if (rowRefs.value) {
if (isBt(columnObj)) {
if (isBt(columnObj) || isOo(columnObj)) {
rowObj.row[columnObj.title] = row.row[columnObj.title]
await rowRefs.value[ctx.row]!.addLTARRef(rowObj.row[columnObj.title], columnObj)
@ -335,7 +336,7 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
const columnObj = fields.value[ctx.col]
if (rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) && columnObj.id === col.id) {
if (rowRefs.value) {
if (isBt(columnObj)) {
if (isBt(columnObj) || isOo(columnObj)) {
await rowRefs.value[ctx.row]!.clearLTARCell(columnObj)
} else if (isMm(columnObj)) {
await rowRefs.value[ctx.row]!.cleaMMCell(columnObj)
@ -357,7 +358,7 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
},
scope: defineViewScope({ view: view.value }),
})
if (isBt(columnObj) && rowRefs.value) await rowRefs.value[ctx.row]!.clearLTARCell(columnObj)
if ((isBt(columnObj) || isOo(columnObj)) && rowRefs.value) await rowRefs.value[ctx.row]!.clearLTARCell(columnObj)
return
}
@ -928,7 +929,7 @@ async function clearSelectedRangeOfCells() {
// TODO handle LinkToAnotherRecord
if (isVirtualCol(col)) {
if ((isBt(col) || isMm(col)) && !isInfoShown) {
if ((isBt(col) || isOo(col) || isMm(col)) && !isInfoShown) {
message.info(t('msg.info.groupClearIsNotSupportedOnLinksColumn'))
isInfoShown = true
}

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

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ColumnReqType, ColumnType, FormulaType, LinkToAnotherRecordType, LookupType, RollupType } from 'nocodb-sdk'
import { UITypes, UITypesName, substituteColumnIdWithAliasInFormula } from 'nocodb-sdk'
import { RelationTypes, UITypes, UITypesName, substituteColumnIdWithAliasInFormula } from 'nocodb-sdk'
import {
ColumnInj,
IsFormInj,
@ -122,6 +122,10 @@ const columnTypeName = computed(() => {
if (column.value.uidt === UITypes.LongText && parseProp(column?.value?.meta)?.richMode) {
return UITypesName.RichText
}
if (column.value.uidt === UITypes.LinkToAnotherRecord && column.value.colOptions?.type === RelationTypes.ONE_TO_ONE) {
return UITypesName[UITypes.Links]
}
return column.value.uidt ? UITypesName[column.value.uidt] : ''
})

2
packages/nc-gui/components/smartsheet/header/VirtualCellIcon.ts

@ -30,6 +30,8 @@ const renderIcon = (column: ColumnType, relationColumn?: ColumnType) => {
return { icon: iconMap.hm_solid }
case RelationTypes.BELONGS_TO:
return { icon: iconMap.bt_solid }
case RelationTypes.ONE_TO_ONE:
return { icon: iconMap.oneToOneSolid, color: 'text-blue-500' }
}
break
case UITypes.SpecificDBType:

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

@ -50,7 +50,12 @@ const options = computed<ColumnType[]>(
return false
} else {
/** ignore hasmany and manytomany relations if it's using within group menu */
return !(isLinksOrLTAR(c) && (c.colOptions as LinkToAnotherRecordType).type !== RelationTypes.BELONGS_TO)
return !(
isLinksOrLTAR(c) &&
![RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(
(c.colOptions as LinkToAnotherRecordType).type as RelationTypes,
)
)
}
})
.filter((c: ColumnType) => !groupBy.value.find((g) => g.column?.id === c.id))

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

@ -46,7 +46,12 @@ const options = computed<ColumnType[]>(
return false
} else {
/** ignore hasmany and manytomany relations if it's using within sort menu */
return !(isLinksOrLTAR(c) && (c.colOptions as LinkToAnotherRecordType).type !== RelationTypes.BELONGS_TO)
return !(
isLinksOrLTAR(c) &&
![RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(
(c.colOptions as LinkToAnotherRecordType).type as RelationTypes,
)
)
/** ignore virtual fields which are system fields ( mm relation ) and qr code fields */
}
})

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

@ -67,7 +67,10 @@ const availableColumns = computed(() => {
return false
} else {
/** ignore hasmany and manytomany relations if it's using within sort menu */
return !(isLinksOrLTAR(c) && (c.colOptions as LinkToAnotherRecordType).type !== RelationTypes.BELONGS_TO)
return !(
isLinksOrLTAR(c) &&
![RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes((c.colOptions as LinkToAnotherRecordType).type)
)
/** ignore virtual fields which are system fields ( mm relation ) and qr code fields */
}
})

18
packages/nc-gui/components/virtual-cell/BelongsTo.vue

@ -42,12 +42,8 @@ const listItemsDlg = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, unlink } = useProvideLTARStore(
column as Ref<Required<ColumnType>>,
row,
isNew,
reloadRowTrigger.trigger,
)
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } =
useProvideLTARStore(column as Ref<Required<ColumnType>>, row, isNew, reloadRowTrigger.trigger)
await loadRelatedTableMeta()
@ -96,10 +92,14 @@ watch([listItemsDlg], () => {
<template>
<div class="flex w-full chips-wrapper items-center" :class="{ active }">
<div class="nc-cell-field chips flex items-center flex-1">
<template v-if="value && relatedTableDisplayValueProp">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
<VirtualCellComponentsItemChip
:item="value"
:value="!Array.isArray(value) && typeof value === 'object' ? value[relatedTableDisplayValueProp] : value"
:value="
!Array.isArray(value) && typeof value === 'object'
? value[relatedTableDisplayValueProp] ?? value[relatedTableDisplayValuePropId]
: value
"
:column="belongsToColumn"
:show-unlink-button="true"
@unlink="unlinkRef(value)"
@ -116,7 +116,7 @@ watch([listItemsDlg], () => {
>
<GeneralIcon
:icon="addIcon"
class="text-sm nc-action-icon group-focus:visible invisible text-gray-500/50 hover:text-gray-500 select-none group-hover:(text-gray-500) nc-plus"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="listItemsDlg = true"
/>
</div>

12
packages/nc-gui/components/virtual-cell/Lookup.vue

@ -77,9 +77,12 @@ watch([lookupColumn, rowHeight], () => {
const arrValue = computed(() => {
if (!cellValue.value) return []
// if lookup column is Attachment and relation type is Belongs to wrap the value in an array
// if lookup column is Attachment and relation type is Belongs/OneToOne to wrap the value in an array
// since the attachment component expects an array or JSON string array
if (lookupColumn.value?.uidt === UITypes.Attachment && relationColumn.value?.colOptions?.type === RelationTypes.BELONGS_TO)
if (
lookupColumn.value?.uidt === UITypes.Attachment &&
[RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(relationColumn.value?.colOptions?.type)
)
return [cellValue.value]
// TODO: We are filtering null as cell value can be null. Find the root cause and fix it
@ -114,11 +117,12 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
<template v-if="lookupColumn">
<!-- Render virtual cell -->
<div v-if="isVirtualCol(lookupColumn)" class="flex h-full">
<!-- If non-belongs-to LTAR column then pass the array value, else iterate and render -->
<!-- If non-belongs-to and non-one-to-one LTAR column then pass the array value, else iterate and render -->
<template
v-if="
lookupColumn.uidt !== UITypes.LinkToAnotherRecord ||
(lookupColumn.uidt === UITypes.LinkToAnotherRecord && lookupColumn.colOptions.type === RelationTypes.BELONGS_TO)
(lookupColumn.uidt === UITypes.LinkToAnotherRecord &&
[RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(lookupColumn.colOptions.type))
"
>
<LazySmartsheetVirtualCell

150
packages/nc-gui/components/virtual-cell/OneToOne.vue

@ -0,0 +1,150 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import {
ActiveCellInj,
CellValueInj,
ColumnInj,
IsFormInj,
IsUnderLookupInj,
ReadonlyInj,
ReloadRowDataHookInj,
RowInj,
computed,
createEventHook,
inject,
ref,
useProvideLTARStore,
useRoles,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow,
} from '#imports'
const column = inject(ColumnInj)!
const reloadRowTrigger = inject(ReloadRowDataHookInj, createEventHook())
const cellValue = inject(CellValueInj, ref<any>(null))
const row = inject(RowInj)!
const active = inject(ActiveCellInj)!
const readOnly = inject(ReadonlyInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const { isUIAllowed } = useRoles()
const listItemsDlg = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } =
useProvideLTARStore(column as Ref<Required<ColumnType>>, row, isNew, reloadRowTrigger.trigger)
await loadRelatedTableMeta()
const addIcon = computed(() => (cellValue?.value ? 'expand' : 'plus'))
const value = computed(() => {
if (cellValue?.value) {
return cellValue?.value
} else if (isNew.value) {
return state?.value?.[column?.value.title as string]
}
return null
})
const unlinkRef = async (rec: Record<string, any>) => {
if (isNew.value) {
await removeLTARRef(rec, column?.value as ColumnType)
} else {
await unlink(rec)
}
}
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
listItemsDlg.value = true
e.stopPropagation()
break
}
})
const belongsToColumn = computed(
() =>
relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined,
)
const plusBtnRef = ref<HTMLElement | null>(null)
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
}
})
</script>
<template>
<div class="flex w-full chips-wrapper items-center" :class="{ active }">
<div class="nc-cell-field chips flex items-center flex-1">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
<VirtualCellComponentsItemChip
:item="value"
:value="
!Array.isArray(value) && typeof value === 'object'
? value[relatedTableDisplayValueProp] ?? value[relatedTableDisplayValuePropId]
: value
"
:column="belongsToColumn"
:show-unlink-button="true"
@unlink="unlinkRef(value)"
/>
</template>
</div>
<div
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup"
ref="plusBtnRef"
class="flex justify-end group gap-1 min-h-[30px] items-center"
tabindex="0"
@keydown.enter.stop="listItemsDlg = true"
>
<GeneralIcon
:icon="addIcon"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="listItemsDlg = true"
/>
</div>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="belongsToColumn"
@attach-record="listItemsDlg = true"
/>
</div>
</template>
<style scoped lang="scss">
.nc-action-icon {
@apply cursor-pointer;
}
.chips-wrapper:hover,
.chips-wrapper.active {
.nc-action-icon {
@apply inline-block;
}
}
.chips-wrapper:hover {
.nc-action-icon {
@apply visible;
}
}
</style>

11
packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue

@ -51,6 +51,7 @@ const {
unlink,
row,
headerDisplayValue,
resetChildrenExcludedOffsetCount
} = useLTARStoreOrThrow()
const { addLTARRef, isNew, removeLTARRef, state: rowState } = useSmartsheetRowStoreOrThrow()
@ -101,6 +102,9 @@ watch(
}
loadChildrenExcludedList(rowState.value)
}
if(!nextVal){
resetChildrenExcludedOffsetCount()
}
},
{
immediate: true,
@ -255,6 +259,11 @@ onUnmounted(() => {
childrenExcludedListPagination.query = ''
window.removeEventListener('keydown', linkedShortcuts)
})
const onFilterChange = () => {
childrenExcludedListPagination.page = 1;
resetChildrenExcludedOffsetCount()
}
</script>
<template>
@ -285,7 +294,7 @@ onUnmounted(() => {
:placeholder="`${$t('general.searchIn')} ${relatedTableMeta?.title}`"
class="w-full !rounded-md nc-excluded-search xs:min-h-8"
size="small"
@change="childrenExcludedListPagination.page = 1"
@change="onFilterChange"
@keydown.capture.stop="
(e) => {
if (e.key === 'Escape') {

43
packages/nc-gui/components/webhook/Editor.vue

@ -79,7 +79,7 @@ let hookRef = reactive<
const isBodyShownEasterEgg = ref(false)
const isBodyShown = ref(hookRef.version === 'v1' || isEeUI)
const urlTabKey = ref(isBodyShownEasterEgg.value && isBodyShown.value ? 'body' : 'params')
const urlTabKey = ref<'params' | 'headers' | 'body'>('params')
const apps: Record<string, any> = ref()
@ -321,14 +321,6 @@ function setHook(newHook: HookType) {
payload: notification.payload,
},
})
if (hookRef.version === 'v1' || isEeUI) {
urlTabKey.value = 'body'
eventList.value = [
{ text: ['After', 'Insert'], value: ['after', 'insert'] },
{ text: ['After', 'Update'], value: ['after', 'update'] },
{ text: ['After', 'Delete'], value: ['after', 'delete'] },
]
}
}
function onEventChange() {
@ -486,6 +478,13 @@ const getDefaultHookName = (hooks: HookType[]) => {
return extractNextDefaultName([...hooks.map((el) => el?.title || '')], defaultHookName)
}
const handleToggleEasterEgg = () => {
isBodyShownEasterEgg.value = !isBodyShownEasterEgg.value
if (!(isBodyShown.value && isBodyShownEasterEgg.value) && urlTabKey.value === 'body') {
urlTabKey.value = 'params'
}
}
watch(
() => hookRef.eventOperation,
() => {
@ -592,7 +591,13 @@ onMounted(async () => {
class="nc-text-field-hook-event capitalize"
dropdown-class-name="nc-dropdown-webhook-event"
>
<a-select-option v-for="(event, i) in eventList" :key="i" class="capitalize" :value="event.value.join(' ')">
<a-select-option
v-for="(event, i) in eventList"
:key="i"
class="capitalize"
:value="event.value.join(' ')"
:disabled="hookRef.version === 'v1' && ['bulkInsert', 'bulkUpdate', 'bulkDelete'].includes(event.value[1])"
>
<div class="flex items-center gap-2 justify-between">
<div>{{ event.text.join(' ') }}</div>
<component
@ -657,7 +662,7 @@ onMounted(async () => {
size="large"
class="nc-select-hook-url-method"
dropdown-class-name="nc-dropdown-hook-notification-url-method"
@dblclick="isBodyShownEasterEgg = !isBodyShownEasterEgg"
@dblclick="handleToggleEasterEgg"
>
<a-select-option v-for="(method, i) in methodList" :key="i" :value="method.title">
<div class="flex items-center gap-2 justify-between">
@ -687,6 +692,14 @@ onMounted(async () => {
<a-col :span="24">
<NcTabs v-model:activeKey="urlTabKey" type="card" closeable="false" class="border-1 !pb-2 !rounded-lg">
<a-tab-pane key="params" :tab="$t('title.parameter')" force-render>
<LazyApiClientParams v-model="hookRef.notification.payload.parameters" class="p-4" />
</a-tab-pane>
<a-tab-pane key="headers" :tab="$t('title.headers')" class="nc-tab-headers">
<LazyApiClientHeaders v-model="hookRef.notification.payload.headers" class="!p-4" />
</a-tab-pane>
<a-tab-pane v-if="isBodyShown && isBodyShownEasterEgg" key="body" tab="Body">
<LazyMonacoEditor
v-model="hookRef.notification.payload.body"
@ -696,14 +709,6 @@ onMounted(async () => {
/>
</a-tab-pane>
<a-tab-pane key="params" :tab="$t('title.parameter')" force-render>
<LazyApiClientParams v-model="hookRef.notification.payload.parameters" class="p-4" />
</a-tab-pane>
<a-tab-pane key="headers" :tab="$t('title.headers')" class="nc-tab-headers">
<LazyApiClientHeaders v-model="hookRef.notification.payload.headers" class="!p-4" />
</a-tab-pane>
<!-- No in use at this moment -->
<!-- <a-tab-pane key="auth" tab="Auth"> -->
<!-- <LazyMonacoEditor v-model="hook.notification.payload.auth" class="min-h-60 max-h-80" /> -->

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

@ -2,11 +2,10 @@ import type {
type ColumnType,
type LinkToAnotherRecordType,
type PaginatedType,
RelationTypes,
type RequestParams,
type TableType,
} from 'nocodb-sdk'
import { UITypes, dateFormats, parseStringDateTime, timeFormats } from 'nocodb-sdk'
import { RelationTypes, UITypes, dateFormats, parseStringDateTime, timeFormats } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue'
import type { Row } from '#imports'
import {
@ -66,6 +65,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
query: '',
size: 10,
})
const childrenExcludedOffsetCount = ref(0)
const childrenListPagination = reactive({
@ -120,7 +120,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const getRelatedTableRowId = (row: Record<string, any>) => {
return relatedTableMeta.value?.columns
?.filter((c) => c.pk)
.map((c) => row?.[c.title as string])
.map((c) => row?.[c.title as string] ?? row?.[c.id as string])
.join('___')
}
@ -134,6 +134,11 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
return (relatedTableMeta.value?.columns?.find((c) => c.pv) || relatedTableMeta?.value?.columns?.[0])?.title || ''
})
// todo: temp fix, handle in backend
const relatedTableDisplayValuePropId = computed(() => {
return (relatedTableMeta.value?.columns?.find((c) => c.pv) || relatedTableMeta?.value?.columns?.[0])?.id || ''
})
const relatedTablePrimaryKeyProps = computed(() => {
return relatedTableMeta.value?.columns?.filter((c) => c.pk)?.map((c) => c.title) ?? []
})
@ -186,8 +191,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const loadChildrenExcludedList = async (activeState?: any) => {
if (activeState) newRowState.state = activeState
try {
let offset =
childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1) - childrenExcludedOffsetCount.value
let offset = childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1) - childrenExcludedOffsetCount.value
if (offset < 0) {
offset = 0
@ -278,6 +282,12 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
})
}
} catch (e: any) {
// temporary fix to handle when offset is beyond limit
if ((await extractSdkResponseErrorMsg(e)) === 'Offset is beyond the total number of records') {
childrenExcludedListPagination.page = 0
return loadChildrenExcludedList(activeState)
}
message.error(`${t('msg.error.failedToLoadList')}: ${await extractSdkResponseErrorMsg(e)}`)
} finally {
isChildrenExcludedLoading.value = false
@ -287,7 +297,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const loadChildrenList = async () => {
try {
isChildrenLoading.value = true
if (colOptions.value.type === 'bt') return
if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(colOptions.value.type)) return
if (!rowId.value || !column.value) return
let offset = childrenListPagination.size * (childrenListPagination.page - 1) + childrenListOffsetCount.value
@ -438,7 +448,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
}
isChildrenExcludedListLinked.value[index] = false
isChildrenListLinked.value[index] = false
if (colOptions.value.type !== 'bt') {
if (colOptions.value.type !== RelationTypes.BELONGS_TO && colOptions.value.type !== RelationTypes.ONE_TO_ONE) {
childrenListCount.value = childrenListCount.value - 1
}
} catch (e: any) {
@ -505,7 +515,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
isChildrenExcludedListLinked.value[index] = true
isChildrenListLinked.value[index] = true
if (colOptions.value.type !== 'bt') {
if (colOptions.value.type !== RelationTypes.BELONGS_TO && colOptions.value.type !== RelationTypes.ONE_TO_ONE) {
childrenListCount.value = childrenListCount.value + 1
} else {
isChildrenExcludedListLinked.value = Array(childrenExcludedList.value?.list.length).fill(false)
@ -540,6 +550,10 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
})
})
const resetChildrenExcludedOffsetCount = () =>{
childrenExcludedOffsetCount.value = 0;
}
return {
relatedTableMeta,
loadRelatedTableMeta,
@ -569,6 +583,8 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
deleteRelatedRow,
getRelatedTableRowId,
headerDisplayValue,
relatedTableDisplayValuePropId,
resetChildrenExcludedOffsetCount
}
},
'ltar-store',

4
packages/nc-gui/composables/useMultiSelect/convertCellData.ts

@ -2,7 +2,7 @@ import dayjs from 'dayjs'
import type { AttachmentType, ColumnType, LinkToAnotherRecordType, SelectOptionsType } from 'nocodb-sdk'
import { UITypes, getDateFormat, getDateTimeFormat, populateUniqueFileName } from 'nocodb-sdk'
import type { AppInfo } from '~/composables/useGlobal'
import { isBt, isMm, parseProp } from '#imports'
import { isBt, isMm, isOo, parseProp } from '#imports'
export default function convertCellData(
args: { to: UITypes; value: string; column: ColumnType; appInfo: AppInfo; files?: FileList | File[]; oldValue?: unknown },
@ -250,7 +250,7 @@ export default function convertCellData(
return undefined
}
if (isBt(column)) {
if (isBt(column) || isOo(column)) {
const parsedVal = typeof value === 'string' ? JSON.parse(value) : value
if (

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

@ -33,6 +33,7 @@ import {
isExpandedCellInputExist,
isMac,
isMm,
isOo,
isTypableInputColumn,
message,
parseProp,
@ -158,7 +159,7 @@ export function useMultiSelect(
}
}
if (isBt(columnObj)) {
if (isBt(columnObj) || isOo(columnObj)) {
// fk_related_model_id is used to prevent paste operation in different fk_related_model_id cell
textToCopy = {
fk_related_model_id: (columnObj.colOptions as LinkToAnotherRecordType).fk_related_model_id,
@ -859,7 +860,7 @@ export function useMultiSelect(
const pasteCol = colsToPaste[j]
if (!isPasteable(pasteRow, pasteCol)) {
if ((isBt(pasteCol) || isMm(pasteCol)) && !isInfoShown) {
if ((isBt(pasteCol) || isOo(pasteCol) || isMm(pasteCol)) && !isInfoShown) {
message.info(t('msg.info.groupPasteIsNotSupportedOnLinksColumn'))
isInfoShown = true
}
@ -1163,7 +1164,7 @@ export function useMultiSelect(
for (const col of cols) {
if (!col.title || !isPasteable(row, col)) {
if ((isBt(col) || isMm(col)) && !isInfoShown) {
if ((isBt(col) || isOo(pasteCol) || isMm(col)) && !isInfoShown) {
message.info(t('msg.info.groupPasteIsNotSupportedOnLinksColumn'))
isInfoShown = true
}

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

@ -12,7 +12,7 @@ import type {
StringOrNullType,
TableType,
} from 'nocodb-sdk'
import { ErrorMessages, RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { isString } from '@vue/shared'
import { filterNullOrUndefinedObjectProperties } from '~/helpers/parsers/parserHelpers'
import {
@ -22,6 +22,7 @@ import {
createEventHook,
extractSdkResponseErrorMsg,
isNumericFieldType,
isValidURL,
message,
parseProp,
provide,
@ -34,6 +35,7 @@ import {
useMetas,
useProvideSmartsheetRowStore,
useViewsStore,
validateEmail,
watch,
} from '#imports'
import type { SharedViewMeta } from '#imports'
@ -138,7 +140,7 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
c?.cdf &&
!/^\w+\(\)|CURRENT_TIMESTAMP$/.test(c.cdf)
) {
formState.value[c.title] = c.cdf
formState.value[c.title] = typeof c.cdf === 'string' ? c.cdf.replace(/^'|'$/g, '') : c.cdf
}
return {
@ -176,7 +178,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
} catch (e: any) {
if (e.response && e.response.status === 404) {
notFound.value = true
} else if ((await extractSdkResponseErrorMsg(e)) === ErrorMessages.INVALID_SHARED_VIEW_PASSWORD) {
// TODO - handle invalidSharedViewPassword
} else if (await extractSdkResponseErrorMsg(e)) {
passwordDlg.value = true
if (password.value && password.value !== '') passwordError.value = 'Something went wrong. Please check your credentials.'
@ -197,7 +200,9 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
!isVirtualCol(column) &&
((column.rqd && !column.cdf) || (column.pk && !(column.ai || column.cdf)) || column.required)
) {
obj.localState[column.title!] = { required: fieldRequired() }
obj.localState[column.title!] = {
required: fieldRequired(),
}
} else if (
isLinksOrLTAR(column) &&
column.colOptions &&
@ -216,6 +221,37 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
required: fieldRequired(),
}
}
if (
!isVirtualCol(column) &&
parseProp(column.meta)?.validate &&
[UITypes.URL, UITypes.Email].includes(column.uidt as UITypes)
) {
if (column.uidt === UITypes.URL) {
obj.localState[column.title!] = {
...(obj.localState[column.title!] || {}),
validateFormURL: helpers.withMessage(t('msg.error.invalidURL'), (value) => {
return value ? isValidURL(value) : true
}),
}
} else if (column.uidt === UITypes.Email) {
obj.localState[column.title!] = {
...(obj.localState[column.title!] || {}),
validateFormEmail: helpers.withMessage(t('msg.error.invalidEmail'), (value) => {
return value ? validateEmail(value) : true
}),
}
}
}
if ([UITypes.Number, UITypes.Currency, UITypes.Percent].includes(column.uidt as UITypes)) {
obj.localState[column.title!] = {
...(obj.localState[column.title!] || {}),
validateFormNumber: helpers.withMessage(t('msg.plsEnterANumber'), (value) => {
return value ? (column.uidt === UITypes.Number ? /^\d+$/.test(value) : /^\d*\.?\d+$/.test(value)) : true
}),
}
}
}
return obj

11
packages/nc-gui/composables/useSmartsheetRowStore.ts

@ -11,6 +11,7 @@ import {
isBt,
isHm,
isMm,
isOo,
message,
ref,
storeToRefs,
@ -56,7 +57,7 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
} else {
state.value[column.title!]!.push(value)
}
} else if (isBt(column)) {
} else if (isBt(column) || isOo(column)) {
state.value[column.title!] = value
}
}
@ -65,7 +66,7 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
const removeLTARRef = async (value: Record<string, any>, column: ColumnType) => {
if (isHm(column) || isMm(column)) {
state.value[column.title!]?.splice(state.value[column.title!]?.indexOf(value), 1)
} else if (isBt(column)) {
} else if (isBt(column) || isOo(column)) {
state.value[column.title!] = null
}
}
@ -114,7 +115,7 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
{ metaValue },
)
}
} else if (isBt(column) && state.value?.[column.title!]) {
} else if ((isBt(column) || isOo(column)) && state.value?.[column.title!]) {
await linkRecord(
id,
extractPkFromRow(state.value?.[column.title!] as Record<string, any>, relatedTableMeta.columns as ColumnType[]),
@ -139,14 +140,14 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
if (isNew.value) {
state.value[column.title!] = null
} else if (currentRow.value) {
if ((<LinkToAnotherRecordType>column.colOptions)?.type === RelationTypes.BELONGS_TO) {
if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes((<LinkToAnotherRecordType>column.colOptions)?.type)) {
if (!currentRow.value.row[column.title!]) return
await $api.dbTableRow.nestedRemove(
NOCO,
base.value.id as string,
meta.value?.id as string,
extractPkFromRow(currentRow.value.row, meta.value?.columns as ColumnType[]),
'bt' as any,
(<LinkToAnotherRecordType>column.colOptions)?.type as any,
column.id as string,
extractPkFromRow(currentRow.value.row[column.title!], relatedTableMeta?.columns as ColumnType[]),
)

1
packages/nc-gui/lang/ar.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/bn_IN.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/cs.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/da.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/de.json

@ -339,6 +339,7 @@
"removeFile": "Datei löschen",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtuelle Relation",
"linkMore": "Mehr verknüpfen",
"linkMoreRecords": "Weitere Datensätze verknüpfen",

3
packages/nc-gui/lang/en.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",
@ -698,7 +699,7 @@
"hideNocodbBranding": "Hide NocoDB Branding",
"showOnConditions": "Show on condtions",
"showFieldOnConditionsMet": "Shows field only when conditions are met",
"limitOptions": "Limit ptions",
"limitOptions": "Limit options",
"limitOptionsSubtext": "Limit options visible to users by selecting available options",
"clearSelection": "Clear selection"
},

1
packages/nc-gui/lang/es.json

@ -339,6 +339,7 @@
"removeFile": "Eliminar archivo",
"hasMany": "Tiene muchos",
"manyToMany": "Muchos a Muchos",
"oneToOne": "One to One",
"virtualRelation": "Relación virtual",
"linkMore": "Enlace más",
"linkMoreRecords": "Vincular más registros",

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

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Hainbat ditu",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/fa.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/fi.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

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

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

3
packages/nc-gui/lang/he.json

@ -1,7 +1,7 @@
{
"dashboards": {
"create_new_dashboard_project": "Create New Interface",
"connect_data_sources": "Connect data sources",
"connect_data_sources": "בחר מקור מידע",
"alert": "Alert",
"alert-message": "No databases have been connected. Connect database bases to build interfaces. Skip this step and add databases from the base home page later.",
"select_database_projects_that_you_want_to_link_to_this_dashboard_projects": "Select Database Bases that you want to link to this Interface.",
@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/hi.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/hr.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/id.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/it.json

@ -339,6 +339,7 @@
"removeFile": "Rimuovi File",
"hasMany": "Ha Molti",
"manyToMany": "Molti a Molti",
"oneToOne": "One to One",
"virtualRelation": "Relazione Virtuale",
"linkMore": "Collega Altro",
"linkMoreRecords": "Collega più righe",

1
packages/nc-gui/lang/ja.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/ko.json

@ -339,6 +339,7 @@
"removeFile": "파일삭제",
"hasMany": "많이",
"manyToMany": "다대다",
"oneToOne": "One to One",
"virtualRelation": "가상 관계",
"linkMore": "링크 더 보기",
"linkMoreRecords": "더 많은 레코드 연결",

1
packages/nc-gui/lang/lv.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/nl.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/no.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

15
packages/nc-gui/lang/pl.json

@ -198,7 +198,7 @@
"logo": "Logo",
"dropdown": "Rozwijane menu",
"list": "Lista",
"apply": "Apply"
"apply": "Zastosuj"
},
"objects": {
"day": "Dzień",
@ -339,6 +339,7 @@
"removeFile": "Usuń plik",
"hasMany": "Ma wiele",
"manyToMany": "Wiele do wielu",
"oneToOne": "One to One",
"virtualRelation": "Relacja wirtualna",
"linkMore": "Połącz więcej",
"linkMoreRecords": "Połącz więcej rekordów",
@ -430,7 +431,7 @@
},
"selectFieldsFromRightPannelToAddHere": "Wybierz pole z panelu po prawej stronie aby tutaj dodać",
"noOptionsFound": "Nie znaleziono opcji",
"surveyFormSubmitConfirmMsg": "Are you sure you want to submit this form?"
"surveyFormSubmitConfirmMsg": "Czy na pewno chcesz przesłać ten formularz?"
},
"labels": {
"selectYear": "Wybierz rok",
@ -682,7 +683,7 @@
"sourceNameRequired": "nazwa źródła jest wymagana",
"changeWsName": "Zmień nazwę przestrzeni roboczej",
"pressEnter": "Naciśnij Enter",
"newFormLoaded": "Loading new form in",
"newFormLoaded": "Ładowanie nowego formularza w",
"webhook": "Webhook",
"multiField": {
"newField": "Nowe pole",
@ -703,10 +704,10 @@
"clearSelection": "Wyczyść wybór"
},
"activity": {
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"addMember": "Add Member to Base",
"addMembers": "Dodaj Członków",
"enterEmail": "Wpisz adresy e-mail",
"inviteToBase": "Zaproś do bazy",
"addMember": "Dodaj członka do bazy",
"noRange": "Widok kalendarza wymaga zakresu dat",
"goToToday": "Przejdź do dzisiaj",
"toggleSidebar": "Przełącz pasek boczny",

1
packages/nc-gui/lang/pt.json

@ -339,6 +339,7 @@
"removeFile": "Remover ficheiro",
"hasMany": "Tem muitos",
"manyToMany": "De muitos para muitos",
"oneToOne": "One to One",
"virtualRelation": "Relação virtual",
"linkMore": "Vincular mais",
"linkMoreRecords": "Vincular mais registos",

1
packages/nc-gui/lang/pt_BR.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

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

@ -339,6 +339,7 @@
"removeFile": "Удалить файл",
"hasMany": "Имеет много",
"manyToMany": "Многие ко многим",
"oneToOne": "One to One",
"virtualRelation": "Виртуальные отношения",
"linkMore": "Ссылка Подробнее",
"linkMoreRecords": "Связать больше записей",

1
packages/nc-gui/lang/sk.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/sl.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/sv.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/th.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/tr.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

69
packages/nc-gui/lang/uk.json

@ -81,33 +81,33 @@
"createEntity": "Створити {entity}",
"creating": "Створення",
"creatingEntity": "Створення {entity}",
"details": "Details",
"skip": "Skip",
"code": "Code",
"details": "Деталі",
"skip": "Пропустити",
"code": "Код",
"duplicate": "Дублювати",
"duplicating": "Duplicating",
"activate": "Activate",
"action": "Action",
"duplicating": "Дублювати",
"activate": "Активувати",
"action": "Дія",
"insert": "Вставити",
"delete": "Видалити",
"deleteEntity": "Delete {entity}",
"bulkInsert": "Bulk Insert",
"bulkDelete": "Bulk Delete",
"bulkUpdate": "Bulk Update",
"deleting": "Deleting",
"deleteEntity": "Видалити {entity}",
"bulkInsert": "Масова вставка",
"bulkDelete": "Масове видалення",
"bulkUpdate": "Масове оновлення",
"deleting": "Видалення",
"update": "Оновити",
"rename": "Перейменувати",
"reload": "Перезавантажити",
"reset": "Відновити",
"install": "Встановити",
"show": "Показати",
"access": "Access",
"visibility": "Visibility",
"access": "Доступ",
"visibility": "Видимість",
"hide": "Сховати",
"deprecated": "Deprecated",
"deprecated": "Застаріле",
"showAll": "Показати все",
"hideAll": "Сховати все",
"notFound": "Not found",
"notFound": "Не знайдено",
"showMore": "Показати більше",
"showOptions": "Показати опції",
"hideOptions": "Сховати опції",
@ -127,8 +127,8 @@
"upload": "Завантажити",
"download": "Завантажити на ПК",
"default": "За замовчуванням",
"base": "Source",
"datasource": "Data Source",
"base": "Джерело",
"datasource": "Джерело даних",
"more": "Більше",
"less": "Менше",
"event": "Подія",
@ -136,14 +136,14 @@
"after": "Після",
"before": "До",
"search": "Пошук",
"searchIn": "Search In",
"searchIn": "Шукати в",
"notification": "Сповіщення",
"reference": "Посилання",
"function": "Функція",
"confirm": "Підтвердити",
"generate": "Генерувати",
"copy": "Копіювати",
"are": "are",
"are": "є",
"misc": "Інше",
"lock": "Блокувати",
"unlock": "Розблокувати",
@ -157,25 +157,25 @@
"groupingField": "Поле групування",
"insertAfter": "Вставити після",
"insertBefore": "Вставити перед",
"insertAbove": "Insert above",
"insertBelow": "Insert below",
"insertAbove": "Вставити вище",
"insertBelow": "Вставити нижче",
"hideField": "Приховати поле",
"sortAsc": "За зростанням",
"sortDesc": "За спаданням",
"move": "Move",
"move": "Перемістити",
"geoDataField": "Поле геоданих",
"type": "Type",
"name": "Name",
"changes": "Changes",
"new": "New",
"old": "Old",
"data": "Data",
"source": "Source",
"destination": "Destination",
"active": "Active",
"inactive": "Inactive",
"linked": "linked",
"finish": "Finish",
"type": "Тип",
"name": "Назва",
"changes": "Зміни",
"new": "Створити",
"old": "Старий",
"data": "Дата",
"source": "Джерело",
"destination": "Призначення",
"active": "Активний",
"inactive": "Неактивний",
"linked": "прив'язаний",
"finish": "Закінчити",
"min": "Min",
"max": "Max",
"avg": "Avg",
@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

1
packages/nc-gui/lang/vi.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

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

@ -339,6 +339,7 @@
"removeFile": "删除文件",
"hasMany": "单对多",
"manyToMany": "多对多",
"oneToOne": "One to One",
"virtualRelation": "虚拟关系",
"linkMore": "链接更多",
"linkMoreRecords": "链接更多记录",

1
packages/nc-gui/lang/zh-Hant.json

@ -339,6 +339,7 @@
"removeFile": "Remove File",
"hasMany": "Has Many",
"manyToMany": "Many to Many",
"oneToOne": "One to One",
"virtualRelation": "Virtual Relation",
"linkMore": "Link More",
"linkMoreRecords": "Link more records",

2
packages/nc-gui/package.json

@ -117,7 +117,7 @@
"@iconify-json/ion": "^1.1.15",
"@iconify-json/la": "^1.1.8",
"@iconify-json/logos": "^1.1.42",
"@iconify-json/lucide": "^1.1.175",
"@iconify-json/lucide": "^1.1.176",
"@iconify-json/material-symbols": "^1.1.75",
"@iconify-json/mdi": "^1.1.64",
"@iconify-json/mi": "^1.1.8",

19
packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index.vue

@ -28,7 +28,7 @@ router.afterEach((to) => shouldRedirect(to.name as string))
<template>
<div
class="scrollbar-thin-dull h-[100vh] overflow-y-auto overflow-x-hidden color-transition p-4 lg:p-10 nc-form-view relative min-h-[600px]"
class="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-gray-200 hover-scrollbar-thumb-gray-300 h-[100vh] overflow-y-auto overflow-x-hidden flex flex-col color-transition p-4 lg:p-10 nc-form-view min-h-[600px]"
:class="{
'children:(!h-auto my-auto)': sharedViewMeta?.surveyMode,
}"
@ -62,7 +62,8 @@ p {
}
}
.nc-cell {
.nc-cell,
.nc-virtual-cell {
@apply bg-white dark:bg-slate-500 appearance-none;
&.nc-cell-checkbox {
@ -85,7 +86,7 @@ p {
@apply bg-white dark:bg-slate-500;
&.nc-input {
@apply w-full;
@apply w-full h-10;
&:not(.layout-list) {
@apply rounded-lg border-solid border-1 border-gray-200 focus-within:border-brand-500 overflow-hidden;
@ -162,9 +163,12 @@ p {
@apply px-3;
}
}
&:not(.nc-cell-longtext) {
&.nc-cell:not(.nc-cell-longtext) {
@apply p-2;
}
&.nc-virtual-cell {
@apply px-2 py-1;
}
&.nc-cell-json {
@apply h-auto;
@ -177,6 +181,13 @@ p {
input.nc-cell-field {
@apply !py-0 !px-1;
}
&.nc-cell-currency {
@apply !py-0 !pl-0 flex items-stretch;
.nc-currency-code {
@apply !bg-gray-100;
}
}
}
}

10
packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue

@ -113,7 +113,7 @@ const onDecode = async (scannedCodeValue: string) => {
<template v-else-if="submitted">
<div class="flex justify-center">
<div v-if="sharedFormView" class="w-full">
<a-alert class="!mt-2 !mb-4 !py-4 text-left !rounded-lg" type="success" outlined>
<a-alert class="nc-shared-form-success-msg !mt-2 !mb-4 !py-4 text-left !rounded-lg" type="success" outlined>
<template #message>
<LazyCellRichText
v-if="sharedFormView?.success_msg?.trim()"
@ -140,7 +140,6 @@ const onDecode = async (scannedCodeValue: string) => {
v-if="sharedFormView?.submit_another_form"
type="secondary"
:size="isMobileMode ? 'medium' : 'small'"
data-testid="nc-survey-form__btn-submit-another-form"
@click="submitted = false"
>
{{ $t('activity.submitAnotherForm') }}
@ -218,6 +217,11 @@ const onDecode = async (scannedCodeValue: string) => {
:column="field"
:edit-enabled="!field?.read_only"
:read-only="field?.read_only"
@update:model-value="
() => {
v$.localState[field.title]?.$validate()
}
"
/>
<a-button
v-if="field.enable_scanner"
@ -232,7 +236,7 @@ const onDecode = async (scannedCodeValue: string) => {
</LazySmartsheetDivDataCell>
</NcTooltip>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-sm mt-2">
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-xs mt-2">
<template v-if="isVirtualCol(field)">
<div v-for="error of v$.virtual[field.title]?.$errors" :key="`${error}virtual`" class="text-red-500">
{{ error.$message }}

149
packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue

@ -2,21 +2,19 @@
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { breakpointsTailwind } from '@vueuse/core'
import tinycolor from 'tinycolor2'
import {
DropZoneRef,
IsSurveyFormInj,
computed,
isValidURL,
onKeyStroke,
onMounted,
provide,
ref,
useBreakpoints,
useI18n,
usePointerSwipe,
useSharedFormStoreOrThrow,
useStepper,
validateEmail,
} from '#imports'
enum TransitionDirection {
@ -36,8 +34,6 @@ const { md } = useBreakpoints(breakpointsTailwind)
const { v$, formState, formColumns, submitForm, submitted, secondsRemain, sharedFormView, sharedViewMeta, onReset } =
useSharedFormStoreOrThrow()
const { t } = useI18n()
const { isMobileMode } = storeToRefs(useConfigStore())
const isTransitioning = ref(false)
@ -80,7 +76,17 @@ const { index, goToPrevious, goToNext, isFirst, isLast, goTo } = useStepper(step
const field = computed(() => formColumns.value?.[index.value])
const columnValidationError = ref(false)
const fieldHasError = computed(() => {
if (field.value?.title) {
if (isVirtualCol(field.value)) {
return v$.value.virtual[field.value.title]?.$error
} else {
return v$.value.localState[field.value.title]?.$error
}
}
return false
})
function isRequired(column: ColumnType, required = false) {
let columnObj = column
@ -123,41 +129,20 @@ function animate(target: AnimationTarget) {
}, transitionDuration.value / 2)
}
async function validateColumn() {
const f = field.value!
if (parseProp(f.meta)?.validate && formState.value[f.title!]) {
if (f.uidt === UITypes.Email) {
if (!validateEmail(formState.value[f.title!])) {
columnValidationError.value = true
message.error(t('msg.error.invalidEmail'))
return false
}
} else if (f.uidt === UITypes.URL) {
if (!isValidURL(formState.value[f.title!])) {
columnValidationError.value = true
message.error(t('msg.error.invalidURL'))
return false
}
}
}
const validateField = async (title: string, type: 'cell' | 'virtual') => {
const validationField = type === 'cell' ? v$.value.localState[title] : v$.value.virtual[title]
if (validationField) {
return await validationField.$validate()
} else {
return true
}
}
async function goNext(animationTarget?: AnimationTarget) {
columnValidationError.value = false
if (isLast.value || !isStarted.value || submitted.value) return
if (!field.value || !field.value.title) return
if (isLast.value || !isStarted.value || submitted.value || dialogShow.value || !field.value || !field.value.title) return
const validationField = v$.value.localState[field.value.title]
if (validationField) {
const isValid = await validationField.$validate()
if (!isValid) return
}
if (!(await validateColumn())) return
if (field.value?.title && !(await validateField(field.value.title, isVirtualCol(field.value) ? 'virtual' : 'cell'))) return
animate(animationTarget || AnimationTarget.ArrowRight)
@ -172,9 +157,7 @@ async function goNext(animationTarget?: AnimationTarget) {
}
async function goPrevious(animationTarget?: AnimationTarget) {
if (isFirst.value || !isStarted.value || submitted.value) return
columnValidationError.value = false
if (isFirst.value || !isStarted.value || submitted.value || dialogShow.value) return
animate(animationTarget || AnimationTarget.ArrowLeft)
@ -188,7 +171,7 @@ function focusInput() {
const inputEl =
(document.querySelector('.nc-cell input') as HTMLInputElement) ||
(document.querySelector('.nc-cell textarea') as HTMLTextAreaElement) ||
(document.querySelector('.nc-cell [tabindex="0"]') as HTMLInputElement)
(document.querySelector('.nc-cell [tabindex="0"]') as HTMLElement)
if (inputEl) {
activeCell.value = inputEl
@ -207,7 +190,7 @@ function resetForm() {
}
async function submit() {
if (submitted.value || !(await validateColumn())) return
if (submitted.value) return
dialogShow.value = false
submitForm()
}
@ -228,13 +211,27 @@ const handleFocus = () => {
}
}
const showSubmitConfirmModal = async () => {
if (field.value?.title && !(await validateField(field.value.title, isVirtualCol(field.value) ? 'virtual' : 'cell'))) {
return
}
dialogShow.value = true
setTimeout(() => {
// NcButton will only focus if document has already focused element
document.querySelector('.nc-survery-form__confirmation_modal div[tabindex="0"]')?.focus()
document.querySelector('.nc-survey-form-btn-submit.nc-button')?.focus()
}, 50)
}
onKeyStroke(['ArrowLeft', 'ArrowDown'], () => {
goPrevious(AnimationTarget.ArrowLeft)
})
onKeyStroke(['ArrowRight', 'ArrowUp'], () => {
goNext(AnimationTarget.ArrowRight)
})
onKeyStroke(['Enter', 'Space'], () => {
onKeyStroke(['Enter'], async (e) => {
if (submitted.value) return
if (!isStarted.value && !submitted.value) {
@ -244,7 +241,8 @@ onKeyStroke(['Enter', 'Space'], () => {
if (dialogShow.value) {
submit()
} else {
dialogShow.value = true
e.preventDefault()
showSubmitConfirmModal()
}
} else {
const activeElement = document.activeElement as HTMLElement
@ -277,16 +275,6 @@ onMounted(() => {
})
}
})
watch(
formState,
() => {
columnValidationError.value = false
},
{
deep: true,
},
)
</script>
<template>
@ -372,10 +360,7 @@ watch(
<div class="flex justify-end mt-12">
<div class="flex items-center gap-3">
<div class="hidden md:flex text-sm items-center gap-1 text-gray-800">
<span>
{{ $t('labels.pressEnter') }}
</span>
<NcBadge class="pl-4 pr-1 h-[21px] text-gray-600"> </NcBadge>
<span> {{ $t('labels.pressEnter') }} </span>
</div>
<NcButton
:size="isMobileMode ? 'medium' : 'small'"
@ -429,6 +414,7 @@ watch(
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
:read-only="field?.read_only"
@update:model-value="validateField(field.title, 'virtual')"
/>
<LazySmartsheetCell
@ -440,12 +426,20 @@ watch(
:column="field"
:edit-enabled="!field?.read_only"
:read-only="field?.read_only"
@update:model-value="validateField(field.title, 'cell')"
/>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-[0.75rem] my-2 px-1">
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-xs my-2 px-1">
<template v-if="isVirtualCol(field)">
<div v-for="error of v$.virtual[field.title]?.$errors" :key="`${error}virtual`" class="text-red-500">
{{ error.$message }}
</div>
</template>
<template v-else>
<div v-for="error of v$.localState[field.title]?.$errors" :key="error" class="text-red-500">
{{ error.$message }}
</div>
</template>
<div v-if="field.uidt === UITypes.LongText" class="text-sm text-gray-500 flex flex-wrap items-center">
{{ $t('general.shift') }} <MdiAppleKeyboardShift class="mx-1 text-primary" /> + {{ $t('general.enter') }}
@ -458,7 +452,7 @@ watch(
<div class="ml-1 mt-4 flex w-full text-lg">
<div class="flex-1 flex justify-end">
<div v-if="isLast && !v$.$invalid">
<div v-if="isLast">
<NcButton
:size="isMobileMode ? 'medium' : 'small'"
:class="
@ -466,9 +460,9 @@ watch(
? 'transform translate-y-[1px] translate-x-[1px] ring ring-accent ring-opacity-100'
: ''
"
:disabled="v$.localState[field.title]?.$error || columnValidationError"
:disabled="fieldHasError"
data-testid="nc-survey-form__btn-submit-confirm"
@click="dialogShow = true"
@click="showSubmitConfirmModal"
>
{{ $t('general.submit') }} form
</NcButton>
@ -477,17 +471,9 @@ watch(
<div v-else class="flex items-center gap-3">
<div
class="hidden md:flex text-sm items-center gap-1"
:class="v$.localState[field.title]?.$error || columnValidationError ? 'text-gray-200' : 'text-gray-800'"
:class="fieldHasError ? 'text-gray-200' : 'text-gray-800'"
>
<span>
{{ $t('labels.pressEnter') }}
</span>
<NcBadge
class="pl-4 pr-1 h-[21px]"
:class="v$.localState[field.title]?.$error || columnValidationError ? 'text-gray-200' : 'text-gray-600'"
>
</NcBadge>
<span> {{ $t('labels.pressEnter') }} </span>
</div>
<NcButton
:size="isMobileMode ? 'medium' : 'small'"
@ -498,7 +484,7 @@ watch(
? 'transform translate-y-[2px] translate-x-[2px] after:(!ring !ring-accent !ring-opacity-100)'
: '',
]"
:disabled="v$.localState[field.title]?.$error || columnValidationError"
:disabled="fieldHasError"
@click="goNext()"
>
{{ $t('labels.next') }}
@ -513,14 +499,26 @@ watch(
<div class="md:(absolute bottom-0 left-0 right-0 px-4 pb-4) lg:px-10 lg:pb-10">
<div class="flex justify-end items-center gap-4">
<div class="flex justify-center">
<GeneralFormBranding class="inline-flex mx-auto" />
<GeneralFormBranding
class="inline-flex mx-auto"
:style="{
color: tinycolor.isReadable(parseProp(sharedFormView?.meta)?.background_color || '#F9F9FA', '#D5D5D9', {
level: 'AA',
size: 'large',
})
? '#fff'
: tinycolor
.mostReadable(parseProp(sharedFormView?.meta)?.background_color || '#F9F9FA', ['#374151', '#D5D5D9'])
.toHex8String(),
}"
/>
</div>
<div v-if="isStarted && !submitted" class="flex items-center gap-3">
<NcButton
type="secondary"
:size="isMobileMode ? 'medium' : 'small'"
data-testid="nc-survey-form__icon-prev"
:disabled="isFirst || v$.localState[field.title]?.$error"
:disabled="isFirst"
@click="goPrevious()"
>
<GeneralIcon icon="ncArrowLeft"
@ -530,7 +528,7 @@ watch(
:size="isMobileMode ? 'medium' : 'small'"
type="secondary"
data-testid="nc-survey-form__icon-next"
:disabled="isLast || v$.localState[field.title]?.$error || columnValidationError"
:disabled="isLast || fieldHasError"
@click="goNext()"
>
<GeneralIcon icon="ncArrowRight" />
@ -552,6 +550,7 @@ watch(
type="primary"
:size="isMobileMode ? 'medium' : 'small'"
data-testid="nc-survey-form__btn-submit"
class="nc-survey-form-btn-submit"
@click="submit"
>
{{ $t('general.submit') }}

4
packages/nc-gui/store/views.ts

@ -28,7 +28,7 @@ export const useViewsStore = defineStore('viewsStore', () => {
const { activeWorkspaceId } = storeToRefs(useWorkspace())
const { meta: metaKey, ctrlKey } = useMagicKeys()
const { meta: metaKey, control } = useMagicKeys()
const recentViews = computed<RecentView[]>(() =>
allRecentViews.value.filter((f) => f.workspaceId === activeWorkspaceId.value).splice(0, 10),
@ -219,7 +219,7 @@ export const useViewsStore = defineStore('viewsStore', () => {
hardReload?: boolean
doNotSwitchTab?: boolean
}) => {
const cmdOrCtrl = isMac() ? metaKey.value : ctrlKey.value
const cmdOrCtrl = isMac() ? metaKey.value : control.value
const routeName = 'index-typeOrId-baseId-index-index-viewId-viewTitle-slugs'

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

@ -107,7 +107,7 @@ export const rowDefaultData = (columns: ColumnType[] = []) => {
!/^\w+\(\)|CURRENT_TIMESTAMP$/.test(col.cdf)
) {
const defaultValue = col.cdf
acc[col.title!] = typeof defaultValue === 'string' ? defaultValue.replace(/^'/, '').replace(/'$/, '') : defaultValue
acc[col.title!] = typeof defaultValue === 'string' ? defaultValue.replace(/^'|'$/g, '') : defaultValue
}
return acc
}, {} as Record<string, any>)

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

@ -166,6 +166,7 @@ import NcCellSystemText from '~icons/nc-icons/system-text'
import NcCellAttachment from '~icons/nc-icons/cell-attachment'
import NcCircleCheck from '~icons/nc-icons/circle-check'
import OnetoOneIcon from '~icons/nc-icons/onetoone'
// keep it for reference
// todo: remove it after all icons are migrated
@ -358,6 +359,7 @@ export const iconMap = {
mm_solid: ManytoManySolidIcon,
hm_solid: HasManySolidIcon,
bt_solid: BelongsToSolidIcon,
oneToOneSolid: OnetoOneIcon,
workspaceDefault: MsGroup,
project: Project,
search: NcSearch,

3
packages/nc-gui/utils/virtualCell.ts

@ -15,6 +15,9 @@ export const isMm = (column: ColumnType) =>
export const isBt = (column: ColumnType) =>
isLTAR(column.uidt!, column.colOptions) && column.colOptions?.type === RelationTypes.BELONGS_TO
export const isOo = (column: ColumnType) =>
isLTAR(column.uidt!, column.colOptions) && column.colOptions?.type === RelationTypes.ONE_TO_ONE
export const isLookup = (column: ColumnType) => column.uidt === UITypes.Lookup
export const isRollup = (column: ColumnType) => column.uidt === UITypes.Rollup
export const isFormula = (column: ColumnType) => column.uidt === UITypes.Formula

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

@ -1637,7 +1637,7 @@ export interface LinkToAnotherColumnReqType {
/** The title of the virtual column */
title: string;
/** The type of the relationship */
type: 'bt' | 'hm' | 'mm';
type: 'bt' | 'hm' | 'mm' | 'oo';
/** Abstract type of the relationship */
uidt: 'LinkToAnotherRecord' | 'Links';
/** Is this relationship virtual? */
@ -8298,7 +8298,7 @@ export class Api<
baseName: string,
tableName: string,
rowId: any,
relationType: 'mm' | 'hm' | 'bt',
relationType: 'mm' | 'hm' | 'bt' | 'oo',
columnName: string,
query?: {
/** @min 1 */
@ -8346,7 +8346,7 @@ export class Api<
baseName: string,
tableName: string,
rowId: any,
relationType: 'mm' | 'hm' | 'bt',
relationType: 'mm' | 'hm' | 'bt' | 'oo',
columnName: string,
refRowId: string,
query?: {
@ -8413,7 +8413,7 @@ export class Api<
baseName: string,
tableName: string,
rowId: any,
relationType: 'mm' | 'hm' | 'bt',
relationType: 'mm' | 'hm' | 'bt' | 'oo',
columnName: string,
refRowId: string,
params: RequestParams = {}
@ -8453,7 +8453,7 @@ export class Api<
baseName: string,
tableName: string,
rowId: any,
relationType: 'mm' | 'hm' | 'bt',
relationType: 'mm' | 'hm' | 'bt' | 'oo',
columnName: string,
query?: {
/** @min 1 */
@ -9347,7 +9347,7 @@ export class Api<
dataNestedList: (
sharedViewUuid: string,
rowId: any,
relationType: 'mm' | 'hm' | 'bt',
relationType: 'mm' | 'hm' | 'bt' | 'oo',
columnName: string,
query?: {
/** Which fields to be shown */

27
packages/nocodb-sdk/src/lib/globals.ts

@ -20,6 +20,7 @@ export enum RelationTypes {
HAS_MANY = 'hm',
BELONGS_TO = 'bt',
MANY_TO_MANY = 'mm',
ONE_TO_ONE = 'oo',
}
export enum ExportTypes {
@ -27,11 +28,6 @@ export enum ExportTypes {
CSV = 'csv',
}
export enum ErrorMessages {
INVALID_SHARED_VIEW_PASSWORD = 'INVALID_SHARED_VIEW_PASSWORD',
NOT_IMPLEMENTED = 'NOT_IMPLEMENTED',
}
export enum AuditOperationTypes {
COMMENT = 'COMMENT',
DATA = 'DATA',
@ -128,6 +124,27 @@ export enum NcDataErrorCodes {
NC_ERR_MM_MODEL_NOT_FOUND = 'NC_ERR_MM_MODEL_NOT_FOUND',
}
export enum NcErrorType {
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
API_TOKEN_NOT_ALLOWED = 'API_TOKEN_NOT_ALLOWED',
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
BASE_NOT_FOUND = 'BASE_NOT_FOUND',
SOURCE_NOT_FOUND = 'SOURCE_NOT_FOUND',
TABLE_NOT_FOUND = 'TABLE_NOT_FOUND',
VIEW_NOT_FOUND = 'VIEW_NOT_FOUND',
FIELD_NOT_FOUND = 'FIELD_NOT_FOUND',
RECORD_NOT_FOUND = 'RECORD_NOT_FOUND',
ERROR_DUPLICATE_RECORD = 'ERROR_DUPLICATE_RECORD',
USER_NOT_FOUND = 'USER_NOT_FOUND',
INVALID_OFFSET_VALUE = 'INVALID_OFFSET_VALUE',
INVALID_LIMIT_VALUE = 'INVALID_LIMIT_VALUE',
INVALID_FILTER = 'INVALID_FILTER',
INVALID_SHARED_VIEW_PASSWORD = 'INVALID_SHARED_VIEW_PASSWORD',
NOT_IMPLEMENTED = 'NOT_IMPLEMENTED',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
DATABASE_ERROR = 'DATABASE_ERROR',
}
type Roles = OrgUserRoles | ProjectRoles | WorkspaceUserRoles;
type RolesObj = Partial<Record<Roles, boolean>>;

10
packages/nocodb/package.json

@ -68,7 +68,7 @@
"@ntegral/nestjs-sentry": "^4.0.1",
"@sentry/node": "^6.19.7",
"@techpass/passport-openidconnect": "^0.3.3",
"@types/chai": "^4.3.13",
"@types/chai": "^4.3.14",
"airtable": "^0.12.2",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
@ -132,13 +132,13 @@
"mysql2": "^3.9.2",
"nanoid": "^3.3.7",
"nc-help": "0.3.1",
"nc-lib-gui": "0.204.5",
"nc-lib-gui": "0.204.7",
"nc-plugin": "^0.1.3",
"ncp": "^2.0.0",
"nestjs-kafka": "^1.0.6",
"nestjs-throttler-storage-redis": "^0.4.3",
"nestjs-throttler-storage-redis": "^0.4.4",
"nocodb-sdk": "workspace:^",
"nodemailer": "^6.9.12",
"nodemailer": "^6.9.13",
"object-hash": "^3.0.0",
"object-sizeof": "^2.6.4",
"os-locale": "^6.0.2",
@ -184,7 +184,7 @@
"@types/jest": "^29.5.12",
"@types/mocha": "^10.0.6",
"@types/multer": "^1.4.11",
"@types/node": "20.11.29",
"@types/node": "20.11.30",
"@types/passport-google-oauth20": "^2.0.14",
"@types/passport-jwt": "^3.0.13",
"@types/supertest": "^2.0.16",

20
packages/nocodb/src/controllers/data-alias-nested.controller.ts

@ -98,6 +98,26 @@ export class DataAliasNestedController {
});
}
@Get([
'/api/v1/db/data/:orgs/:baseName/:tableName/:rowId/oo/:columnName/exclude',
])
@Acl('ooExcludedList')
async ooExcludedList(
@Req() req: Request,
@Param('columnName') columnName: string,
@Param('rowId') rowId: string,
@Param('baseName') baseName: string,
@Param('tableName') tableName: string,
) {
return await this.dataAliasNestedService.ooExcludedList({
query: req.query,
columnName: columnName,
rowId: rowId,
baseName: baseName,
tableName: tableName,
});
}
// todo: handle case where the given column is not ltar
@Get(['/api/v1/db/data/:orgs/:baseName/:tableName/:rowId/hm/:columnName'])

2
packages/nocodb/src/controllers/old-datas/old-datas.service.ts

@ -136,7 +136,7 @@ export class OldDatasService {
titleOrId: req.params.viewName,
fk_model_id: model.id,
}));
if (!model) NcError.notFound('Table not found');
if (!model) NcError.tableNotFound(req.params.tableName);
return { model, view };
}
}

10
packages/nocodb/src/controllers/public-datas-export.controller.ts

@ -6,7 +6,7 @@ import {
Response,
UseGuards,
} from '@nestjs/common';
import { ErrorMessages, isSystemColumn, ViewTypes } from 'nocodb-sdk';
import { isSystemColumn, ViewTypes } from 'nocodb-sdk';
import * as XLSX from 'xlsx';
import { nocoExecute } from 'nc-help';
import papaparse from 'papaparse';
@ -35,7 +35,7 @@ export class PublicDatasExportController {
@Param('publicDataUuid') publicDataUuid: string,
) {
const view = await View.getByUUID(publicDataUuid);
if (!view) NcError.notFound('Not found');
if (!view) NcError.viewNotFound(publicDataUuid);
if (
view.type !== ViewTypes.GRID &&
view.type !== ViewTypes.KANBAN &&
@ -45,7 +45,7 @@ export class PublicDatasExportController {
NcError.notFound('Not found');
if (view.password && view.password !== req.headers?.['xc-password']) {
NcError.forbidden(ErrorMessages.INVALID_SHARED_VIEW_PASSWORD);
NcError.invalidSharedViewPassword();
}
const model = await view.getModelWithInfo();
@ -88,7 +88,7 @@ export class PublicDatasExportController {
const view = await View.getByUUID(req.params.publicDataUuid);
const fields = req.query.fields;
if (!view) NcError.notFound('Not found');
if (!view) NcError.viewNotFound(req.params.publicDataUuid);
if (
view.type !== ViewTypes.GRID &&
view.type !== ViewTypes.KANBAN &&
@ -98,7 +98,7 @@ export class PublicDatasExportController {
NcError.notFound('Not found');
if (view.password && view.password !== req.headers?.['xc-password']) {
NcError.forbidden(ErrorMessages.INVALID_SHARED_VIEW_PASSWORD);
NcError.invalidSharedViewPassword();
}
const model = await view.getModelWithInfo();

522
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -632,7 +632,7 @@ class BaseModelSqlv2 {
args.column_name.split(',').map(async (col) => {
let column = cols.find((c) => c.column_name === col || c.title === col);
if (!column) {
throw NcError.notFound('Column not found');
throw NcError.fieldNotFound(col);
}
// if qrCode or Barcode replace it with value column nd keep the alias
@ -851,7 +851,7 @@ class BaseModelSqlv2 {
(c) => c.column_name === col || c.title === col,
);
if (!column) {
throw NcError.notFound('Column not found');
throw NcError.fieldNotFound(col);
}
// if qrCode or Barcode replace it with value column nd keep the alias
@ -1805,6 +1805,56 @@ class BaseModelSqlv2 {
?.count;
}
// todo: naming & optimizing
public async countExcludedOneToOneChildren(
{ colId, cid = null },
args,
): Promise<any> {
const { where } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId,
);
const relColOptions =
(await relColumn.getColOptions()) as LinkToAnotherRecordColumn;
const rcn = (await relColOptions.getParentColumn()).column_name;
const parentTable = await (
await relColOptions.getParentColumn()
).getModel();
const cn = (await relColOptions.getChildColumn()).column_name;
const childTable = await (await relColOptions.getChildColumn()).getModel();
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
const rtn = parentTn;
const tn = childTn;
await childTable.getColumns();
// one-to-one relation is combination of both hm and bt to identify table which have
// foreign key column(similar to bt) we are adding a boolean flag `bt` under meta
const isBt = relColumn.meta?.bt;
const qb = this.dbDriver(isBt ? rtn : tn)
.where((qb) => {
qb.whereNotIn(
isBt ? rcn : cn,
this.dbDriver(isBt ? tn : rtn)
.select(isBt ? cn : rcn)
.where(_wherePk((isBt ? childTable : parentTable).primaryKeys, cid))
.whereNotNull(isBt ? cn : rcn),
).orWhereNull(isBt ? rcn : cn);
})
.count(`*`, { as: 'count' });
const aliasColObjMap = await parentTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, qb);
return (await this.execAndParse(qb, null, { raw: true, first: true }))
?.count;
}
// todo: naming & optimizing
public async getBtChildrenExcludedList(
{ colId, cid = null },
@ -1877,6 +1927,85 @@ class BaseModelSqlv2 {
});
}
// todo: naming & optimizing
public async getExcludedOneToOneChildrenList(
{ colId, cid = null },
args,
): Promise<any> {
const { where, ...rest } = this._getListArgs(args as any);
const relColumn = (await this.model.getColumns()).find(
(c) => c.id === colId,
);
const relColOptions =
(await relColumn.getColOptions()) as LinkToAnotherRecordColumn;
const rcn = (await relColOptions.getParentColumn()).column_name;
const parentTable = await (
await relColOptions.getParentColumn()
).getModel();
const cn = (await relColOptions.getChildColumn()).column_name;
const childTable = await (await relColOptions.getChildColumn()).getModel();
const parentModel = await Model.getBaseModelSQL({
dbDriver: this.dbDriver,
model: parentTable,
});
const childModel = await Model.getBaseModelSQL({
dbDriver: this.dbDriver,
model: childTable,
});
const rtn = this.getTnPath(parentTable);
const tn = this.getTnPath(childTable);
await childTable.getColumns();
// one-to-one relation is combination of both hm and bt to identify table which have
// foreign key column(similar to bt) we are adding a boolean flag `bt` under meta
const isBt = relColumn.meta?.bt;
const qb = this.dbDriver(isBt ? rtn : tn).where((qb) => {
qb.whereNotIn(
isBt ? rcn : cn,
this.dbDriver(isBt ? tn : rtn)
.select(isBt ? cn : rcn)
.where(_wherePk((isBt ? childTable : parentTable).primaryKeys, cid))
.whereNotNull(isBt ? cn : rcn),
).orWhereNull(isBt ? rcn : cn);
});
if (+rest?.shuffle) {
await this.shuffle({ qb });
}
await (isBt ? parentModel : childModel).selectObject({ qb });
const aliasColObjMap = await parentTable.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(this, filterObj, qb);
// sort by primary key if not autogenerated string
// if autogenerated string sort by created_at column if present
if (parentTable.primaryKey && parentTable.primaryKey.ai) {
qb.orderBy(parentTable.primaryKey.column_name);
} else if (
parentTable.columns.find((c) => c.column_name === 'created_at')
) {
qb.orderBy('created_at');
}
applyPaginate(qb, rest);
const proto = await (isBt ? parentModel : childModel).getProto();
const data = await this.execAndParse(
qb,
await (isBt ? parentTable : childTable).getColumns(),
);
return data.map((c) => {
c.__proto__ = proto;
return c;
});
}
protected async getSelectQueryBuilderForFormula(
column: Column<any>,
tableAlias?: string,
@ -2106,6 +2235,141 @@ class BaseModelSqlv2 {
return await readLoader.load(this?.[cCol?.title]);
};
// todo : handle mm
} else if (colOptions.type === 'oo') {
const isBt = column.meta?.bt;
if (isBt) {
// @ts-ignore
const colOptions =
(await column.getColOptions()) as LinkToAnotherRecordColumn;
const pCol = await Column.get({
colId: colOptions.fk_parent_column_id,
});
const cCol = await Column.get({
colId: colOptions.fk_child_column_id,
});
// use dataloader to get batches of parent data together rather than getting them individually
// it takes individual keys and callback is invoked with an array of values and we can get the
// result for all those together and return the value in the same order as in the array
// this way all parents data extracted together
const readLoader = new DataLoader(
async (_ids: string[]) => {
// handle binary(16) foreign keys
const ids = _ids.map((id) => {
if (pCol.ct !== 'binary(16)') return id;
// Cast the id to string.
const idAsString = id + '';
// Check if the id is a UUID and the column is binary(16)
const isUUIDBinary16 =
idAsString.length === 36 || idAsString.length === 32;
// If the id is a UUID and the column is binary(16), convert the id to a Buffer. Otherwise, return null to indicate that the id is not a UUID.
const idAsUUID = isUUIDBinary16
? idAsString.length === 32
? idAsString.replace(
/(.{8})(.{4})(.{4})(.{4})(.{12})/,
'$1-$2-$3-$4-$5',
)
: idAsString
: null;
return idAsUUID
? Buffer.from(idAsUUID.replace(/-/g, ''), 'hex')
: id;
});
const data = await (
await Model.getBaseModelSQL({
id: pCol.fk_model_id,
dbDriver: this.dbDriver,
})
).list(
{
fieldsSet: (readLoader as any).args?.fieldsSet,
filterArr: [
new Filter({
id: null,
fk_column_id: pCol.id,
fk_model_id: pCol.fk_model_id,
value: ids as any[],
comparison_op: 'in',
}),
],
},
{
ignoreViewFilterAndSort: true,
ignorePagination: true,
},
);
const groupedList = groupBy(data, pCol.title);
return _ids.map(
async (id: string) => groupedList?.[id]?.[0],
);
},
{
cache: false,
},
);
// defining BelongsTo read resolver method
proto[column.title] = async function (args?: any) {
if (
this?.[cCol?.title] === null ||
this?.[cCol?.title] === undefined
)
return null;
(readLoader as any).args = args;
return await readLoader.load(this?.[cCol?.title]);
};
} else {
const listLoader = new DataLoader(
async (ids: string[]) => {
if (ids.length > 1) {
const data = await this.multipleHmList(
{
colId: column.id,
ids,
},
(listLoader as any).args,
);
return ids.map((id: string) =>
data[id] ? data[id]?.[0] : null,
);
} else {
return [
(
await this.hmList(
{
colId: column.id,
id: ids[0],
},
(listLoader as any).args,
)
)?.[0] ?? null,
];
}
},
{
cache: false,
},
);
const self: BaseModelSqlv2 = this;
proto[
column.uidt === UITypes.Links
? `_nc_lk_${column.title}`
: column.title
] = async function (args): Promise<any> {
(listLoader as any).args = args;
return listLoader.load(
getCompositePk(self.model.primaryKeys, this),
);
};
}
}
}
break;
@ -2806,7 +3070,7 @@ class BaseModelSqlv2 {
const nestedCols = (await this.model.getColumns()).filter((c) =>
isLinksOrLTAR(c),
);
const postInsertOps = await this.prepareNestedLinkQb({
const { postInsertOps, preInsertOps } = await this.prepareNestedLinkQb({
nestedCols,
data,
insertObj,
@ -2818,6 +3082,8 @@ class BaseModelSqlv2 {
await this.prepareNocoData(insertObj, true, cookie);
await Promise.all(preInsertOps.map((f) => f(this.dbDriver)));
let response;
const query = this.dbDriver(this.tnPath).insert(insertObj);
@ -2958,6 +3224,7 @@ class BaseModelSqlv2 {
insertObj: Record<string, any>;
}) {
const postInsertOps: ((rowId: any, trx?: any) => Promise<void>)[] = [];
const preInsertOps: ((trx?: any) => Promise<void>)[] = [];
for (const col of nestedCols) {
if (col.title in data) {
const colOptions = await col.getColOptions<LinkToAnotherRecordColumn>();
@ -2981,6 +3248,45 @@ class BaseModelSqlv2 {
insertObj[childCol.column_name] = nestedData?.[parentCol.title];
}
break;
case RelationTypes.ONE_TO_ONE:
{
const isBt = col.meta?.bt;
const childCol = await colOptions.getChildColumn();
const childModel = await childCol.getModel();
await childModel.getColumns();
if (isBt) {
// todo: unlink the ref record
preInsertOps.push(async (trx: any = this.dbDriver) => {
await trx(this.getTnPath(childModel.table_name))
.update({
[childCol.column_name]: null,
})
.where(
childCol.column_name,
nestedData[childModel.primaryKey.title],
);
});
if (typeof nestedData !== 'object') continue;
const childCol = await colOptions.getChildColumn();
const parentCol = await colOptions.getParentColumn();
insertObj[childCol.column_name] = nestedData?.[parentCol.title];
} else {
postInsertOps.push(async (rowId, trx: any = this.dbDriver) => {
await trx(this.getTnPath(childModel.table_name))
.update({
[childCol.column_name]: rowId,
})
.where(
childModel.primaryKey.column_name,
nestedData[childModel.primaryKey.title],
);
});
}
}
break;
case RelationTypes.HAS_MANY:
{
if (!Array.isArray(nestedData)) continue;
@ -3033,7 +3339,7 @@ class BaseModelSqlv2 {
}
}
}
return postInsertOps;
return { postInsertOps, preInsertOps };
}
async bulkInsert(
@ -3061,6 +3367,7 @@ class BaseModelSqlv2 {
// TODO: ag column handling for raw bulk insert
const insertDatas = raw ? datas : [];
let postInsertOps: ((rowId: any, trx?: any) => Promise<void>)[] = [];
let preInsertOps: ((trx?: any) => Promise<void>)[] = [];
let aiPkCol: Column;
let agPkCol: Column;
@ -3209,11 +3516,14 @@ class BaseModelSqlv2 {
// prepare nested link data for insert only if it is single record insertion
if (isSingleRecordInsertion) {
postInsertOps = await this.prepareNestedLinkQb({
const operations = await this.prepareNestedLinkQb({
nestedCols,
data: d,
insertObj,
});
postInsertOps = operations.postInsertOps;
preInsertOps = operations.preInsertOps;
}
insertDatas.push(insertObj);
@ -3244,6 +3554,8 @@ class BaseModelSqlv2 {
}
}
await Promise.all(preInsertOps.map((f) => f(trx)));
let responses;
// insert one by one as fallback to get ids for sqlite and mysql
@ -3371,9 +3683,7 @@ class BaseModelSqlv2 {
if (!pkValues) {
// throw or skip if no pk provided
if (throwExceptionIfNotExist) {
NcError.unprocessableEntity(
`Record with pk ${JSON.stringify(pkValues)} not found`,
);
NcError.recordNotFound(JSON.stringify(pkValues));
}
continue;
}
@ -3384,9 +3694,7 @@ class BaseModelSqlv2 {
if (!oldRecord) {
// throw or skip if no record found
if (throwExceptionIfNotExist) {
NcError.unprocessableEntity(
`Record with pk ${JSON.stringify(pkValues)} not found`,
);
NcError.recordNotFound(JSON.stringify(pkValues));
}
continue;
}
@ -3541,9 +3849,7 @@ class BaseModelSqlv2 {
if (!pkValues) {
// throw or skip if no pk provided
if (throwExceptionIfNotExist) {
NcError.unprocessableEntity(
`Record with pk ${JSON.stringify(pkValues)} not found`,
);
NcError.recordNotFound(JSON.stringify(pkValues));
}
continue;
}
@ -3552,9 +3858,7 @@ class BaseModelSqlv2 {
if (!deletedRecord) {
// throw or skip if no record found
if (throwExceptionIfNotExist) {
NcError.unprocessableEntity(
`Record with pk ${JSON.stringify(pkValues)} not found`,
);
NcError.recordNotFound(JSON.stringify(pkValues));
}
continue;
}
@ -4118,7 +4422,7 @@ class BaseModelSqlv2 {
!column ||
![UITypes.LinkToAnotherRecord, UITypes.Links].includes(column.uidt)
)
NcError.notFound('Column not found');
NcError.fieldNotFound(colId);
const colOptions = await column.getColOptions<LinkToAnotherRecordColumn>();
@ -4245,6 +4549,54 @@ class BaseModelSqlv2 {
});
}
break;
case RelationTypes.ONE_TO_ONE:
{
const isBt = column.meta?.bt;
// todo: unlink if it's already mapped
// unlink already mapped record if any
await this.execAndParse(
this.dbDriver(childTn)
.where({
[childColumn.column_name]: this.dbDriver.from(
this.dbDriver(parentTn)
.select(parentColumn.column_name)
.where(
_wherePk(parentTable.primaryKeys, isBt ? childId : rowId),
)
.first()
.as('___cn_alias'),
),
})
.update({ [childColumn.column_name]: null }),
null,
{ raw: true },
);
await this.execAndParse(
this.dbDriver(childTn)
.update({
[childColumn.column_name]: this.dbDriver.from(
this.dbDriver(parentTn)
.select(parentColumn.column_name)
.where(
_wherePk(parentTable.primaryKeys, isBt ? childId : rowId),
)
.first()
.as('___cn_alias'),
),
})
.where(_wherePk(childTable.primaryKeys, isBt ? rowId : childId)),
null,
{ raw: true },
);
await this.updateLastModified({
model: parentTable,
rowIds: [childId],
cookie,
});
}
break;
}
const response = await this.readByPk(
@ -4290,7 +4642,7 @@ class BaseModelSqlv2 {
!column ||
![UITypes.LinkToAnotherRecord, UITypes.Links].includes(column.uidt)
)
NcError.notFound('Column not found');
NcError.fieldNotFound(colId);
const colOptions = await column.getColOptions<LinkToAnotherRecordColumn>();
@ -4395,6 +4747,24 @@ class BaseModelSqlv2 {
});
}
break;
case RelationTypes.ONE_TO_ONE:
{
const isBt = column.meta?.bt;
await this.execAndParse(
this.dbDriver(childTn)
.where(_wherePk(childTable.primaryKeys, isBt ? rowId : childId))
.update({ [childColumn.column_name]: null }),
null,
{ raw: true },
);
await this.updateLastModified({
model: parentTable,
rowIds: [childId],
cookie,
});
}
break;
}
const newData = await this.readByPk(
@ -4440,9 +4810,9 @@ class BaseModelSqlv2 {
.getColumns()
.then((cols) => cols?.find((col) => col.id === args.groupColumnId));
if (!column) NcError.notFound('Column not found');
if (!column) NcError.fieldNotFound(args.groupColumnId);
if (isVirtualCol(column))
NcError.notImplemented('Grouping for virtual columns not implemented');
NcError.notImplemented('Grouping for virtual columns');
// extract distinct group column values
let groupingValues: Set<any>;
@ -4602,9 +4972,9 @@ class BaseModelSqlv2 {
.getColumns()
.then((cols) => cols?.find((col) => col.id === args.groupColumnId));
if (!column) NcError.notFound('Column not found');
if (!column) NcError.fieldNotFound(args.groupColumnId);
if (isVirtualCol(column))
NcError.notImplemented('Grouping for virtual columns not implemented');
NcError.notImplemented('Grouping for virtual columns');
const qb = this.dbDriver(this.tnPath)
.count('*', { as: 'count' })
@ -4767,7 +5137,11 @@ class BaseModelSqlv2 {
}
idToAliasMap[col.id] = col.title;
if (col.colOptions?.type === 'bt') {
if (
[RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(
col.colOptions?.type,
)
) {
btMap[col.id] = true;
const btData = Object.values(data).find(
(d) => d[col.id] && Object.keys(d[col.id]),
@ -5200,7 +5574,7 @@ class BaseModelSqlv2 {
async addLinks({
cookie,
childIds,
childIds: _childIds,
colId,
rowId,
}: {
@ -5212,8 +5586,7 @@ class BaseModelSqlv2 {
const columns = await this.model.getColumns();
const column = columns.find((c) => c.id === colId);
if (!column || !isLinksOrLTAR(column))
NcError.notFound(`Link column ${colId} not found`);
if (!column || !isLinksOrLTAR(column)) NcError.fieldNotFound(colId);
const row = await this.readByPk(
rowId,
@ -5224,10 +5597,10 @@ class BaseModelSqlv2 {
// validate rowId
if (!row) {
NcError.notFound(`Record with id '${rowId}' not found`);
NcError.recordNotFound(rowId);
}
if (!childIds.length) return;
if (!_childIds.length) return;
const colOptions = await column.getColOptions<LinkToAnotherRecordColumn>();
@ -5241,7 +5614,39 @@ class BaseModelSqlv2 {
const childTn = this.getTnPath(childTable);
const parentTn = this.getTnPath(parentTable);
switch (colOptions.type) {
let relationType = colOptions.type;
let childIds = _childIds;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = column.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
childIds = childIds.slice(0, 1);
// unlink
await this.execAndParse(
this.dbDriver(childTn)
.where({
[childColumn.column_name]: this.dbDriver.from(
this.dbDriver(parentTn)
.select(parentColumn.column_name)
.where(
_wherePk(
parentTable.primaryKeys,
column.meta?.bt ? childIds[0] : rowId,
),
)
.first()
.as('___cn_alias'),
),
})
.update({ [childColumn.column_name]: null }),
null,
{ raw: true },
);
}
switch (relationType) {
case RelationTypes.MANY_TO_MANY:
{
const vChildCol = await colOptions.getMMChildColumn();
@ -5303,11 +5708,7 @@ class BaseModelSqlv2 {
!childRows.find((r) => r[parentColumn.column_name] === id),
);
NcError.unprocessableEntity(
`Child record with id [${extractIdsString(
missingIds,
)}] not found`,
);
NcError.recordNotFound(extractIds(missingIds));
}
insertData = childRows
@ -5380,11 +5781,7 @@ class BaseModelSqlv2 {
!childRows.find((r) => r[parentColumn.column_name] === id),
);
NcError.unprocessableEntity(
`Child record with id [${extractIdsString(
missingIds,
)}] not found`,
);
NcError.recordNotFound(extractIds(missingIds));
}
}
const updateQb = this.dbDriver(childTn).update({
@ -5438,12 +5835,7 @@ class BaseModelSqlv2 {
});
if (!childRow) {
NcError.unprocessableEntity(
`Child record with id [${extractIdsString(
childIds,
true,
)}] not found`,
);
NcError.recordNotFound(extractIds(childIds, true));
}
}
@ -5499,8 +5891,7 @@ class BaseModelSqlv2 {
const columns = await this.model.getColumns();
const column = columns.find((c) => c.id === colId);
if (!column || !isLinksOrLTAR(column))
NcError.notFound(`Link column ${colId} not found`);
if (!column || !isLinksOrLTAR(column)) NcError.fieldNotFound(colId);
const row = await this.readByPk(
rowId,
@ -5511,7 +5902,7 @@ class BaseModelSqlv2 {
// validate rowId
if (!row) {
NcError.notFound(`Record with id '${rowId}' not found`);
NcError.recordNotFound(rowId);
}
if (!childIds.length) return;
@ -5585,11 +5976,7 @@ class BaseModelSqlv2 {
),
);
NcError.unprocessableEntity(
`Child record with id [${extractIdsString(
missingIds,
)}] not found`,
);
NcError.recordNotFound(extractIds(missingIds));
}
}
@ -5672,11 +6059,7 @@ class BaseModelSqlv2 {
),
);
NcError.unprocessableEntity(
`Child record with id [${extractIdsString(
missingIds,
)}] not found`,
);
NcError.recordNotFound(extractIds(missingIds));
}
}
@ -5734,12 +6117,7 @@ class BaseModelSqlv2 {
});
if (!childRow) {
NcError.unprocessableEntity(
`Child record with id [${extractIdsString(
childIds,
true,
)}] not found`,
);
NcError.recordNotFound(extractIds(childIds, true));
}
}
@ -5799,7 +6177,7 @@ class BaseModelSqlv2 {
// validate rowId
if (!row) {
NcError.notFound(`Record with id ${id} not found`);
NcError.recordNotFound(id);
}
const parentCol = await (
@ -6087,7 +6465,7 @@ export function extractSortsObject(
else sort.fk_column_id = aliasColObjMap[s.replace(/^\+/, '')]?.id;
if (throwErrorIfInvalid && !sort.fk_column_id)
NcError.unprocessableEntity(`Invalid field: ${s.replace(/^[+-]/, '')}`);
NcError.fieldNotFound(s.replace(/^[+-]/, ''));
return new Sort(sort);
});
@ -6258,7 +6636,7 @@ export function extractCondition(
validateFilterComparison(aliasColObjMap[alias].uidt, op, sub_op);
} else if (throwErrorIfInvalid) {
NcError.unprocessableEntity(`Invalid field: ${alias}`);
NcError.invalidFilter(str);
}
return new Filter({
@ -6414,13 +6792,13 @@ export function getListArgs(
return obj;
}
function extractIdsString(
function extractIds(
childIds: (string | number | Record<string, any>)[],
isBt = false,
) {
return (isBt ? childIds.slice(0, 1) : childIds)
.map((r) => (typeof r === 'object' ? JSON.stringify(r) : r))
.join(', ');
return (isBt ? childIds.slice(0, 1) : childIds).map((r) =>
typeof r === 'object' ? JSON.stringify(r) : `${r}`,
);
}
export { BaseModelSqlv2 };

33
packages/nocodb/src/db/conditionV2.ts

@ -16,7 +16,6 @@ import type LookupColumn from '~/models/LookupColumn';
import type RollupColumn from '~/models/RollupColumn';
import type FormulaColumn from '~/models/FormulaColumn';
import { getColumnName } from '~/db/BaseModelSqlv2';
import { type BarcodeColumn, BaseUser, type QrCodeColumn } from '~/models';
import { NcError } from '~/helpers/catchError';
import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2';
import genRollupSelectv2 from '~/db/genRollupSelectv2';
@ -25,6 +24,7 @@ import Filter from '~/models/Filter';
import generateLookupSelectQuery from '~/db/generateLookupSelectQuery';
import { getAliasGenerator } from '~/utils';
import { getRefColumnIfAlias } from '~/helpers';
import { type BarcodeColumn, BaseUser, type QrCodeColumn } from '~/models';
// tod: tobe fixed
// extend(customParseFormat);
@ -167,7 +167,7 @@ const parseConditionV2 = async (
const column = await getRefColumnIfAlias(await filter.getColumn());
if (!column) {
if (throwErrorIfInvalid) {
NcError.unprocessableEntity(`Invalid field: ${filter.fk_column_id}`);
NcError.fieldNotFound(filter.fk_column_id);
}
return;
}
@ -180,7 +180,16 @@ const parseConditionV2 = async (
await childModel.getColumns();
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
if (colOptions.type === RelationTypes.HAS_MANY) {
let relationType = colOptions.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = column.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
if (relationType === RelationTypes.HAS_MANY) {
if (
['blank', 'notblank', 'checked', 'notchecked'].includes(
filter.comparison_op,
@ -245,7 +254,7 @@ const parseConditionV2 = async (
qbP.whereNotIn(parentColumn.column_name, selectQb);
else qbP.whereIn(parentColumn.column_name, selectQb);
};
} else if (colOptions.type === RelationTypes.BELONGS_TO) {
} else if (relationType === RelationTypes.BELONGS_TO) {
if (
['blank', 'notblank', 'checked', 'notchecked'].includes(
filter.comparison_op,
@ -315,7 +324,7 @@ const parseConditionV2 = async (
);
} else qbP.whereIn(childColumn.column_name, selectQb);
};
} else if (colOptions.type === RelationTypes.MANY_TO_MANY) {
} else if (relationType === RelationTypes.MANY_TO_MANY) {
const mmModel = await colOptions.getMMModel();
const mmParentColumn = await colOptions.getMMParentColumn();
const mmChildColumn = await colOptions.getMMChildColumn();
@ -1247,7 +1256,15 @@ async function generateLookupCondition(
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
if (relationColumnOptions.type === RelationTypes.HAS_MANY) {
let relationType = relationColumnOptions.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = relationColumn.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
if (relationType === RelationTypes.HAS_MANY) {
qb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel.table_name),
@ -1278,7 +1295,7 @@ async function generateLookupCondition(
qbP.whereNotIn(parentColumn.column_name, qb);
else qbP.whereIn(parentColumn.column_name, qb);
};
} else if (relationColumnOptions.type === RelationTypes.BELONGS_TO) {
} else if (relationType === RelationTypes.BELONGS_TO) {
qb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(parentModel.table_name),
@ -1312,7 +1329,7 @@ async function generateLookupCondition(
);
else qbP.whereIn(childColumn.column_name, qb);
};
} else if (relationColumnOptions.type === RelationTypes.MANY_TO_MANY) {
} else if (relationType === RelationTypes.MANY_TO_MANY) {
const mmModel = await relationColumnOptions.getMMModel();
const mmParentColumn = await relationColumnOptions.getMMParentColumn();
const mmChildColumn = await relationColumnOptions.getMMChildColumn();

95
packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts

@ -2,6 +2,7 @@ import jsep from 'jsep';
import {
FormulaDataTypes,
jsepCurlyHook,
RelationTypes,
UITypes,
validateDateWithUnknownFormat,
validateFormulaAndExtractTreeWithType,
@ -141,14 +142,14 @@ async function _formulaQueryBuilder(
aliasToColumn[col.id] = async (): Promise<any> => {
let aliasCount = 0;
let selectQb;
let isMany = false;
let isArray = false;
const alias = `__nc_formula${aliasCount++}`;
const lookup = await col.getColOptions<LookupColumn>();
{
const relationCol = await lookup.getRelationColumn();
const relation =
await relationCol.getColOptions<LinkToAnotherRecordColumn>();
// if (relation.type !== 'bt') continue;
// if (relation.type !== RelationTypes.BELONGS_TO) continue;
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
@ -156,8 +157,17 @@ async function _formulaQueryBuilder(
await childModel.getColumns();
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
switch (relation.type) {
case 'bt':
let relationType = relation.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = relationCol.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
switch (relationType) {
case RelationTypes.BELONGS_TO:
selectQb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(parentModel.table_name),
@ -173,8 +183,8 @@ async function _formulaQueryBuilder(
]),
);
break;
case 'hm':
isMany = true;
case RelationTypes.HAS_MANY:
isArray = relation.type !== RelationTypes.ONE_TO_ONE;
selectQb = knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel.table_name),
@ -190,9 +200,9 @@ async function _formulaQueryBuilder(
]),
);
break;
case 'mm':
case RelationTypes.MANY_TO_MANY:
{
isMany = true;
isArray = true;
const mmModel = await relation.getMMModel();
const mmParentColumn = await relation.getMMParentColumn();
const mmChildColumn = await relation.getMMChildColumn();
@ -236,7 +246,7 @@ async function _formulaQueryBuilder(
await relationCol.getColOptions<LinkToAnotherRecordColumn>();
// if any of the relation in nested lookup is
// not belongs to then ignore the sort option
// if (relation.type !== 'bt') continue;
// if (relation.type !== RelationTypes.BELONGS_TO) continue;
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
@ -245,8 +255,16 @@ async function _formulaQueryBuilder(
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
switch (relation.type) {
case 'bt':
let relationType = relation.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = relationCol.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
switch (relationType) {
case RelationTypes.BELONGS_TO:
{
selectQb.join(
knex.raw(`?? as ??`, [
@ -258,9 +276,9 @@ async function _formulaQueryBuilder(
);
}
break;
case 'hm':
case RelationTypes.HAS_MANY:
{
isMany = true;
isArray = relation.type !== RelationTypes.ONE_TO_ONE;
selectQb.join(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel.table_name),
@ -271,8 +289,8 @@ async function _formulaQueryBuilder(
);
}
break;
case 'mm': {
isMany = true;
case RelationTypes.MANY_TO_MANY: {
isArray = true;
const mmModel = await relation.getMMModel();
const mmParentColumn = await relation.getMMParentColumn();
const mmChildColumn = await relation.getMMChildColumn();
@ -324,7 +342,7 @@ async function _formulaQueryBuilder(
).builder;
// selectQb.select(builder);
if (isMany) {
if (isArray) {
const qb = selectQb;
selectQb = (fn) =>
knex
@ -346,7 +364,7 @@ async function _formulaQueryBuilder(
const nestedAlias = `__nc_formula${aliasCount++}`;
const relation =
await lookupColumn.getColOptions<LinkToAnotherRecordColumn>();
// if (relation.type !== 'bt') continue;
// if (relation.type !== RelationTypes.BELONGS_TO) continue;
const colOptions =
(await lookupColumn.getColOptions()) as LinkToAnotherRecordColumn;
@ -357,8 +375,17 @@ async function _formulaQueryBuilder(
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
let cn;
switch (relation.type) {
case 'bt':
let relationType = relation.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = relationCol.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
switch (relationType) {
case RelationTypes.BELONGS_TO:
{
selectQb.join(
knex.raw(`?? as ??`, [
@ -374,9 +401,9 @@ async function _formulaQueryBuilder(
]);
}
break;
case 'hm':
case RelationTypes.HAS_MANY:
{
isMany = true;
isArray = relation.type !== RelationTypes.ONE_TO_ONE;
selectQb.join(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel.table_name),
@ -391,9 +418,9 @@ async function _formulaQueryBuilder(
]);
}
break;
case 'mm':
case RelationTypes.MANY_TO_MANY:
{
isMany = true;
isArray = true;
const mmModel = await relation.getMMModel();
const mmParentColumn =
await relation.getMMParentColumn();
@ -434,7 +461,7 @@ async function _formulaQueryBuilder(
`${prevAlias}.${childColumn.column_name}`,
);
if (isMany) {
if (isArray) {
const qb = selectQb;
selectQb = (fn) =>
knex
@ -464,7 +491,7 @@ async function _formulaQueryBuilder(
aliasToColumn,
formulaOption.getParsedTree(),
);
if (isMany) {
if (isArray) {
const qb = selectQb;
selectQb = (fn) =>
knex
@ -483,7 +510,7 @@ async function _formulaQueryBuilder(
break;
default:
{
if (isMany) {
if (isArray) {
const qb = selectQb;
selectQb = (fn) =>
knex
@ -529,7 +556,7 @@ async function _formulaQueryBuilder(
aliasToColumn[col.id] = async (): Promise<any> => {
const alias = `__nc_formula_ll`;
const relation = await col.getColOptions<LinkToAnotherRecordColumn>();
// if (relation.type !== 'bt') continue;
// if (relation.type !== RelationTypes.BELONGS_TO) continue;
const colOptions =
(await col.getColOptions()) as LinkToAnotherRecordColumn;
@ -540,8 +567,16 @@ async function _formulaQueryBuilder(
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
let relationType = relation.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = col.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
let selectQb;
if (relation.type === 'bt') {
if (relationType === RelationTypes.BELONGS_TO) {
selectQb = knex(baseModelSqlv2.getTnPath(parentModel.table_name))
.select(parentModel?.displayValue?.column_name)
.where(
@ -555,7 +590,7 @@ async function _formulaQueryBuilder(
}.${childColumn.column_name}`,
]),
);
} else if (relation.type == 'hm') {
} else if (relationType == RelationTypes.HAS_MANY) {
const qb = knex(baseModelSqlv2.getTnPath(childModel.table_name))
// .select(knex.raw(`GROUP_CONCAT(??)`, [childModel?.pv?.title]))
.where(
@ -582,7 +617,7 @@ async function _formulaQueryBuilder(
.wrap('(', ')');
// getAggregateFn();
} else if (relation.type == 'mm') {
} else if (relationType == RelationTypes.MANY_TO_MANY) {
// todo:
// const qb = knex(childModel.title)
// // .select(knex.raw(`GROUP_CONCAT(??)`, [childModel?.pv?.title]))

21
packages/nocodb/src/db/genRollupSelectv2.ts

@ -51,6 +51,27 @@ export default async function ({
knex.ref(`${refTableAlias}.${childCol.column_name}`),
),
};
case RelationTypes.ONE_TO_ONE:
return {
builder: knex(
knex.raw(`?? as ??`, [
baseModelSqlv2.getTnPath(childModel?.table_name),
refTableAlias,
]),
)
[columnOptions.rollup_function as string]?.(
knex.ref(`${refTableAlias}.${rollupColumn.column_name}`),
)
.where(
knex.ref(
`${alias || baseModelSqlv2.getTnPath(parentModel.table_name)}.${
parentCol.column_name
}`,
),
'=',
knex.ref(`${refTableAlias}.${childCol.column_name}`),
),
};
case RelationTypes.MANY_TO_MANY: {
const mmModel = await relationColumnOption.getMMModel();
const mmChildCol = await relationColumnOption.getMMChildColumn();

39
packages/nocodb/src/db/generateLookupSelectQuery.ts

@ -70,8 +70,15 @@ export default async function generateLookupSelectQuery({
const relation =
await relationCol.getColOptions<LinkToAnotherRecordColumn>();
// if not belongs to then throw error as we don't support
if (relation.type === RelationTypes.BELONGS_TO) {
let relationType = relation.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = relationCol.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
if (relationType === RelationTypes.BELONGS_TO) {
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
const childModel = await childColumn.getModel();
@ -92,10 +99,7 @@ export default async function generateLookupSelectQuery({
}`,
]),
);
}
// if not belongs to then throw error as we don't support
else if (relation.type === RelationTypes.HAS_MANY) {
} else if (relationType === RelationTypes.HAS_MANY) {
isBtLookup = false;
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
@ -117,10 +121,7 @@ export default async function generateLookupSelectQuery({
}`,
]),
);
}
// if not belongs to then throw error as we don't support
else if (relation.type === RelationTypes.MANY_TO_MANY) {
} else if (relationType === RelationTypes.MANY_TO_MANY) {
isBtLookup = false;
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
@ -191,9 +192,17 @@ export default async function generateLookupSelectQuery({
const relation =
await relationCol.getColOptions<LinkToAnotherRecordColumn>();
let relationType = relation.type;
if (relationType === RelationTypes.ONE_TO_ONE) {
relationType = relationCol.meta?.bt
? RelationTypes.BELONGS_TO
: RelationTypes.HAS_MANY;
}
// if any of the relation in nested lookupColOpt is
// not belongs to then throw error as we don't support
if (relation.type === RelationTypes.BELONGS_TO) {
if (relationType === RelationTypes.BELONGS_TO) {
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
const childModel = await childColumn.getModel();
@ -209,7 +218,7 @@ export default async function generateLookupSelectQuery({
`${nestedAlias}.${parentColumn.column_name}`,
`${prevAlias}.${childColumn.column_name}`,
);
} else if (relation.type === RelationTypes.HAS_MANY) {
} else if (relationType === RelationTypes.HAS_MANY) {
isBtLookup = false;
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
@ -226,7 +235,7 @@ export default async function generateLookupSelectQuery({
`${nestedAlias}.${childColumn.column_name}`,
`${prevAlias}.${parentColumn.column_name}`,
);
} else if (relation.type === RelationTypes.MANY_TO_MANY) {
} else if (relationType === RelationTypes.MANY_TO_MANY) {
isBtLookup = false;
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
@ -425,8 +434,6 @@ export default async function generateLookupSelectQuery({
};
}
NcError.notImplemented(
'Database not supported this operation on Lookup/LTAR',
);
NcError.notImplemented('This operation on Lookup/LTAR for this database');
}
}

2
packages/nocodb/src/db/sortV2.ts

@ -33,7 +33,7 @@ export default async function sortV2(
const column = await getRefColumnIfAlias(await sort.getColumn());
if (!column) {
if (throwErrorIfInvalid) {
NcError.unprocessableEntity(`Invalid field: ${sort.fk_column_id}`);
NcError.fieldNotFound(sort.fk_column_id);
}
continue;
}

1
packages/nocodb/src/db/sql-client/lib/mysql/MysqlClient.ts

@ -2496,6 +2496,7 @@ class MysqlClient extends KnexClient {
query += n.un ? ' UNSIGNED' : '';
query += n.rqd ? ' NOT NULL' : ' NULL';
query += n.ai ? ' auto_increment' : '';
query += n.unique ? ` UNIQUE` : '';
const defaultValue = this.sanitiseDefaultValue(n.cdf);
query += defaultValue
? `

2
packages/nocodb/src/db/sql-client/lib/pg/PgClient.ts

@ -2863,6 +2863,7 @@ class PGClient extends KnexClient {
);
query += n.rqd ? ' NOT NULL' : ' NULL';
query += defaultValue ? ` DEFAULT ${defaultValue}` : '';
query += n.unique ? ` UNIQUE` : '';
}
} else if (change === 1) {
query += this.genQuery(
@ -2872,6 +2873,7 @@ class PGClient extends KnexClient {
);
query += n.rqd ? ' NOT NULL' : ' NULL';
query += defaultValue ? ` DEFAULT ${defaultValue}` : '';
query += n.unique ? ` UNIQUE` : '';
query = this.genQuery(`ALTER TABLE ?? ${query};`, [t], shouldSanitize);
} else {
if (n.cn !== o.cn) {

5
packages/nocodb/src/db/sql-client/lib/sqlite/SqliteClient.ts

@ -2132,6 +2132,7 @@ class SqliteClient extends KnexClient {
? ' '
: ` DEFAULT ''`;
addNewColumnQuery += n.rqd ? ` NOT NULL` : ' ';
query += n.unique ? ` UNIQUE` : '';
addNewColumnQuery = this.genQuery(
`ALTER TABLE ?? ${addNewColumnQuery};`,
[t],
@ -2161,6 +2162,8 @@ class SqliteClient extends KnexClient {
query += n.dtxp && n.dt !== 'text' ? `(${this.genRaw(n.dtxp)})` : '';
query += n.cdf ? ` DEFAULT ${this.genValue(n.cdf)}` : ' ';
query += n.rqd ? ` NOT NULL` : ' ';
// todo: unique constraint should be added using index
// query += n.unique ? ` UNIQUE` : '';
} else if (change === 1) {
shouldSanitize = true;
query += this.genQuery(
@ -2175,6 +2178,8 @@ class SqliteClient extends KnexClient {
? ' '
: ` DEFAULT ''`;
query += n.rqd ? ` NOT NULL` : ' ';
// todo: unique constraint should be added using index
// query += n.unique ? ` UNIQUE` : '';
query = this.genQuery(`ALTER TABLE ?? ${query};`, [t], shouldSanitize);
} else {
// if(n.cn!==o.cno) {

20
packages/nocodb/src/filters/global-exception/global-exception.filter.ts

@ -8,9 +8,8 @@ import {
BadRequest,
extractDBError,
Forbidden,
InternalServerError,
NcBaseErrorv2,
NotFound,
NotImplemented,
Unauthorized,
UnprocessableEntity,
} from '~/helpers/catchError';
@ -37,7 +36,6 @@ export class GlobalExceptionFilter implements ExceptionFilter {
exception instanceof Unauthorized ||
exception instanceof Forbidden ||
exception instanceof NotFound ||
exception instanceof NotImplemented ||
exception instanceof UnprocessableEntity ||
exception instanceof NotFoundException ||
exception instanceof ThrottlerException
@ -83,22 +81,18 @@ export class GlobalExceptionFilter implements ExceptionFilter {
exception.getStatus?.() === 404
) {
return response.status(404).json({ msg: exception.message });
} else if (
exception instanceof InternalServerError ||
exception.getStatus?.() === 500
) {
return response.status(500).json({ msg: exception.message });
} else if (
exception instanceof NotImplemented ||
exception.getStatus?.() === 501
) {
return response.status(501).json({ msg: exception.message });
} else if (exception instanceof AjvError) {
return response
.status(400)
.json({ msg: exception.message, errors: exception.errors });
} else if (exception instanceof UnprocessableEntity) {
return response.status(422).json({ msg: exception.message });
} else if (exception instanceof NcBaseErrorv2) {
return response.status(exception.code).json({
error: exception.error,
message: exception.message,
details: exception.details,
});
}
// handle different types of exceptions

6
packages/nocodb/src/helpers/NcPluginMgrv2.ts

@ -249,7 +249,7 @@ class NcPluginMgrv2 {
await tempPlugin.init(args?.input);
if (!tempPlugin?.getAdapter()?.test)
NcError.notImplemented('Plugin test is not implemented');
NcError.notImplemented('Plugin Test');
return tempPlugin?.getAdapter()?.test?.();
}
@ -263,7 +263,7 @@ class NcPluginMgrv2 {
await tempPlugin.init(args?.input);
if (!tempPlugin?.getAdapter()?.test)
NcError.notImplemented('Plugin test is not implemented');
NcError.notImplemented('Plugin Test');
return tempPlugin?.getAdapter()?.test?.();
}
@ -276,7 +276,7 @@ class NcPluginMgrv2 {
await tempPlugin.init(args?.input);
if (!tempPlugin?.getAdapter()?.test)
NcError.notImplemented('Plugin test is not implemented');
NcError.notImplemented('Plugin Test');
return tempPlugin?.getAdapter()?.test?.();
}

2
packages/nocodb/src/helpers/PagedResponse.ts

@ -42,7 +42,7 @@ export class PagedResponseImpl<T> {
if (additionalProps) Object.assign(this, additionalProps);
if (offset && offset >= +count) {
NcError.badRequest('Offset is beyond the total number of records');
NcError.invalidOffsetValue(offset);
}
}

298
packages/nocodb/src/helpers/catchError.ts

@ -1,5 +1,7 @@
import { NcErrorType } from 'nocodb-sdk';
import type { NextFunction, Request, Response } from 'express';
import type { ErrorObject } from 'ajv';
import { defaultLimitConfig } from '~/helpers/extractLimitAndOffset';
export enum DBError {
TABLE_EXIST = 'TABLE_EXIST',
@ -372,6 +374,12 @@ export function extractDBError(error): {
case 'EHOSTDOWN':
message = 'The host is down.';
break;
default:
// if error message contains -- then extract message after --
if (error.message && error.message.includes('--')) {
message = error.message.split('--')[1];
}
break;
}
if (message) {
@ -400,7 +408,6 @@ export default function (
e instanceof Unauthorized ||
e instanceof Forbidden ||
e instanceof NotFound ||
e instanceof NotImplemented ||
e instanceof UnprocessableEntity
)
)
@ -409,7 +416,15 @@ export default function (
const dbError = extractDBError(e);
if (dbError) {
return res.status(400).json(dbError);
const error = new NcBaseErrorv2(NcErrorType.DATABASE_ERROR, {
params: dbError.message,
details: dbError,
});
return res.status(error.code).json({
error: error.error,
message: error.message,
details: error.details,
});
}
if (e instanceof BadRequest) {
@ -420,16 +435,16 @@ export default function (
return res.status(403).json({ msg: e.message });
} else if (e instanceof NotFound) {
return res.status(404).json({ msg: e.message });
} else if (e instanceof InternalServerError) {
return res.status(500).json({ msg: e.message });
} else if (e instanceof NotImplemented) {
return res.status(501).json({ msg: e.message });
} else if (e instanceof AjvError) {
return res.status(400).json({ msg: e.message, errors: e.errors });
} else if (e instanceof UnprocessableEntity) {
return res.status(422).json({ msg: e.message });
} else if (e instanceof NotAllowed) {
return res.status(405).json({ msg: e.message });
} else if (e instanceof NcBaseErrorv2) {
return res
.status(e.code)
.json({ error: e.error, message: e.message, details: e.details });
}
// if some other error occurs then send 500 and a generic message
res.status(500).json({ msg: 'Internal server error' });
@ -453,10 +468,6 @@ export class Forbidden extends NcBaseError {}
export class NotFound extends NcBaseError {}
export class InternalServerError extends NcBaseError {}
export class NotImplemented extends NcBaseError {}
export class UnprocessableEntity extends NcBaseError {}
export class AjvError extends NcBaseError {
@ -468,7 +479,266 @@ export class AjvError extends NcBaseError {
errors: ErrorObject[];
}
const errorHelpers: {
[key in NcErrorType]: {
message: string | ((...params: string[]) => string);
code: number;
};
} = {
[NcErrorType.INTERNAL_SERVER_ERROR]: {
message: (message: string) => message || `Internal server error`,
code: 500,
},
[NcErrorType.DATABASE_ERROR]: {
message: (message: string) =>
message || `There was an error while running the query`,
code: 500,
},
[NcErrorType.AUTHENTICATION_REQUIRED]: {
message: 'Authentication required to access this resource',
code: 401,
},
[NcErrorType.API_TOKEN_NOT_ALLOWED]: {
message: 'This request is not allowed with API token',
code: 401,
},
[NcErrorType.WORKSPACE_NOT_FOUND]: {
message: (id: string) => `Workspace '${id}' not found`,
code: 404,
},
[NcErrorType.BASE_NOT_FOUND]: {
message: (id: string) => `Base '${id}' not found`,
code: 404,
},
[NcErrorType.SOURCE_NOT_FOUND]: {
message: (id: string) => `Source '${id}' not found`,
code: 404,
},
[NcErrorType.TABLE_NOT_FOUND]: {
message: (id: string) => `Table '${id}' not found`,
code: 404,
},
[NcErrorType.VIEW_NOT_FOUND]: {
message: (id: string) => `View '${id}' not found`,
code: 404,
},
[NcErrorType.FIELD_NOT_FOUND]: {
message: (id: string) => `Field '${id}' not found`,
code: 404,
},
[NcErrorType.RECORD_NOT_FOUND]: {
message: (...ids: string[]) => {
const isMultiple = Array.isArray(ids) && ids.length > 1;
return `Record${isMultiple ? 's' : ''} '${ids.join(', ')}' not found`;
},
code: 404,
},
[NcErrorType.ERROR_DUPLICATE_RECORD]: {
message: (...ids: string[]) => {
const isMultiple = Array.isArray(ids) && ids.length > 1;
return `Record${isMultiple ? 's' : ''} '${ids.join(
', ',
)}' already exists`;
},
code: 422,
},
[NcErrorType.USER_NOT_FOUND]: {
message: (idOrEmail: string) => {
const isEmail = idOrEmail.includes('@');
return `User ${
isEmail ? 'with email' : 'with id'
} '${idOrEmail}' not found`;
},
code: 404,
},
[NcErrorType.INVALID_OFFSET_VALUE]: {
message: (offset: string) => `Offset value '${offset}' is invalid`,
code: 422,
},
[NcErrorType.INVALID_LIMIT_VALUE]: {
message: `Limit value should be between ${defaultLimitConfig.limitMin} and ${defaultLimitConfig.limitMax}`,
code: 422,
},
[NcErrorType.INVALID_FILTER]: {
message: (filter: string) => `Filter '${filter}' is invalid`,
code: 422,
},
[NcErrorType.INVALID_SHARED_VIEW_PASSWORD]: {
message: 'Invalid shared view password',
code: 403,
},
[NcErrorType.NOT_IMPLEMENTED]: {
message: (feature: string) => `${feature} is not implemented`,
code: 501,
},
};
function generateError(
type: NcErrorType,
args?: NcErrorArgs,
): {
message: string;
code: number;
details?: any;
} {
const errorHelper = errorHelpers[type];
const { params, customMessage, details } = args || {};
if (!errorHelper) {
return {
message: 'An error occurred',
code: 500,
details: details,
};
}
let message: string;
const messageHelper = customMessage || errorHelper.message;
if (typeof messageHelper === 'function') {
message = messageHelper(...(Array.isArray(params) ? params : [params]));
} else {
message = messageHelper;
}
return {
message,
code: errorHelper.code,
details: details,
};
}
type NcErrorArgs = {
params?: string | string[];
customMessage?: string | ((...args: string[]) => string);
details?: any;
};
export class NcBaseErrorv2 extends NcBaseError {
error: NcErrorType;
code: number;
details?: any;
constructor(error: NcErrorType, args?: NcErrorArgs) {
const errorHelper = generateError(error, args);
super(errorHelper.message);
this.error = error;
this.code = errorHelper.code;
this.details = args?.details;
}
}
export class NcError {
static authenticationRequired(args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.AUTHENTICATION_REQUIRED, args);
}
static apiTokenNotAllowed(args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.API_TOKEN_NOT_ALLOWED, args);
}
static workspaceNotFound(id: string, args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.WORKSPACE_NOT_FOUND, {
params: id,
...args,
});
}
static baseNotFound(id: string, args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.BASE_NOT_FOUND, {
params: id,
...args,
});
}
static sourceNotFound(id: string, args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.SOURCE_NOT_FOUND, {
params: id,
...args,
});
}
static tableNotFound(id: string, args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.TABLE_NOT_FOUND, {
params: id,
...args,
});
}
static userNotFound(id: string, args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.USER_NOT_FOUND, {
params: id,
...args,
});
}
static viewNotFound(id: string, args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.VIEW_NOT_FOUND, {
params: id,
...args,
});
}
static recordNotFound(id: string | string[], args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.RECORD_NOT_FOUND, {
params: id,
...args,
});
}
static duplicateRecord(id: string | string[], args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.ERROR_DUPLICATE_RECORD, {
params: id,
...args,
});
}
static fieldNotFound(id: string, args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.FIELD_NOT_FOUND, {
params: id,
...args,
});
}
static invalidOffsetValue(offset: string | number, args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.INVALID_OFFSET_VALUE, {
params: `${offset}`,
...args,
});
}
static invalidLimitValue(args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.INVALID_LIMIT_VALUE, {
...args,
});
}
static invalidFilter(filter: string, args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.INVALID_FILTER, {
params: filter,
...args,
});
}
static invalidSharedViewPassword(args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.INVALID_SHARED_VIEW_PASSWORD, {
...args,
});
}
static notImplemented(feature: string = 'Feature', args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.NOT_IMPLEMENTED, {
params: feature,
...args,
});
}
static internalServerError(message: string, args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.INTERNAL_SERVER_ERROR, {
params: message,
...args,
});
}
static notFound(message = 'Not found') {
throw new NotFound(message);
}
@ -485,14 +755,6 @@ export class NcError {
throw new Forbidden(message);
}
static internalServerError(message = 'Internal server error') {
throw new InternalServerError(message);
}
static notImplemented(message = 'Not implemented') {
throw new NotImplemented(message);
}
static ajvValidationError(param: { message: string; errors: ErrorObject[] }) {
throw new AjvError(param);
}

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

Loading…
Cancel
Save