Browse Source

Merge pull request #4554 from nocodb/develop

pull/4555/head 0.100.0
github-actions[bot] 2 years ago committed by GitHub
parent
commit
7c758d83fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      .github/workflows/ci-cd.yml
  2. 16
      .github/workflows/playwright-test-workflow.yml
  3. 4
      package.json
  4. 12
      packages/nc-cli/package-lock.json
  5. 4
      packages/nc-gui/app.vue
  6. 36
      packages/nc-gui/assets/css/global.css
  7. 5
      packages/nc-gui/assets/style.scss
  8. 6
      packages/nc-gui/components.d.ts
  9. 2
      packages/nc-gui/components/account/SignupSettings.vue
  10. 1
      packages/nc-gui/components/account/Token.vue
  11. 1
      packages/nc-gui/components/account/UsersModal.vue
  12. 17
      packages/nc-gui/components/cell/Checkbox.vue
  13. 2
      packages/nc-gui/components/cell/Currency.vue
  14. 80
      packages/nc-gui/components/cell/DatePicker.vue
  15. 86
      packages/nc-gui/components/cell/DateTimePicker.vue
  16. 2
      packages/nc-gui/components/cell/Decimal.vue
  17. 2
      packages/nc-gui/components/cell/Duration.vue
  18. 2
      packages/nc-gui/components/cell/Email.vue
  19. 2
      packages/nc-gui/components/cell/Float.vue
  20. 2
      packages/nc-gui/components/cell/Integer.vue
  21. 32
      packages/nc-gui/components/cell/Json.vue
  22. 2
      packages/nc-gui/components/cell/MultiSelect.vue
  23. 2
      packages/nc-gui/components/cell/Percent.vue
  24. 4
      packages/nc-gui/components/cell/SingleSelect.vue
  25. 2
      packages/nc-gui/components/cell/Text.vue
  26. 2
      packages/nc-gui/components/cell/TextArea.vue
  27. 2
      packages/nc-gui/components/cell/Url.vue
  28. 1
      packages/nc-gui/components/cell/attachment/Modal.vue
  29. 7
      packages/nc-gui/components/cell/attachment/index.vue
  30. 35
      packages/nc-gui/components/dashboard/TreeView.vue
  31. 2
      packages/nc-gui/components/dashboard/settings/AppStore.vue
  32. 1
      packages/nc-gui/components/dashboard/settings/Modal.vue
  33. 78
      packages/nc-gui/components/dlg/AirtableImport.vue
  34. 245
      packages/nc-gui/components/dlg/KeyboardShortcuts.vue
  35. 1
      packages/nc-gui/components/dlg/QuickImport.vue
  36. 1
      packages/nc-gui/components/dlg/TableCreate.vue
  37. 1
      packages/nc-gui/components/dlg/TableRename.vue
  38. 8
      packages/nc-gui/components/dlg/ViewCreate.vue
  39. 8
      packages/nc-gui/components/dlg/ViewDelete.vue
  40. 20
      packages/nc-gui/components/general/FullScreen.vue
  41. 42
      packages/nc-gui/components/general/ShareBaseButton.vue
  42. 27
      packages/nc-gui/components/general/SocialCard.vue
  43. 7
      packages/nc-gui/components/monaco/Editor.vue
  44. 1
      packages/nc-gui/components/shared-view/AskPassword.vue
  45. 5
      packages/nc-gui/components/smartsheet/Cell.vue
  46. 31
      packages/nc-gui/components/smartsheet/Form.vue
  47. 279
      packages/nc-gui/components/smartsheet/Grid.vue
  48. 7
      packages/nc-gui/components/smartsheet/Kanban.vue
  49. 3
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  50. 10
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  51. 5
      packages/nc-gui/components/smartsheet/column/EditOrAddProvider.vue
  52. 34
      packages/nc-gui/components/smartsheet/column/FormulaOptions.vue
  53. 4
      packages/nc-gui/components/smartsheet/column/LookupOptions.vue
  54. 68
      packages/nc-gui/components/smartsheet/column/QrCodeOptions.vue
  55. 14
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  56. 2
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  57. 36
      packages/nc-gui/components/smartsheet/header/Cell.vue
  58. 197
      packages/nc-gui/components/smartsheet/header/Menu.vue
  59. 29
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  60. 13
      packages/nc-gui/components/smartsheet/header/VirtualCellIcon.ts
  61. 4
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue
  62. 1
      packages/nc-gui/components/smartsheet/toolbar/Erd.vue
  63. 11
      packages/nc-gui/components/smartsheet/toolbar/FieldListAutoCompleteDropdown.vue
  64. 16
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  65. 1
      packages/nc-gui/components/smartsheet/toolbar/MoreActions.vue
  66. 1
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  67. 11
      packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue
  68. 1
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  69. 2
      packages/nc-gui/components/tabs/auth/ApiTokenManagement.vue
  70. 1
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  71. 3
      packages/nc-gui/components/tabs/auth/user-management/UsersModal.vue
  72. 31
      packages/nc-gui/components/virtual-cell/Formula.vue
  73. 12
      packages/nc-gui/components/virtual-cell/Lookup.vue
  74. 55
      packages/nc-gui/components/virtual-cell/QrCode.vue
  75. 18
      packages/nc-gui/components/virtual-cell/Rollup.vue
  76. 10
      packages/nc-gui/components/virtual-cell/components/ListItems.vue
  77. 8
      packages/nc-gui/composables/useColumnCreateStore.ts
  78. 54
      packages/nc-gui/composables/useMultiSelect/cellRange.ts
  79. 90
      packages/nc-gui/composables/useMultiSelect/convertCellData.ts
  80. 28
      packages/nc-gui/composables/useMultiSelect/copyValue.ts
  81. 390
      packages/nc-gui/composables/useMultiSelect/index.ts
  82. 6
      packages/nc-gui/composables/useSharedView.ts
  83. 21
      packages/nc-gui/composables/useShowNotEditableWarning.ts
  84. 6
      packages/nc-gui/composables/useSmartsheetStore.ts
  85. 18
      packages/nc-gui/composables/useViewData.ts
  86. 2
      packages/nc-gui/context/index.ts
  87. 12
      packages/nc-gui/lang/ar.json
  88. 12
      packages/nc-gui/lang/bn_IN.json
  89. 12
      packages/nc-gui/lang/da.json
  90. 12
      packages/nc-gui/lang/de.json
  91. 28
      packages/nc-gui/lang/en.json
  92. 12
      packages/nc-gui/lang/es.json
  93. 12
      packages/nc-gui/lang/fa.json
  94. 12
      packages/nc-gui/lang/fi.json
  95. 12
      packages/nc-gui/lang/fr.json
  96. 12
      packages/nc-gui/lang/he.json
  97. 12
      packages/nc-gui/lang/hi.json
  98. 12
      packages/nc-gui/lang/hr.json
  99. 12
      packages/nc-gui/lang/id.json
  100. 12
      packages/nc-gui/lang/it.json
  101. Some files were not shown because too many files have changed in this diff Show More

4
.github/workflows/ci-cd.yml

@ -10,6 +10,7 @@ on:
- "packages/nc-gui/**"
- "packages/nocodb/**"
- ".github/workflows/ci-cd.yml"
- "tests/playwright/**"
pull_request:
types: [opened, reopened, synchronize, ready_for_review, labeled]
branches: [develop]
@ -17,6 +18,7 @@ on:
- "packages/nc-gui/**"
- "packages/nocodb/**"
- ".github/workflows/ci-cd.yml"
- "tests/playwright/**"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@ -96,4 +98,4 @@ jobs:
uses: ./.github/workflows/playwright-test-workflow.yml
with:
db: pg
shard: 2
shard: 2

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

@ -98,6 +98,14 @@ jobs:
working-directory: ./tests/playwright
run: E2E_DB_TYPE=${{ inputs.db }} npm run ci:test:shard:${{ inputs.shard }}
# Stress test added/modified tests
- name: Fetch develop branch
working-directory: ./tests/playwright
run: git fetch origin develop
- name: Stress test
working-directory: ./tests/playwright
run: E2E_DB_TYPE=${{ inputs.db }} node ./scripts/stressTestNewlyAddedTest.js
# Quick tests (pg on sqlite shard 0 and sqlite on sqlite shard 1)
- name: Run quick server and tests (pg)
if: ${{ inputs.db == 'sqlite' && inputs.shard == '1' }}
@ -132,13 +140,19 @@ jobs:
name: playwright-report-quick-${{ inputs.shard }}
path: ./tests/playwright/playwright-report-quick/
retention-days: 2
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report-${{ inputs.db }}-${{ inputs.shard }}
path: ./tests/playwright/playwright-report/
retention-days: 2
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report-stress-${{ inputs.db }}-${{ inputs.shard }}
path: ./tests/playwright/playwright-report-stress/
retention-days: 2
- uses: actions/upload-artifact@v3
if: always()
with:

4
package.json

@ -42,7 +42,9 @@
"doc": "lerna run doc",
"install:local:dep": "cd packages/nc-lib-gui;npm uninstall -S xc-lib;rm package-lock.json; npm i ../../../xc-lib-private; cd ../xc-instant;npm uninstall -S xc-lib xc-lib-gui;npm i ../../../xc-lib-private;npm i ../xc-lib-gui",
"install:npm:dep": "cd packages/nc-lib-gui;npm uninstall -S xc-lib; npm i -S xc-lib@latest; cd ../xc-instant;npm uninstall -S xc-lib xc-lib-gui;npm i -S xc-lib@latest xc-lib-gui@latest;npm i ../xc-lib-gui",
"prepare": "husky install"
"prepare": "husky install",
"start:pg": "docker-compose -f ./tests/playwright/scripts/docker-compose-pg.yml up -d",
"stop:pg": "docker-compose -f ./tests/playwright/scripts/docker-compose-pg.yml down"
},
"dependencies": {
"express": "^4.18.1",

12
packages/nc-cli/package-lock.json generated

@ -9465,9 +9465,9 @@
}
},
"node_modules/minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@ -22773,9 +22773,9 @@
"dev": true
},
"minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"requires": {
"brace-expansion": "^1.1.7"
}

4
packages/nc-gui/app.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, useRoute, useTheme } from '#imports'
import { applyNonSelectable, computed, useRoute, useTheme } from '#imports'
const route = useRoute()
@ -7,6 +7,8 @@ const disableBaseLayout = computed(() => route.path.startsWith('/nc/view') || ro
useTheme()
applyNonSelectable()
// TODO: Remove when https://github.com/vuejs/core/issues/5513 fixed
const key = ref(0)

36
packages/nc-gui/assets/css/global.css

@ -1,15 +1,16 @@
html {
font-size: 16px;
word-spacing: 1px;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
box-sizing: border-box;
font-size: 16px;
word-spacing: 1px;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
box-sizing: border-box;
}
body {
font-family: "Source Sans Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, Vazirmatn, sans-serif;
font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, Vazirmatn,
sans-serif;
}
/*
@ -17,16 +18,27 @@ Apply Vazirmatn for rtl
*/
.rtl .v-application *:not(.material-icons) {
font-family: Vazirmatn !important;
font-family: Vazirmatn !important;
}
.rtl .v-application .ml-n1 {
margin-left: 0px !important;
margin-left: 0px !important;
}
/*
For Drag and Drop
*/
.grabbing * {
cursor: grabbing;
}
cursor: grabbing;
}
/*
Prevent Ctrl + A selection
*/
.non-selectable {
-webkit-user-select: none;
-webkit-touch-callout: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}

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

@ -86,6 +86,11 @@ a {
@apply relative after:(absolute top-[-2px] right-[-2px] w-[8px] h-[8px] rounded-full bg-primary content-[''] !z-20);
}
// badge with count
.nc-count-badge {
@apply absolute flex items-center top-[-6px] right-[-6px] px-1 min-w-[14px] h-[14px] rounded-full bg-primary bg-opacity-100 text-white !text-[9px] !z-21;
}
// for highlighting toolbar menu item
.nc-active-btn > .ant-btn {
@apply bg-primary bg-opacity-20 hover:(bg-primary bg-opacity-20);

6
packages/nc-gui/components.d.ts vendored

@ -168,6 +168,7 @@ declare module '@vue/runtime-core' {
MdiFileEyeOutline: typeof import('~icons/mdi/file-eye-outline')['default']
MdiFileImageBox: typeof import('~icons/mdi/file-image-box')['default']
MdiFilePlusOutline: typeof import('~icons/mdi/file-plus-outline')['default']
MdiFileReplaceOutline: typeof import('~icons/mdi/file-replace-outline')['default']
MdiFileUploadOutline: typeof import('~icons/mdi/file-upload-outline')['default']
MdiFilterOutline: typeof import('~icons/mdi/filter-outline')['default']
MdiFlag: typeof import('~icons/mdi/flag')['default']
@ -179,6 +180,7 @@ declare module '@vue/runtime-core' {
MdiHook: typeof import('~icons/mdi/hook')['default']
MdiInformation: typeof import('~icons/mdi/information')['default']
MdiJson: typeof import('~icons/mdi/json')['default']
MdiKeyboard: typeof import('~icons/mdi/keyboard')['default']
MdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
MdiKeyChange: typeof import('~icons/mdi/key-change')['default']
MdiKeyStar: typeof import('~icons/mdi/key-star')['default']
@ -210,11 +212,15 @@ declare module '@vue/runtime-core' {
MdiShieldKeyOutline: typeof import('~icons/mdi/shield-key-outline')['default']
MdiSlack: typeof import('~icons/mdi/slack')['default']
MdiSort: typeof import('~icons/mdi/sort')['default']
MdiSortAscending: typeof import('~icons/mdi/sort-ascending')['default']
MdiSortDescending: typeof import('~icons/mdi/sort-descending')['default']
MdiStar: typeof import('~icons/mdi/star')['default']
MdiStarOutline: typeof import('~icons/mdi/star-outline')['default']
MdiStorefrontOutline: typeof import('~icons/mdi/storefront-outline')['default']
MdiTable: typeof import('~icons/mdi/table')['default']
MdiTableArrowRight: typeof import('~icons/mdi/table-arrow-right')['default']
MdiTableColumnPlusAfter: typeof import('~icons/mdi/table-column-plus-after')['default']
MdiTableColumnPlusBefore: typeof import('~icons/mdi/table-column-plus-before')['default']
MdiTableLarge: typeof import('~icons/mdi/table-large')['default']
MdiText: typeof import('~icons/mdi/text')['default']
MdiThumbUp: typeof import('~icons/mdi/thumb-up')['default']

2
packages/nc-gui/components/account/SignupSettings.vue

@ -51,6 +51,6 @@ loadSettings()
<style scoped>
:deep(.ant-checkbox-wrapper) {
@apply !flex-row-reverse !flex !justify-start gap-4;
justify-content: start;
justify-content: flex-start;
}
</style>

1
packages/nc-gui/components/account/Token.vue

@ -211,6 +211,7 @@ const descriptionInput = (el) => {
<a-modal
v-model:visible="showNewTokenModal"
:class="{ active: showNewTokenModal }"
:closable="false"
width="28rem"
centered

1
packages/nc-gui/components/account/UsersModal.vue

@ -115,6 +115,7 @@ const emailInput = ref((el) => {
<template>
<a-modal
:class="{ active: show }"
:footer="null"
centered
:visible="show"

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

@ -17,9 +17,9 @@ const emits = defineEmits<Emits>()
const active = inject(ActiveCellInj, ref(false))
let vModel = $computed({
let vModel = $computed<boolean>({
get: () => !!props.modelValue && props.modelValue !== '0' && props.modelValue !== 0,
set: (val) => emits('update:modelValue', val),
set: (val: boolean) => emits('update:modelValue', val),
})
const column = inject(ColumnInj)
@ -39,7 +39,13 @@ const checkboxMeta = $computed(() => {
}
})
function onClick(force?: boolean) {
function onClick(force?: boolean, event?: MouseEvent) {
if (
(event?.target as HTMLElement)?.classList?.contains('nc-checkbox') ||
(event?.target as HTMLElement)?.closest('.nc-checkbox')
) {
return
}
if (!readOnly?.value && (force || active.value)) {
vModel = !vModel
}
@ -64,16 +70,17 @@ useSelectedCellKeyupListener(active, (e) => {
'nc-cell-hover-show': !vModel && !readOnly,
'opacity-0': readOnly && !vModel,
}"
@click="onClick(false)"
@click="onClick(false, $event)"
>
<div class="px-1 pt-1 rounded-full items-center" :class="{ 'bg-gray-100': !vModel, '!ml-[-8px]': readOnly }">
<Transition name="layout" mode="out-in" :duration="100">
<component
:is="getMdiIcon(vModel ? checkboxMeta.icon.checked : checkboxMeta.icon.unchecked)"
class="nc-checkbox"
:style="{
color: checkboxMeta.color,
}"
@click.stop="onClick(true)"
@click="onClick(true)"
/>
</Transition>
</div>

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

@ -66,6 +66,8 @@ onMounted(() => {
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else-if="vModel">{{ currency }}</span>

80
packages/nc-gui/components/cell/DatePicker.vue

@ -7,6 +7,7 @@ import {
ReadonlyInj,
computed,
inject,
isDrawerOrModalExist,
ref,
useSelectedCellKeyupListener,
watch,
@ -33,7 +34,7 @@ let isDateInvalid = $ref(false)
const dateFormat = $computed(() => columnMeta?.value?.meta?.date_format ?? 'YYYY-MM-DD')
const localState = $computed({
let localState = $computed({
get() {
if (!modelValue) {
return undefined
@ -76,14 +77,89 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
open.value = true
// skip if drawer / modal is active
if (isDrawerOrModalExist()) {
return
}
if (!open.value) {
// open date picker
open.value = true
} else {
// select the current day
const el = document.querySelector('.nc-picker-date.active .ant-picker-cell-selected') as HTMLButtonElement
if (el) {
el.click()
open.value = false
}
}
break
case 'Escape':
// skip if drawer / modal is active
if (isDrawerOrModalExist()) {
return
}
if (open.value) {
e.stopPropagation()
open.value = false
}
break
case 'ArrowLeft':
if (!localState) {
;(document.querySelector('.nc-picker-date.active .ant-picker-header-prev-btn') as HTMLButtonElement)?.click()
} else {
const prevEl = document.querySelector('.nc-picker-date.active .ant-picker-cell-selected')
?.previousElementSibling as HTMLButtonElement
if (prevEl) {
prevEl.click()
} else {
// get the last td from previous tr
const prevRowLastEl = document
.querySelector('.nc-picker-date.active .ant-picker-cell-selected')
?.closest('tr')
?.previousElementSibling?.querySelector('td:last-child') as HTMLButtonElement
if (prevRowLastEl) {
prevRowLastEl.click()
} else {
// go to the previous month
;(document.querySelector('.nc-picker-date.active .ant-picker-header-prev-btn') as HTMLButtonElement)?.click()
}
}
}
break
case 'ArrowRight':
if (!localState) {
;(document.querySelector('.nc-picker-date.active .ant-picker-header-next-btn') as HTMLButtonElement)?.click()
} else {
const nextEl = document.querySelector('.nc-picker-date.active .ant-picker-cell-selected')
?.nextElementSibling as HTMLButtonElement
if (nextEl) {
nextEl.click()
} else {
// get the last td from previous tr
const nextRowFirstEl = document
.querySelector('.nc-picker-date.active .ant-picker-cell-selected')
?.closest('tr')
?.nextElementSibling?.querySelector('td:first-child') as HTMLButtonElement
if (nextRowFirstEl) {
nextRowFirstEl.click()
} else {
// go to the next month
;(document.querySelector('.nc-picker-date.active .ant-picker-header-next-btn') as HTMLButtonElement)?.click()
}
}
}
break
case 'ArrowUp':
if (!localState)
(document.querySelector('.nc-picker-date.active .ant-picker-header-super-prev-btn') as HTMLButtonElement)?.click()
break
case 'ArrowDown':
if (!localState)
(document.querySelector('.nc-picker-date.active .ant-picker-header-super-next-btn') as HTMLButtonElement)?.click()
break
case ';':
localState = dayjs(new Date())
break
}
})
</script>

86
packages/nc-gui/components/cell/DateTimePicker.vue

@ -1,6 +1,15 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { ActiveCellInj, ReadonlyInj, inject, ref, useProject, useSelectedCellKeyupListener, watch } from '#imports'
import {
ActiveCellInj,
ReadonlyInj,
inject,
isDrawerOrModalExist,
ref,
useProject,
useSelectedCellKeyupListener,
watch,
} from '#imports'
interface Props {
modelValue?: string | null
@ -23,7 +32,7 @@ let isDateInvalid = $ref(false)
const dateFormat = isMysql ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
const localState = $computed({
let localState = $computed({
get() {
if (!modelValue) {
return undefined
@ -65,14 +74,85 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
open.value = true
// skip if drawer / modal is active
if (isDrawerOrModalExist()) {
return
}
if (!open.value) {
// open date picker
open.value = true
} else {
// click Ok button to save the currently selected date
;(document.querySelector('.nc-picker-datetime.active .ant-picker-ok button') as HTMLButtonElement)?.click()
}
break
case 'Escape':
// skip if drawer / modal is active
if (isDrawerOrModalExist()) {
return
}
if (open.value) {
e.stopPropagation()
open.value = false
}
break
case 'ArrowLeft':
if (!localState) {
;(document.querySelector('.nc-picker-datetime.active .ant-picker-header-prev-btn') as HTMLButtonElement)?.click()
} else {
const prevEl = document.querySelector('.nc-picker-datetime.active .ant-picker-cell-selected')
?.previousElementSibling as HTMLButtonElement
if (prevEl) {
prevEl.click()
} else {
// get the last td from previous tr
const prevRowLastEl = document
.querySelector('.nc-picker-datetime.active .ant-picker-cell-selected')
?.closest('tr')
?.previousElementSibling?.querySelector('td:last-child') as HTMLButtonElement
if (prevRowLastEl) {
prevRowLastEl.click()
} else {
// go to the previous month
;(document.querySelector('.nc-picker-datetime.active .ant-picker-header-prev-btn') as HTMLButtonElement)?.click()
}
}
}
break
case 'ArrowRight':
if (!localState) {
;(document.querySelector('.nc-picker-datetime.active .ant-picker-header-next-btn') as HTMLButtonElement)?.click()
} else {
const nextEl = document.querySelector('.nc-picker-datetime.active .ant-picker-cell-selected')
?.nextElementSibling as HTMLButtonElement
if (nextEl) {
nextEl.click()
} else {
// get the last td from previous tr
const nextRowFirstEl = document
.querySelector('.nc-picker-datetime.active .ant-picker-cell-selected')
?.closest('tr')
?.nextElementSibling?.querySelector('td:first-child') as HTMLButtonElement
if (nextRowFirstEl) {
nextRowFirstEl.click()
} else {
// go to the next month
;(document.querySelector('.nc-picker-datetime.active .ant-picker-header-next-btn') as HTMLButtonElement)?.click()
}
}
}
break
case 'ArrowUp':
if (!localState)
(document.querySelector('.nc-picker-datetime.active .ant-picker-header-super-prev-btn') as HTMLButtonElement)?.click()
break
case 'ArrowDown':
if (!localState)
(document.querySelector('.nc-picker-datetime.active .ant-picker-header-super-next-btn') as HTMLButtonElement)?.click()
break
case ';':
localState = dayjs(new Date())
break
}
})
</script>

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

@ -35,6 +35,8 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else class="text-sm">{{ vModel }}</span>
</template>

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

@ -89,6 +89,8 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else> {{ localState }}</span>

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

@ -35,6 +35,8 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<a v-else-if="validEmail" class="text-sm underline hover:opacity-75" :href="`mailto:${vModel}`" target="_blank">

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

@ -35,6 +35,8 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else class="text-sm">{{ vModel }}</span>
</template>

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

@ -39,6 +39,8 @@ function onKeyDown(evt: KeyboardEvent) {
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else class="text-sm">{{ vModel }}</span>
</template>

32
packages/nc-gui/components/cell/Json.vue

@ -1,5 +1,17 @@
<script setup lang="ts">
import { Modal as AModal, EditModeInj, IsFormInj, ReadonlyInj, computed, inject, ref, useVModel, watch } from '#imports'
import {
Modal as AModal,
ActiveCellInj,
EditModeInj,
IsFormInj,
ReadonlyInj,
computed,
inject,
ref,
useSelectedCellKeyupListener,
useVModel,
watch,
} from '#imports'
interface Props {
modelValue: string | Record<string, any> | undefined
@ -15,6 +27,8 @@ const emits = defineEmits<Emits>()
const editEnabled = inject(EditModeInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const readonly = inject(ReadonlyInj)
@ -89,6 +103,22 @@ watch(editEnabled, () => {
localValue.value = vModel.value
})
useSelectedCellKeyupListener(active, (e) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
if (e.shiftKey) {
return true
}
if (editEnabled.value) {
onSave()
} else {
editEnabled.value = true
}
break
}
})
</script>
<template>

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

@ -180,7 +180,7 @@ useSelectedCellKeyupListener(active, (e) => {
break
default:
// toggle only if char key pressed
if (e.key?.length === 1) {
if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1) {
e.stopPropagation()
isOpen.value = true
}

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

@ -33,6 +33,8 @@ const focus: VNodeRef = (el) => {
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else>{{ vModel }}</span>
</template>

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

@ -106,7 +106,7 @@ useSelectedCellKeyupListener(active, (e) => {
break
default:
// toggle only if char key pressed
if (e.key?.length === 1) {
if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1) {
e.stopPropagation()
isOpen.value = true
}
@ -153,7 +153,7 @@ async function addIfMissingAndSave() {
)
vModel.value = newOptValue
await getMeta(column.value.fk_model_id!, true)
} catch (e) {
} catch (e: any) {
console.log(e)
message.error(await extractSdkResponseErrorMsg(e))
}

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

@ -34,6 +34,8 @@ const focus: VNodeRef = (el) => {
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else>{{ vModel }}</span>

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

@ -31,6 +31,8 @@ const focus: VNodeRef = (el) => (el as HTMLTextAreaElement)?.focus()
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else>{{ vModel }}</span>

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

@ -84,6 +84,8 @@ watch(
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
/>
<nuxt-link

1
packages/nc-gui/components/cell/attachment/Modal.vue

@ -61,6 +61,7 @@ function onClick(item: Record<string, any>) {
<a-modal
v-model:visible="modalVisible"
class="nc-attachment-modal"
:class="{ active: modalVisible }"
width="80%"
:footer="null"
wrap-class-name="nc-modal-attachment-expand-cell"

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

@ -150,7 +150,12 @@ watch(
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e) => {
if (e.key === 'Enter' && !isReadonly.value) {
e.stopPropagation()
modalVisible.value = true
if (!modalVisible.value) {
modalVisible.value = true
} else {
// click Attach File button
;(document.querySelector('.nc-attachment-modal.active .nc-attach-file') as HTMLDivElement)?.click()
}
}
})
</script>

35
packages/nc-gui/components/dashboard/TreeView.vue

@ -6,20 +6,23 @@ import GithubButton from 'vue-github-button'
import type { VNodeRef } from '#imports'
import {
Empty,
TabType,
computed,
isDrawerOrModalExist,
isMac,
reactive,
ref,
resolveComponent,
useDialog,
useNuxtApp,
useProject,
useRoute,
useTable,
useTabs,
useToggle,
useUIPermission,
watchEffect,
} from '#imports'
import { TabType } from '~/lib'
import MdiView from '~icons/mdi/eye-circle-outline'
import MdiTableLarge from '~icons/mdi/table-large'
@ -35,6 +38,8 @@ const { deleteTable } = useTable()
const { isUIAllowed } = useUIPermission()
const route = useRoute()
const [searchActive, toggleSearchActive] = useToggle()
let key = $ref(0)
@ -216,6 +221,34 @@ const onSearchCloseIconClick = () => {
filterQuery = ''
toggleSearchActive(false)
}
const isCreateTableAllowed = computed(
() =>
isUIAllowed('table-create') &&
route.name !== 'index' &&
route.name !== 'index-index' &&
route.name !== 'index-index-create' &&
route.name !== 'index-index-create-external' &&
route.name !== 'index-user-index',
)
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (e.altKey && !e.shiftKey && !cmdOrCtrl) {
switch (e.keyCode) {
case 84: {
// ALT + T
if (isCreateTableAllowed.value && !isDrawerOrModalExist()) {
// prevent the key `T` is inputted to table title input
e.preventDefault()
$e('c:shortcut', { key: 'ALT + T' })
openTableCreateDialog()
}
break
}
}
}
})
</script>
<template>

2
packages/nc-gui/components/dashboard/settings/AppStore.vue

@ -72,6 +72,7 @@ onMounted(async () => {
<template>
<a-modal
v-model:visible="showPluginInstallModal"
:class="{ active: showPluginInstallModal }"
:closable="false"
centered
min-height="300"
@ -89,6 +90,7 @@ onMounted(async () => {
<a-modal
v-model:visible="showPluginUninstallModal"
:class="{ active: showPluginUninstallModal }"
:closable="false"
width="24rem"
centered

1
packages/nc-gui/components/dashboard/settings/Modal.vue

@ -163,6 +163,7 @@ watch(
<template>
<a-modal
v-model:visible="vModel"
:class="{ active: vModel }"
:footer="null"
width="max(90vw, 600px)"
:closable="false"

78
packages/nc-gui/components/dlg/AirtableImport.vue

@ -40,8 +40,12 @@ const progress = ref<Record<string, any>[]>([])
const logRef = ref<typeof AntCard>()
const enableAbort = ref(false)
let socket: Socket | null
let socketInterval: NodeJS.Timer
const syncSource = ref({
id: '',
type: 'Airtable',
@ -121,6 +125,7 @@ async function loadSyncSrc() {
srcs[0].details = srcs[0].details || {}
syncSource.value = migrateSync(srcs[0])
syncSource.value.details.syncSourceUrlOrId = srcs[0].details.shareId
socket?.emit('subscribe', syncSource.value.id)
} else {
syncSource.value = {
id: '',
@ -146,7 +151,6 @@ async function loadSyncSrc() {
}
async function sync() {
step.value = 2
try {
await $fetch(`/api/v1/db/meta/syncs/${syncSource.value.id}/trigger`, {
baseURL,
@ -156,11 +160,36 @@ async function sync() {
id: socket?.id,
},
})
socket?.emit('subscribe', syncSource.value.id)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
async function abort() {
Modal.confirm({
title: 'Are you sure you want to abort this job?',
type: 'warn',
content:
"This is a highly experimental feature and only marks job as not started, please don't abort the job unless you are sure job is stuck.",
onOk: async () => {
try {
await $fetch(`/api/v1/db/meta/syncs/${syncSource.value.id}/abort`, {
baseURL,
method: 'POST',
headers: { 'xc-auth': $state.token.value as string },
params: {
id: socket?.id,
},
})
step.value = 1
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
},
})
}
function migrateSync(src: any) {
if (!src.details?.options) {
src.details.options = {
@ -193,16 +222,6 @@ onMounted(async () => {
extraHeaders: { 'xc-auth': $state.token.value as string },
})
socket.on('connect_error', () => {
socket?.disconnect()
socket = null
})
// connect event does not provide data
socket.on('connect', () => {
console.log('socket connected')
})
socket.on('progress', async (d: Record<string, any>) => {
progress.value.push(d)
@ -219,19 +238,53 @@ onMounted(async () => {
}
})
socket.on('disconnect', () => {
console.log('socket disconnected')
const rcInterval = setInterval(() => {
if (socket?.connected) {
clearInterval(rcInterval)
socket?.emit('subscribe', syncSource.value.id)
} else {
socket?.connect()
}
}, 2000)
})
socket.on('job', () => {
step.value = 2
})
// connect event does not provide data
socket.on('connect', () => {
console.log('socket connected')
if (syncSource.value.id) {
socket?.emit('subscribe', syncSource.value.id)
}
})
socket?.io.on('reconnect', () => {
console.log('socket reconnected')
if (syncSource.value.id) {
socket?.emit('subscribe', syncSource.value.id)
}
})
await loadSyncSrc()
})
onBeforeUnmount(() => {
if (socket) {
socket.removeAllListeners()
socket.disconnect()
}
clearInterval(socketInterval)
})
</script>
<template>
<a-modal
v-model:visible="dialogShow"
:class="{ active: dialogShow }"
width="max(30vw, 600px)"
class="p-2"
wrap-class-name="nc-modal-airtable-import"
@ -239,7 +292,7 @@ onBeforeUnmount(() => {
>
<div class="px-5">
<!-- Quick Import -->
<div class="mt-5 prose-xl font-weight-bold">{{ $t('title.quickImport') }} - AIRTABLE</div>
<div class="mt-5 prose-xl font-weight-bold" @dblclick="enableAbort = true">{{ $t('title.quickImport') }} - AIRTABLE</div>
<div v-if="step === 1">
<div class="mb-4">
@ -381,6 +434,7 @@ onBeforeUnmount(() => {
<a-button v-if="showGoToDashboardButton" class="mt-4" size="large" @click="dialogShow = false">
{{ $t('labels.goToDashboard') }}
</a-button>
<a-button v-else-if="enableAbort" class="mt-4" size="large" danger @click="abort()">ABORT</a-button>
</div>
</div>
</div>

245
packages/nc-gui/components/dlg/KeyboardShortcuts.vue

@ -0,0 +1,245 @@
<script lang="ts" setup>
import { isMac } from '#imports'
const { modelValue } = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits(['update:modelValue'])
const dialogShow = computed({
get: () => modelValue,
set: (v) => emit('update:modelValue', v),
})
const renderCmdOrCtrlKey = () => {
return isMac() ? '⌘' : 'CTRL'
}
const shortcutList = [
{
title: 'General',
shortcuts: [
{
keys: ['ALT', 'T'],
behaviour: 'Insert new table',
},
{
keys: ['ALT', 'R'],
behaviour: 'Insert new row',
},
{
keys: ['ALT', 'C'],
behaviour: 'Insert new column',
},
{
keys: ['ALT', 'F'],
behaviour: 'Toggle fullscreen mode',
},
{
keys: ['ALT', 'I'],
behaviour: 'Invite a member to team',
},
{
keys: ['ALT', ','],
behaviour: 'Open Team & Settings',
},
],
},
{
title: 'Grid View',
shortcuts: [
{
keys: [renderCmdOrCtrlKey(), '←'],
behaviour: 'Jump to leftmost column in this row',
},
{
keys: [renderCmdOrCtrlKey(), '→'],
behaviour: 'Jump to rightmost column in this row',
},
{
keys: [renderCmdOrCtrlKey(), '↑'],
behaviour: 'Jump to first record in this column (in same page)',
},
{
keys: [renderCmdOrCtrlKey(), '↓'],
behaviour: 'Jump to last record in this column (in same page)',
},
{
keys: [renderCmdOrCtrlKey(), 'C'],
behaviour: 'Copy cell contents',
},
{
keys: ['Enter'],
behaviour: 'Switch cell in focus to EDIT mode; opens modal / picker if cell is associated with one',
},
{
keys: ['Esc'],
behaviour: 'Exit cell EDIT mode',
},
{
keys: ['Delete'],
behaviour: 'Clear cell',
},
{
keys: ['Space'],
behaviour: 'Expand current row',
},
{
keys: ['←', '→', '↑', '↓'],
behaviour: 'General cell navigation',
},
{
keys: ['Tab'],
behaviour: 'Move to next cell horizontally; if on last cell, move to next row beginning',
},
],
},
{
title: 'Text / Number',
shortcuts: [
{
keys: ['←'],
behaviour: 'Move cursor to the left',
},
{
keys: ['→'],
behaviour: 'Move cursor to the right',
},
{
keys: ['↑'],
behaviour: 'Move cursor to the left',
},
{
keys: ['↓'],
behaviour: 'Move cursor to the right',
},
],
},
{
title: 'SingleSelect',
shortcuts: [
{
keys: ['↑'],
behaviour: 'Move to the previous option',
},
{
keys: ['↓'],
behaviour: 'Move to the next option',
},
{
keys: ['Enter'],
behaviour: 'Select the current option',
},
],
},
{
title: 'MultiSelect',
shortcuts: [
{
keys: ['↑'],
behaviour: 'Move to the previous option',
},
{
keys: ['↓'],
behaviour: 'Move to the next option',
},
{
keys: ['Enter'],
behaviour: 'Select / deselect the current option',
},
],
},
{
title: 'Date / DateTime',
shortcuts: [
{
keys: [renderCmdOrCtrlKey(), ';'],
behaviour: 'Select current date time',
},
],
},
{
title: 'LinkToAnotherRecord',
shortcuts: [
{
keys: ['↑'],
behaviour: 'Move to the previous option',
},
{
keys: ['↓'],
behaviour: 'Move to the next option',
},
],
},
{
title: 'Checkbox',
shortcuts: [
{
keys: ['Enter'],
behaviour: 'Toggle',
},
],
},
{
title: 'Rating',
shortcuts: [
{
keys: ['<0 ~ Max>'],
behaviour: 'Enter number to toggle rating',
},
],
},
{
title: 'Expanded Form',
shortcuts: [
{
keys: [renderCmdOrCtrlKey(), 'Enter'],
behaviour: 'Save current expanded form item',
},
],
},
]
</script>
<template>
<a-modal
v-model:visible="dialogShow"
:class="{ active: dialogShow }"
width="max(30vw, 600px)"
class="p-2"
:footer="null"
:wrap-class-name="`nc-modal-keyboard-shortcuts ${dialogShow ? 'active' : ''}`"
@keydown.esc="dialogShow = false"
>
<template #title> {{ $t('title.keyboardShortcut') }} </template>
<a-list
v-for="(shortcutItem, shortcutItemIdx) of shortcutList"
:key="shortcutItemIdx"
class="nc-shortcut-list !mb-5"
size="small"
bordered
:data-source="shortcutItem.shortcuts"
>
<template #header>
<div class="font-bold">{{ shortcutItem.title }}</div>
</template>
<template #renderItem="{ item }">
<a-list-item>
<span class="inline-block">
<kbd
v-for="(key, keyIdx) of item.keys"
:key="keyIdx"
class="ml-[1px] mr-[1px] px-[8px] py-[3px] border-b-[3px] uppercase border-1 border-solid border-primary border-opacity-50 rounded"
>
{{ key }}
</kbd>
</span>
<span class="inline-block text-right">
{{ item.behaviour }}
</span>
</a-list-item>
</template>
</a-list>
</a-modal>
</template>

1
packages/nc-gui/components/dlg/QuickImport.vue

@ -345,6 +345,7 @@ const beforeUpload = (file: UploadFile) => {
<template>
<a-modal
v-model:visible="dialogShow"
:class="{ active: dialogShow }"
:width="modalWidth"
wrap-class-name="nc-modal-quick-import"
@keydown.esc="dialogShow = false"

1
packages/nc-gui/components/dlg/TableCreate.vue

@ -99,6 +99,7 @@ onMounted(() => {
<template>
<a-modal
v-model:visible="dialogShow"
:class="{ active: dialogShow }"
width="max(30vw, 600px)"
centered
wrap-class-name="nc-modal-table-create"

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

@ -149,6 +149,7 @@ const renameTable = async () => {
<template>
<a-modal
v-model:visible="dialogShow"
:class="{ active: dialogShow }"
:title="$t('activity.renameTable')"
:mask-closable="false"
wrap-class-name="nc-modal-table-rename"

8
packages/nc-gui/components/dlg/ViewCreate.vue

@ -182,7 +182,13 @@ async function onSubmit() {
</script>
<template>
<a-modal v-model:visible="vModel" class="!top-[35%]" :confirm-loading="loading" wrap-class-name="nc-modal-view-create">
<a-modal
v-model:visible="vModel"
class="!top-[35%]"
:class="{ active: vModel }"
:confirm-loading="loading"
wrap-class-name="nc-modal-view-create"
>
<template #title>
{{ $t(`general.${selectedViewId ? 'duplicate' : 'create'}`) }} <span class="capitalize">{{ typeAlias }}</span>
{{ $t('objects.view') }}

8
packages/nc-gui/components/dlg/ViewDelete.vue

@ -48,7 +48,13 @@ async function onDelete() {
</script>
<template>
<a-modal v-model:visible="vModel" class="!top-[35%]" :confirm-loading="isLoading" wrap-class-name="nc-modal-view-delete">
<a-modal
v-model:visible="vModel"
class="!top-[35%]"
:class="{ active: vModel }"
:confirm-loading="isLoading"
wrap-class-name="nc-modal-view-delete"
>
<template #title> {{ $t('general.delete') }} {{ $t('objects.view') }} </template>
{{ $t('msg.info.deleteViewConfirmation') }}

20
packages/nc-gui/components/general/FullScreen.vue

@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed, useSidebar } from '#imports'
import { computed, isDrawerOrModalExist, isMac, useNuxtApp, useSidebar } from '#imports'
const rightSidebar = useSidebar('nc-right-sidebar')
const leftSidebar = useSidebar('nc-left-sidebar')
const { $e } = useNuxtApp()
const isSidebarsOpen = computed({
get: () => rightSidebar.isOpen.value || leftSidebar.isOpen.value,
set: (value) => {
@ -12,6 +14,22 @@ const isSidebarsOpen = computed({
leftSidebar.toggle(value)
},
})
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (e.altKey && !e.shiftKey && !cmdOrCtrl) {
switch (e.keyCode) {
case 70: {
// ALT + F
if (!isDrawerOrModalExist()) {
$e('c:shortcut', { key: 'ALT + F' })
isSidebarsOpen.value = !isSidebarsOpen.value
}
break
}
}
}
})
</script>
<template>

42
packages/nc-gui/components/general/ShareBaseButton.vue

@ -1,24 +1,44 @@
<script setup lang="ts">
import { useRoute, useUIPermission } from '#imports'
import { isDrawerOrModalExist, isMac, useNuxtApp, useRoute, useUIPermission } from '#imports'
const route = useRoute()
const showUserModal = $ref(false)
const showUserModal = ref(false)
const { isUIAllowed } = useUIPermission()
const { $e } = useNuxtApp()
const isShareBaseAllowed = computed(
() =>
isUIAllowed('newUser') &&
route.name !== 'index' &&
route.name !== 'index-index' &&
route.name !== 'index-index-create' &&
route.name !== 'index-index-create-external' &&
route.name !== 'index-user-index',
)
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (e.altKey && !e.shiftKey && !cmdOrCtrl) {
switch (e.keyCode) {
case 73: {
// ALT + I
if (isShareBaseAllowed.value && !isDrawerOrModalExist()) {
$e('c:shortcut', { key: 'ALT + I' })
showUserModal.value = true
}
break
}
}
}
})
</script>
<template>
<div class="flex items-center w-full pl-3 hover:(text-primary bg-primary bg-opacity-5)" @click="showUserModal = true">
<div
v-if="
isUIAllowed('newUser') &&
route.name !== 'index' &&
route.name !== 'index-index-create' &&
route.name !== 'index-index-create-external' &&
route.name !== 'index-user-index'
"
>
<div v-if="isShareBaseAllowed">
<div class="flex items-center space-x-1">
<MdiAccountPlusOutline class="mr-1 nc-share-base" />

27
packages/nc-gui/components/general/SocialCard.vue

@ -1,9 +1,27 @@
<script setup lang="ts">
import { enumColor as colors, useGlobal } from '#imports'
import { enumColor as colors, useDialog, useGlobal, useNuxtApp } from '#imports'
const { $e } = useNuxtApp()
const { lang: currentLang } = useGlobal()
const isRtlLang = $computed(() => ['fa', 'ar'].includes(currentLang.value))
function openKeyboardShortcutDialog() {
$e('a:actions:keyboard-shortcut')
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgKeyboardShortcuts'), {
'modelValue': isOpen,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
</script>
<template>
@ -163,6 +181,13 @@ const isRtlLang = $computed(() => ['fa', 'ar'].includes(currentLang.value))
</div>
</nuxt-link>
</a-list-item>
<a-list-item @click="openKeyboardShortcutDialog">
<div class="ml-3 flex items-center text-sm">
<MdiKeyboard class="text-lg text-primary" />
<span class="ml-4">{{ $t('title.keyboardShortcut') }}</span>
</div>
</a-list-item>
</a-list>
</a-card>
</template>

7
packages/nc-gui/components/monaco/Editor.vue

@ -3,7 +3,7 @@ import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import TypescriptWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import type { editor as MonacoEditor } from 'monaco-editor'
import { deepCompare, onMounted, ref, watch } from '#imports'
import { deepCompare, isDrawerOrModalExist, onMounted, ref, watch } from '#imports'
interface Props {
modelValue: string | Record<string, any>
@ -117,6 +117,11 @@ onMounted(async () => {
console.log(e)
}
})
if (!isDrawerOrModalExist()) {
// auto focus on json cells only
editor.focus()
}
}
})

1
packages/nc-gui/components/shared-view/AskPassword.vue

@ -33,6 +33,7 @@ const focus: VNodeRef = (el: typeof InputPassword) => el?.$el?.querySelector('in
<template>
<a-modal
v-model:visible="vModel"
:class="{ active: vModel }"
:closable="false"
width="28rem"
centered

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

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import { isSystemColumn } from 'nocodb-sdk'
import {
ActiveCellInj,
ColumnInj,
@ -128,7 +129,7 @@ const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
class="nc-cell w-full"
:class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
{ 'text-blue-600': isPrimary(column) && !virtual && !isForm },
{ 'text-blue-600': isPrimary(column) && !props.virtual && !isForm },
]"
@keydown.enter.exact="syncAndNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="syncAndNavigate(NavigateDir.PREV, $event)"
@ -157,7 +158,7 @@ const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
<LazyCellJson v-else-if="isJSON(column)" v-model="vModel" />
<LazyCellText v-else v-model="vModel" />
<div
v-if="(isLocked || (isPublic && readOnly && !isForm)) && !isAttachment(column)"
v-if="(isLocked || (isPublic && readOnly && !isForm) || isSystemColumn(column)) && !isAttachment(column)"
class="nc-locked-overlay"
@click.stop.prevent
/>

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

@ -33,6 +33,8 @@ provide(IsGalleryInj, ref(false))
// todo: generate hideCols based on default values
const hiddenCols = ['created_at', 'updated_at']
const hiddenColTypes = [UITypes.Rollup, UITypes.Lookup, UITypes.Formula, UITypes.QrCode, UITypes.SpecificDBType]
const state = useGlobal()
const formRef = ref()
@ -227,7 +229,7 @@ async function addAllColumns() {
}
function shouldSkipColumn(col: Record<string, any>) {
return isDbRequired(col) || !!col.required || (!!col.rqd && !col.cdf)
return isDbRequired(col) || !!col.required || (!!col.rqd && !col.cdf) || col.uidt === UITypes.QrCode
}
async function removeAllColumns() {
@ -256,7 +258,7 @@ async function checkSMTPStatus() {
}
function setFormData() {
const col = (formColumnData as Record<string, any>)?.value
const col = formColumnData?.value || []
formViewData.value = {
...formViewData.value,
@ -273,27 +275,14 @@ function setFormData() {
emailMe.value = data[state.user.value?.email as string]
localColumns.value = col
.filter(
(f: Record<string, any>) =>
f.show &&
f.uidt !== UITypes.Rollup &&
f.uidt !== UITypes.Lookup &&
f.uidt !== UITypes.Formula &&
f.uidt !== UITypes.SpecificDBType,
)
.sort((a: Record<string, any>, b: Record<string, any>) => a.order - b.order)
.map((c: Record<string, any>) => ({ ...c, required: !!(c.required || 0) }))
systemFieldsIds.value = getSystemColumns(col).map((c: Record<string, any>) => c.fk_column_id)
.filter((f) => f.show && !hiddenColTypes.includes(f.uidt))
.sort((a, b) => a.order - b.order)
.map((c) => ({ ...c, required: !!c.required }))
systemFieldsIds.value = getSystemColumns(col).map((c) => c.fk_column_id)
hiddenColumns.value = col.filter(
(f: Record<string, any>) =>
!f.show &&
!systemFieldsIds.value.includes(f.fk_column_id) &&
f.uidt !== UITypes.Rollup &&
f.uidt !== UITypes.Lookup &&
f.uidt !== UITypes.Formula &&
f.uidt !== UITypes.SpecificDBType,
(f) => !f.show && !systemFieldsIds.value.includes(f.fk_column_id) && !hiddenColTypes.includes(f.uidt),
)
}

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

@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { ColumnType, TableType, ViewType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType, TableType, ViewType } from 'nocodb-sdk'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import {
ActiveViewInj,
@ -11,17 +11,20 @@ import {
IsGridInj,
IsLockedInj,
MetaInj,
NavigateDir,
OpenNewRecordFormHookInj,
PaginationDataInj,
ReadonlyInj,
ReloadRowDataHookInj,
ReloadViewDataHookInj,
SmartsheetStoreEvents,
computed,
createEventHook,
enumColor,
extractPkFromRow,
inject,
isColumnRequiredAndNull,
isDrawerOrModalExist,
isMac,
message,
onBeforeUnmount,
@ -34,6 +37,7 @@ import {
useI18n,
useMetas,
useMultiSelect,
useNuxtApp,
useRoles,
useRoute,
useSmartsheetStoreOrThrow,
@ -42,7 +46,6 @@ import {
watch,
} from '#imports'
import type { Row } from '~/lib'
import { NavigateDir } from '~/lib'
const { t } = useI18n()
@ -50,6 +53,8 @@ const meta = inject(MetaInj, ref())
const view = inject(ActiveViewInj, ref())
const { $e } = useNuxtApp()
// keep a root fields variable and will get modified from
// fields menu and get used in grid and gallery
const fields = inject(FieldsInj, ref([]))
@ -71,7 +76,7 @@ const isView = false
let editEnabled = $ref(false)
const { xWhere, isPkAvail, isSqlView } = useSmartsheetStoreOrThrow()
const { xWhere, isPkAvail, isSqlView, eventBus } = useSmartsheetStoreOrThrow()
const visibleColLength = $computed(() => fields.value?.length)
@ -95,6 +100,10 @@ const tbodyEl = ref<HTMLElement>()
const gridWrapper = ref<HTMLElement>()
const tableHead = ref<HTMLElement>()
const isAddingColumnAllowed = !readOnly.value && !isLocked.value && isUIAllowed('add-column') && !isSqlView.value
const isAddingEmptyRowAllowed = !isView && !isLocked.value && hasEditPermission && !isSqlView.value
const {
isLoading,
loadData,
@ -162,83 +171,132 @@ const getContainerScrollForElement = (
return scroll
}
const { selectCell, selectBlock, selectedRange, clearRangeRows, startSelectRange, selected } = useMultiSelect(
fields,
data,
$$(editEnabled),
isPkAvail,
clearCell,
makeEditable,
scrollToCell,
(e: KeyboardEvent) => {
// ignore navigating if picker(Date, Time, DateTime, Year)
// or single/multi select options is open
const activePickerOrDropdownEl = document.querySelector(
'.nc-picker-datetime.active,.nc-dropdown-single-select-cell.active,.nc-dropdown-multi-select-cell.active,.nc-picker-date.active,.nc-picker-year.active,.nc-picker-time.active',
)
if (activePickerOrDropdownEl) {
e.preventDefault()
return true
}
// if expanded form is active skip keyboard event handling
if (document.querySelector('.nc-drawer-expanded-form.active')) {
return true
}
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (e.key === ' ') {
if (selected.row !== null && !editEnabled) {
const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyValue, isCellSelected, selectedCell } =
useMultiSelect(
meta,
fields,
data,
$$(editEnabled),
isPkAvail,
clearCell,
makeEditable,
scrollToCell,
(e: KeyboardEvent) => {
// ignore navigating if picker(Date, Time, DateTime, Year)
// or single/multi select options is open
const activePickerOrDropdownEl = document.querySelector(
'.nc-picker-datetime.active,.nc-dropdown-single-select-cell.active,.nc-dropdown-multi-select-cell.active,.nc-picker-date.active,.nc-picker-year.active,.nc-picker-time.active',
)
if (activePickerOrDropdownEl) {
e.preventDefault()
const row = data.value[selected.row]
expandForm(row)
return true
}
} else if (e.key === 'Escape') {
if (editEnabled) {
editEnabled = false
return true
}
} else if (e.key === 'Enter') {
if (editEnabled) {
editEnabled = false
// skip keyboard event handling if there is a drawer / modal
if (isDrawerOrModalExist()) {
return true
}
}
if (cmdOrCtrl) {
switch (e.key) {
case 'ArrowUp':
selected.row = 0
selected.col = selected.col ?? 0
scrollToCell?.()
editEnabled = false
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
const altOrOptionKey = e.altKey
if (e.key === ' ') {
if (selectedCell.row !== null && !editEnabled) {
e.preventDefault()
const row = data.value[selectedCell.row]
expandForm(row)
return true
case 'ArrowDown':
selected.row = data.value.length - 1
selected.col = selected.col ?? 0
scrollToCell?.()
}
} else if (e.key === 'Escape') {
if (editEnabled) {
editEnabled = false
return true
case 'ArrowRight':
selected.row = selected.row ?? 0
selected.col = fields.value?.length - 1
scrollToCell?.()
editEnabled = false
}
} else if (e.key === 'Enter') {
if (e.shiftKey) {
// add a line break for types like LongText / JSON
return true
case 'ArrowLeft':
selected.row = selected.row ?? 0
selected.col = 0
scrollToCell?.()
}
if (editEnabled) {
editEnabled = false
return true
}
}
if (cmdOrCtrl) {
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
$e('c:shortcut', { key: 'CTRL + ArrowUp' })
selectedCell.row = 0
selectedCell.col = selectedCell.col ?? 0
scrollToCell?.()
editEnabled = false
return true
case 'ArrowDown':
e.preventDefault()
$e('c:shortcut', { key: 'CTRL + ArrowDown' })
selectedCell.row = data.value.length - 1
selectedCell.col = selectedCell.col ?? 0
scrollToCell?.()
editEnabled = false
return true
case 'ArrowRight':
e.preventDefault()
$e('c:shortcut', { key: 'CTRL + ArrowRight' })
selectedCell.row = selectedCell.row ?? 0
selectedCell.col = fields.value?.length - 1
scrollToCell?.()
editEnabled = false
return true
case 'ArrowLeft':
e.preventDefault()
$e('c:shortcut', { key: 'CTRL + ArrowLeft' })
selectedCell.row = selectedCell.row ?? 0
selectedCell.col = 0
scrollToCell?.()
editEnabled = false
return true
}
}
}
},
)
if (altOrOptionKey) {
switch (e.keyCode) {
case 82: {
// ALT + R
if (isAddingEmptyRowAllowed) {
$e('c:shortcut', { key: 'ALT + R' })
addEmptyRow()
}
break
}
case 67: {
// ALT + C
if (isAddingColumnAllowed) {
$e('c:shortcut', { key: 'ALT + C' })
addColumnDropdown.value = true
}
break
}
}
}
},
async (ctx: { row: number; col?: number; updatedColumnTitle?: string }) => {
const rowObj = data.value[ctx.row]
const columnObj = ctx.col !== null && ctx.col !== undefined ? fields.value[ctx.col] : null
if (!ctx.updatedColumnTitle && isVirtualCol(columnObj)) {
return
}
// update/save cell value
await updateOrSaveRow(rowObj, ctx.updatedColumnTitle || columnObj.title)
},
)
function scrollToCell(row?: number | null, col?: number | null) {
row = row ?? selected.row
col = col ?? selected.col
row = row ?? selectedCell.row
col = col ?? selectedCell.col
if (row !== undefined && col !== undefined && row !== null && col !== null) {
// get active cell
const rows = tbodyEl.value?.querySelectorAll('tr')
@ -341,8 +399,13 @@ watch(contextMenu, () => {
const rowRefs = $ref<any[]>()
async function clearCell(ctx: { row: number; col: number } | null) {
if (!ctx) return
async function clearCell(ctx: { row: number; col: number } | null, skipUpdate = false) {
if (
!ctx ||
!hasEditPermission ||
(fields.value[ctx.col].uidt !== UITypes.LinkToAnotherRecord && isVirtualCol(fields.value[ctx.col]))
)
return
const rowObj = data.value[ctx.row]
const columnObj = fields.value[ctx.col]
@ -353,8 +416,11 @@ async function clearCell(ctx: { row: number; col: number } | null) {
}
rowObj.row[columnObj.title] = null
// update/save cell value
await updateOrSaveRow(rowObj, columnObj.title)
if (!skipUpdate) {
// update/save cell value
await updateOrSaveRow(rowObj, columnObj.title)
}
}
function makeEditable(row: Row, col: ColumnType) {
@ -393,10 +459,12 @@ useEventListener(document, 'keyup', async (e: KeyboardEvent) => {
/** On clicking outside of table reset active cell */
const smartTable = ref(null)
onClickOutside(smartTable, (e) => {
clearRangeRows()
if (selected.col === null) return
// do nothing if context menu was open
if (contextMenu.value) return
clearSelectedRange()
if (selectedCell.col === null) return
const activeCol = fields.value[selected.col]
const activeCol = fields.value[selectedCell.col]
if (editEnabled && (isVirtualCol(activeCol) || activeCol.uidt === UITypes.JSON)) return
@ -412,30 +480,30 @@ onClickOutside(smartTable, (e) => {
)
return
// if expanded form is active skip resetting the active cell
if (document.querySelector('.nc-drawer-expanded-form.active')) {
// skip if drawer / modal is active
if (isDrawerOrModalExist()) {
return
}
selected.row = null
selected.col = null
selectedCell.row = null
selectedCell.col = null
})
const onNavigate = (dir: NavigateDir) => {
if (selected.row === null || selected.col === null) return
if (selectedCell.row === null || selectedCell.col === null) return
editEnabled = false
switch (dir) {
case NavigateDir.NEXT:
if (selected.row < data.value.length - 1) {
selected.row++
if (selectedCell.row < data.value.length - 1) {
selectedCell.row++
} else {
addEmptyRow()
selected.row++
selectedCell.row++
}
break
case NavigateDir.PREV:
if (selected.row > 0) {
selected.row--
if (selectedCell.row > 0) {
selectedCell.row--
}
break
}
@ -549,6 +617,20 @@ watch(
},
{ immediate: true },
)
const columnOrder = ref<Pick<ColumnReqType, 'column_order'> | null>(null)
eventBus.on(async (event, payload) => {
if (event === SmartsheetStoreEvents.FIELD_ADD) {
columnOrder.value = payload
addColumnDropdown.value = true
}
})
const closeAddColumnDropdown = () => {
columnOrder.value = null
addColumnDropdown.value = false
}
</script>
<template>
@ -607,7 +689,7 @@ watch(
</div>
</th>
<th
v-if="!readOnly && !isLocked && isUIAllowed('add-column') && !isSqlView"
v-if="isAddingColumnAllowed"
v-e="['c:column:add']"
class="cursor-pointer"
@click.stop="addColumnDropdown = true"
@ -624,8 +706,9 @@ watch(
<template #overlay>
<SmartsheetColumnEditOrAddProvider
v-if="addColumnDropdown"
@submit="addColumnDropdown = false"
@cancel="addColumnDropdown = false"
:column-position="columnOrder"
@submit="closeAddColumnDropdown"
@cancel="closeAddColumnDropdown"
@click.stop
@keydown.stop
/>
@ -634,8 +717,7 @@ watch(
</th>
</tr>
</thead>
<!-- this prevent select text from field if not in edit mode -->
<tbody ref="tbodyEl" @selectstart.prevent>
<tbody ref="tbodyEl">
<LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row">
<template #default="{ state }">
<tr class="nc-grid-row" :data-testid="`grid-row-${rowIndex}`">
@ -696,9 +778,7 @@ watch(
:key="columnObj.id"
class="cell relative cursor-pointer nc-grid-cell"
:class="{
'active':
(hasEditPermission && selected.col === colIndex && selected.row === rowIndex) ||
(hasEditPermission && selectedRange(rowIndex, colIndex)),
'active': hasEditPermission && isCellSelected(rowIndex, colIndex),
'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row),
}"
:data-testid="`cell-${columnObj.title}-${rowIndex}`"
@ -708,7 +788,7 @@ watch(
@click="selectCell(rowIndex, colIndex)"
@dblclick="makeEditable(row, columnObj)"
@mousedown="startSelectRange($event, rowIndex, colIndex)"
@mouseover="selectBlock(rowIndex, colIndex)"
@mouseover="endSelectRange(rowIndex, colIndex)"
@contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })"
>
<div v-if="!switchingTab" class="w-full h-full">
@ -716,7 +796,7 @@ watch(
v-if="isVirtualCol(columnObj)"
v-model="row.row[columnObj.title]"
:column="columnObj"
:active="selected.col === colIndex && selected.row === rowIndex"
:active="selectedCell.col === colIndex && selectedCell.row === rowIndex"
:row="row"
@navigate="onNavigate"
/>
@ -726,10 +806,10 @@ watch(
v-model="row.row[columnObj.title]"
:column="columnObj"
:edit-enabled="
!!hasEditPermission && !!editEnabled && selected.col === colIndex && selected.row === rowIndex
!!hasEditPermission && !!editEnabled && selectedCell.col === colIndex && selectedCell.row === rowIndex
"
:row-index="rowIndex"
:active="selected.col === colIndex && selected.row === rowIndex"
:active="selectedCell.col === colIndex && selectedCell.row === rowIndex"
@update:edit-enabled="editEnabled = $event"
@save="updateOrSaveRow(row, columnObj.title, state)"
@navigate="onNavigate"
@ -741,7 +821,7 @@ watch(
</template>
</LazySmartsheetRow>
<tr v-if="!isView && !isLocked && hasEditPermission && !isSqlView">
<tr v-if="isAddingEmptyRowAllowed">
<td
v-e="['c:row:add:grid-bottom']"
:colspan="visibleColLength + 1"
@ -794,6 +874,13 @@ watch(
{{ $t('activity.insertRow') }}
</div>
</a-menu-item>
<a-menu-item v-if="contextMenuTarget" @click="copyValue(contextMenuTarget)">
<div v-e="['a:row:copy']" class="nc-project-menu-item">
<!-- Copy -->
{{ $t('general.copy') }}
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>

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

@ -620,7 +620,12 @@ watch(view, async (nextView) => {
/>
</Suspense>
<a-modal v-model:visible="deleteStackVModel" class="!top-[35%]" wrap-class-name="nc-modal-kanban-delete-stack">
<a-modal
v-model:visible="deleteStackVModel"
class="!top-[35%]"
:class="{ active: deleteStackVModel }"
wrap-class-name="nc-modal-kanban-delete-stack"
>
<template #title>
{{ $t('activity.deleteKanbanStack') }}
</template>

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

@ -13,6 +13,7 @@ import {
isHm,
isLookup,
isMm,
isQrCode,
isRollup,
provide,
toRef,
@ -39,7 +40,6 @@ provide(RowInj, row)
provide(CellValueInj, toRef(props, 'modelValue'))
const isForm = inject(IsFormInj, ref(false))
function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
emit('navigate', dir)
@ -58,6 +58,7 @@ function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
<LazyVirtualCellBelongsTo v-else-if="isBt(column)" />
<LazyVirtualCellRollup v-else-if="isRollup(column)" />
<LazyVirtualCellFormula v-else-if="isFormula(column)" />
<LazyVirtualCellQrCode v-else-if="isQrCode(column)" />
<LazyVirtualCellCount v-else-if="isCount(column)" />
<LazyVirtualCellLookup v-else-if="isLookup(column)" />
</div>

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

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useEventListener } from '@vueuse/core'
import type { ColumnReqType } from 'nocodb-sdk'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import {
IsFormInj,
@ -13,6 +13,7 @@ import {
ref,
uiTypes,
useColumnCreateStoreOrThrow,
useEventListener,
useI18n,
useMetas,
useNuxtApp,
@ -22,6 +23,10 @@ import MdiPlusIcon from '~icons/mdi/plus-circle-outline'
import MdiMinusIcon from '~icons/mdi/minus-circle-outline'
import MdiIdentifierIcon from '~icons/mdi/identifier'
const props = defineProps<{
columnPosition?: Pick<ColumnReqType, 'column_order'>
}>()
const emit = defineEmits(['submit', 'cancel'])
const { formState, generateNewColumnMeta, addOrUpdate, onAlter, onUidtOrIdTypeChange, validateInfos, isEdit } =
@ -71,7 +76,7 @@ const reloadMetaAndData = async () => {
}
async function onSubmit() {
const saved = await addOrUpdate(reloadMetaAndData)
const saved = await addOrUpdate(reloadMetaAndData, props.columnPosition)
if (!saved) return
@ -165,6 +170,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
</a-form-item>
<LazySmartsheetColumnFormulaOptions v-if="formState.uidt === UITypes.Formula" v-model:value="formState" />
<LazySmartsheetColumnQrCodeOptions v-if="formState.uidt === UITypes.QrCode" v-model="formState" />
<LazySmartsheetColumnCurrencyOptions v-if="formState.uidt === UITypes.Currency" v-model:value="formState" />
<LazySmartsheetColumnDurationOptions v-if="formState.uidt === UITypes.Duration" v-model:value="formState" />
<LazySmartsheetColumnRatingOptions v-if="formState.uidt === UITypes.Rating" v-model:value="formState" />

5
packages/nc-gui/components/smartsheet/column/EditOrAddProvider.vue

@ -1,10 +1,11 @@
<script lang="ts" setup>
// todo: Remove this "Provider" component and use the "EditOrAdd" component directly
import type { ColumnType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType } from 'nocodb-sdk'
import { MetaInj, inject, ref, toRef, useProvideColumnCreateStore } from '#imports'
interface Props {
column?: ColumnType & { meta: any }
columnPosition?: Pick<ColumnReqType, 'column_order'>
}
const props = defineProps<Props>()
@ -19,5 +20,5 @@ useProvideColumnCreateStore(meta, column)
</script>
<template>
<SmartsheetColumnEditOrAdd @submit="emit('submit')" @cancel="emit('cancel')" />
<SmartsheetColumnEditOrAdd :column-position="props.columnPosition" @submit="emit('submit')" @cancel="emit('cancel')" />
</template>

34
packages/nc-gui/components/smartsheet/column/FormulaOptions.vue

@ -26,6 +26,8 @@ const props = defineProps<{
const emit = defineEmits(['update:value'])
const uiTypesNotSupportedInFormulas = [UITypes.QrCode]
const vModel = useVModel(props, 'value', emit)
const { setAdditionalValidations, validateInfos, sqlUi, column } = useColumnCreateStoreOrThrow()
@ -44,7 +46,9 @@ enum JSEPNode {
const meta = inject(MetaInj, ref())
const columns = computed(() => meta?.value?.columns || [])
const supportedColumns = computed(
() => meta?.value?.columns?.filter((col) => !uiTypesNotSupportedInFormulas.includes(col.uidt as UITypes)) || [],
)
const validators = {
formula_raw: [
@ -97,8 +101,8 @@ const suggestionsList = computed(() => {
syntax: formulas[fn].syntax,
examples: formulas[fn].examples,
})),
...columns.value
.filter((c: Record<string, any>) => {
...supportedColumns.value
.filter((c) => {
// skip system LTAR columns
if (c.uidt === UITypes.LinkToAnotherRecord && c.system) return false
// v1 logic? skip the current column
@ -237,11 +241,7 @@ function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = n
errors = new Set([...errors, ...typeErrors])
} else if (parsedTree.type === JSEPNode.IDENTIFIER) {
if (
columns.value
.filter((c: Record<string, any>) => !column || column.value?.id !== c.id)
.every((c: Record<string, any>) => c.title !== parsedTree.name)
) {
if (supportedColumns.value.filter((c) => !column || column.value?.id !== c.id).every((c) => c.title !== parsedTree.name)) {
errors.add(`Column '${parsedTree.name}' is not available`)
}
@ -249,8 +249,8 @@ function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = n
// e.g. formula1 -> formula2 -> formula1 should return circular reference error
// get all formula columns excluding itself
const formulaPaths = columns.value
.filter((c: Record<string, any>) => c.id !== column.value?.id && c.uidt === UITypes.Formula)
const formulaPaths = supportedColumns.value
.filter((c) => c.id !== column.value?.id && c.uidt === UITypes.Formula)
.reduce((res: Record<string, any>[], c: Record<string, any>) => {
// in `formula`, get all the (unique) target neighbours
// i.e. all column id (e.g. cl_xxxxxxxxxxxxxx) with formula type
@ -258,7 +258,7 @@ function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = n
...new Set(
(c.colOptions.formula.match(/cl_\w{14}/g) || []).filter(
(colId: string) =>
columns.value.filter((col: ColumnType) => col.id === colId && col.uidt === UITypes.Formula).length,
supportedColumns.value.filter((col: ColumnType) => col.id === colId && col.uidt === UITypes.Formula).length,
),
),
]
@ -269,7 +269,9 @@ function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = n
return res
}, [])
// include target formula column (i.e. the one to be saved if applicable)
const targetFormulaCol = columns.value.find((c: ColumnType) => c.title === parsedTree.name && c.uidt === UITypes.Formula)
const targetFormulaCol = supportedColumns.value.find(
(c: ColumnType) => c.title === parsedTree.name && c.uidt === UITypes.Formula,
)
if (targetFormulaCol && column.value?.id) {
formulaPaths.push({
@ -362,7 +364,7 @@ function validateAgainstType(parsedTree: any, expectedType: string, func: any, t
}
}
} else if (parsedTree.type === JSEPNode.IDENTIFIER) {
const col = columns.value.find((c) => c.title === parsedTree.name)
const col = supportedColumns.value.find((c) => c.title === parsedTree.name)
if (col === undefined) {
return
@ -432,6 +434,7 @@ function validateAgainstType(parsedTree: any, expectedType: string, func: any, t
case UITypes.Button:
case UITypes.Checkbox:
case UITypes.Collaborator:
case UITypes.QrCode:
default:
typeErrors.add(`Not supported to reference column '${parsedTree.name}'`)
break
@ -455,7 +458,7 @@ function getRootDataType(parsedTree: any): any {
if (parsedTree.type === JSEPNode.CALL_EXP) {
return formulas[parsedTree.callee.name].type
} else if (parsedTree.type === JSEPNode.IDENTIFIER) {
const col = columns.value.find((c) => c.title === parsedTree.name) as Record<string, any>
const col = supportedColumns.value.find((c) => c.title === parsedTree.name) as Record<string, any>
if (col?.uidt === UITypes.Formula) {
return getRootDataType(jsep(col?.formula_raw))
} else {
@ -500,6 +503,7 @@ function getRootDataType(parsedTree: any): any {
case UITypes.Button:
case UITypes.Checkbox:
case UITypes.Collaborator:
case UITypes.QrCode:
default:
return 'N/A'
}
@ -561,7 +565,7 @@ function handleInput() {
.complete(wordToComplete.value)
?.sort((x: Record<string, any>, y: Record<string, any>) => sortOrder[x.type] - sortOrder[y.type])
if (!isCurlyBracketBalanced()) {
suggestion.value = suggestion.value.filter((v: Record<string, any>) => v.type === 'column')
suggestion.value = suggestion.value.filter((v) => v.type === 'column')
}
autocomplete.value = !!suggestion.value.length
}

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

@ -54,7 +54,9 @@ const columns = $computed(() => {
return []
}
return metas[selectedTable.id].columns.filter((c: any) => !isSystemColumn(c))
return metas[selectedTable.id].columns.filter((c: any) => {
return !(isSystemColumn(c) || c.uidt === UITypes.QrCode)
})
})
</script>

68
packages/nc-gui/components/smartsheet/column/QrCodeOptions.vue

@ -0,0 +1,68 @@
<script setup lang="ts">
import type { UITypes } from 'nocodb-sdk'
import { AllowedColumnTypesForQrCode } from 'nocodb-sdk'
import type { SelectProps } from 'ant-design-vue'
import { useVModel } from '#imports'
const props = defineProps<{
modelValue: any
}>()
const emit = defineEmits(['update:modelValue'])
const meta = inject(MetaInj, ref())
const activeView = inject(ActiveViewInj, ref())
const reloadDataHook = inject(ReloadViewDataHookInj)!
const { fields, metaColumnById } = useViewColumns(activeView, meta, () => reloadDataHook.trigger())
const vModel = useVModel(props, 'modelValue', emit)
const { setAdditionalValidations, validateInfos, column } = useColumnCreateStoreOrThrow()
const columnsAllowedAsQrValue = computed<SelectProps['options']>(() => {
return fields.value
?.filter(
(el) =>
el.fk_column_id &&
// AllowedColumnTypesForQrCode.map((el) => el.toString()).includes(metaColumnById.value[el.fk_column_id].uidt),
AllowedColumnTypesForQrCode.includes(metaColumnById.value[el.fk_column_id].uidt as UITypes),
)
.map((field) => {
return {
value: field.fk_column_id,
label: field.title,
}
})
})
onMounted(() => {
// set default value
vModel.value.fk_qr_value_column_id = (column?.value?.colOptions as Record<string, any>)?.fk_qr_value_column_id || ''
})
setAdditionalValidations({
fk_qr_value_column_id: [{ required: true, message: 'Required' }],
})
</script>
<template>
<a-row>
<a-col :span="24">
<a-form-item
class="flex w-1/2 pb-2 nc-qr-code-value-column-select"
:label="$t('labels.qrCodeValueColumn')"
v-bind="validateInfos.fk_qr_value_column_id"
>
<a-select
v-model:value="vModel.fk_qr_value_column_id"
:options="columnsAllowedAsQrValue"
placeholder="Select a column for the QR code value"
@click.stop
/>
</a-form-item>
</a-col>
</a-row>
</template>

14
packages/nc-gui/components/smartsheet/expanded-form/Header.vue

@ -3,6 +3,7 @@ import { message } from 'ant-design-vue'
import type { ViewType } from 'nocodb-sdk'
import {
ReloadRowDataHookInj,
isMac,
useExpandedFormStoreOrThrow,
useSmartsheetRowStoreOrThrow,
useSmartsheetStoreOrThrow,
@ -58,6 +59,19 @@ const copyRecordUrl = () => {
)
message.success('Copied to clipboard')
}
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (cmdOrCtrl) {
switch (e.key) {
case 'Enter': {
if (isUIAllowed('tableRowUpdate')) {
await save()
}
}
}
}
})
</script>
<template>

2
packages/nc-gui/components/smartsheet/expanded-form/index.vue

@ -167,7 +167,7 @@ export default {
<div
:ref="i ? null : (el) => (cellWrapperEl = el)"
class="!bg-white rounded px-1 min-h-[35px] flex items-center mt-2"
class="!bg-white rounded px-1 min-h-[35px] flex items-center mt-2 relative"
>
<LazySmartsheetVirtualCell v-if="isVirtualCol(col)" v-model="row.row[col.title]" :row="row" :column="col" />

36
packages/nc-gui/components/smartsheet/header/Cell.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType } from 'nocodb-sdk'
import { ColumnInj, IsFormInj, IsKanbanInj, inject, provide, ref, toRef, useUIPermission } from '#imports'
const props = defineProps<{ column: ColumnType & { meta: any }; required?: boolean | number; hideMenu?: boolean }>()
@ -17,6 +17,18 @@ const { isUIAllowed } = useUIPermission()
provide(ColumnInj, column)
const editColumnDropdown = ref(false)
const columnOrder = ref<Pick<ColumnReqType, 'column_order'> | null>(null)
const addField = async (payload) => {
columnOrder.value = payload
editColumnDropdown.value = true
}
const closeAddColumnDropdown = () => {
columnOrder.value = null
editColumnDropdown.value = false
}
</script>
<template>
@ -25,14 +37,25 @@ const editColumnDropdown = ref(false)
:class="{ 'h-full': column, '!text-gray-400': isKanban }"
>
<SmartsheetHeaderCellIcon v-if="column" />
<span v-if="column" class="name" style="white-space: nowrap" :title="column.title">{{ column.title }}</span>
<span
v-if="column"
class="name cursor-pointer"
style="white-space: nowrap"
:title="column.title"
@dblclick="editColumnDropdown = true"
>{{ column.title }}</span
>
<span v-if="(column.rqd && !column.cdf) || required" class="text-red-500">&nbsp;*</span>
<template v-if="!hideMenu">
<div class="flex-1" />
<LazySmartsheetHeaderMenu v-if="!isForm && isUIAllowed('edit-column')" @edit="editColumnDropdown = true" />
<LazySmartsheetHeaderMenu
v-if="!isForm && isUIAllowed('edit-column')"
@add-column="addField"
@edit="editColumnDropdown = true"
/>
</template>
<a-dropdown
@ -47,10 +70,11 @@ const editColumnDropdown = ref(false)
<template #overlay>
<SmartsheetColumnEditOrAddProvider
v-if="editColumnDropdown"
:column="column"
:column="columnOrder ? null : column"
:column-position="columnOrder"
class="w-full"
@submit="editColumnDropdown = false"
@cancel="editColumnDropdown = false"
@submit="closeAddColumnDropdown"
@cancel="closeAddColumnDropdown"
@click.stop
@keydown.stop
/>

197
packages/nc-gui/components/smartsheet/header/Menu.vue

@ -1,27 +1,40 @@
<script lang="ts" setup>
import type { LinkToAnotherRecordType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import {
ActiveViewInj,
ColumnInj,
IsLockedInj,
MetaInj,
Modal,
ReloadViewDataHookInj,
SmartsheetStoreEvents,
defineEmits,
defineProps,
extractSdkResponseErrorMsg,
getUniqueColumnName,
inject,
message,
useI18n,
useMetas,
useNuxtApp,
useSmartsheetStoreOrThrow,
} from '#imports'
const { virtual = false } = defineProps<{ virtual?: boolean }>()
const emit = defineEmits(['edit'])
const emit = defineEmits(['edit', 'addColumn'])
const { eventBus } = useSmartsheetStoreOrThrow()
const column = inject(ColumnInj)
const reloadDataHook = inject(ReloadViewDataHookInj)
const meta = inject(MetaInj, ref())
const view = inject(ActiveViewInj, ref())
const isLocked = inject(IsLockedInj)
const { $api, $e } = useNuxtApp()
@ -49,7 +62,7 @@ const deleteColumn = () =>
}
$e('a:column:delete')
} catch (e: any) {
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
},
@ -69,6 +82,131 @@ const setAsPrimaryValue = async () => {
message.error(t('msg.error.primaryColumnUpdateFailed'))
}
}
const sortByColumn = async (direction: 'asc' | 'desc') => {
try {
$e('a:sort:add', { from: 'column-menu' })
await $api.dbTableSort.create(view.value?.id as string, {
fk_column_id: column!.value.id,
direction,
push_to_top: true,
})
eventBus.emit(SmartsheetStoreEvents.SORT_RELOAD)
reloadDataHook?.trigger()
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const duplicateColumn = async () => {
let columnCreatePayload = {}
// generate duplicate column name
const duplicateColumnName = getUniqueColumnName(`${column!.value.title}_copy`, meta!.value!.columns!)
// construct column create payload
switch (column.value.uidt) {
case UITypes.LinkToAnotherRecord:
case UITypes.Lookup:
case UITypes.Rollup:
case UITypes.Formula:
return message.info('Not available at the moment')
case UITypes.SingleSelect:
case UITypes.MultiSelect:
columnCreatePayload = {
...column!.value!,
title: duplicateColumnName,
column_name: duplicateColumnName,
id: undefined,
order: undefined,
colOptions: {
options:
column.value.colOptions?.options?.map((option: Record<string, any>) => ({
...option,
id: undefined,
})) ?? [],
},
}
break
default:
columnCreatePayload = {
...column!.value!,
...(column!.value.colOptions ?? {}),
title: duplicateColumnName,
column_name: duplicateColumnName,
id: undefined,
colOptions: undefined,
order: undefined,
}
break
}
try {
const gridViewColumnList = await $api.dbViewColumn.list(view.value?.id as string)
const currentColumnIndex = gridViewColumnList.findIndex((f) => f.fk_column_id === column!.value.id)
let newColumnOrder
if (currentColumnIndex === gridViewColumnList.length - 1) {
newColumnOrder = gridViewColumnList[currentColumnIndex].order + 1
} else {
newColumnOrder = (gridViewColumnList[currentColumnIndex].order! + gridViewColumnList[currentColumnIndex + 1]?.order) / 2
}
await $api.dbTableColumn.create(meta!.value!.id!, {
...columnCreatePayload,
column_order: {
order: newColumnOrder,
view_id: view.value?.id as string,
},
})
await getMeta(meta!.value!.id!, true)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
message.success(t('msg.success.columnDuplicated'))
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
// add column before or after current column
const addColumn = async (before = false) => {
const gridViewColumnList = await $api.dbViewColumn.list(view.value?.id as string)
const currentColumnIndex = gridViewColumnList.findIndex((f) => f.fk_column_id === column!.value.id)
let newColumnOrder
if (before) {
if (currentColumnIndex === 0) {
newColumnOrder = gridViewColumnList[currentColumnIndex].order / 2
} else {
newColumnOrder = (gridViewColumnList[currentColumnIndex].order! + gridViewColumnList[currentColumnIndex - 1]?.order) / 2
}
} else {
if (currentColumnIndex === gridViewColumnList.length - 1) {
newColumnOrder = gridViewColumnList[currentColumnIndex].order + 1
} else {
newColumnOrder = (gridViewColumnList[currentColumnIndex].order! + gridViewColumnList[currentColumnIndex + 1]?.order) / 2
}
}
emit('addColumn', {
column_order: {
order: newColumnOrder,
view_id: view.value?.id as string,
},
})
}
// hide the field in view
const hideField = async () => {
const gridViewColumnList = await $api.dbViewColumn.list(view.value?.id as string)
const currentColumn = gridViewColumnList.find((f) => f.fk_column_id === column!.value.id)
await $api.dbViewColumn.update(view.value.id, currentColumn.id, { show: false })
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
}
</script>
<template>
@ -84,6 +222,59 @@ const setAsPrimaryValue = async () => {
{{ $t('general.edit') }}
</div>
</a-menu-item>
<template v-if="column.uidt !== UITypes.LinkToAnotherRecord || column.colOptions.type !== RelationTypes.BELONGS_TO">
<a-divider class="!my-0" />
<a-menu-item @click="sortByColumn('asc')">
<div v-e="['a:field:sort', { dir: 'asc' }]" class="nc-column-insert-after nc-header-menu-item">
<MdiSortAscending class="text-primary" />
<!-- Sort Ascending -->
{{ $t('general.sortAsc') }}
</div>
</a-menu-item>
<a-menu-item @click="sortByColumn('desc')">
<div v-e="['a:field:sort', { dir: 'desc' }]" class="nc-column-insert-before nc-header-menu-item">
<MdiSortDescending class="text-primary" />
<!-- Sort Descending -->
{{ $t('general.sortDesc') }}
</div>
</a-menu-item>
</template>
<a-divider class="!my-0" />
<a-menu-item @click="hideField">
<div v-e="['a:field:hide']" class="nc-column-insert-before nc-header-menu-item">
<MdiEyeOffOutline class="text-primary" />
<!-- Hide Field -->
{{ $t('general.hideField') }}
</div>
</a-menu-item>
<a-divider class="!my-0" />
<a-menu-item
v-if="column.uidt !== UITypes.LinkToAnotherRecord && column.uidt !== UITypes.Lookup && !column.pk"
@click="duplicateColumn"
>
<div v-e="['a:field:duplicate']" class="nc-column-duplicate nc-header-menu-item">
<MdiFileReplaceOutline class="text-primary" />
<!-- Duplicate -->
{{ t('general.duplicate') }}
</div>
</a-menu-item>
<a-menu-item @click="addColumn()">
<div v-e="['a:field:insert:after']" class="nc-column-insert-after nc-header-menu-item">
<MdiTableColumnPlusAfter class="text-primary" />
<!-- Insert After -->
{{ t('general.insertAfter') }}
</div>
</a-menu-item>
<a-menu-item @click="addColumn(true)">
<div v-e="['a:field:insert:before']" class="nc-column-insert-before nc-header-menu-item">
<MdiTableColumnPlusBefore class="text-primary" />
<!-- Insert Before -->
{{ t('general.insertBefore') }}
</div>
</a-menu-item>
<a-divider class="!my-0" />
<a-menu-item v-if="!virtual" @click="setAsPrimaryValue">
<div class="nc-column-set-primary nc-header-menu-item">

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

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { ColumnType, FormulaType, LinkToAnotherRecordType, LookupType, RollupType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType, FormulaType, LinkToAnotherRecordType, LookupType, RollupType } from 'nocodb-sdk'
import { substituteColumnIdWithAliasInFormula } from 'nocodb-sdk'
import {
ColumnInj,
@ -99,6 +99,18 @@ const tooltipMsg = computed(() => {
}
return ''
})
const columnOrder = ref<Pick<ColumnReqType, 'column_order'> | null>(null)
const addField = async (payload) => {
columnOrder.value = payload
editColumnDropdown.value = true
}
const closeAddColumnDropdown = () => {
columnOrder.value = null
editColumnDropdown.value = false
}
</script>
<template>
@ -117,7 +129,12 @@ const tooltipMsg = computed(() => {
<template v-if="!hideMenu">
<div class="flex-1" />
<LazySmartsheetHeaderMenu v-if="!isForm && isUIAllowed('edit-column')" :virtual="true" @edit="editColumnDropdown = true" />
<LazySmartsheetHeaderMenu
v-if="!isForm && isUIAllowed('edit-column')"
:virtual="true"
@add-column="addField"
@edit="editColumnDropdown = true"
/>
</template>
<a-dropdown
@ -128,14 +145,14 @@ const tooltipMsg = computed(() => {
overlay-class-name="nc-dropdown-edit-column"
>
<div />
<template #overlay>
<SmartsheetColumnEditOrAddProvider
v-if="editColumnDropdown"
:column="column"
:column="columnOrder ? null : column"
:column-position="columnOrder"
class="w-full"
@submit="editColumnDropdown = false"
@cancel="editColumnDropdown = false"
@submit="closeAddColumnDropdown"
@cancel="closeAddColumnDropdown"
@click.stop
@keydown.stop
/>

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

@ -8,10 +8,11 @@ import HMIcon from '~icons/mdi/table-arrow-right'
import BTIcon from '~icons/mdi/table-arrow-left'
import MMIcon from '~icons/mdi/table-network'
import FormulaIcon from '~icons/mdi/math-integral'
import QrCodeScan from '~icons/mdi/qrcode-scan'
import RollupIcon from '~icons/mdi/movie-roll'
import CountIcon from '~icons/mdi/counter'
import SpecificDBTypeIcon from '~icons/mdi/database-settings'
import TableColumnPlusBefore from '~icons/mdi/table-column-plus-before'
import MdiTextSearchVariant from '~icons/mdi/text-search-variant'
const renderIcon = (column: ColumnType, relationColumn?: ColumnType) => {
switch (column.uidt) {
@ -29,16 +30,18 @@ const renderIcon = (column: ColumnType, relationColumn?: ColumnType) => {
return { icon: SpecificDBTypeIcon, color: 'text-grey' }
case UITypes.Formula:
return { icon: FormulaIcon, color: 'text-grey' }
case UITypes.QrCode:
return { icon: QrCodeScan, color: 'text-grey' }
case UITypes.Lookup:
switch ((relationColumn?.colOptions as LinkToAnotherRecordType)?.type) {
case RelationTypes.MANY_TO_MANY:
return { icon: TableColumnPlusBefore, color: 'text-accent' }
return { icon: MdiTextSearchVariant, color: 'text-accent' }
case RelationTypes.HAS_MANY:
return { icon: TableColumnPlusBefore, color: 'text-yellow-500' }
return { icon: MdiTextSearchVariant, color: 'text-yellow-500' }
case RelationTypes.BELONGS_TO:
return { icon: TableColumnPlusBefore, color: 'text-sky-500' }
return { icon: MdiTextSearchVariant, color: 'text-sky-500' }
}
return { icon: TableColumnPlusBefore, color: 'text-grey' }
return { icon: MdiTextSearchVariant, color: 'text-grey' }
case UITypes.Rollup:
switch ((relationColumn?.colOptions as LinkToAnotherRecordType)?.type) {
case RelationTypes.MANY_TO_MANY:

4
packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue

@ -71,13 +71,15 @@ useMenuCloseOnEsc(open)
<template>
<a-dropdown v-model:visible="open" :trigger="['click']" overlay-class-name="nc-dropdown-filter-menu">
<div :class="{ 'nc-badge nc-active-btn': filtersLength }">
<div :class="{ 'nc-active-btn': filtersLength }">
<a-button v-e="['c:filter']" class="nc-filter-menu-btn nc-toolbar-btn txt-sm" :disabled="isLocked">
<div class="flex items-center gap-1">
<MdiFilterOutline />
<!-- Filter -->
<span class="text-capitalize !text-sm font-weight-normal">{{ $t('activity.filter') }}</span>
<MdiMenuDown class="text-grey" />
<span v-if="filtersLength" class="nc-count-badge">{{ filtersLength }}</span>
</div>
</a-button>
</div>

1
packages/nc-gui/components/smartsheet/toolbar/Erd.vue

@ -17,6 +17,7 @@ const selectedView = inject(ActiveViewInj)
<template>
<a-modal
v-model:visible="vModel"
:class="{ active: vModel }"
size="small"
:footer="null"
width="max(900px,60vw)"

11
packages/nc-gui/components/smartsheet/toolbar/FieldListAutoCompleteDropdown.vue

@ -21,14 +21,17 @@ const localValue = computed({
const options = computed<SelectProps['options']>(() =>
meta.value?.columns
?.filter((c: ColumnType) => {
/** ignore hasmany and manytomany relations if it's using within sort menu */
if (isSort) {
if (c.uidt === UITypes.QrCode) {
return false
} else if (isSort) {
/** ignore hasmany and manytomany relations if it's using within sort menu */
return !(
c.uidt === UITypes.LinkToAnotherRecord && (c.colOptions as LinkToAnotherRecordType).type !== RelationTypes.BELONGS_TO
)
/** ignore virtual fields which are system fields ( mm relation ) */
/** ignore virtual fields which are system fields ( mm relation ) and qr code fields */
} else {
return !c.colOptions || !c.system
const isVirtualSystemField = c.colOptions && c.system
return !isVirtualSystemField
}
})
.map((c: ColumnType) => ({

16
packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue

@ -16,6 +16,7 @@ import {
resolveComponent,
useMenuCloseOnEsc,
useNuxtApp,
useSmartsheetStoreOrThrow,
useViewColumns,
watch,
} from '#imports'
@ -46,8 +47,17 @@ const {
hideAll,
saveOrUpdate,
metaColumnById,
loadViewColumns,
} = useViewColumns(activeView, meta, () => reloadDataHook.trigger())
const { eventBus } = useSmartsheetStoreOrThrow()
eventBus.on((event) => {
if (event === SmartsheetStoreEvents.FIELD_RELOAD) {
loadViewColumns()
}
})
watch(
sortedAndFilteredFields,
(v) => {
@ -56,7 +66,7 @@ watch(
{ immediate: true },
)
const isAnyFieldHidden = computed(() => filteredFieldList.value?.some((field) => !field.show))
const numberOfHiddenFields = computed(() => filteredFieldList.value?.filter((field) => !field.show)?.length)
const onMove = (_event: { moved: { newIndex: number } }) => {
// todo : sync with server
@ -128,7 +138,7 @@ useMenuCloseOnEsc(open)
<template>
<a-dropdown v-model:visible="open" :trigger="['click']" overlay-class-name="nc-dropdown-fields-menu">
<div :class="{ 'nc-badge nc-active-btn': isAnyFieldHidden }">
<div :class="{ 'nc-active-btn': numberOfHiddenFields }">
<a-button v-e="['c:fields']" class="nc-fields-menu-btn nc-toolbar-btn" :disabled="isLocked">
<div class="flex items-center gap-1">
<MdiEyeOffOutline />
@ -137,6 +147,8 @@ useMenuCloseOnEsc(open)
<span class="text-capitalize !text-sm font-weight-normal">{{ $t('objects.fields') }}</span>
<MdiMenuDown class="text-grey" />
<span v-if="numberOfHiddenFields" class="nc-count-badge">{{ numberOfHiddenFields }}</span>
</div>
</a-button>
</div>

1
packages/nc-gui/components/smartsheet/toolbar/MoreActions.vue

@ -172,6 +172,7 @@ const exportFile = async (exportType: ExportTypes) => {
<a-modal
v-model:visible="sharedViewListDlg"
:class="{ active: sharedViewListDlg }"
:title="$t('activity.listSharedView')"
width="max(900px,60vw)"
:footer="null"

1
packages/nc-gui/components/smartsheet/toolbar/ShareView.vue

@ -217,6 +217,7 @@ const isRtl = computed(() => isRtlLang(locale.value as any))
<!-- This view is shared via a private link -->
<a-modal
v-model:visible="showShareModel"
:class="{ active: showShareModel }"
size="small"
:title="$t('msg.info.privateLink')"
:footer="null"

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

@ -10,6 +10,7 @@ import {
inject,
ref,
useMenuCloseOnEsc,
useSmartsheetStoreOrThrow,
useViewSorts,
watch,
} from '#imports'
@ -19,8 +20,16 @@ const view = inject(ActiveViewInj, ref())
const isLocked = inject(IsLockedInj, ref(false))
const reloadDataHook = inject(ReloadViewDataHookInj)
const { eventBus } = useSmartsheetStoreOrThrow()
const { sorts, saveOrUpdate, loadSorts, addSort, deleteSort } = useViewSorts(view, () => reloadDataHook?.trigger())
eventBus.on((event) => {
if (event === SmartsheetStoreEvents.SORT_RELOAD) {
loadSorts()
}
})
const columns = computed(() => meta.value?.columns || [])
const columnByID = computed(() =>
@ -54,6 +63,8 @@ useMenuCloseOnEsc(open)
<!-- Sort -->
<span class="text-capitalize !text-sm font-weight-normal">{{ $t('activity.sort') }}</span>
<MdiMenuDown class="text-grey" />
<span v-if="sorts?.length" class="nc-count-badge">{{ sorts.length }}</span>
</div>
</a-button>
</div>

1
packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue

@ -241,6 +241,7 @@ useMenuCloseOnEsc(open)
<a-modal
v-model:visible="sharedViewListDlg"
:class="{ active: sharedViewListDlg }"
:title="$t('activity.listSharedView')"
width="max(900px,60vw)"
:footer="null"

2
packages/nc-gui/components/tabs/auth/ApiTokenManagement.vue

@ -90,6 +90,7 @@ onMounted(() => {
<template>
<a-modal
v-model:visible="showNewTokenModal"
:class="{ active: showNewTokenModal }"
:closable="false"
width="28rem"
centered
@ -133,6 +134,7 @@ onMounted(() => {
<a-modal
v-model:visible="showDeleteTokenModal"
:class="{ active: showDeleteTokenModal }"
:closable="false"
width="28rem"
centered

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

@ -183,6 +183,7 @@ const isSuperAdmin = (user: { main_roles?: string }) => {
<a-modal
v-model:visible="showUserDeleteModal"
:class="{ active: showUserDeleteModal }"
:closable="false"
width="28rem"
centered

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

@ -145,6 +145,7 @@ const emailField = (inputEl: typeof Input) => {
:footer="null"
centered
:visible="show"
:class="{ active: show }"
:closable="false"
width="max(50vw, 44rem)"
wrap-class-name="nc-modal-invite-user-and-share-base"
@ -177,7 +178,7 @@ const emailField = (inputEl: typeof Input) => {
<a-alert class="mt-1" type="success" show-icon>
<template #message>
<div class="flex flex-row justify-between items-center py-1">
<div class="flex pl-2 text-green-700 text-xs">
<div class="flex pl-2 text-green-700 text-xs" data-testid="invite-modal-invitation-url">
{{ inviteUrl }}
</div>

31
packages/nc-gui/components/virtual-cell/Formula.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { CellValueInj, ColumnInj, computed, handleTZ, inject, ref, refAutoReset, replaceUrlsWithLink, useProject } from '#imports'
import { CellValueInj, ColumnInj, computed, handleTZ, inject, replaceUrlsWithLink, useProject } from '#imports'
// todo: column type doesn't have required property `error` - throws in typecheck
const column = inject(ColumnInj) as Ref<ColumnType & { colOptions: { error: any } }>
@ -14,21 +14,8 @@ const result = computed(() => (isPg.value ? handleTZ(cellValue?.value) : cellVal
const urls = computed(() => replaceUrlsWithLink(result.value))
const timeout = 3000 // in ms
const showEditFormulaWarning = refAutoReset(false, timeout)
const showClearFormulaWarning = refAutoReset(false, timeout)
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
showEditFormulaWarning.value = true
break
case 'Delete':
showClearFormulaWarning.value = true
break
}
})
const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activateShowEditNonEditableFieldWarning } =
useShowNotEditableWarning()
</script>
<template>
@ -41,18 +28,16 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
<span>ERR!</span>
</a-tooltip>
<div class="p-2" @dblclick="showEditFormulaWarning = true">
<div class="p-2" @dblclick="activateShowEditNonEditableFieldWarning">
<div v-if="urls" v-html="urls" />
<div v-else>{{ result }}</div>
<div v-if="showEditFormulaWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Formula fields should be configured in the field menu dropdown.
<div v-if="showEditNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
{{ $t('msg.info.computedFieldEditWarning') }}
</div>
<div v-if="showClearFormulaWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Computed field - unable to clear text.
<div v-if="showClearNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
{{ $t('msg.info.computedFieldDeleteWarning') }}
</div>
</div>
</div>

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

@ -78,6 +78,7 @@ provide(CellUrlDisableOverlayInj, ref(true))
const timeout = 3000 // in ms
const showEditWarning = refAutoReset(false, timeout)
const showClearWarning = refAutoReset(false, timeout)
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
@ -85,16 +86,15 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
case 'Enter':
showEditWarning.value = true
break
case 'Delete':
default:
showClearWarning.value = true
break
}
})
</script>
<template>
<div class="h-full">
<div class="h-full flex gap-1 overflow-x-auto p-1">
<div class="h-full flex gap-1 overflow-x-auto p-1" @dblclick="showEditWarning = true">
<template v-if="lookupColumn">
<!-- Render virtual cell -->
<div v-if="isVirtualCol(lookupColumn)">
@ -134,12 +134,10 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
</div>
<div>
<div v-if="showEditWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Computed field - unable to edit content.
{{ $t('msg.info.computedFieldEditWarning') }}
</div>
<div v-if="showClearWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Computed field - unable to clear content.
{{ $t('msg.info.computedFieldDeleteWarning') }}
</div>
</div>
</div>

55
packages/nc-gui/components/virtual-cell/QrCode.vue

@ -0,0 +1,55 @@
<script setup lang="ts">
import { useQRCode } from '@vueuse/integrations/useQRCode'
const maxNumberOfAllowedCharsForQrValue = 2000
const cellValue = inject(CellValueInj)
const qrValue = computed(() => String(cellValue?.value))
const tooManyCharsForQrCode = computed(() => qrValue?.value.length > maxNumberOfAllowedCharsForQrValue)
const qrCode = useQRCode(qrValue, {
width: 150,
})
const qrCodeLarge = useQRCode(qrValue, {
width: 600,
})
const modalVisible = ref(false)
const showQrModal = (ev: MouseEvent) => {
ev.stopPropagation()
modalVisible.value = true
}
const handleModalOkClick = () => (modalVisible.value = false)
const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning } = useShowNotEditableWarning()
</script>
<template>
<a-modal
v-model:visible="modalVisible"
:class="{ active: modalVisible }"
wrap-class-name="nc-qr-code-large"
:body-style="{ padding: '0px' }"
@ok="handleModalOkClick"
>
<template #footer>
<div class="mr-4" data-testid="nc-qr-code-large-value-label">{{ qrValue }}</div>
</template>
<img v-if="qrValue && !tooManyCharsForQrCode" :src="qrCodeLarge" alt="QR Code" />
</a-modal>
<div v-if="tooManyCharsForQrCode" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
{{ $t('labels.qrCodeValueTooLong') }}
</div>
<img v-if="qrValue && !tooManyCharsForQrCode" :src="qrCode" alt="QR Code" @click="showQrModal" />
<div v-if="showEditNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
{{ $t('msg.warning.nonEditableFields.computedFieldUnableToClear') }}
</div>
<div v-if="showClearNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
{{ $t('msg.warning.nonEditableFields.qrFieldsCannotBeDirectlyChanged') }}
</div>
</template>

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

@ -6,18 +6,10 @@ const value = inject(CellValueInj)
const timeout = 3000 // in ms
const showEditWarning = refAutoReset(false, timeout)
const showClearWarning = refAutoReset(false, timeout)
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
showEditWarning.value = true
break
case 'Delete':
showClearWarning.value = true
break
}
})
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), () => (showClearWarning.value = true))
</script>
<template>
@ -28,12 +20,10 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
<div>
<div v-if="showEditWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Computed field - unable to edit content.
{{ $t('msg.info.computedFieldEditWarning') }}
</div>
<div v-if="showClearWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Computed field - unable to clear content.
{{ $t('msg.info.computedFieldDeleteWarning') }}
</div>
</div>
</div>

10
packages/nc-gui/components/virtual-cell/components/ListItems.vue

@ -24,6 +24,8 @@ const vModel = useVModel(props, 'modelValue', emit)
const column = inject(ColumnInj)
const filterQueryRef = ref()
const {
childrenExcludedList,
loadChildrenExcludedList,
@ -141,6 +143,12 @@ useSelectedCellKeyupListener(vModel, (e: KeyboardEvent) => {
}
}
break
default: {
const el = filterQueryRef.value?.$el
if (el) {
filterQueryRef.value.$el.focus()
}
}
}
})
const activeRow = (vNode?: InstanceType<typeof Card>) => {
@ -151,6 +159,7 @@ const activeRow = (vNode?: InstanceType<typeof Card>) => {
<template>
<a-modal
v-model:visible="vModel"
:class="{ active: vModel }"
:footer="null"
:title="$t('activity.linkRecord')"
:body-style="{ padding: 0 }"
@ -159,6 +168,7 @@ const activeRow = (vNode?: InstanceType<typeof Card>) => {
<div class="max-h-[max(calc(100vh_-_300px)_,500px)] flex flex-col py-6">
<div class="flex mb-4 items-center gap-2 px-12">
<a-input
ref="filterQueryRef"
v-model:value="childrenExcludedListPagination.query"
:placeholder="$t('placeholder.filterQuery')"
class="max-w-[200px]"

8
packages/nc-gui/composables/useColumnCreateStore.ts

@ -1,5 +1,5 @@
import clone from 'just-clone'
import type { ColumnType, TableType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType, TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import type { Ref } from 'vue'
import {
@ -191,10 +191,10 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
if (cdf) formState.value.cdf = formState.value.cdf || null
}
const addOrUpdate = async (onSuccess: () => void) => {
const addOrUpdate = async (onSuccess: () => void, columnPosition?: Pick<ColumnReqType, 'column_order'>) => {
try {
if (!(await validate())) return
} catch (e) {
} catch (e: any) {
const errorMsgs = e.errorFields
?.map((e: any) => e.errors?.join(', '))
.filter(Boolean)
@ -228,7 +228,7 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
// };
// }
}
await $api.dbTableColumn.create(meta.value?.id as string, formState.value)
await $api.dbTableColumn.create(meta.value?.id as string, { ...formState.value, ...columnPosition })
/** if LTAR column then force reload related table meta */
if (formState.value.uidt === UITypes.LinkToAnotherRecord && meta.value?.id !== formState.value.childId) {

54
packages/nc-gui/composables/useMultiSelect/cellRange.ts

@ -0,0 +1,54 @@
export interface Cell {
row: number | null
col: number | null
}
export class CellRange {
_start: Cell | null
_end: Cell | null
constructor(start = null, end = null) {
this._start = start
this._end = end ?? this._start
}
get start() {
return {
row: Math.min(this._start?.row ?? NaN, this._end?.row ?? NaN),
col: Math.min(this._start?.col ?? NaN, this._end?.col ?? NaN),
}
}
get end() {
return {
row: Math.max(this._start?.row ?? NaN, this._end?.row ?? NaN),
col: Math.max(this._start?.col ?? NaN, this._end?.col ?? NaN),
}
}
startRange(value: Cell) {
if (value == null) {
return
}
this._start = value
this._end = value
}
endRange(value: Cell) {
if (value == null) {
return
}
this._end = value
}
clear() {
this._start = null
this._end = null
}
isEmpty() {
return this._start == null || this._end == null
}
}

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

@ -0,0 +1,90 @@
import dayjs from 'dayjs'
import { UITypes } from 'nocodb-sdk'
export default function convertCellData(args: { from: UITypes; to: UITypes; value: any }, isMysql = false) {
const { from, to, value } = args
if (from === to && ![UITypes.Attachment, UITypes.Date, UITypes.DateTime, UITypes.Time, UITypes.Year].includes(to)) {
return value
}
const dateFormat = isMysql ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
switch (to) {
case UITypes.Number: {
const parsedNumber = Number(value)
if (isNaN(parsedNumber)) {
throw new TypeError(`Cannot convert '${value}' to number`)
}
return parsedNumber
}
case UITypes.Rating: {
const parsedNumber = Number(value ?? 0)
if (isNaN(parsedNumber)) {
throw new TypeError(`Cannot convert '${value}' to rating`)
}
return parsedNumber
}
case UITypes.Checkbox:
return Boolean(value)
case UITypes.Date: {
const parsedDate = dayjs(value)
if (!parsedDate.isValid()) throw new Error('Not a valid date')
return parsedDate.format('YYYY-MM-DD')
}
case UITypes.DateTime: {
const parsedDateTime = dayjs(value)
if (!parsedDateTime.isValid()) {
throw new Error('Not a valid datetime value')
}
return parsedDateTime.format(dateFormat)
}
case UITypes.Time: {
let parsedTime = dayjs(value)
if (!parsedTime.isValid()) {
parsedTime = dayjs(value, 'HH:mm:ss')
}
if (!parsedTime.isValid()) {
parsedTime = dayjs(`1999-01-01 ${value}`)
}
if (!parsedTime.isValid()) {
throw new Error('Not a valid time value')
}
return parsedTime.format(dateFormat)
}
case UITypes.Year: {
if (/^\d+$/.test(value)) {
return +value
}
const parsedDate = dayjs(value)
if (parsedDate.isValid()) {
return parsedDate.format('YYYY')
}
throw new Error('Not a valid year value')
}
case UITypes.Attachment: {
let parsedVal
try {
parsedVal = typeof value === 'string' ? JSON.parse(value) : value
parsedVal = Array.isArray(parsedVal) ? parsedVal : [parsedVal]
} catch (e) {
throw new Error('Invalid attachment data')
}
if (parsedVal.some((v: any) => v && !(v.url || v.data))) {
throw new Error('Invalid attachment data')
}
return JSON.stringify(parsedVal)
}
case UITypes.LinkToAnotherRecord:
case UITypes.Lookup:
case UITypes.Rollup:
case UITypes.Formula:
case UITypes.QrCode:
throw new Error(`Unsupported conversion from ${from} to ${to}`)
default:
return value
}
}

28
packages/nc-gui/composables/useMultiSelect/copyValue.ts

@ -0,0 +1,28 @@
import type { ColumnType } from 'nocodb-sdk'
import type { Row } from '~/lib'
export const copyTable = async (rows: Row[], cols: ColumnType[]) => {
let copyHTML = '<table>'
let copyPlainText = ''
rows.forEach((row) => {
let copyRow = '<tr>'
cols.forEach((col) => {
let value = (col.title && row.row[col.title]) ?? ''
if (typeof value === 'object') {
value = JSON.stringify(value)
}
copyRow += `<td>${value}</td>`
copyPlainText = `${copyPlainText} ${value} \t`
})
copyHTML += `${copyRow}</tr>`
copyPlainText = `${copyPlainText.trim()}\n`
})
copyHTML += '</table>'
copyPlainText.trim()
const blobHTML = new Blob([copyHTML], { type: 'text/html' })
const blobPlainText = new Blob([copyPlainText], { type: 'text/plain' })
return navigator.clipboard.write([new ClipboardItem({ [blobHTML.type]: blobHTML, [blobPlainText.type]: blobPlainText })])
}

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

@ -1,124 +1,146 @@
import type { MaybeRef } from '@vueuse/core'
import { UITypes } from 'nocodb-sdk'
import { message, reactive, ref, unref, useCopy, useEventListener, useI18n } from '#imports'
interface SelectedBlock {
row: number | null
col: number | null
}
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import type { Cell } from './cellRange'
import { CellRange } from './cellRange'
import convertCellData from './convertCellData'
import type { Row } from '~/lib'
import {
copyTable,
extractPkFromRow,
extractSdkResponseErrorMsg,
isMac,
message,
reactive,
ref,
unref,
useCopy,
useEventListener,
useI18n,
useMetas,
useProject,
} from '#imports'
/**
* Utility to help with multi-selecting rows/cells in the smartsheet
*/
export function useMultiSelect(
fields: MaybeRef<any[]>,
data: MaybeRef<any[]>,
_meta: MaybeRef<TableType>,
fields: MaybeRef<ColumnType[]>,
data: MaybeRef<Row[]>,
_editEnabled: MaybeRef<boolean>,
isPkAvail: MaybeRef<boolean | undefined>,
clearCell: Function,
makeEditable: Function,
scrollToActiveCell?: (row?: number | null, col?: number | null) => void,
keyEventHandler?: Function,
syncCellData?: Function,
) {
const meta = ref(_meta)
const { t } = useI18n()
const { copy } = useCopy()
const editEnabled = ref(_editEnabled)
const { getMeta } = useMetas()
const selected = reactive<SelectedBlock>({ row: null, col: null })
const { isMysql } = useProject()
// save the first and the last column where the mouse is down while the value isSelectedRow is true
const selectedRows = reactive({ startCol: NaN, endCol: NaN, startRow: NaN, endRow: NaN })
let clipboardContext = $ref<{ value: any; uidt: UITypes } | null>(null)
// calculate the min and the max column where the mouse is down while the value isSelectedRow is true
const rangeRows = reactive({ minRow: NaN, maxRow: NaN, minCol: NaN, maxCol: NaN })
const editEnabled = ref(_editEnabled)
// check if mouse is down or up false=mouseup and true=mousedown
let isSelectedBlock = $ref(false)
const selectedCell = reactive<Cell>({ row: null, col: null })
const selectedRange = reactive(new CellRange())
let isMouseDown = $ref(false)
const columnLength = $computed(() => unref(fields)?.length)
async function copyValue(ctx?: Cell) {
try {
if (!selectedRange.isEmpty()) {
const cprows = unref(data).slice(selectedRange.start.row, selectedRange.end.row + 1) // slice the selected rows for copy
const cpcols = unref(fields).slice(selectedRange.start.col, selectedRange.end.col + 1) // slice the selected cols for copy
await copyTable(cprows, cpcols)
message.success(t('msg.info.copiedToClipboard'))
} else {
// if copy was called with context (right click position) - copy value from context
// else if there is just one selected cell, copy it's value
const cpRow = ctx?.row ?? selectedCell?.row
const cpCol = ctx?.col ?? selectedCell?.col
if (cpRow != null && cpCol != null) {
const rowObj = unref(data)[cpRow]
const columnObj = unref(fields)[cpCol]
let textToCopy = (columnObj.title && rowObj.row[columnObj.title]) || ''
if (columnObj.uidt === UITypes.Checkbox) {
textToCopy = !!textToCopy
}
if (typeof textToCopy === 'object') {
textToCopy = JSON.stringify(textToCopy)
}
await copy(textToCopy)
message.success(t('msg.info.copiedToClipboard'))
}
}
} catch {
message.error(t('msg.error.copyToClipboardError'))
}
}
function selectCell(row: number, col: number) {
clearRangeRows()
if (selected.row === row && selected.col === col) return
selectedRange.clear()
if (selectedCell.row === row && selectedCell.col === col) return
editEnabled.value = false
selected.row = row
selected.col = col
selectedCell.row = row
selectedCell.col = col
}
function selectBlock(row: number, col: number) {
// if selected.col and selected.row are null and isSelectedBlock is true that means you are selecting a block
if (selected.col === null || selected.row === null) {
if (isSelectedBlock) {
// save the next value after the selectionStart
selectedRows.endCol = col
selectedRows.endRow = row
}
} else if (selected.col !== col || selected.row !== row) {
// if selected.col and selected.row is not null but the selected col and row is not equal at the row and col where the mouse is clicking
// and isSelectedBlock is true that means you are selecting a block
if (isSelectedBlock) {
selected.col = null
selected.row = null
// save the next value after the selectionStart
selectedRows.endCol = col
selectedRows.endRow = row
}
function endSelectRange(row: number, col: number) {
if (!isMouseDown) {
return
}
selectedCell.row = null
selectedCell.col = null
selectedRange.endRange({ row, col })
}
function selectedRange(row: number, col: number) {
if (
!isNaN(selectedRows.startRow) &&
!isNaN(selectedRows.startCol) &&
!isNaN(selectedRows.endRow) &&
!isNaN(selectedRows.endCol)
) {
// check if column selection is up or down
rangeRows.minRow = Math.min(selectedRows.startRow, selectedRows.endRow)
rangeRows.maxRow = Math.max(selectedRows.startRow, selectedRows.endRow)
rangeRows.minCol = Math.min(selectedRows.startCol, selectedRows.endCol)
rangeRows.maxCol = Math.max(selectedRows.startCol, selectedRows.endCol)
// return if the column is in between the selection
return col >= rangeRows.minCol && col <= rangeRows.maxCol && row >= rangeRows.minRow && row <= rangeRows.maxRow
} else {
function isCellSelected(row: number, col: number) {
if (selectedCell?.row === row && selectedCell?.col === col) {
return true
}
if (selectedRange.isEmpty()) {
return false
}
return (
col >= selectedRange.start.col &&
col <= selectedRange.end.col &&
row >= selectedRange.start.row &&
row <= selectedRange.end.row
)
}
function startSelectRange(event: MouseEvent, row: number, col: number) {
// if editEnabled but the selected col or the selected row is not equal like the actual row or col, enabled selected multiple rows
if (unref(editEnabled) && (selected.col !== col || selected.row !== row)) {
event.preventDefault()
} else if (!unref(editEnabled)) {
// if editEnabled is not true, enabled selected multiple rows
event.preventDefault()
// if there was a right click on selected range, don't restart the selection
const leftClickButton = 0
if (event?.button !== leftClickButton && isCellSelected(row, col)) {
return
}
// clear the selection when the mouse is down
selectedRows.startCol = NaN
selectedRows.endCol = NaN
selectedRows.startRow = NaN
selectedRows.endRow = NaN
// asing where the selection start
selectedRows.startCol = col
selectedRows.startRow = row
isSelectedBlock = true
}
if (unref(editEnabled)) {
event.preventDefault()
return
}
function clearRangeRows() {
// when the selection starts or ends or when enter/arrow/tab is pressed
// this clear the previous selection
rangeRows.minCol = NaN
rangeRows.maxCol = NaN
rangeRows.minRow = NaN
rangeRows.maxRow = NaN
selectedRows.startRow = NaN
selectedRows.startCol = NaN
selectedRows.endRow = NaN
selectedRows.endCol = NaN
isMouseDown = true
selectedRange.clear()
selectedRange.startRange({ row, col })
}
useEventListener(document, 'mouseup', (e) => {
@ -127,7 +149,7 @@ export function useMultiSelect(
e.preventDefault()
}
isSelectedBlock = false
isMouseDown = false
})
const onKeyDown = async (e: KeyboardEvent) => {
@ -136,41 +158,36 @@ export function useMultiSelect(
return true
}
if (
!isNaN(selectedRows.startRow) &&
!isNaN(selectedRows.startCol) &&
!isNaN(selectedRows.endRow) &&
!isNaN(selectedRows.endCol)
) {
if (!selectedRange.isEmpty()) {
// In case the user press tabs or arrows keys
selected.row = selectedRows.startRow
selected.col = selectedRows.startCol
selectedCell.row = selectedRange.start.row
selectedCell.col = selectedRange.start.col
}
if (selected.row === null || selected.col === null) return
if (selectedCell.row === null || selectedCell.col === null) return
/** on tab key press navigate through cells */
switch (e.key) {
case 'Tab':
e.preventDefault()
clearRangeRows()
selectedRange.clear()
if (e.shiftKey) {
if (selected.col > 0) {
selected.col--
if (selectedCell.col > 0) {
selectedCell.col--
editEnabled.value = false
} else if (selected.row > 0) {
selected.row--
selected.col = unref(columnLength) - 1
} else if (selectedCell.row > 0) {
selectedCell.row--
selectedCell.col = unref(columnLength) - 1
editEnabled.value = false
}
} else {
if (selected.col < unref(columnLength) - 1) {
selected.col++
if (selectedCell.col < unref(columnLength) - 1) {
selectedCell.col++
editEnabled.value = false
} else if (selected.row < unref(data).length - 1) {
selected.row++
selected.col = 0
} else if (selectedCell.row < unref(data).length - 1) {
selectedCell.row++
selectedCell.col = 0
editEnabled.value = false
}
}
@ -179,91 +196,147 @@ export function useMultiSelect(
/** on enter key press make cell editable */
case 'Enter':
e.preventDefault()
clearRangeRows()
makeEditable(unref(data)[selected.row], unref(fields)[selected.col])
selectedRange.clear()
makeEditable(unref(data)[selectedCell.row], unref(fields)[selectedCell.col])
break
/** on delete key press clear cell */
case 'Delete':
e.preventDefault()
clearRangeRows()
await clearCell(selected as { row: number; col: number })
selectedRange.clear()
await clearCell(selectedCell as { row: number; col: number })
break
/** on arrow key press navigate through cells */
case 'ArrowRight':
e.preventDefault()
clearRangeRows()
if (selected.col < unref(columnLength) - 1) {
selected.col++
selectedRange.clear()
if (selectedCell.col < unref(columnLength) - 1) {
selectedCell.col++
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowLeft':
clearRangeRows()
selectedRange.clear()
e.preventDefault()
clearRangeRows()
if (selected.col > 0) {
selected.col--
if (selectedCell.col > 0) {
selectedCell.col--
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowUp':
clearRangeRows()
selectedRange.clear()
e.preventDefault()
clearRangeRows()
if (selected.row > 0) {
selected.row--
if (selectedCell.row > 0) {
selectedCell.row--
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowDown':
clearRangeRows()
selectedRange.clear()
e.preventDefault()
clearRangeRows()
if (selected.row < unref(data).length - 1) {
selected.row++
if (selectedCell.row < unref(data).length - 1) {
selectedCell.row++
scrollToActiveCell?.()
editEnabled.value = false
}
break
default:
{
const rowObj = unref(data)[selected.row]
const columnObj = unref(fields)[selected.col]
let cptext = '' // variable for save the text to be copy
if (!isNaN(rangeRows.minRow) && !isNaN(rangeRows.maxRow) && !isNaN(rangeRows.minCol) && !isNaN(rangeRows.maxCol)) {
const cprows = unref(data).slice(rangeRows.minRow, rangeRows.maxRow + 1) // slice the selected rows for copy
const cpcols = unref(fields).slice(rangeRows.minCol, rangeRows.maxCol + 1) // slice the selected cols for copy
cprows.forEach((row) => {
cpcols.forEach((col) => {
// todo: JSON stringify the attachment cell and LTAR contents for copy
// filter attachment cells and LATR cells from copy
if (col.uidt !== UITypes.Attachment && col.uidt !== UITypes.LinkToAnotherRecord) {
cptext = `${cptext} ${row.row[col.title]} \t`
}
})
cptext = `${cptext.trim()}\n`
})
cptext.trim()
} else {
cptext = rowObj.row[columnObj.title] || ''
}
if ((!unref(editEnabled) && e.metaKey) || e.ctrlKey) {
const rowObj = unref(data)[selectedCell.row]
const columnObj = unref(fields)[selectedCell.col]
if (
(!unref(editEnabled) ||
[
UITypes.DateTime,
UITypes.Date,
UITypes.Year,
UITypes.Time,
UITypes.Lookup,
UITypes.Rollup,
UITypes.Formula,
UITypes.Attachment,
UITypes.Checkbox,
UITypes.Rating,
].includes(columnObj.uidt as UITypes)) &&
(isMac() ? e.metaKey : e.ctrlKey)
) {
switch (e.keyCode) {
// copy - ctrl/cmd +c
case 67:
await copy(cptext)
// set clipboard context only if single cell selected
if (rowObj.row[columnObj.title!]) {
clipboardContext = {
value: rowObj.row[columnObj.title!],
uidt: columnObj.uidt as UITypes,
}
} else {
clipboardContext = null
}
await copyValue()
break
case 86:
try {
// handle belongs to column
if (
columnObj.uidt === UITypes.LinkToAnotherRecord &&
(columnObj.colOptions as LinkToAnotherRecordType)?.type === RelationTypes.BELONGS_TO
) {
if (!clipboardContext || typeof clipboardContext.value !== 'object') {
return message.info('Invalid data')
}
rowObj.row[columnObj.title!] = convertCellData(
{
value: clipboardContext.value,
from: clipboardContext.uidt,
to: columnObj.uidt as UITypes,
},
isMysql.value,
)
e.preventDefault()
const foreignKeyColumn = meta.value?.columns?.find(
(column: ColumnType) => column.id === (columnObj.colOptions as LinkToAnotherRecordType)?.fk_child_column_id,
)
const relatedTableMeta = await getMeta((columnObj.colOptions as LinkToAnotherRecordType).fk_related_model_id!)
if (!foreignKeyColumn) return
rowObj.row[foreignKeyColumn.title!] = extractPkFromRow(
clipboardContext.value,
(relatedTableMeta as any)!.columns!,
)
return await syncCellData?.({ ...selectedCell, updatedColumnTitle: foreignKeyColumn.title })
}
// if it's a virtual column excluding belongs to cell type skip paste
if (isVirtualCol(columnObj)) {
return message.info(t('msg.info.pasteNotSupported'))
}
if (clipboardContext) {
rowObj.row[columnObj.title!] = convertCellData(
{
value: clipboardContext.value,
from: clipboardContext.uidt,
to: columnObj.uidt as UITypes,
},
isMysql.value,
)
e.preventDefault()
syncCellData?.(selectedCell)
} else {
clearCell(selectedCell as { row: number; col: number }, true)
makeEditable(rowObj, columnObj)
}
} catch (error: any) {
message.error(await extractSdkResponseErrorMsg(error))
}
}
}
@ -277,7 +350,7 @@ export function useMultiSelect(
// Update not allowed for table which doesn't have primary Key
return message.info(t('msg.info.updateNotAllowedWithoutPK'))
}
if (makeEditable(rowObj, columnObj)) {
if (makeEditable(rowObj, columnObj) && columnObj.title) {
rowObj.row[columnObj.title] = ''
}
// editEnabled = true
@ -291,12 +364,11 @@ export function useMultiSelect(
return {
selectCell,
selectBlock,
selectedRange,
clearRangeRows,
startSelectRange,
selected,
selectedRows,
rangeRows,
endSelectRange,
clearSelectedRange: selectedRange.clear.bind(selectedRange),
copyValue,
isCellSelected,
selectedCell,
}
}

6
packages/nc-gui/composables/useSharedView.ts

@ -36,7 +36,11 @@ export function useSharedView() {
(meta.value as TableType)?.columns
?.filter(
(f: Record<string, any>) =>
f.show && f.uidt !== UITypes.Rollup && f.uidt !== UITypes.Lookup && f.uidt !== UITypes.Formula,
f.show &&
f.uidt !== UITypes.Rollup &&
f.uidt !== UITypes.Lookup &&
f.uidt !== UITypes.Formula &&
f.uidt !== UITypes.QrCode,
)
.sort((a: Record<string, any>, b: Record<string, any>) => a.order - b.order)
.map((c: Record<string, any>) => ({ ...c, required: !!(c.required || 0) })) ?? [],

21
packages/nc-gui/composables/useShowNotEditableWarning.ts

@ -0,0 +1,21 @@
const timeout = 3000 // in ms
export default function useShowNotEditableWarning() {
const showEditNonEditableFieldWarning = refAutoReset(false, timeout)
const showClearNonEditableFieldWarning = refAutoReset(false, timeout)
const activateShowEditNonEditableFieldWarning = () => (showEditNonEditableFieldWarning.value = true)
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
showEditNonEditableFieldWarning.value = true
break
case 'Delete':
showClearNonEditableFieldWarning.value = true
break
}
})
return { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activateShowEditNonEditableFieldWarning }
}

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

@ -1,7 +1,8 @@
import { ViewTypes } from 'nocodb-sdk'
import type { FilterType, KanbanType, SortType, TableType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { computed, ref, unref, useFieldQuery, useInjectionState, useNuxtApp, useProject } from '#imports'
import { computed, ref, unref, useEventBus, useFieldQuery, useInjectionState, useNuxtApp, useProject } from '#imports'
import type { SmartsheetStoreEvents } from '~/lib'
const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
(
@ -19,6 +20,8 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const { search } = useFieldQuery(view)
const eventBus = useEventBus<SmartsheetStoreEvents>(Symbol('SmartsheetStore'))
// getters
const isLocked = computed(() => view.value?.lock_type === 'locked')
const isPkAvail = computed(() => (meta.value as TableType)?.columns?.some((c) => c.pk))
@ -63,6 +66,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
sorts,
nestedFilters,
isSqlView,
eventBus,
}
},
'smartsheet-store',

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

@ -1,5 +1,5 @@
import { UITypes, ViewTypes } from 'nocodb-sdk'
import type { Api, ColumnType, FormType, GalleryType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import type { Api, ColumnType, FormColumnType, FormType, GalleryType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue'
import {
IsPublicInj,
@ -54,7 +54,7 @@ export function useViewData(
const galleryData = ref<GalleryType>()
const formColumnData = ref<FormType>()
const formColumnData = ref<Record<string, any>[]>()
const formViewData = ref<FormType>()
@ -289,7 +289,13 @@ export function useViewData(
Object.assign(
toUpdate.row,
metaValue!.columns!.reduce<Record<string, any>>((acc: Record<string, any>, col: ColumnType) => {
if (col.uidt === UITypes.Formula || col.uidt === UITypes.Rollup || col.au || col.cdf?.includes(' on update '))
if (
col.uidt === UITypes.Formula ||
col.uidt === UITypes.QrCode ||
col.uidt === UITypes.Rollup ||
col.au ||
col.cdf?.includes(' on update ')
)
acc[col.title!] = updatedRowData[col.title!]
return acc
}, {} as Record<string, any>),
@ -412,14 +418,14 @@ export function useViewData(
async function loadFormView() {
if (!viewMeta?.value?.id) return
try {
const { columns, ...view } = (await $api.dbView.formRead(viewMeta.value.id)) as Record<string, any>
const { columns, ...view } = await $api.dbView.formRead(viewMeta.value.id)
const fieldById = columns.reduce(
const fieldById = (columns || []).reduce(
(o: Record<string, any>, f: Record<string, any>) => ({
...o,
[f.fk_column_id]: f,
}),
{},
{} as Record<string, FormColumnType>,
)
let order = 1

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

@ -26,7 +26,7 @@ export const ReloadViewDataHookInj: InjectionKey<EventHook<boolean | void>> = Sy
export const ReloadViewMetaHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-meta-injection')
export const ReloadRowDataHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-row-data-injection')
export const OpenNewRecordFormHookInj: InjectionKey<EventHook<void>> = Symbol('open-new-record-form-injection')
export const FieldsInj: InjectionKey<Ref<any[]>> = Symbol('fields-injection')
export const FieldsInj: InjectionKey<Ref<ColumnType[]>> = Symbol('fields-injection')
export const EditModeInj: InjectionKey<Ref<boolean>> = Symbol('edit-mode-injection')
export const SharedViewPasswordInj: InjectionKey<Ref<string | null>> = Symbol('shared-view-password-injection')
export const CellUrlDisableOverlayInj: InjectionKey<Ref<boolean>> = Symbol('cell-url-disable-url')

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

@ -241,6 +241,8 @@
"created": "إنشاء",
"sqlOutput": "إخراج SQL",
"addOption": "إضافة خيار",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "وظيفة التجميع",
"dbCreateIfNotExists": "قاعدة البيانات: إنشاء إذا لم يكن موجودا",
"clientKey": "مفتاح العميل",
@ -487,6 +489,12 @@
"selectField": "حدد حقل"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
@ -666,7 +674,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "تصدير البيانات الوصفية للمشروع بنجاح",
@ -686,6 +695,7 @@
"futureRelease": "قريبا!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "تم تحديث ACL واجهة المستخدم للجداول بنجاح",
"pluginUninstalled": "تم إلغاء تثبيت الإضافة بنجاح",
"pluginSettingsSaved": "تم حفظ إعدادات الإضافة بنجاح",

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

@ -241,6 +241,8 @@
"created": "তি",
"sqlOutput": "এসকিউএল আউটপট",
"addOption": "বিকলপ যগ করন",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "সমগিক ফশন",
"dbCreateIfNotExists": "ডস: উপসিত নকলি করন",
"clientKey": "কট ক",
@ -487,6 +489,12 @@
"selectField": "Select field"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
@ -666,7 +674,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "পরকলপ ম সফলভ রফতি কর",
@ -686,6 +695,7 @@
"futureRelease": "শরই আসছ!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",

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

@ -241,6 +241,8 @@
"created": "Oprettet",
"sqlOutput": "SQL Output.",
"addOption": "Tilføj option",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "Aggregate Function.",
"dbCreateIfNotExists": "DATABASE: Opret, hvis ikke eksisterer",
"clientKey": "Klientnøgle",
@ -487,6 +489,12 @@
"selectField": "Select field"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
@ -666,7 +674,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "Project Metadata eksporteres med succes",
@ -686,6 +695,7 @@
"futureRelease": "Kommer snart!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",

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

@ -241,6 +241,8 @@
"created": "Erstellt",
"sqlOutput": "SQL-Ausgabe",
"addOption": "Option hinzufügen",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "Globale Funktion",
"dbCreateIfNotExists": "Datenbank: Erstellen, falls nicht vorhanden",
"clientKey": "Client-Schlüssel",
@ -487,6 +489,12 @@
"selectField": "Select field"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
@ -666,7 +674,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "Projektmetadaten erfolgreich exportiert",
@ -686,6 +695,7 @@
"futureRelease": "Kommt bald!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin erfolgreich deinstalliert",
"pluginSettingsSaved": "Plugin-Einstellungen erfolgreich gespeichert",

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

@ -69,7 +69,13 @@
"betaNote": "This feature is currently in beta.",
"moreInfo": "More information can be found here",
"logs": "Logs",
"groupingField": "Grouping Field"
"groupingField": "Grouping Field",
"duplicate": "Duplicate",
"insertAfter": "Insert After",
"insertBefore": "Insert Before",
"hideField": "Hide Field",
"sortAsc": "Sort Ascending",
"sortDesc": "Sort Descending"
},
"objects": {
"project": "Project",
@ -199,7 +205,8 @@
"quickImportFrom": "Quick Import From",
"quickImport": "Quick Import",
"advancedSettings": "Advanced Settings",
"codeSnippet": "Code Snippet"
"codeSnippet": "Code Snippet",
"keyboardShortcut": "Keyboard Shortcuts"
},
"labels": {
"createdBy": "Created By",
@ -241,6 +248,8 @@
"created": "Created",
"sqlOutput": "SQL Output",
"addOption": "Add option",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "Aggregate function",
"dbCreateIfNotExists": "Database : create if not exists",
"clientKey": "Client Key",
@ -487,7 +496,14 @@
"selectField": "Select field"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"pasteNotSupported": "Paste operation is not supported on the active cell",
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
"orgViewer": "Viewer is not allowed to create new projects but they can access any invited project."
@ -605,7 +621,9 @@
"deleteViewConfirmation": "Are you sure you want to delete this view?",
"deleteTableConfirmation": "Do you want to delete the table",
"showM2mTables": "Show M2M Tables",
"deleteKanbanStackConfirmation": "Deleting this stack will also remove the select option `{stackToBeDeleted}` from the `{groupingField}`. The records will move to the uncategorized stack."
"deleteKanbanStackConfirmation": "Deleting this stack will also remove the select option `{stackToBeDeleted}` from the `{groupingField}`. The records will move to the uncategorized stack.",
"computedFieldEditWarning": "Computed field: contents are read-only. Use column edit menu to reconfigure",
"computedFieldDeleteWarning": "Computed field: contents are read-only. Unable to clear content."
},
"error": {
"searchProject": "Your search for {search} found no results",
@ -666,7 +684,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "Project metadata exported successfully",
@ -686,6 +705,7 @@
"futureRelease": "Coming soon!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",

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

@ -241,6 +241,8 @@
"created": "Creado",
"sqlOutput": "Salida SQL",
"addOption": "Añadir opción",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "Función agregada",
"dbCreateIfNotExists": "Base de datos : Crear si no existe",
"clientKey": "Clave de Cliente",
@ -487,6 +489,12 @@
"selectField": "Select field"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
@ -666,7 +674,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "Metadatos del proyecto exportados con éxito.",
@ -686,6 +695,7 @@
"futureRelease": "¡Próximamente!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",

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

@ -241,6 +241,8 @@
"created": "ایجاد شده",
"sqlOutput": "خروجی SQL",
"addOption": "افزودن گزینه",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "تابع جمع",
"dbCreateIfNotExists": "پایگاه داده: ایجاد در صورت عدم وجود",
"clientKey": "کلید Client",
@ -487,6 +489,12 @@
"selectField": "Select field"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
@ -666,7 +674,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "فراداده پروژه با موفقیت خارج شد",
@ -686,6 +695,7 @@
"futureRelease": "به زودی!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",

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

@ -241,6 +241,8 @@
"created": "Luotu",
"sqlOutput": "SQL-lähtö",
"addOption": "Lisää vaihtoehto",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "Kokonaistoiminto",
"dbCreateIfNotExists": "Tietokanta: Luo jos ei ole olemassa",
"clientKey": "Asiakasnäppäin",
@ -487,6 +489,12 @@
"selectField": "Select field"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
@ -666,7 +674,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "Project Metadata viedään onnistuneesti",
@ -686,6 +695,7 @@
"futureRelease": "Tulossa pian!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",

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

@ -241,6 +241,8 @@
"created": "Créé",
"sqlOutput": "Sortie SQL",
"addOption": "Ajouter une option",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "Fonction agrégée",
"dbCreateIfNotExists": "Base de données : la créer si elle n'existe pas",
"clientKey": "Clé client",
@ -487,6 +489,12 @@
"selectField": "Select field"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
@ -666,7 +674,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "Les métadonnées de projet sont exportées avec succès",
@ -686,6 +695,7 @@
"futureRelease": "Bientôt disponible !"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",

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

@ -241,6 +241,8 @@
"created": "נוצר",
"sqlOutput": "פלט SQL",
"addOption": "הוסף אפשרות",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "פונקציה מצטברת",
"dbCreateIfNotExists": "מסד נתונים: צור אם לא קיים",
"clientKey": "מפתח הלקוח",
@ -487,6 +489,12 @@
"selectField": "Select field"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
@ -666,7 +674,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "פרויקט Metadata מיוצא בהצלחה",
@ -686,6 +695,7 @@
"futureRelease": "בקרוב!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",

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

@ -241,6 +241,8 @@
"created": "बन",
"sqlOutput": "SQL आउटपट",
"addOption": "विकलप ज",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "कल समह",
"dbCreateIfNotExists": "डस: बन यदिद नह",
"clientKey": "गहक क",
@ -487,6 +489,12 @@
"selectField": "Select field"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
@ -666,7 +674,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "परिजन सफलतवक नित क गई",
@ -686,6 +695,7 @@
"futureRelease": "जलद आ रह!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",

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

@ -241,6 +241,8 @@
"created": "Stvoren",
"sqlOutput": "SQL izlaz",
"addOption": "Dodajte opciju",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "Agregatna funkcija",
"dbCreateIfNotExists": "Baza podataka: stvoriti ako ne postoji",
"clientKey": "Ključ klijenta",
@ -487,6 +489,12 @@
"selectField": "Select field"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
@ -666,7 +674,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "Projektni metapodaci su uspješno izvozili",
@ -686,6 +695,7 @@
"futureRelease": "Dolazi uskoro!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",

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

@ -241,6 +241,8 @@
"created": "Dibuat.",
"sqlOutput": "SQL Output",
"addOption": "Tambahkan opsi",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "Fungsi agregat.",
"dbCreateIfNotExists": "Basis Data: Buat jika tidak ada",
"clientKey": "Kunci klien",
@ -487,6 +489,12 @@
"selectField": "Select field"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
@ -666,7 +674,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "Metadata proyek berhasil diekspor",
@ -686,6 +695,7 @@
"futureRelease": "Segera akan datang!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",

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

@ -241,6 +241,8 @@
"created": "Creato",
"sqlOutput": "Output SQL",
"addOption": "Aggiungi opzione",
"qrCodeValueColumn": "Column with QR code value",
"qrCodeValueTooLong": "Too many characters for a QR code",
"aggregateFunction": "Aggrega funzione",
"dbCreateIfNotExists": "Database: crea se non esiste",
"clientKey": "Chiave client",
@ -487,6 +489,12 @@
"selectField": "Select field"
},
"msg": {
"warning": {
"nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed."
}
},
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
@ -666,7 +674,8 @@
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
"projectNotAccessible": "Project not accessible",
"copyToClipboardError": "Failed to copy to clipboard"
},
"toast": {
"exportMetadata": "Metadati del progetto esportati con successo",
@ -686,6 +695,7 @@
"futureRelease": "Prossimamente!"
},
"success": {
"columnDuplicated": "Column duplicated successfully",
"updatedUIACL": "Updated UI ACL for tables successfully",
"pluginUninstalled": "Plugin uninstalled successfully",
"pluginSettingsSaved": "Plugin settings saved successfully",

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

Loading…
Cancel
Save