Browse Source

Merge branch 'develop' into qr-prototyping-cleanedup

pull/4142/head
Daniel Spaude 2 years ago
parent
commit
7174401fc8
No known key found for this signature in database
GPG Key ID: 654A3D1FA4F35FFE
  1. 52
      .github/workflows/playwright-test-workflow.yml
  2. 2
      package.json
  3. BIN
      packages/nc-gui/assets/img/brand/Transparent.png
  4. BIN
      packages/nc-gui/assets/img/brand/favicon-128.png
  5. BIN
      packages/nc-gui/assets/img/brand/favicon-16.png
  6. BIN
      packages/nc-gui/assets/img/brand/favicon-32.png
  7. BIN
      packages/nc-gui/assets/img/brand/favicon-64.png
  8. BIN
      packages/nc-gui/assets/img/brand/full-logo.png
  9. BIN
      packages/nc-gui/assets/img/brand/text.png
  10. BIN
      packages/nc-gui/assets/img/icon.png
  11. BIN
      packages/nc-gui/assets/img/icons/256.png
  12. 9
      packages/nc-gui/components.d.ts
  13. 8
      packages/nc-gui/components/account/AppStore.vue
  14. 45
      packages/nc-gui/components/account/License.vue
  15. 146
      packages/nc-gui/components/account/ResetPassword.vue
  16. 56
      packages/nc-gui/components/account/SignupSettings.vue
  17. 262
      packages/nc-gui/components/account/Token.vue
  18. 281
      packages/nc-gui/components/account/UserList.vue
  19. 256
      packages/nc-gui/components/account/UsersModal.vue
  20. 21
      packages/nc-gui/components/cell/Checkbox.vue
  21. 5
      packages/nc-gui/components/cell/Currency.vue
  22. 37
      packages/nc-gui/components/cell/DatePicker.vue
  23. 27
      packages/nc-gui/components/cell/DateTimePicker.vue
  24. 5
      packages/nc-gui/components/cell/Decimal.vue
  25. 5
      packages/nc-gui/components/cell/Duration.vue
  26. 13
      packages/nc-gui/components/cell/Email.vue
  27. 5
      packages/nc-gui/components/cell/Float.vue
  28. 5
      packages/nc-gui/components/cell/Integer.vue
  29. 47
      packages/nc-gui/components/cell/MultiSelect.vue
  30. 5
      packages/nc-gui/components/cell/Percent.vue
  31. 19
      packages/nc-gui/components/cell/Rating.vue
  32. 39
      packages/nc-gui/components/cell/SingleSelect.vue
  33. 5
      packages/nc-gui/components/cell/Text.vue
  34. 5
      packages/nc-gui/components/cell/TextArea.vue
  35. 27
      packages/nc-gui/components/cell/TimePicker.vue
  36. 5
      packages/nc-gui/components/cell/Url.vue
  37. 29
      packages/nc-gui/components/cell/YearPicker.vue
  38. 21
      packages/nc-gui/components/cell/attachment/index.vue
  39. 8
      packages/nc-gui/components/dashboard/TreeView.vue
  40. 2
      packages/nc-gui/components/dashboard/settings/AuditTab.vue
  41. 4
      packages/nc-gui/components/dashboard/settings/Modal.vue
  42. 32
      packages/nc-gui/components/dlg/TableCreate.vue
  43. 2
      packages/nc-gui/components/general/TruncateText.vue
  44. 19
      packages/nc-gui/components/general/language/Menu.vue
  45. 1
      packages/nc-gui/components/smartsheet/Cell.vue
  46. 46
      packages/nc-gui/components/smartsheet/Form.vue
  47. 4
      packages/nc-gui/components/smartsheet/Gallery.vue
  48. 190
      packages/nc-gui/components/smartsheet/Grid.vue
  49. 2
      packages/nc-gui/components/smartsheet/Kanban.vue
  50. 2
      packages/nc-gui/components/smartsheet/Pagination.vue
  51. 2
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  52. 6
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  53. 8
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  54. 3
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  55. 8
      packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue
  56. 2
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue
  57. 4
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  58. 49
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  59. 2
      packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue
  60. 2
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  61. 2
      packages/nc-gui/components/tabs/auth/user-management/ShareBase.vue
  62. 10
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  63. 36
      packages/nc-gui/components/virtual-cell/Formula.vue
  64. 10
      packages/nc-gui/components/virtual-cell/HasMany.vue
  65. 95
      packages/nc-gui/components/virtual-cell/Lookup.vue
  66. 11
      packages/nc-gui/components/virtual-cell/ManyToMany.vue
  67. 37
      packages/nc-gui/components/virtual-cell/Rollup.vue
  68. 13
      packages/nc-gui/components/virtual-cell/components/ListChildItems.vue
  69. 57
      packages/nc-gui/components/virtual-cell/components/ListItems.vue
  70. 4
      packages/nc-gui/composables/useColumnCreateStore.ts
  71. 29
      packages/nc-gui/composables/useMultiSelect/index.ts
  72. 21
      packages/nc-gui/composables/useSelectedCellKeyupListener/index.ts
  73. 1
      packages/nc-gui/composables/useTable.ts
  74. 1
      packages/nc-gui/lang/bn_IN.json
  75. 24
      packages/nc-gui/lang/en.json
  76. 42
      packages/nc-gui/layouts/base.vue
  77. 2
      packages/nc-gui/layouts/default.vue
  78. 2
      packages/nc-gui/layouts/shared-view.vue
  79. 8
      packages/nc-gui/lib/constants.ts
  80. 3
      packages/nc-gui/lib/enums.ts
  81. 1
      packages/nc-gui/lib/types.ts
  82. 2
      packages/nc-gui/middleware/auth.global.ts
  83. 43
      packages/nc-gui/pages/[projectType]/[projectId]/index.vue
  84. 6
      packages/nc-gui/pages/[projectType]/[projectId]/index/index.vue
  85. 8
      packages/nc-gui/pages/[projectType]/form/[viewId].vue
  86. 6
      packages/nc-gui/pages/[projectType]/form/[viewId]/index/index.vue
  87. 43
      packages/nc-gui/pages/[projectType]/form/[viewId]/index/survey.vue
  88. 112
      packages/nc-gui/pages/account/index.vue
  89. 6
      packages/nc-gui/pages/account/index/[page].vue
  90. 5
      packages/nc-gui/pages/account/index/users.vue
  91. 22
      packages/nc-gui/pages/account/index/users/[[nestedPage]].vue
  92. 7
      packages/nc-gui/pages/index/index.vue
  93. 6
      packages/nc-gui/pages/index/index/index.vue
  94. 12
      packages/nc-gui/pages/index/index/user.vue
  95. 15
      packages/nc-gui/pages/signin.vue
  96. 6
      packages/nc-gui/pages/signup/[[token]].vue
  97. 5
      packages/nc-gui/plugins/a.i18n.ts
  98. 2
      packages/nc-gui/plugins/tele.ts
  99. 79
      packages/nc-gui/tests/playwright/README.md
  100. 2
      packages/nc-gui/utils/browserUtils.ts
  101. Some files were not shown because too many files have changed in this diff Show More

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

@ -14,6 +14,7 @@ on:
jobs: jobs:
playwright: playwright:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
timeout-minutes: 30
steps: steps:
# Reference: https://github.com/pierotofy/set-swap-space/blob/master/action.yml # Reference: https://github.com/pierotofy/set-swap-space/blob/master/action.yml
- name: Set 5gb swap - name: Set 5gb swap
@ -55,34 +56,36 @@ jobs:
- name: setup mysql - name: setup mysql
if: ${{ inputs.db == 'mysql' }} if: ${{ inputs.db == 'mysql' }}
working-directory: ./ working-directory: ./
run: docker-compose -f ./packages/nc-gui/tests/playwright/scripts/docker-compose-mysql-playwright.yml up -d & run: docker-compose -f ./tests/playwright/scripts/docker-compose-mysql-playwright.yml up -d &
- name: setup pg - name: setup pg
if: ${{ inputs.db == 'pg' }} if: ${{ inputs.db == 'pg' }}
working-directory: ./ working-directory: ./
run: docker-compose -f ./packages/nc-gui/tests/playwright/scripts/docker-compose-playwright-pg.yml up -d & run: docker-compose -f ./tests/playwright/scripts/docker-compose-playwright-pg.yml up -d &
- name: setup pg for quick tests - name: setup pg for quick tests
if: ${{ inputs.db == 'sqlite' && inputs.shard == '1' }} if: ${{ inputs.db == 'sqlite' && inputs.shard == '1' }}
working-directory: ./ working-directory: ./
run: docker-compose -f ./packages/nc-gui/tests/playwright/scripts/docker-compose-pg-pw-quick.yml up -d & run: docker-compose -f ./tests/playwright/scripts/docker-compose-pg-pw-quick.yml up -d &
- name: run frontend - name: run frontend
working-directory: ./packages/nc-gui working-directory: ./packages/nc-gui
run: npm run ci:run run: npm run ci:run
- name: Run backend - name: Run backend
working-directory: ./packages/nocodb working-directory: ./packages/nocodb
run: npm run ci:run & run: |
npm install
npm run watch:run:playwright > ${{ inputs.db }}_${{ inputs.shard }}_test_backend.log &
- name: Cache playwright npm modules - name: Cache playwright npm modules
uses: actions/cache@v3 uses: actions/cache@v3
id: playwright-cache id: playwright-cache
with: with:
path: | path: |
**/playwright/node_modules **/tests/playwright/node_modules
key: cache-nc-playwright-${{ hashFiles('**/playwright/package-lock.json') }} key: cache-nc-playwright-${{ hashFiles('**/tests/playwright/package-lock.json') }}
- name: Install dependencies - name: Install dependencies
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: ./packages/nc-gui/tests/playwright working-directory: ./tests/playwright
run: npm install run: npm install
- name: Install Playwright Browsers - name: Install Playwright Browsers
working-directory: ./packages/nc-gui/tests/playwright working-directory: ./tests/playwright
run: npx playwright install chromium --with-deps run: npx playwright install chromium --with-deps
- name: Wait for backend - name: Wait for backend
run: | run: |
@ -92,7 +95,7 @@ jobs:
done done
- name: Run Playwright tests - name: Run Playwright tests
working-directory: ./packages/nc-gui/tests/playwright working-directory: ./tests/playwright
run: E2E_DB_TYPE=${{ inputs.db }} npm run ci:test:shard:${{ inputs.shard }} run: E2E_DB_TYPE=${{ inputs.db }} npm run ci:test:shard:${{ inputs.shard }}
# Quick tests (pg on sqlite shard 0 and sqlite on sqlite shard 1) # Quick tests (pg on sqlite shard 0 and sqlite on sqlite shard 1)
@ -102,26 +105,43 @@ jobs:
run: | run: |
kill -9 $(lsof -t -i:8080) kill -9 $(lsof -t -i:8080)
npm run watch:run:playwright:pg:cyquick & npm run watch:run:playwright:pg:cyquick &
cd ../nc-gui/tests/playwright
npm run test:quick
- name: Run quick server and tests (sqlite) - name: Run quick server and tests (sqlite)
if: ${{ inputs.db == 'sqlite' && inputs.shard == '2' }} if: ${{ inputs.db == 'sqlite' && inputs.shard == '2' }}
working-directory: ./packages/nocodb working-directory: ./packages/nocodb
run: | run: |
kill -9 $(lsof -t -i:8080) kill -9 $(lsof -t -i:8080)
npm run watch:run:playwright:quick & npm run watch:run:playwright:quick > quick_${{ inputs.shard }}_test_backend.log &
cd ../nc-gui/tests/playwright - name: Wait for backend & run quick tests
npm run test:quick if: ${{ inputs.db == 'sqlite' }}
working-directory: ./tests/playwright
run: |
while ! curl --output /dev/null --silent --head --fail http://localhost:8080; do
printf '.'
sleep 2
done
PLAYWRIGHT_HTML_REPORT=playwright-report-quick npm run test:quick
- uses: actions/upload-artifact@v3
if: ${{ inputs.db == 'sqlite' }}
with:
name: quick-backend-log-${{ inputs.shard }}
path: ./packages/nocodb/quick_${{ inputs.shard }}_test_backend.log
retention-days: 2
- uses: actions/upload-artifact@v3
if: ${{ inputs.db == 'sqlite' }}
with:
name: playwright-report-quick-${{ inputs.shard }}
path: ./tests/playwright/playwright-report-quick/
retention-days: 2
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: playwright-report-${{ inputs.db }}-${{ inputs.shard }} name: playwright-report-${{ inputs.db }}-${{ inputs.shard }}
path: ./packages/nc-gui/tests/playwright/playwright-report/ path: ./tests/playwright/playwright-report/
retention-days: 2 retention-days: 2
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: backend-logs-${{ inputs.db }}-${{ inputs.shard }} name: backend-logs-${{ inputs.db }}-${{ inputs.shard }}
path: ./packages/nocodb/mysql_test_backend.log path: ./packages/nocodb/${{ inputs.db }}_${{ inputs.shard }}_test_backend.log
retention-days: 2 retention-days: 2

2
package.json

@ -36,7 +36,7 @@
] ]
}, },
"scripts": { "scripts": {
"lint:staged:playwright": "cd packages/nc-gui/tests/playwright; npx lint-staged; cd -", "lint:staged:playwright": "cd ./tests/playwright; npx lint-staged; cd -",
"build:common": "cd ./packages/nocodb-sdk; npm install; npm run build", "build:common": "cd ./packages/nocodb-sdk; npm install; npm run build",
"install:common": "cd ./packages/nocodb; npm install ../nocodb-sdk; cd ../nc-gui; npm install ../nocodb-sdk", "install:common": "cd ./packages/nocodb; npm install ../nocodb-sdk; cd ../nc-gui; npm install ../nocodb-sdk",
"start:api": "npm run build:common ; cd ./packages/nocodb; npm install ../nocodb-sdk; npm install; NC_DISABLE_CACHE=true NC_DISABLE_TELE=true npm run watch:run:cypress", "start:api": "npm run build:common ; cd ./packages/nocodb; npm install ../nocodb-sdk; npm install; NC_DISABLE_CACHE=true NC_DISABLE_TELE=true npm run watch:run:cypress",

BIN
packages/nc-gui/assets/img/brand/Transparent.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

BIN
packages/nc-gui/assets/img/brand/favicon-128.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

BIN
packages/nc-gui/assets/img/brand/favicon-16.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

BIN
packages/nc-gui/assets/img/brand/favicon-32.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

BIN
packages/nc-gui/assets/img/brand/favicon-64.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

BIN
packages/nc-gui/assets/img/brand/full-logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
packages/nc-gui/assets/img/brand/text.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
packages/nc-gui/assets/img/icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

BIN
packages/nc-gui/assets/img/icons/256.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

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

@ -104,8 +104,11 @@ declare module '@vue/runtime-core' {
MaterialSymbolsWarning: typeof import('~icons/material-symbols/warning')['default'] MaterialSymbolsWarning: typeof import('~icons/material-symbols/warning')['default']
MdiAccount: typeof import('~icons/mdi/account')['default'] MdiAccount: typeof import('~icons/mdi/account')['default']
MdiAccountCircle: typeof import('~icons/mdi/account-circle')['default'] MdiAccountCircle: typeof import('~icons/mdi/account-circle')['default']
MdiAccountCircleOutline: typeof import('~icons/mdi/account-circle-outline')['default']
MdiAccountOutline: typeof import('~icons/mdi/account-outline')['default'] MdiAccountOutline: typeof import('~icons/mdi/account-outline')['default']
MdiAccountPlusOutline: typeof import('~icons/mdi/account-plus-outline')['default'] MdiAccountPlusOutline: typeof import('~icons/mdi/account-plus-outline')['default']
MdiAccountSupervisorOutline: typeof import('~icons/mdi/account-supervisor-outline')['default']
MdiAdd: typeof import('~icons/mdi/add')['default']
MdiAlpha: typeof import('~icons/mdi/alpha')['default'] MdiAlpha: typeof import('~icons/mdi/alpha')['default']
MdiAlphaA: typeof import('~icons/mdi/alpha-a')['default'] MdiAlphaA: typeof import('~icons/mdi/alpha-a')['default']
MdiApi: typeof import('~icons/mdi/api')['default'] MdiApi: typeof import('~icons/mdi/api')['default']
@ -128,6 +131,7 @@ declare module '@vue/runtime-core' {
MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default'] MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default']
MdiChevronRight: typeof import('~icons/mdi/chevron-right')['default'] MdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
MdiClipboard: typeof import('~icons/mdi/clipboard')['default']
MdiClose: typeof import('~icons/mdi/close')['default'] MdiClose: typeof import('~icons/mdi/close')['default']
MdiCloseBox: typeof import('~icons/mdi/close-box')['default'] MdiCloseBox: typeof import('~icons/mdi/close-box')['default']
MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default'] MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']
@ -138,12 +142,14 @@ declare module '@vue/runtime-core' {
MdiCommentTextOutline: typeof import('~icons/mdi/comment-text-outline')['default'] MdiCommentTextOutline: typeof import('~icons/mdi/comment-text-outline')['default']
MdiContentCopy: typeof import('~icons/mdi/content-copy')['default'] MdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
MdiContentSave: typeof import('~icons/mdi/content-save')['default'] MdiContentSave: typeof import('~icons/mdi/content-save')['default']
MdiCopy: typeof import('~icons/mdi/copy')['default']
MdiCurrencyUsd: typeof import('~icons/mdi/currency-usd')['default'] MdiCurrencyUsd: typeof import('~icons/mdi/currency-usd')['default']
MdiDatabaseOutline: typeof import('~icons/mdi/database-outline')['default'] MdiDatabaseOutline: typeof import('~icons/mdi/database-outline')['default']
MdiDatabaseSync: typeof import('~icons/mdi/database-sync')['default'] MdiDatabaseSync: typeof import('~icons/mdi/database-sync')['default']
MdiDelete: typeof import('~icons/mdi/delete')['default'] MdiDelete: typeof import('~icons/mdi/delete')['default']
MdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default'] MdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
MdiDiscord: typeof import('~icons/mdi/discord')['default'] MdiDiscord: typeof import('~icons/mdi/discord')['default']
MdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
MdiDotsVertical: typeof import('~icons/mdi/dots-vertical')['default'] MdiDotsVertical: typeof import('~icons/mdi/dots-vertical')['default']
MdiDownload: typeof import('~icons/mdi/download')['default'] MdiDownload: typeof import('~icons/mdi/download')['default']
MdiDownloadOutline: typeof import('~icons/mdi/download-outline')['default'] MdiDownloadOutline: typeof import('~icons/mdi/download-outline')['default']
@ -174,6 +180,7 @@ declare module '@vue/runtime-core' {
MdiInformation: typeof import('~icons/mdi/information')['default'] MdiInformation: typeof import('~icons/mdi/information')['default']
MdiJson: typeof import('~icons/mdi/json')['default'] MdiJson: typeof import('~icons/mdi/json')['default']
MdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default'] MdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
MdiKeyChainVariant: typeof import('~icons/mdi/key-chain-variant')['default']
MdiKeyChange: typeof import('~icons/mdi/key-change')['default'] MdiKeyChange: typeof import('~icons/mdi/key-change')['default']
MdiKeyStar: typeof import('~icons/mdi/key-star')['default'] MdiKeyStar: typeof import('~icons/mdi/key-star')['default']
MdiLink: typeof import('~icons/mdi/link')['default'] MdiLink: typeof import('~icons/mdi/link')['default']
@ -200,6 +207,8 @@ declare module '@vue/runtime-core' {
MdiRocketLaunchOutline: typeof import('~icons/mdi/rocket-launch-outline')['default'] MdiRocketLaunchOutline: typeof import('~icons/mdi/rocket-launch-outline')['default']
MdiScriptTextKeyOutline: typeof import('~icons/mdi/script-text-key-outline')['default'] MdiScriptTextKeyOutline: typeof import('~icons/mdi/script-text-key-outline')['default']
MdiScriptTextOutline: typeof import('~icons/mdi/script-text-outline')['default'] MdiScriptTextOutline: typeof import('~icons/mdi/script-text-outline')['default']
MdiShieldAccountOutline: typeof import('~icons/mdi/shield-account-outline')['default']
MdiShieldKeyOutline: typeof import('~icons/mdi/shield-key-outline')['default']
MdiSlack: typeof import('~icons/mdi/slack')['default'] MdiSlack: typeof import('~icons/mdi/slack')['default']
MdiSort: typeof import('~icons/mdi/sort')['default'] MdiSort: typeof import('~icons/mdi/sort')['default']
MdiStar: typeof import('~icons/mdi/star')['default'] MdiStar: typeof import('~icons/mdi/star')['default']

8
packages/nc-gui/components/account/AppStore.vue

@ -0,0 +1,8 @@
<template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">{{ $t('title.appStore') }}</div>
<div>
<LazyDashboardSettingsAppStore />
</div>
</div>
</template>

45
packages/nc-gui/components/account/License.vue

@ -0,0 +1,45 @@
<script lang="ts" setup>
import { useNuxtApp } from '#app'
import { message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, useApi } from '#imports'
const { api, isLoading } = useApi()
const {$e} = useNuxtApp()
let key = $ref('')
const loadLicense = async () => {
try {
const response = await api.orgLicense.get()
key = response.key
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const setLicense = async () => {
try {
await api.orgLicense.set({ key: key })
message.success('License key updated')
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:account:license')
}
loadLicense()
</script>
<template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">License</div>
<div>
<a-textarea v-model:value="key" placeholder="License key" class="!mt-2 !max-w-[600px]"></a-textarea>
</div>
<a-button class="mt-4" @click="setLicense" type="primary">Save license key</a-button>
</div>
</template>
<style scoped></style>

146
packages/nc-gui/components/account/ResetPassword.vue

@ -0,0 +1,146 @@
<script lang="ts" setup>
import { message, navigateTo, reactive, ref, useApi, useGlobal, useI18n } from '#imports'
const { api, error } = useApi({ useGlobalInstance: true })
const { t } = useI18n()
const { signOut } = useGlobal()
const formValidator = ref()
const form = reactive({
currentPassword: '',
password: '',
passwordRepeat: '',
})
const formRules = {
currentPassword: [
// Current password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
],
password: [
// Password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
{ min: 8, message: t('msg.error.signUpRules.passwdLength') },
],
passwordRepeat: [
// PasswordRepeat is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
// Passwords match
{
validator: (_: unknown, _v: string) => {
return new Promise((resolve, reject) => {
if (form.password === form.passwordRepeat) return resolve(true)
reject(new Error(t('msg.error.signUpRules.passwdMismatch')))
})
},
message: t('msg.error.signUpRules.passwdMismatch'),
},
],
}
const passwordChange = async () => {
const valid = formValidator.value.validate()
if (!valid) return
error.value = null
await api.auth.passwordChange({
currentPassword: form.currentPassword,
newPassword: form.password,
})
message.success(t('msg.success.passwordChanged'))
signOut()
navigateTo('/signin')
}
const resetError = () => {
if (error.value) error.value = null
}
</script>
<template>
<div class="mx-auto relative flex flex-col justify-center gap-2 w-full px-8 md:(bg-white) max-w-[900px]">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">{{ $t('activity.changePwd') }}</div>
<a-form
ref="formValidator"
data-testid="nc-user-settings-form"
layout="vertical"
class="change-password lg:max-w-3/4 w-full !mx-auto"
no-style
:model="form"
@finish="passwordChange"
>
<Transition name="layout">
<div v-if="error" class="mx-auto mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1">
<div data-testid="nc-user-settings-form__error" class="flex items-center gap-2 justify-center">
<MaterialSymbolsWarning />
{{ error }}
</div>
</div>
</Transition>
<a-form-item :label="$t('placeholder.password.current')" name="currentPassword" :rules="formRules.currentPassword">
<a-input-password
v-model:value="form.currentPassword"
data-testid="nc-user-settings-form__current-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.current')"
@focus="resetError"
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.new')" name="password" :rules="formRules.password">
<a-input-password
v-model:value="form.password"
data-testid="nc-user-settings-form__new-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.new')"
@focus="resetError"
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.confirm')" name="passwordRepeat" :rules="formRules.passwordRepeat">
<a-input-password
v-model:value="form.passwordRepeat"
data-testid="nc-user-settings-form__new-password-repeat"
size="large"
class="password"
:placeholder="$t('placeholder.password.confirm')"
@focus="resetError"
/>
</a-form-item>
<div class="text-center">
<button data-testid="nc-user-settings-form__submit" class="scaling-btn bg-opacity-100" type="submit">
<span class="flex items-center gap-2">
<MdiKeyChange />
{{ $t('activity.changePwd') }}
</span>
</button>
</div>
</a-form>
</div>
</template>
<style lang="scss">
.change-password {
.ant-input-affix-wrapper,
.ant-input {
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded;
}
.password {
input {
@apply !border-none !m-0;
}
}
}
</style>

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

@ -0,0 +1,56 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, useApi } from '#imports'
const { api } = useApi()
const { t } = useI18n()
let settings = $ref<{ invite_only_signup?: boolean }>({ invite_only_signup: false })
const loadSettings = async () => {
try {
const response = await api.orgAppSettings.get()
settings = response
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const saveSettings = async () => {
try {
await api.orgAppSettings.set(settings)
message.success(t('msg.success.settingsSaved'))
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
loadSettings()
</script>
<template>
<div data-testid="nc-app-settings">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">Settings</div>
<div class="flex justify-center">
<a-form-item>
<a-checkbox
v-model:checked="settings.invite_only_signup"
v-e="['c:account:enable-signup']"
class="nc-checkbox nc-invite-only-signup-checkbox"
name="virtual"
@change="saveSettings"
>
{{ $t('labels.inviteOnlySignup') }}
</a-checkbox>
</a-form-item>
</div>
</div>
</template>
<style scoped>
:deep(.ant-checkbox-wrapper) {
@apply !flex-row-reverse !flex !justify-start gap-4;
justify-content: start;
}
</style>

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

@ -0,0 +1,262 @@
<script lang="ts" setup>
import { Empty, Modal, message } from 'ant-design-vue'
import type { ApiTokenType, RequestParams, UserType } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, useApi, useCopy, useNuxtApp } from '#imports'
const { api, isLoading } = useApi()
const { $e } = useNuxtApp()
const { copy } = useCopy()
const { t } = useI18n()
let tokens = $ref<UserType[]>([])
let currentPage = $ref(1)
let showNewTokenModal = $ref(false)
const currentLimit = $ref(10)
let selectedTokenData = $ref<ApiTokenType>({})
const searchText = ref<string>('')
const pagination = reactive({
total: 0,
pageSize: 10,
})
const loadTokens = async (page = currentPage, limit = currentLimit) => {
currentPage = page
try {
const response: any = await api.orgTokens.list({
query: {
limit,
offset: searchText.value.length === 0 ? (page - 1) * limit : 0,
},
} as RequestParams)
if (!response) return
pagination.total = response.pageInfo.totalRows ?? 0
pagination.pageSize = 10
tokens = response.list as UserType[]
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
loadTokens()
const deleteToken = async (token: string) => {
Modal.confirm({
title: t('msg.info.deleteTokenConfirmation'),
type: 'warn',
onOk: async () => {
try {
// todo: delete token
await api.orgTokens.delete(token)
message.success(t('msg.success.tokenDeleted'))
await loadTokens()
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:account:token:delete')
},
})
}
const generateToken = async () => {
try {
await api.orgTokens.create(selectedTokenData)
showNewTokenModal = false
// Token generated successfully
message.success(t('msg.success.tokenGenerated'))
selectedTokenData = {}
await loadTokens()
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:api-token:generate')
}
const copyToken = (token: string | undefined) => {
if (!token) return
copy(token)
// Copied to clipboard
message.info(t('msg.info.copiedToClipboard'))
$e('c:api-token:copy')
}
const descriptionInput = (el) => {
el?.focus()
}
</script>
<template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">Token Management</div>
<div class="max-w-[900px] mx-auto p-4" data-testid="nc-token-list">
<div class="py-2 flex gap-4 items-center">
<div class="flex-grow"></div>
<MdiReload class="cursor-pointer" @click="loadTokens" />
<a-button data-testid="nc-token-create" size="small" type="primary" @click="showNewTokenModal = true">
<div class="flex items-center gap-1">
<MdiAdd />
Add new token
</div>
</a-button>
</div>
<a-table
:row-key="(record) => record.id"
:data-source="tokens"
:pagination="{ position: ['bottomCenter'] }"
:loading="isLoading"
size="small"
@change="loadTokens($event.current)"
>
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<!-- Created By -->
<a-table-column key="created_by" :title="$t('labels.createdBy')" data-index="created_by">
<template #default="{ text }">
<div v-if="text">
{{ text }}
</div>
<div v-else class="text-gray-400">N/A</div>
</template>
</a-table-column>
<!-- Description -->
<a-table-column key="description" :title="$t('labels.description')" data-index="description">
<template #default="{ text }">
{{ text }}
</template>
</a-table-column>
<!-- Token -->
<a-table-column key="token" :title="$t('labels.token')" data-index="token">
<template #default="{ text, record }">
<div class="w-[320px]">
<span v-if="record.show">{{ text }}</span>
<span v-else>*******************************************</span>
</div>
</template>
</a-table-column>
<!-- Actions -->
<a-table-column key="actions" :title="$t('labels.actions')" data-index="token">
<template #default="{ record }">
<div class="flex items-center gap-2">
<a-tooltip placement="bottom">
<template #title>
<span v-if="record.show"> {{ $t('general.hide') }} </span>
<span v-else> {{ $t('general.show') }} </span>
</template>
<a-button type="text" class="!rounded-md nc-toggle-token-visibility" @click="record.show = !record.show">
<template #icon>
<MaterialSymbolsVisibilityOff v-if="record.show" class="flex mx-auto h-[1.1rem]" />
<MaterialSymbolsVisibility v-else class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title> {{ $t('general.copy') }}</template>
<a-button type="text" class="!rounded-md" @click="copyToken(record.token)">
<template #icon>
<MdiContentCopy class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-dropdown
:trigger="['click']"
class="flex"
placement="bottomRight"
overlay-class-name="nc-dropdown-api-token-mgmt"
>
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<IcBaselineMoreVert class="nc-token-menu" />
</div>
</a-button>
</div>
<template #overlay>
<a-menu data-testid="nc-token-row-action-icon">
<a-menu-item>
<div class="flex flex-row items-center py-3 h-[1rem] nc-delete-token" @click="deleteToken(record.token)">
<MdiDeleteOutline class="flex" />
<div class="text-xs pl-2">{{ $t('general.remove') }}</div>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
</a-table-column>
</a-table>
</div>
<a-modal
v-model:visible="showNewTokenModal"
:closable="false"
width="28rem"
centered
:footer="null"
wrap-class-name="nc-modal-generate-token"
>
<div class="relative flex flex-col h-full">
<a-button type="text" class="!absolute top-0 right-0 rounded-md -mt-2 -mr-3" @click="showNewTokenModal = false">
<template #icon>
<MaterialSymbolsCloseRounded class="flex mx-auto" />
</template>
</a-button>
<!-- Generate Token -->
<div class="flex flex-row justify-center w-full -mt-1 mb-3">
<a-typography-title :level="5">{{ $t('title.generateToken') }}</a-typography-title>
</div>
<!-- Description -->
<a-form
ref="form"
:model="selectedTokenData"
name="basic"
layout="vertical"
class="flex flex-col justify-center space-y-6"
no-style
autocomplete="off"
@finish="generateToken"
>
<a-input
:ref="descriptionInput"
v-model:value="selectedTokenData.description"
data-testid="nc-token-modal-description"
:placeholder="$t('labels.description')"
/>
<!-- Generate -->
<div class="flex flex-row justify-center">
<a-button type="primary" html-type="submit" data-testid="nc-token-modal-save">
{{ $t('general.generate') }}
</a-button>
</div>
</a-form>
</div>
</a-modal>
</div>
</template>
<style scoped></style>

281
packages/nc-gui/components/account/UserList.vue

@ -0,0 +1,281 @@
<script lang="ts" setup>
import { Modal, message } from 'ant-design-vue'
import type { RequestParams, UserType } from 'nocodb-sdk'
import { Role, extractSdkResponseErrorMsg, useApi, useCopy, useDashboard, useNuxtApp } from '#imports'
import type { User } from '~/lib'
const { api, isLoading } = useApi()
const { $e } = useNuxtApp()
const { t } = useI18n()
const { dashboardUrl } = $(useDashboard())
const { copy } = useCopy()
let users = $ref<UserType[]>([])
let currentPage = $ref(1)
const currentLimit = $ref(10)
const showUserModal = ref(false)
const userMadalKey = ref(0)
const searchText = ref<string>('')
const pagination = reactive({
total: 0,
pageSize: 10,
})
const loadUsers = async (page = currentPage, limit = currentLimit) => {
currentPage = page
try {
const response: any = await api.orgUsers.list({
query: {
limit,
offset: searchText.value.length === 0 ? (page - 1) * limit : 0,
query: searchText.value,
},
} as RequestParams)
if (!response) return
pagination.total = response.pageInfo.totalRows ?? 0
pagination.pageSize = 10
users = response.list as UserType[]
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
loadUsers()
const updateRole = async (userId: string, roles: Role) => {
try {
await api.orgUsers.update(userId, {
roles,
} as unknown as UserType)
message.success(t('msg.success.roleUpdated'))
$e('a:org-user:role-updated', { role: roles })
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const deleteUser = async (userId: string) => {
Modal.confirm({
title: 'Are you sure you want to delete this user?',
type: 'warn',
content: 'On deleting, user will remove from organization and any sync source(Airtable) created by user will get removed',
onOk: async () => {
try {
await api.orgUsers.delete(userId)
message.success(t('msg.success.userDeleted'))
await loadUsers()
$e('a:org-user:user-deleted')
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
},
})
}
const resendInvite = async (user: User) => {
try {
await api.orgUsers.resendInvite(user.id)
// Invite email sent successfully
message.success(t('msg.success.inviteEmailSent'))
await loadUsers()
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:org-user:resend-invite')
}
const copyInviteUrl = (user: User) => {
if (!user.invite_token) return
copy(`${dashboardUrl}#/signup/${user.invite_token}`)
// Invite URL copied to clipboard
message.success(t('msg.success.inviteURLCopied'))
$e('c:user:copy-url')
}
const copyPasswordResetUrl = async (user: User) => {
try {
const { reset_password_url } = await api.orgUsers.generatePasswordResetToken(user.id)
copy(reset_password_url)
// Invite URL copied to clipboard
message.success(t('msg.success.passwordResetURLCopied'))
$e('c:user:copy-url')
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<div data-testid="nc-super-user-list">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">User Management</div>
<div class="max-w-[900px] mx-auto p-4">
<div class="py-2 flex gap-4 items-center">
<a-input-search
v-model:value="searchText"
size="small"
class="max-w-[300px]"
placeholder="Filter by email"
@blur="loadUsers"
@keydown.enter="loadUsers"
>
</a-input-search>
<div class="flex-grow"></div>
<MdiReload class="cursor-pointer" @click="loadUsers" />
<a-button
data-testid="nc-super-user-invite"
size="small"
type="primary"
@click="
() => {
showUserModal = true
userMadalKey++
}
"
>
<div class="flex items-center gap-1">
<MdiAdd />
Invite new user
</div>
</a-button>
</div>
<a-table
:row-key="(record) => record.id"
:data-source="users"
:pagination="{ position: ['bottomCenter'] }"
:loading="isLoading"
size="small"
@change="loadUsers($event.current)"
>
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<!-- Email -->
<a-table-column key="email" :title="$t('labels.email')" data-index="email">
<template #default="{ text }">
<div>
{{ text }}
</div>
</template>
</a-table-column>
<!-- Role -->
<a-table-column key="roles" :title="$t('objects.role')" data-index="roles">
<template #default="{ record }">
<div>
<div v-if="record.roles.includes('super')" class="font-weight-bold">Super Admin</div>
<a-select
v-else
v-model:value="record.roles"
class="w-[220px] nc-user-roles"
:dropdown-match-select-width="false"
@change="updateRole(record.id, record.roles)"
>
<a-select-option
class="nc-users-list-role-option"
:value="Role.OrgLevelCreator"
:label="$t(`objects.roleType.orgLevelCreator`)"
>
<div>{{ $t(`objects.roleType.orgLevelCreator`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
{{ $t('msg.info.roles.orgCreator') }}
</span>
</a-select-option>
<a-select-option
class="nc-users-list-role-option"
:value="Role.OrgLevelViewer"
:label="$t(`objects.roleType.orgLevelViewer`)"
>
<div>{{ $t(`objects.roleType.orgLevelViewer`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
{{ $t('msg.info.roles.orgViewer') }}
</span>
</a-select-option>
</a-select>
</div>
</template>
</a-table-column>
<!-- &lt;!&ndash; Projects &ndash;&gt;
<a-table-column key="projectsCount" :title="$t('objects.projects')" data-index="projectsCount">
<template #default="{ text }">
<div>
{{ text }}
</div>
</template>
</a-table-column> -->
<!-- Actions -->
<a-table-column key="id" :title="$t('labels.actions')" data-index="id">
<template #default="{ text, record }">
<div v-if="!record.roles.includes('super')" class="flex items-center gap-2">
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight" overlay-class-name="nc-dropdown-user-mgmt">
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<MdiDotsHorizontal class="nc-user-row-action" />
</div>
</a-button>
</div>
<template #overlay>
<a-menu>
<template v-if="record.invite_token">
<a-menu-item>
<!-- Resend invite Email -->
<div class="flex flex-row items-center py-3" @click="resendInvite(record)">
<MdiEmailArrowRightOutline class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.resendInvite') }}</div>
</div>
</a-menu-item>
<a-menu-item>
<div class="flex flex-row items-center py-3" @click="copyInviteUrl(record)">
<MdiContentCopy class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.copyInviteURL') }}</div>
</div>
</a-menu-item>
</template>
<a-menu-item>
<div class="flex flex-row items-center py-3" @click="copyPasswordResetUrl(record)">
<MdiContentCopy class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.copyPasswordResetURL') }}</div>
</div>
</a-menu-item>
<a-menu-item>
<div class="flex flex-row items-center py-3" @click="deleteUser(text)">
<MdiDeleteOutline data-testid="nc-super-user-delete" class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('general.delete') }}</div>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<span v-else></span>
</template>
</a-table-column>
</a-table>
<LazyAccountUsersModal :key="userMadalKey" :show="showUserModal" @closed="showUserModal = false" @reload="loadUsers" />
</div>
</div>
</template>

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

@ -0,0 +1,256 @@
<script setup lang="ts">
import type { UserType } from 'nocodb-sdk'
import {
Form,
computed,
extractSdkResponseErrorMsg,
isEmail,
message,
ref,
useCopy,
useDashboard,
useI18n,
useNuxtApp,
} from '#imports'
import type { User } from '~/lib'
import { Role } from '~/lib'
interface Props {
show: boolean
selectedUser?: User
}
interface Users {
emails: string
role: Role.OrgLevelCreator | Role.OrgLevelViewer
invitationToken?: string
}
const { show } = defineProps<Props>()
const emit = defineEmits(['closed', 'reload'])
const { t } = useI18n()
const { $api, $e } = useNuxtApp()
const { copy } = useCopy()
const { dashboardUrl } = $(useDashboard())
const usersData = $ref<Users>({ emails: '', role: Role.OrgLevelViewer, invitationToken: undefined })
const formRef = ref()
const useForm = Form.useForm
const validators = computed(() => {
return {
emails: [
{
validator: (rule: any, value: string, callback: (errMsg?: string) => void) => {
if (!value || value.length === 0) {
callback('Email is required')
return
}
const invalidEmails = (value || '').split(/\s*,\s*/).filter((e: string) => !isEmail(e))
if (invalidEmails.length > 0) {
callback(`${invalidEmails.length > 1 ? ' Invalid emails:' : 'Invalid email:'} ${invalidEmails.join(', ')} `)
} else {
callback()
}
},
},
],
}
})
const { validateInfos } = useForm(usersData, validators)
const saveUser = async () => {
$e('a:org-user:invite', { role: usersData.role })
await formRef.value?.validateFields()
try {
// todo: update sdk(swagger.json)
const res = await $api.orgUsers.add({
roles: usersData.role,
email: usersData.emails,
} as unknown as UserType)
usersData.invitationToken = res.invite_token
emit('reload')
// Successfully updated the user details
message.success(t('msg.success.userAdded'))
} catch (e: any) {
console.error(e)
message.error(await extractSdkResponseErrorMsg(e))
}
}
const inviteUrl = $computed(() => (usersData.invitationToken ? `${dashboardUrl}#/signup/${usersData.invitationToken}` : null))
const copyUrl = async () => {
if (!inviteUrl) return
await copy(inviteUrl)
// Copied shareable base url to clipboard!
message.success(t('msg.success.shareableURLCopied'))
$e('c:shared-base:copy-url')
}
const clickInviteMore = () => {
$e('c:user:invite-more')
usersData.invitationToken = undefined
usersData.role = Role.OrgLevelViewer
usersData.emails = ''
}
const emailInput = ref((el) => {
el?.focus()
})
</script>
<template>
<a-modal
:footer="null"
centered
:visible="show"
:closable="false"
width="max(50vw, 44rem)"
wrap-class-name="nc-modal-invite-user"
@cancel="emit('closed')"
>
<div class="flex flex-col">
<div class="flex flex-row justify-between items-center pb-1.5 mb-2 border-b-1 w-full">
<a-typography-title class="select-none" :level="4"> {{ $t('activity.inviteUser') }}</a-typography-title>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="emit('closed')">
<template #icon>
<MaterialSymbolsCloseRounded data-testid="nc-root-user-invite-modal-close" class="flex mx-auto" />
</template>
</a-button>
</div>
<div class="px-2 mt-1.5">
<template v-if="usersData.invitationToken">
<div class="flex flex-col mt-1 border-b-1 pb-5">
<div class="flex flex-row items-center pl-1.5 pb-1 h-[1.1rem]">
<MdiAccountOutline />
<div class="text-xs ml-0.5 mt-0.5">Copy Invite Token</div>
</div>
<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">
{{ inviteUrl }}
</div>
<a-button type="text" class="!rounded-md -mt-0.5" @click="copyUrl">
<template #icon>
<MdiContentCopy class="flex mx-auto text-green-700 h-[1rem]" />
</template>
</a-button>
</div>
</template>
</a-alert>
<div class="flex text-xs text-gray-500 mt-2 justify-start ml-2">
{{ $t('msg.info.userInviteNoSMTP') }}
{{ usersData.invitationToken && usersData.emails }}
</div>
<div class="flex flex-row justify-start mt-4 ml-2">
<a-button size="small" outlined @click="clickInviteMore">
<div class="flex flex-row justify-center items-center space-x-0.5">
<MaterialSymbolsSendOutline class="flex mx-auto text-gray-600 h-[0.8rem]" />
<div class="text-xs text-gray-600">{{ $t('activity.inviteMore') }}</div>
</div>
</a-button>
</div>
</div>
</template>
<div v-else class="flex flex-col pb-4">
<div class="flex flex-row items-center pl-2 pb-1 h-[1rem]">
<MdiAccountOutline />
<div class="text-xs ml-0.5 mt-0.5">{{ $t('activity.inviteUser') }}</div>
</div>
<div class="border-1 py-3 px-4 rounded-md mt-1">
<a-form
ref="formRef"
:validate-on-rule-change="false"
:model="usersData"
validate-trigger="onBlur"
@finish="saveUser"
>
<div class="flex flex-row space-x-4">
<div class="flex flex-col w-3/4">
<a-form-item
v-bind="validateInfos.emails"
validate-trigger="onBlur"
name="emails"
:rules="[{ required: true, message: 'Please input email' }]"
>
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('datatype.Email') }}:</div>
<a-input
:ref="emailInput"
v-model:value="usersData.emails"
validate-trigger="onBlur"
:placeholder="$t('labels.email')"
/>
</a-form-item>
</div>
<div class="flex flex-col w-2/4">
<a-form-item name="role" :rules="[{ required: true, message: 'Role required' }]">
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('labels.selectUserRole') }}</div>
<a-select v-model:value="usersData.role" class="nc-user-roles" dropdown-class-name="nc-dropdown-user-role">
<a-select-option
class="nc-role-option"
:value="Role.OrgLevelCreator"
:label="$t(`objects.roleType.orgLevelCreator`)"
>
<div>{{ $t(`objects.roleType.orgLevelCreator`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
{{ $t('msg.info.roles.orgCreator') }}
</span>
</a-select-option>
<a-select-option
class="nc-role-option"
:value="Role.OrgLevelViewer"
:label="$t(`objects.roleType.orgLevelViewer`)"
>
<div>{{ $t(`objects.roleType.orgLevelViewer`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
{{ $t('msg.info.roles.orgViewer') }}
</span>
</a-select-option>
</a-select>
</a-form-item>
</div>
</div>
<div class="flex flex-row justify-center">
<a-button type="primary" html-type="submit">
<div class="flex flex-row justify-center items-center space-x-1.5">
<MaterialSymbolsSendOutline class="flex h-[0.8rem]" />
<div>{{ $t('activity.invite') }}</div>
</div>
</a-button>
</div>
</a-form>
</div>
</div>
</div>
</div>
</a-modal>
</template>

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

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ColumnInj, IsFormInj, ReadonlyInj, getMdiIcon, inject } from '#imports' import { ActiveCellInj, ColumnInj, IsFormInj, ReadonlyInj, getMdiIcon, inject, useSelectedCellKeyupListener } from '#imports'
interface Props { interface Props {
// If the previous cell value was a text, the initial checkbox value is a string type // If the previous cell value was a text, the initial checkbox value is a string type
@ -15,6 +15,8 @@ const props = defineProps<Props>()
const emits = defineEmits<Emits>() const emits = defineEmits<Emits>()
const active = inject(ActiveCellInj, ref(false))
let vModel = $computed({ let vModel = $computed({
get: () => !!props.modelValue && props.modelValue !== '0' && props.modelValue !== 0, get: () => !!props.modelValue && props.modelValue !== '0' && props.modelValue !== 0,
set: (val) => emits('update:modelValue', val), set: (val) => emits('update:modelValue', val),
@ -37,11 +39,20 @@ const checkboxMeta = $computed(() => {
} }
}) })
function onClick() { function onClick(force?: boolean) {
if (!readOnly?.value) { if (!readOnly?.value && (force || active.value)) {
vModel = !vModel vModel = !vModel
} }
} }
useSelectedCellKeyupListener(active, (e) => {
switch (e.key) {
case 'Enter':
onClick()
e.stopPropagation()
break
}
})
</script> </script>
<template> <template>
@ -53,7 +64,7 @@ function onClick() {
'nc-cell-hover-show': !vModel && !readOnly, 'nc-cell-hover-show': !vModel && !readOnly,
'opacity-0': readOnly && !vModel, 'opacity-0': readOnly && !vModel,
}" }"
@click="onClick" @click="onClick(false)"
> >
<div class="px-1 pt-1 rounded-full items-center" :class="{ 'bg-gray-100': !vModel, '!ml-[-8px]': readOnly }"> <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"> <Transition name="layout" mode="out-in" :duration="100">
@ -62,6 +73,7 @@ function onClick() {
:style="{ :style="{
color: checkboxMeta.color, color: checkboxMeta.color,
}" }"
@click.stop="onClick(true)"
/> />
</Transition> </Transition>
</div> </div>
@ -72,6 +84,7 @@ function onClick() {
.nc-cell-hover-show { .nc-cell-hover-show {
opacity: 0; opacity: 0;
transition: 0.3s opacity; transition: 0.3s opacity;
&:hover { &:hover {
opacity: 0.7; opacity: 0.7;
} }

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

@ -61,6 +61,11 @@ onMounted(() => {
v-model="vModel" v-model="vModel"
class="w-full h-full border-none outline-none px-2" class="w-full h-full border-none outline-none px-2"
@blur="submitCurrency" @blur="submitCurrency"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/> />
<span v-else-if="vModel">{{ currency }}</span> <span v-else-if="vModel">{{ currency }}</span>

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

@ -1,6 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { ColumnInj, ReadonlyInj, computed, inject, ref, watch } from '#imports' import {
ActiveCellInj,
ColumnInj,
EditModeInj,
ReadonlyInj,
computed,
inject,
ref,
useSelectedCellKeyupListener,
watch,
} from '#imports'
interface Props { interface Props {
modelValue?: string | null modelValue?: string | null
@ -15,6 +25,10 @@ const columnMeta = inject(ColumnInj, null)!
const readOnly = inject(ReadonlyInj, ref(false)) const readOnly = inject(ReadonlyInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
let isDateInvalid = $ref(false) let isDateInvalid = $ref(false)
const dateFormat = $computed(() => columnMeta?.value?.meta?.date_format ?? 'YYYY-MM-DD') const dateFormat = $computed(() => columnMeta?.value?.meta?.date_format ?? 'YYYY-MM-DD')
@ -57,6 +71,21 @@ watch(
) )
const placeholder = computed(() => (isDateInvalid ? 'Invalid date' : '')) const placeholder = computed(() => (isDateInvalid ? 'Invalid date' : ''))
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
open.value = true
break
case 'Escape':
if (open.value) {
e.stopPropagation()
open.value = false
}
break
}
})
</script> </script>
<template> <template>
@ -68,9 +97,9 @@ const placeholder = computed(() => (isDateInvalid ? 'Invalid date' : ''))
:placeholder="placeholder" :placeholder="placeholder"
:allow-clear="!readOnly && !localState && !isPk" :allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true" :input-read-only="true"
:dropdown-class-name="`${randomClass} nc-picker-date`" :dropdown-class-name="`${randomClass} nc-picker-date ${open ? 'active' : ''}`"
:open="readOnly || (localState && isPk) ? false : open" :open="(readOnly || (localState && isPk)) && !active && !editable ? false : open"
@click="open = !open" @click="open = (active || editable) && !open"
> >
<template #suffixIcon></template> <template #suffixIcon></template>
</a-date-picker> </a-date-picker>

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

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { ReadonlyInj, inject, ref, useProject, watch } from '#imports' import { ActiveCellInj, ReadonlyInj, inject, ref, useProject, useSelectedCellKeyupListener, watch } from '#imports'
interface Props { interface Props {
modelValue?: string | null modelValue?: string | null
@ -15,6 +15,10 @@ const { isMysql } = useProject()
const readOnly = inject(ReadonlyInj, ref(false)) const readOnly = inject(ReadonlyInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
let isDateInvalid = $ref(false) let isDateInvalid = $ref(false)
const dateFormat = isMysql ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ' const dateFormat = isMysql ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
@ -56,6 +60,21 @@ watch(
}, },
{ flush: 'post' }, { flush: 'post' },
) )
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
open.value = true
break
case 'Escape':
if (open.value) {
e.stopPropagation()
open.value = false
}
break
}
})
</script> </script>
<template> <template>
@ -68,10 +87,10 @@ watch(
:placeholder="isDateInvalid ? 'Invalid date' : ''" :placeholder="isDateInvalid ? 'Invalid date' : ''"
:allow-clear="!readOnly && !localState && !isPk" :allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true" :input-read-only="true"
:dropdown-class-name="`${randomClass} nc-picker-datetime`" :dropdown-class-name="`${randomClass} nc-picker-datetime ${open ? 'active' : ''}`"
:open="readOnly || (localState && isPk) ? false : open" :open="readOnly || (localState && isPk) ? false : open && (active || editable)"
:disabled="readOnly || (localState && isPk)" :disabled="readOnly || (localState && isPk)"
@click="open = !open" @click="open = (active || editable) && !open"
@ok="open = !open" @ok="open = !open"
> >
<template #suffixIcon></template> <template #suffixIcon></template>

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

@ -30,6 +30,11 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
type="number" type="number"
step="0.1" step="0.1"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/> />
<span v-else class="text-sm">{{ vModel }}</span> <span v-else class="text-sm">{{ vModel }}</span>
</template> </template>

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

@ -84,6 +84,11 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@blur="submitDuration" @blur="submitDuration"
@keypress="checkDurationFormat($event)" @keypress="checkDurationFormat($event)"
@keydown.enter="submitDuration" @keydown.enter="submitDuration"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/> />
<span v-else> {{ localState }}</span> <span v-else> {{ localState }}</span>

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

@ -24,7 +24,18 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
</script> </script>
<template> <template>
<input v-if="editEnabled" :ref="focus" v-model="vModel" class="outline-none text-sm px-2" @blur="editEnabled = false" /> <input
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="outline-none text-sm px-2"
@blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/>
<a v-else-if="validEmail" class="text-sm underline hover:opacity-75" :href="`mailto:${vModel}`" target="_blank"> <a v-else-if="validEmail" class="text-sm underline hover:opacity-75" :href="`mailto:${vModel}`" target="_blank">
{{ vModel }} {{ vModel }}

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

@ -30,6 +30,11 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
type="number" type="number"
step="0.1" step="0.1"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/> />
<span v-else class="text-sm">{{ vModel }}</span> <span v-else class="text-sm">{{ vModel }}</span>
</template> </template>

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

@ -34,6 +34,11 @@ function onKeyDown(evt: KeyboardEvent) {
type="number" type="number"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown="onKeyDown" @keydown="onKeyDown"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/> />
<span v-else class="text-sm">{{ vModel }}</span> <span v-else class="text-sm">{{ vModel }}</span>
</template> </template>

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

@ -14,6 +14,7 @@ import {
ref, ref,
useEventListener, useEventListener,
useProject, useProject,
useSelectedCellKeyupListener,
watch, watch,
} from '#imports' } from '#imports'
import MdiCloseCircle from '~icons/mdi/close-circle' import MdiCloseCircle from '~icons/mdi/close-circle'
@ -85,17 +86,7 @@ const selectedTitles = computed(() =>
: [], : [],
) )
const handleKeys = (e: KeyboardEvent) => { const v = Math.floor(Math.random() * 1000)
switch (e.key) {
case 'Escape':
e.preventDefault()
isOpen.value = false
break
case 'Enter':
e.stopPropagation()
break
}
}
const handleClose = (e: MouseEvent) => { const handleClose = (e: MouseEvent) => {
if (aselect.value && !aselect.value.$el.contains(e.target)) { if (aselect.value && !aselect.value.$el.contains(e.target)) {
@ -131,7 +122,24 @@ watch(
) )
watch(isOpen, (n, _o) => { watch(isOpen, (n, _o) => {
if (!n) aselect.value?.$el.blur() if (!n) {
aselect.value?.$el?.querySelector('input')?.blur()
} else {
aselect.value?.$el?.querySelector('input')?.focus()
}
})
useSelectedCellKeyupListener(active, (e) => {
switch (e.key) {
case 'Escape':
isOpen.value = false
break
case 'Enter':
if (active.value && !isOpen.value) {
isOpen.value = true
}
break
}
}) })
</script> </script>
@ -139,23 +147,23 @@ watch(isOpen, (n, _o) => {
<a-select <a-select
ref="aselect" ref="aselect"
v-model:value="vModel" v-model:value="vModel"
v-model:open="isOpen"
mode="multiple" mode="multiple"
class="w-full" class="w-full"
:bordered="false" :bordered="false"
:show-arrow="!readOnly" :show-arrow="!readOnly"
:show-search="false" :show-search="false"
:open="isOpen"
:disabled="readOnly" :disabled="readOnly"
:class="{ '!ml-[-8px]': readOnly }" :class="{ '!ml-[-8px]': readOnly }"
dropdown-class-name="nc-dropdown-multi-select-cell" :dropdown-class-name="`nc-dropdown-multi-select-cell ${isOpen ? 'active' : ''}`"
@keydown="handleKeys" @keydown.enter.stop
@click="isOpen = !isOpen" @click="isOpen = active && !isOpen"
> >
<a-select-option <a-select-option
v-for="op of options" v-for="op of options"
:key="op.id" :key="op.id"
:value="op.title" :value="op.title"
:data-nc="`select-option-${column.title}-${rowIndex}`" :data-testid="`select-option-${column.title}-${rowIndex}`"
@click.stop @click.stop
> >
<a-tag class="rounded-tag" :color="op.color"> <a-tag class="rounded-tag" :color="op.color">
@ -221,18 +229,23 @@ watch(isOpen, (n, _o) => {
margin-right: -6px; margin-right: -6px;
margin-left: 3px; margin-left: 3px;
} }
.ms-close-icon:before { .ms-close-icon:before {
display: block; display: block;
} }
.ms-close-icon:hover { .ms-close-icon:hover {
color: rgba(0, 0, 0, 0.45); color: rgba(0, 0, 0, 0.45);
} }
.rounded-tag { .rounded-tag {
@apply py-0 px-[12px] rounded-[12px]; @apply py-0 px-[12px] rounded-[12px];
} }
:deep(.ant-tag) { :deep(.ant-tag) {
@apply "rounded-tag" my-[2px]; @apply "rounded-tag" my-[2px];
} }
:deep(.ant-tag-close-icon) { :deep(.ant-tag-close-icon) {
@apply "text-slate-500"; @apply "text-slate-500";
} }

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

@ -21,6 +21,11 @@ const vModel = useVModel(props, 'modelValue', emits)
class="w-full !border-none text-base" class="w-full !border-none text-base"
:class="{ '!px-2': editEnabled }" :class="{ '!px-2': editEnabled }"
type="number" type="number"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/> />
<span v-else>{{ vModel }}</span> <span v-else>{{ vModel }}</span>
</template> </template>

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

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ColumnInj, EditModeInj, computed, inject } from '#imports' import { ActiveCellInj, ColumnInj, computed, inject, useSelectedCellKeyupListener } from '#imports'
interface Props { interface Props {
modelValue?: number | null | undefined modelValue?: number | null | undefined
@ -11,8 +11,6 @@ const emits = defineEmits(['update:modelValue'])
const column = inject(ColumnInj)! const column = inject(ColumnInj)!
const editEnabled = inject(EditModeInj)
const ratingMeta = computed(() => { const ratingMeta = computed(() => {
return { return {
icon: { icon: {
@ -29,16 +27,17 @@ const vModel = computed({
get: () => modelValue ?? NaN, get: () => modelValue ?? NaN,
set: (val) => emits('update:modelValue', val), set: (val) => emits('update:modelValue', val),
}) })
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
if (/^\d$/.test(e.key)) {
e.stopPropagation()
vModel.value = +e.key === +vModel.value ? 0 : +e.key
}
})
</script> </script>
<template> <template>
<a-rate <a-rate v-model:value="vModel" :count="ratingMeta.max" :style="`color: ${ratingMeta.color}; padding: 0px 5px`">
v-model:value="vModel"
:count="ratingMeta.max"
:style="`color: ${ratingMeta.color}; padding: 0px 5px`"
:class="{ '!ml-[-8px]': !editEnabled }"
:disabled="!editEnabled"
>
<template #character> <template #character>
<MdiStar v-if="ratingMeta.icon.full === 'mdi-star'" class="text-sm" /> <MdiStar v-if="ratingMeta.icon.full === 'mdi-star'" class="text-sm" />
<MdiHeart v-if="ratingMeta.icon.full === 'mdi-heart'" class="text-sm" /> <MdiHeart v-if="ratingMeta.icon.full === 'mdi-heart'" class="text-sm" />

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

@ -3,6 +3,7 @@ import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue' import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType } from 'nocodb-sdk' import type { SelectOptionType } from 'nocodb-sdk'
import { ActiveCellInj, ColumnInj, IsKanbanInj, ReadonlyInj, computed, inject, ref, useEventListener, watch } from '#imports' import { ActiveCellInj, ColumnInj, IsKanbanInj, ReadonlyInj, computed, inject, ref, useEventListener, watch } from '#imports'
import { useSelectedCellKeyupListener } from '~/composables/useSelectedCellKeyupListener'
interface Props { interface Props {
modelValue?: string | undefined modelValue?: string | undefined
@ -44,15 +45,6 @@ const options = computed<SelectOptionType[]>(() => {
return [] return []
}) })
const handleKeys = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
e.preventDefault()
isOpen.value = false
break
}
}
const handleClose = (e: MouseEvent) => { const handleClose = (e: MouseEvent) => {
if (aselect.value && !aselect.value.$el.contains(e.target)) { if (aselect.value && !aselect.value.$el.contains(e.target)) {
isOpen.value = false isOpen.value = false
@ -63,7 +55,24 @@ const handleClose = (e: MouseEvent) => {
useEventListener(document, 'click', handleClose) useEventListener(document, 'click', handleClose)
watch(isOpen, (n, _o) => { watch(isOpen, (n, _o) => {
if (!n) aselect.value?.$el.blur() if (!n) {
aselect.value?.$el?.querySelector('input')?.blur()
} else {
aselect.value?.$el?.querySelector('input')?.focus()
}
})
useSelectedCellKeyupListener(active, (e) => {
switch (e.key) {
case 'Escape':
isOpen.value = false
break
case 'Enter':
if (active.value && !isOpen.value) {
isOpen.value = true
}
break
}
}) })
</script> </script>
@ -77,16 +86,16 @@ watch(isOpen, (n, _o) => {
:open="isOpen" :open="isOpen"
:disabled="readOnly" :disabled="readOnly"
:show-arrow="!readOnly && (active || vModel === null)" :show-arrow="!readOnly && (active || vModel === null)"
dropdown-class-name="nc-dropdown-single-select-cell" :dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen ? 'active' : ''}`"
@select="isOpen = false" @select="isOpen = false"
@keydown="handleKeys" @keydown.enter.stop
@click="isOpen = !isOpen" @click="isOpen = active && !isOpen"
> >
<a-select-option <a-select-option
v-for="op of options" v-for="op of options"
:key="op.title" :key="op.title"
:value="op.title" :value="op.title"
:data-nc="`select-option-${column.title}-${rowIndex}`" :data-testid="`select-option-${column.title}-${rowIndex}`"
@click.stop @click.stop
> >
<a-tag class="rounded-tag" :color="op.color"> <a-tag class="rounded-tag" :color="op.color">
@ -110,9 +119,11 @@ watch(isOpen, (n, _o) => {
.rounded-tag { .rounded-tag {
@apply py-0 px-[12px] rounded-[12px]; @apply py-0 px-[12px] rounded-[12px];
} }
:deep(.ant-tag) { :deep(.ant-tag) {
@apply "rounded-tag"; @apply "rounded-tag";
} }
:deep(.ant-select-clear) { :deep(.ant-select-clear) {
opacity: 1; opacity: 1;
} }

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

@ -27,6 +27,11 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
class="h-full w-full outline-none bg-transparent" class="h-full w-full outline-none bg-transparent"
:class="{ '!px-2': editEnabled }" :class="{ '!px-2': editEnabled }"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/> />
<span v-else>{{ vModel }}</span> <span v-else>{{ vModel }}</span>

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

@ -26,6 +26,11 @@ const focus: VNodeRef = (el) => (el as HTMLTextAreaElement)?.focus()
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.alt.enter.stop @keydown.alt.enter.stop
@keydown.shift.enter.stop @keydown.shift.enter.stop
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/> />
<span v-else>{{ vModel }}</span> <span v-else>{{ vModel }}</span>

27
packages/nc-gui/components/cell/TimePicker.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { ReadonlyInj, inject, onClickOutside, useProject, watch } from '#imports' import { ActiveCellInj, ReadonlyInj, inject, onClickOutside, useProject, useSelectedCellKeyupListener, watch } from '#imports'
interface Props { interface Props {
modelValue?: string | null | undefined modelValue?: string | null | undefined
@ -15,6 +15,10 @@ const { isMysql } = useProject()
const readOnly = inject(ReadonlyInj, ref(false)) const readOnly = inject(ReadonlyInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
let isTimeInvalid = $ref(false) let isTimeInvalid = $ref(false)
const dateFormat = isMysql.value ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ' const dateFormat = isMysql.value ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
@ -65,6 +69,21 @@ watch(
}, },
{ flush: 'post' }, { flush: 'post' },
) )
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
open.value = true
break
case 'Escape':
if (open.value) {
e.stopPropagation()
open.value = false
}
break
}
})
</script> </script>
<template> <template>
@ -79,9 +98,9 @@ watch(
:placeholder="isTimeInvalid ? 'Invalid time' : ''" :placeholder="isTimeInvalid ? 'Invalid time' : ''"
:allow-clear="!readOnly && !localState && !isPk" :allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true" :input-read-only="true"
:open="readOnly || (localState && isPk) ? false : open" :open="(readOnly || (localState && isPk)) && !active && !editable ? false : open"
:popup-class-name="`${randomClass} nc-picker-time`" :popup-class-name="`${randomClass} nc-picker-time ${open ? 'active' : ''}`"
@click="open = !open" @click="open = (active || editable) && !open"
@ok="open = !open" @ok="open = !open"
> >
<template #suffixIcon></template> <template #suffixIcon></template>

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

@ -79,6 +79,11 @@ watch(
v-model="vModel" v-model="vModel"
class="outline-none text-sm w-full px-2" class="outline-none text-sm w-full px-2"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/> />
<nuxt-link <nuxt-link

29
packages/nc-gui/components/cell/YearPicker.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { ReadonlyInj, computed, inject, onClickOutside, ref, watch } from '#imports' import { ActiveCellInj, ReadonlyInj, computed, inject, onClickOutside, ref, useSelectedCellKeyupListener, watch } from '#imports'
interface Props { interface Props {
modelValue?: number | string | null modelValue?: number | string | null
@ -13,6 +13,10 @@ const emit = defineEmits(['update:modelValue'])
const readOnly = inject(ReadonlyInj, ref(false)) const readOnly = inject(ReadonlyInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
let isYearInvalid = $ref(false) let isYearInvalid = $ref(false)
const localState = $computed({ const localState = $computed({
@ -55,6 +59,21 @@ watch(
) )
const placeholder = computed(() => (isYearInvalid ? 'Invalid year' : '')) const placeholder = computed(() => (isYearInvalid ? 'Invalid year' : ''))
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
open.value = true
break
case 'Escape':
if (open.value) {
e.stopPropagation()
open.value = false
}
break
}
})
</script> </script>
<template> <template>
@ -66,10 +85,10 @@ const placeholder = computed(() => (isYearInvalid ? 'Invalid year' : ''))
:placeholder="placeholder" :placeholder="placeholder"
:allow-clear="!readOnly && !localState && !isPk" :allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true" :input-read-only="true"
:open="readOnly || (localState && isPk) ? false : open" :open="(readOnly || (localState && isPk)) && !active && !editable ? false : open"
:dropdown-class-name="`${randomClass} nc-picker-year`" :dropdown-class-name="`${randomClass} nc-picker-year ${open ? 'active' : ''}`"
@click="open = !open" @click="open = (active || editable) && !open"
@change="open = !open" @change="open = (active || editable) && !open"
> >
<template #suffixIcon></template> <template #suffixIcon></template>
</a-date-picker> </a-date-picker>

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

@ -3,6 +3,7 @@ import { onKeyDown } from '@vueuse/core'
import { useProvideAttachmentCell } from './utils' import { useProvideAttachmentCell } from './utils'
import { useSortable } from './sort' import { useSortable } from './sort'
import { import {
ActiveCellInj,
DropZoneRef, DropZoneRef,
IsGalleryInj, IsGalleryInj,
IsKanbanInj, IsKanbanInj,
@ -12,6 +13,7 @@ import {
openLink, openLink,
ref, ref,
useDropZone, useDropZone,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow, useSmartsheetRowStoreOrThrow,
useSmartsheetStoreOrThrow, useSmartsheetStoreOrThrow,
watch, watch,
@ -113,9 +115,17 @@ watch(
attachments.value = [] attachments.value = []
} }
} }
} else {
if (isPublic.value && isForm.value) {
storedFiles.value = []
} else {
attachments.value = []
}
} }
}, },
{ immediate: true }, {
immediate: true,
},
) )
/** updates attachments array for autosave */ /** updates attachments array for autosave */
@ -136,6 +146,13 @@ watch(
rowState.value[column.value!.title!] = storedFiles.value rowState.value[column.value!.title!] = storedFiles.value
}, },
) )
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e) => {
if (e.key === 'Enter' && !isReadonly.value) {
e.stopPropagation()
modalVisible.value = true
}
})
</script> </script>
<template> <template>
@ -161,7 +178,7 @@ watch(
v-if="!isReadonly" v-if="!isReadonly"
:class="{ 'mx-auto px-4': !visibleItems.length }" :class="{ 'mx-auto px-4': !visibleItems.length }"
class="group cursor-pointer flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-1 p-1 shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)" class="group cursor-pointer flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-1 p-1 shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)"
data-nc="attachment-cell-file-picker-button" data-testid="attachment-cell-file-picker-button"
@click.stop="open" @click.stop="open"
> >
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" /> <MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />

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

@ -331,13 +331,13 @@ const onSearchCloseIconClick = () => {
class="nc-tree-item text-sm cursor-pointer group" class="nc-tree-item text-sm cursor-pointer group"
:data-order="table.order" :data-order="table.order"
:data-id="table.id" :data-id="table.id"
:data-nc="`tree-view-table-${table.title}`" :data-testid="`tree-view-table-${table.title}`"
@click="addTableTab(table)" @click="addTableTab(table)"
> >
<GeneralTooltip class="pl-5 pr-3 py-2" modifier-key="Alt"> <GeneralTooltip class="pl-5 pr-3 py-2" modifier-key="Alt">
<template #title>{{ table.table_name }}</template> <template #title>{{ table.table_name }}</template>
<div class="flex items-center gap-2 h-full" @contextmenu="setMenuContext('table', table)"> <div class="flex items-center gap-2 h-full" @contextmenu="setMenuContext('table', table)">
<div class="flex w-auto" :data-nc="`tree-view-table-draggable-handle-${table.title}`"> <div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`">
<MdiDragVertical <MdiDragVertical
v-if="isUIAllowed('treeview-drag-n-drop')" v-if="isUIAllowed('treeview-drag-n-drop')"
:class="`nc-child-draggable-icon-${table.title}`" :class="`nc-child-draggable-icon-${table.title}`"
@ -366,14 +366,14 @@ const onSearchCloseIconClick = () => {
<template #overlay> <template #overlay>
<a-menu class="!py-0 rounded text-sm"> <a-menu class="!py-0 rounded text-sm">
<a-menu-item v-if="isUIAllowed('table-rename')" @click="openRenameTableDialog(table)"> <a-menu-item v-if="isUIAllowed('table-rename')" @click="openRenameTableDialog(table)">
<div class="nc-project-menu-item" :data-nc="`sidebar-table-rename-${table.title}`"> <div class="nc-project-menu-item" :data-testid="`sidebar-table-rename-${table.title}`">
{{ $t('general.rename') }} {{ $t('general.rename') }}
</div> </div>
</a-menu-item> </a-menu-item>
<a-menu-item <a-menu-item
v-if="isUIAllowed('table-delete')" v-if="isUIAllowed('table-delete')"
:data-nc="`sidebar-table-delete-${table.title}`" :data-testid="`sidebar-table-delete-${table.title}`"
@click="deleteTable(table)" @click="deleteTable(table)"
> >
<div class="nc-project-menu-item"> <div class="nc-project-menu-item">

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

@ -112,7 +112,7 @@ const columns = [
:columns="columns" :columns="columns"
:pagination="false" :pagination="false"
:loading="isLoading" :loading="isLoading"
data-nc="audit-tab-table" data-testid="audit-tab-table"
> >
<template #emptyText> <template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" /> <a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />

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

@ -178,7 +178,7 @@ watch(
<a-button <a-button
type="text" type="text"
class="!rounded-md border-none -mt-1.5 -mr-1" class="!rounded-md border-none -mt-1.5 -mr-1"
data-nc="settings-modal-close-button" data-testid="settings-modal-close-button"
@click="vModel = false" @click="vModel = false"
> >
<template #icon> <template #icon>
@ -220,7 +220,7 @@ watch(
</a-menu-item> </a-menu-item>
</a-menu> </a-menu>
<component :is="selectedSubTab?.body" class="px-2 py-6" :data-nc="`nc-settings-subtab-${selectedSubTab.title}`" /> <component :is="selectedSubTab?.body" class="px-2 py-6" :data-testid="`nc-settings-subtab-${selectedSubTab.title}`" />
</a-layout-content> </a-layout-content>
</a-layout> </a-layout>
</a-modal> </a-modal>

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

@ -32,13 +32,21 @@ const { table, createTable, generateUniqueTitle, tables, project } = useTable(as
const useForm = Form.useForm const useForm = Form.useForm
const validateDuplicateAlias = (v: string) => (tables.value || []).every((t) => t.title !== (v || '')) || 'Duplicate table alias'
const validators = computed(() => { const validators = computed(() => {
return { return {
title: [ title: [
validateTableName, validateTableName,
validateDuplicateAlias, {
validator: (_: any, value: any) => {
// validate duplicate alias
return new Promise((resolve, reject) => {
if ((tables.value || []).some((t) => t.title === (value || ''))) {
return reject(new Error('Duplicate table alias'))
}
return resolve(true)
})
},
},
{ {
validator: (rule: any, value: any) => { validator: (rule: any, value: any) => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
@ -62,13 +70,23 @@ const validators = computed(() => {
table_name: [validateTableName], table_name: [validateTableName],
} }
}) })
const { validateInfos } = useForm(table, validators) const { validate, validateInfos } = useForm(table, validators)
const systemColumnsCheckboxInfo = SYSTEM_COLUMNS.map((c, index) => ({ const systemColumnsCheckboxInfo = SYSTEM_COLUMNS.map((c, index) => ({
value: c, value: c,
disabled: index === 0, disabled: index === 0,
})) }))
const _createTable = async () => {
try {
await validate()
} catch (e: any) {
e.errorFields.map((f: Record<string, any>) => message.error(f.errors.join(',')))
if (e.errorFields.length) return
}
await createTable()
}
onMounted(() => { onMounted(() => {
generateUniqueTitle() generateUniqueTitle()
@ -87,11 +105,11 @@ onMounted(() => {
<template #footer> <template #footer>
<a-button key="back" size="large" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button> <a-button key="back" size="large" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button>
<a-button key="submit" size="large" type="primary" @click="createTable()">{{ $t('general.submit') }}</a-button> <a-button key="submit" size="large" type="primary" @click="_createTable">{{ $t('general.submit') }}</a-button>
</template> </template>
<div class="pl-10 pr-10 pt-5"> <div class="pl-10 pr-10 pt-5">
<a-form :model="table" name="create-new-table-form" @keydown.enter="createTable"> <a-form :model="table" name="create-new-table-form" @keydown.enter="_createTable">
<!-- Create A New Table --> <!-- Create A New Table -->
<div class="prose-xl font-bold self-center my-4">{{ $t('activity.createTable') }}</div> <div class="prose-xl font-bold self-center my-4">{{ $t('activity.createTable') }}</div>
@ -105,7 +123,7 @@ onMounted(() => {
v-model:value="table.title" v-model:value="table.title"
size="large" size="large"
hide-details hide-details
data-nc="create-table-title-input" data-testid="create-table-title-input"
:placeholder="$t('msg.info.enterTableName')" :placeholder="$t('msg.info.enterTableName')"
/> />
</a-form-item> </a-form-item>

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

@ -38,7 +38,7 @@ const shortName = computed(() =>
</template> </template>
<div class="w-full">{{ shortName }}</div> <div class="w-full">{{ shortName }}</div>
</a-tooltip> </a-tooltip>
<div v-else class="w-full" data-nc="truncate-label"> <div v-else class="w-full" data-testid="truncate-label">
<slot /> <slot />
</div> </div>
<div ref="text" class="hidden"> <div ref="text" class="hidden">

19
packages/nc-gui/components/general/language/Menu.vue

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Language } from '~/lib' import { Language } from '~/lib'
import { onMounted, useGlobal, useI18n, useNuxtApp } from '#imports' import { useGlobal, useI18n, useNuxtApp } from '#imports'
import { setI18nLanguage } from '~/plugins/a.i18n' import { setI18nLanguage } from '~/plugins/a.i18n'
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
@ -11,31 +11,14 @@ const { locale } = useI18n()
const languages = $computed(() => Object.entries(Language).sort() as [keyof typeof Language, Language][]) const languages = $computed(() => Object.entries(Language).sort() as [keyof typeof Language, Language][])
const isRtlLang = $computed(() => ['fa', 'ar'].includes(currentLang.value))
function applyDirection() {
const targetDirection = isRtlLang ? 'rtl' : 'ltr'
const oppositeDirection = targetDirection === 'ltr' ? 'rtl' : 'ltr'
document.body.classList.remove(oppositeDirection)
document.body.classList.add(targetDirection)
document.body.style.direction = targetDirection
}
async function changeLanguage(lang: string) { async function changeLanguage(lang: string) {
const nextLang = lang as keyof typeof Language const nextLang = lang as keyof typeof Language
await setI18nLanguage(nextLang) await setI18nLanguage(nextLang)
currentLang.value = nextLang currentLang.value = nextLang
applyDirection()
$e('c:navbar:lang', { lang }) $e('c:navbar:lang', { lang })
} }
onMounted(() => {
applyDirection()
})
</script> </script>
<template> <template>

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

@ -111,6 +111,7 @@ const vModel = computed({
}) })
const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => { const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
console.log('syncAndNavigate', e.target)
if (isJSON.value) return if (isJSON.value) return
if (currentRow.value.rowMeta.changed || currentRow.value.rowMeta.new) { if (currentRow.value.rowMeta.changed || currentRow.value.rowMeta.new) {

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

@ -378,7 +378,7 @@ watch(view, (nextView) => {
</script> </script>
<template> <template>
<a-row v-if="submitted" class="h-full" data-nc="nc-form-wrapper-submit"> <a-row v-if="submitted" class="h-full" data-testid="nc-form-wrapper-submit">
<a-col :span="24"> <a-col :span="24">
<div v-if="formViewData" class="items-center justify-center text-center mt-2"> <div v-if="formViewData" class="items-center justify-center text-center mt-2">
<a-alert type="success"> <a-alert type="success">
@ -398,7 +398,7 @@ watch(view, (nextView) => {
</a-col> </a-col>
</a-row> </a-row>
<a-row v-else class="h-full flex" data-nc="nc-form-wrapper"> <a-row v-else class="h-full flex" data-testid="nc-form-wrapper">
<a-col v-if="isEditable" :span="8" class="shadow p-2 md:p-4 h-full overflow-auto scrollbar-thin-dull nc-form-left-drawer"> <a-col v-if="isEditable" :span="8" class="shadow p-2 md:p-4 h-full overflow-auto scrollbar-thin-dull nc-form-left-drawer">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<div class="flex-1 text-lg"> <div class="flex-1 text-lg">
@ -410,7 +410,7 @@ watch(view, (nextView) => {
v-if="hiddenColumns.length" v-if="hiddenColumns.length"
type="button" type="button"
class="nc-form-add-all color-transition bg-white transform hover:(text-primary ring ring-accent ring-opacity-100) active:translate-y-[1px] px-2 py-1 shadow-md rounded" class="nc-form-add-all color-transition bg-white transform hover:(text-primary ring ring-accent ring-opacity-100) active:translate-y-[1px] px-2 py-1 shadow-md rounded"
data-nc="nc-form-add-all" data-testid="nc-form-add-all"
@click="addAllColumns" @click="addAllColumns"
> >
<!-- Add all --> <!-- Add all -->
@ -421,7 +421,7 @@ watch(view, (nextView) => {
v-if="localColumns.length" v-if="localColumns.length"
type="button" type="button"
class="nc-form-remove-all color-transition bg-white transform hover:(text-primary ring ring-accent ring-opacity-100) active:translate-y-[1px] px-2 py-1 shadow-md rounded" class="nc-form-remove-all color-transition bg-white transform hover:(text-primary ring ring-accent ring-opacity-100) active:translate-y-[1px] px-2 py-1 shadow-md rounded"
data-nc="nc-form-remove-all" data-testid="nc-form-remove-all"
@click="removeAllColumns" @click="removeAllColumns"
> >
<!-- Remove all --> <!-- Remove all -->
@ -443,7 +443,7 @@ watch(view, (nextView) => {
<a-card <a-card
size="small" size="small"
class="!border-0 color-transition cursor-pointer item hover:(bg-primary ring-1 ring-accent ring-opacity-100) bg-opacity-10 !rounded !shadow-lg" class="!border-0 color-transition cursor-pointer item hover:(bg-primary ring-1 ring-accent ring-opacity-100) bg-opacity-10 !rounded !shadow-lg"
:data-nc="`nc-form-hidden-column-${element.label || element.title}`" :data-testid="`nc-form-hidden-column-${element.label || element.title}`"
@mousedown="moved = false" @mousedown="moved = false"
@mousemove="moved = false" @mousemove="moved = false"
@mouseup="handleMouseUp(element, index)" @mouseup="handleMouseUp(element, index)"
@ -471,7 +471,7 @@ watch(view, (nextView) => {
<template #footer> <template #footer>
<div <div
class="my-4 select-none border-dashed border-2 border-gray-400 py-3 text-gray-400 text-center nc-drag-n-drop-to-hide" class="my-4 select-none border-dashed border-2 border-gray-400 py-3 text-gray-400 text-center nc-drag-n-drop-to-hide"
data-nc="nc-drag-n-drop-to-hide" data-testid="nc-drag-n-drop-to-hide"
> >
<!-- Drag and drop fields here to hide --> <!-- Drag and drop fields here to hide -->
{{ $t('msg.info.dragDropHide') }} {{ $t('msg.info.dragDropHide') }}
@ -529,7 +529,7 @@ watch(view, (nextView) => {
hide-details hide-details
placeholder="Form Title" placeholder="Form Title"
:bordered="false" :bordered="false"
data-nc="nc-form-heading" data-testid="nc-form-heading"
@blur="updateView" @blur="updateView"
@keydown.enter="updateView" @keydown.enter="updateView"
/> />
@ -549,7 +549,7 @@ watch(view, (nextView) => {
:placeholder="$t('msg.info.formDesc')" :placeholder="$t('msg.info.formDesc')"
:bordered="false" :bordered="false"
:disabled="!isEditable" :disabled="!isEditable"
data-nc="nc-form-sub-heading" data-testid="nc-form-sub-heading"
@blur="updateView" @blur="updateView"
@click="updateView" @click="updateView"
/> />
@ -579,7 +579,7 @@ watch(view, (nextView) => {
'bg-primary bg-opacity-5 ring-0.5 ring-accent ring-opacity-100': activeRow === element.title, 'bg-primary bg-opacity-5 ring-0.5 ring-accent ring-opacity-100': activeRow === element.title,
}, },
]" ]"
data-nc="nc-form-fields" data-testid="nc-form-fields"
@click="activeRow = element.title" @click="activeRow = element.title"
> >
<div <div
@ -588,7 +588,7 @@ watch(view, (nextView) => {
> >
<MdiEyeOffOutline <MdiEyeOffOutline
class="opacity-0 nc-field-remove-icon" class="opacity-0 nc-field-remove-icon"
data-nc="nc-field-remove-icon" data-testid="nc-field-remove-icon"
@click.stop="hideColumn(index)" @click.stop="hideColumn(index)"
/> />
</div> </div>
@ -597,7 +597,7 @@ watch(view, (nextView) => {
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<span <span
class="text-gray-500 mr-2 nc-form-input-required" class="text-gray-500 mr-2 nc-form-input-required"
data-nc="nc-form-input-required" data-testid="nc-form-input-required"
@click=" @click="
() => { () => {
element.required = !element.required element.required = !element.required
@ -621,7 +621,7 @@ watch(view, (nextView) => {
v-model:value="element.label" v-model:value="element.label"
type="text" type="text"
class="form-meta-input nc-form-input-label" class="form-meta-input nc-form-input-label"
data-nc="nc-form-input-label" data-testid="nc-form-input-label"
:placeholder="$t('msg.info.formInput')" :placeholder="$t('msg.info.formInput')"
@change="updateColMeta(element)" @change="updateColMeta(element)"
> >
@ -633,7 +633,7 @@ watch(view, (nextView) => {
v-model:value="element.description" v-model:value="element.description"
type="text" type="text"
class="form-meta-input text-sm nc-form-input-help-text" class="form-meta-input text-sm nc-form-input-help-text"
data-nc="nc-form-input-help-text" data-testid="nc-form-input-help-text"
:placeholder="$t('msg.info.formHelpText')" :placeholder="$t('msg.info.formHelpText')"
@change="updateColMeta(element)" @change="updateColMeta(element)"
/> />
@ -646,7 +646,7 @@ watch(view, (nextView) => {
:column="{ ...element, title: element.label || element.title }" :column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)" :required="isRequired(element, element.required)"
:hide-menu="true" :hide-menu="true"
data-nc="nc-form-input-label" data-testid="nc-form-input-label"
/> />
<LazySmartsheetHeaderCell <LazySmartsheetHeaderCell
@ -654,7 +654,7 @@ watch(view, (nextView) => {
:column="{ ...element, title: element.label || element.title }" :column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)" :required="isRequired(element, element.required)"
:hide-menu="true" :hide-menu="true"
data-nc="nc-form-input-label" data-testid="nc-form-input-label"
/> />
</div> </div>
@ -669,7 +669,7 @@ watch(view, (nextView) => {
:row="row" :row="row"
class="nc-input" class="nc-input"
:class="`nc-form-input-${element.title.replaceAll(' ', '')}`" :class="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:data-nc="`nc-form-input-${element.title.replaceAll(' ', '')}`" :data-testid="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:column="element" :column="element"
@click.stop.prevent @click.stop.prevent
/> />
@ -685,14 +685,14 @@ watch(view, (nextView) => {
v-model="formState[element.title]" v-model="formState[element.title]"
class="nc-input" class="nc-input"
:class="`nc-form-input-${element.title.replaceAll(' ', '')}`" :class="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:data-nc="`nc-form-input-${element.title.replaceAll(' ', '')}`" :data-testid="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:column="element" :column="element"
:edit-enabled="true" :edit-enabled="true"
@click.stop.prevent @click.stop.prevent
/> />
</a-form-item> </a-form-item>
<div class="text-gray-500 text-xs" data-nc="nc-form-input-help-text-label">{{ element.description }}</div> <div class="text-gray-500 text-xs" data-testid="nc-form-input-help-text-label">{{ element.description }}</div>
</div> </div>
</template> </template>
@ -707,7 +707,7 @@ watch(view, (nextView) => {
</Draggable> </Draggable>
<div class="justify-center flex mt-6"> <div class="justify-center flex mt-6">
<button type="submit" class="uppercase scaling-btn nc-form-submit" data-nc="nc-form-submit" @click="submitForm"> <button type="submit" class="uppercase scaling-btn nc-form-submit" data-testid="nc-form-submit" @click="submitForm">
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>
</div> </div>
@ -729,7 +729,7 @@ watch(view, (nextView) => {
:rows="3" :rows="3"
hide-details hide-details
class="nc-form-after-submit-msg" class="nc-form-after-submit-msg"
data-nc="nc-form-after-submit-msg" data-testid="nc-form-after-submit-msg"
@change="updateView" @change="updateView"
/> />
@ -742,7 +742,7 @@ watch(view, (nextView) => {
v-e="[`a:form-view:submit-another-form`]" v-e="[`a:form-view:submit-another-form`]"
size="small" size="small"
class="nc-form-checkbox-submit-another-form" class="nc-form-checkbox-submit-another-form"
data-nc="nc-form-checkbox-submit-another-form" data-testid="nc-form-checkbox-submit-another-form"
@change="updateView" @change="updateView"
/> />
<span class="ml-4">{{ $t('msg.info.submitAnotherForm') }}</span> <span class="ml-4">{{ $t('msg.info.submitAnotherForm') }}</span>
@ -755,7 +755,7 @@ watch(view, (nextView) => {
v-e="[`a:form-view:show-blank-form`]" v-e="[`a:form-view:show-blank-form`]"
size="small" size="small"
class="nc-form-checkbox-show-blank-form" class="nc-form-checkbox-show-blank-form"
data-nc="nc-form-checkbox-show-blank-form" data-testid="nc-form-checkbox-show-blank-form"
@change="updateView" @change="updateView"
/> />
@ -768,7 +768,7 @@ watch(view, (nextView) => {
v-e="[`a:form-view:email-me`]" v-e="[`a:form-view:email-me`]"
size="small" size="small"
class="nc-form-checkbox-send-email" class="nc-form-checkbox-send-email"
data-nc="nc-form-checkbox-send-email" data-testid="nc-form-checkbox-send-email"
@change="onEmailChange" @change="onEmailChange"
/> />

4
packages/nc-gui/components/smartsheet/Gallery.vue

@ -163,14 +163,14 @@ watch(view, async (nextView) => {
</script> </script>
<template> <template>
<div class="flex flex-col h-full w-full overflow-auto nc-gallery" data-nc="nc-gallery-wrapper"> <div class="flex flex-col h-full w-full overflow-auto nc-gallery" data-testid="nc-gallery-wrapper">
<div class="nc-gallery-container grid gap-2 my-4 px-3"> <div class="nc-gallery-container grid gap-2 my-4 px-3">
<div v-for="record in data" :key="`record-${record.row.id}`"> <div v-for="record in data" :key="`record-${record.row.id}`">
<LazySmartsheetRow :row="record"> <LazySmartsheetRow :row="record">
<a-card <a-card
hoverable hoverable
class="!rounded-lg h-full overflow-hidden break-all max-w-[450px]" class="!rounded-lg h-full overflow-hidden break-all max-w-[450px]"
:data-nc="`nc-gallery-card-${record.row.id}`" :data-testid="`nc-gallery-card-${record.row.id}`"
@click="expandFormClick($event, record)" @click="expandFormClick($event, record)"
> >
<template v-if="galleryData?.fk_cover_image_col_id" #cover> <template v-if="galleryData?.fk_cover_image_col_id" #cover>

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

@ -22,6 +22,7 @@ import {
extractPkFromRow, extractPkFromRow,
inject, inject,
isColumnRequiredAndNull, isColumnRequiredAndNull,
isMac,
message, message,
onBeforeUnmount, onBeforeUnmount,
onClickOutside, onClickOutside,
@ -168,52 +169,118 @@ const { selectCell, selectBlock, selectedRange, clearRangeRows, startSelectRange
isPkAvail, isPkAvail,
clearCell, clearCell,
makeEditable, makeEditable,
(row?: number | null, col?: number | null) => { scrollToCell,
row = row ?? selected.row (e: KeyboardEvent) => {
col = col ?? selected.col // ignore navigating if picker(Date, Time, DateTime, Year)
if (row !== undefined && col !== undefined && row !== null && col !== null) { // or single/multi select options is open
// get active cell const activePickerOrDropdownEl = document.querySelector(
const rows = tbodyEl.value?.querySelectorAll('tr') '.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',
const cols = rows?.[row].querySelectorAll('td') )
const td = cols?.[col === 0 ? 0 : col + 1] if (activePickerOrDropdownEl) {
e.preventDefault()
if (!td || !gridWrapper.value) return return true
}
const { height: headerHeight } = tableHead.value!.getBoundingClientRect()
const tdScroll = getContainerScrollForElement(td, gridWrapper.value, { top: headerHeight, bottom: 9, right: 9 }) // if expanded form is active skip keyboard event handling
if (document.querySelector('.nc-drawer-expanded-form.active')) {
if (rows && row === rows.length - 2) { return true
// if last row make 'Add New Row' visible }
gridWrapper.value.scrollTo({
top: gridWrapper.value.scrollHeight,
left:
cols && col === cols.length - 2 // if corner cell
? gridWrapper.value.scrollWidth
: tdScroll.left,
behavior: 'smooth',
})
return
}
if (cols && col === cols.length - 2) { const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
// if last column make 'Add New Column' visible if (e.key === ' ') {
gridWrapper.value.scrollTo({ if (selected.row !== null && !editEnabled) {
top: tdScroll.top, e.preventDefault()
left: gridWrapper.value.scrollWidth, const row = data.value[selected.row]
behavior: 'smooth', expandForm(row)
}) return true
return }
} else if (e.key === 'Escape') {
if (editEnabled) {
editEnabled = false
return true
}
} else if (e.key === 'Enter') {
if (editEnabled) {
editEnabled = false
return true
} }
}
if (cmdOrCtrl) {
switch (e.key) {
case 'ArrowUp':
selected.row = 0
selected.col = selected.col ?? 0
scrollToCell?.()
editEnabled = false
return true
case 'ArrowDown':
selected.row = data.value.length - 1
selected.col = selected.col ?? 0
scrollToCell?.()
editEnabled = false
return true
case 'ArrowRight':
selected.row = selected.row ?? 0
selected.col = fields.value?.length - 1
scrollToCell?.()
editEnabled = false
return true
case 'ArrowLeft':
selected.row = selected.row ?? 0
selected.col = 0
scrollToCell?.()
editEnabled = false
return true
}
}
},
)
function scrollToCell(row?: number | null, col?: number | null) {
row = row ?? selected.row
col = col ?? selected.col
if (row !== undefined && col !== undefined && row !== null && col !== null) {
// get active cell
const rows = tbodyEl.value?.querySelectorAll('tr')
const cols = rows?.[row].querySelectorAll('td')
const td = cols?.[col === 0 ? 0 : col + 1]
if (!td || !gridWrapper.value) return
const { height: headerHeight } = tableHead.value!.getBoundingClientRect()
const tdScroll = getContainerScrollForElement(td, gridWrapper.value, { top: headerHeight, bottom: 9, right: 9 })
// scroll into the active cell if (rows && row === rows.length - 2) {
// if last row make 'Add New Row' visible
gridWrapper.value.scrollTo({
top: gridWrapper.value.scrollHeight,
left:
cols && col === cols.length - 2 // if corner cell
? gridWrapper.value.scrollWidth
: tdScroll.left,
behavior: 'smooth',
})
return
}
if (cols && col === cols.length - 2) {
// if last column make 'Add New Column' visible
gridWrapper.value.scrollTo({ gridWrapper.value.scrollTo({
top: tdScroll.top, top: tdScroll.top,
left: tdScroll.left, left: gridWrapper.value.scrollWidth,
behavior: 'smooth', behavior: 'smooth',
}) })
return
} }
},
) // scroll into the active cell
gridWrapper.value.scrollTo({
top: tdScroll.top,
left: tdScroll.left,
behavior: 'smooth',
})
}
}
onMounted(loadGridViewColumns) onMounted(loadGridViewColumns)
@ -234,7 +301,7 @@ const showLoading = ref(true)
const skipRowRemovalOnCancel = ref(false) const skipRowRemovalOnCancel = ref(false)
const expandForm = (row: Row, state?: Record<string, any>, fromToolbar = false) => { function expandForm(row: Row, state?: Record<string, any>, fromToolbar = false) {
const rowId = extractPkFromRow(row.row, meta.value?.columns as ColumnType[]) const rowId = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
if (rowId) { if (rowId) {
@ -325,7 +392,7 @@ useEventListener(document, 'keyup', async (e: KeyboardEvent) => {
/** On clicking outside of table reset active cell */ /** On clicking outside of table reset active cell */
const smartTable = ref(null) const smartTable = ref(null)
onClickOutside(smartTable, () => { onClickOutside(smartTable, (e) => {
clearRangeRows() clearRangeRows()
if (selected.col === null) return if (selected.col === null) return
@ -333,6 +400,23 @@ onClickOutside(smartTable, () => {
if (editEnabled && (isVirtualCol(activeCol) || activeCol.uidt === UITypes.JSON)) return if (editEnabled && (isVirtualCol(activeCol) || activeCol.uidt === UITypes.JSON)) return
// ignore unselecting if clicked inside or on the picker(Date, Time, DateTime, Year)
// or single/multi select options
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 (
e.target &&
activePickerOrDropdownEl &&
(activePickerOrDropdownEl === e.target || activePickerOrDropdownEl?.contains(e.target as Element))
)
return
// if expanded form is active skip resetting the active cell
if (document.querySelector('.nc-drawer-expanded-form.active')) {
return
}
selected.row = null selected.row = null
selected.col = null selected.col = null
}) })
@ -383,8 +467,8 @@ const saveOrUpdateRecords = async (args: { metaValue?: TableType; viewMetaValue?
currentRow.rowMeta.changed = false currentRow.rowMeta.changed = false
for (const field of (args.metaValue || meta.value)?.columns ?? []) { for (const field of (args.metaValue || meta.value)?.columns ?? []) {
if (isVirtualCol(field)) continue if (isVirtualCol(field)) continue
if (field.title! in currentRow.row && currentRow.row[field.title!] !== currentRow.oldRow[field.title!]) { if (currentRow.row[field.title!] !== currentRow.oldRow[field.title!]) {
await updateOrSaveRow(currentRow, field.title!, {}, args) await updateOrSaveRow(currentRow, field.title!, args)
} }
} }
} }
@ -460,8 +544,8 @@ watch(
</script> </script>
<template> <template>
<div class="relative flex flex-col h-full min-h-0 w-full" data-nc="nc-grid-wrapper"> <div class="relative flex flex-col h-full min-h-0 w-full" data-testid="nc-grid-wrapper">
<general-overlay :model-value="isLoading" inline transition class="!bg-opacity-15" data-nc="grid-load-spinner"> <general-overlay :model-value="isLoading" inline transition class="!bg-opacity-15" data-testid="grid-load-spinner">
<div class="flex items-center justify-center h-full w-full !bg-white !bg-opacity-85 z-1000"> <div class="flex items-center justify-center h-full w-full !bg-white !bg-opacity-85 z-1000">
<a-spin size="large" /> <a-spin size="large" />
</div> </div>
@ -480,8 +564,8 @@ watch(
> >
<thead ref="tableHead"> <thead ref="tableHead">
<tr class="nc-grid-header border-1 bg-gray-100 sticky top[-1px]"> <tr class="nc-grid-header border-1 bg-gray-100 sticky top[-1px]">
<th data-nc="grid-id-column"> <th data-testid="grid-id-column">
<div class="w-full h-full bg-gray-100 flex min-w-[70px] pl-5 pr-1 items-center" data-nc="nc-check-all"> <div class="w-full h-full bg-gray-100 flex min-w-[70px] pl-5 pr-1 items-center" data-testid="nc-check-all">
<template v-if="!readOnly"> <template v-if="!readOnly">
<div class="nc-no-label text-gray-500" :class="{ hidden: selectedAllRecords }">#</div> <div class="nc-no-label text-gray-500" :class="{ hidden: selectedAllRecords }">#</div>
<div <div
@ -546,8 +630,8 @@ watch(
<tbody ref="tbodyEl" @selectstart.prevent> <tbody ref="tbodyEl" @selectstart.prevent>
<LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row"> <LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row">
<template #default="{ state }"> <template #default="{ state }">
<tr class="nc-grid-row" :data-nc="`grid-row-${rowIndex}`"> <tr class="nc-grid-row" :data-testid="`grid-row-${rowIndex}`">
<td key="row-index" class="caption nc-grid-cell pl-5 pr-1" :data-nc="`cell-Id-${rowIndex}`"> <td key="row-index" class="caption nc-grid-cell pl-5 pr-1" :data-testid="`cell-Id-${rowIndex}`">
<div class="items-center flex gap-1 min-w-[55px]"> <div class="items-center flex gap-1 min-w-[55px]">
<div <div
v-if="!readOnly || !isLocked" v-if="!readOnly || !isLocked"
@ -568,10 +652,14 @@ watch(
<div <div
v-if="!readOnly || hasRole('commenter', true) || hasRole('viewer', true)" v-if="!readOnly || hasRole('commenter', true) || hasRole('viewer', true)"
class="nc-expand" class="nc-expand"
:data-nc="`nc-expand-${rowIndex}`" :data-testid="`nc-expand-${rowIndex}`"
:class="{ 'nc-comment': row.rowMeta?.commentCount }" :class="{ 'nc-comment': row.rowMeta?.commentCount }"
> >
<a-spin v-if="row.rowMeta.saving" class="!flex items-center" :data-nc="`row-save-spinner-${rowIndex}`" /> <a-spin
v-if="row.rowMeta.saving"
class="!flex items-center"
:data-testid="`row-save-spinner-${rowIndex}`"
/>
<template v-else> <template v-else>
<span <span
v-if="row.rowMeta?.commentCount" v-if="row.rowMeta?.commentCount"
@ -605,7 +693,7 @@ watch(
(hasEditPermission && selectedRange(rowIndex, colIndex)), (hasEditPermission && selectedRange(rowIndex, colIndex)),
'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row), 'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row),
}" }"
:data-nc="`cell-${columnObj.title}-${rowIndex}`" :data-testid="`cell-${columnObj.title}-${rowIndex}`"
:data-key="rowIndex + columnObj.id" :data-key="rowIndex + columnObj.id"
:data-col="columnObj.id" :data-col="columnObj.id"
:data-title="columnObj.title" :data-title="columnObj.title"

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

@ -309,7 +309,7 @@ watch(view, async (nextView) => {
</script> </script>
<template> <template>
<div class="flex h-full bg-white px-2" data-nc="nc-kanban-wrapper"> <div class="flex h-full bg-white px-2" data-testid="nc-kanban-wrapper">
<div ref="kanbanContainerRef" class="nc-kanban-container flex my-4 px-3 overflow-x-scroll overflow-y-hidden"> <div ref="kanbanContainerRef" class="nc-kanban-container flex my-4 px-3 overflow-x-scroll overflow-y-hidden">
<a-dropdown v-model:visible="contextMenu" :trigger="['contextmenu']" overlay-class-name="nc-dropdown-kanban-context-menu"> <a-dropdown v-model:visible="contextMenu" :trigger="['contextmenu']" overlay-class-name="nc-dropdown-kanban-context-menu">
<!-- Draggable Stack --> <!-- Draggable Stack -->

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

@ -19,7 +19,7 @@ const page = computed({
<template> <template>
<div class="flex items-center mb-1"> <div class="flex items-center mb-1">
<span v-if="count !== null && count !== Infinity" class="caption ml-5 text-gray-500" data-nc="grid-pagination"> <span v-if="count !== null && count !== Infinity" class="caption ml-5 text-gray-500" data-testid="grid-pagination">
{{ count }} {{ count !== 1 ? $t('objects.records') : $t('objects.record') }} {{ count }} {{ count !== 1 ? $t('objects.records') : $t('objects.record') }}
</span> </span>

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

@ -124,7 +124,7 @@ onMounted(() => {
:class="{ '!w-[600px]': formState.uidt === UITypes.Formula }" :class="{ '!w-[600px]': formState.uidt === UITypes.Formula }"
@click.stop @click.stop
> >
<a-form v-model="formState" no-style name="column-create-or-edit" layout="vertical" data-nc="add-or-edit-column"> <a-form v-model="formState" no-style name="column-create-or-edit" layout="vertical" data-testid="add-or-edit-column">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<a-form-item :label="$t('labels.columnName')" v-bind="validateInfos.title"> <a-form-item :label="$t('labels.columnName')" v-bind="validateInfos.title">
<a-input <a-input

6
packages/nc-gui/components/smartsheet/column/SelectOptions.vue

@ -137,7 +137,7 @@ watch(inputs, () => {
v-if="!isKanban" v-if="!isKanban"
small small
class="nc-child-draggable-icon handle" class="nc-child-draggable-icon handle"
:data-nc="`select-option-column-handle-icon-${element.title}`" :data-testid="`select-option-column-handle-icon-${element.title}`"
/> />
<a-dropdown <a-dropdown
v-model:visible="colorMenus[index]" v-model:visible="colorMenus[index]"
@ -162,14 +162,14 @@ watch(inputs, () => {
ref="inputs" ref="inputs"
v-model:value="element.title" v-model:value="element.title"
class="caption" class="caption"
:data-nc="`select-column-option-input-${index}`" :data-testid="`select-column-option-input-${index}`"
@change="optionChanged(element.id)" @change="optionChanged(element.id)"
/> />
<MdiClose <MdiClose
class="ml-2 hover:!text-black" class="ml-2 hover:!text-black"
:style="{ color: 'red' }" :style="{ color: 'red' }"
:data-nc="`select-column-option-remove-${index}`" :data-testid="`select-column-option-remove-${index}`"
@click="removeOption(index)" @click="removeOption(index)"
/> />
</div> </div>

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

@ -45,9 +45,11 @@ const { copy } = useClipboard()
const copyRecordUrl = () => { const copyRecordUrl = () => {
copy( copy(
`${dashboardUrl?.value}#/${route.params.projectType}/${route.params.projectId}/${route.params.type}/${meta.value?.title}${ encodeURI(
props.view ? `/${props.view.title}` : '' `${dashboardUrl?.value}#/${route.params.projectType}/${route.params.projectId}/${route.params.type}/${meta.value?.title}${
}?rowId=${primaryKey.value}`, props.view ? `/${props.view.title}` : ''
}?rowId=${primaryKey.value}`,
),
) )
message.success('Copied to clipboard') message.success('Copied to clipboard')
} }

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

@ -137,6 +137,7 @@ export default {
:body-style="{ 'padding': 0, 'display': 'flex', 'flex-direction': 'column' }" :body-style="{ 'padding': 0, 'display': 'flex', 'flex-direction': 'column' }"
:closable="false" :closable="false"
class="nc-drawer-expanded-form" class="nc-drawer-expanded-form"
:class="{ 'active': isExpanded }"
> >
<SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" /> <SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" />
@ -150,7 +151,7 @@ export default {
:key="col.title" :key="col.title"
class="mt-2 py-2" class="mt-2 py-2"
:class="`nc-expand-col-${col.title}`" :class="`nc-expand-col-${col.title}`"
:data-nc="`nc-expand-col-${col.title}`" :data-testid="`nc-expand-col-${col.title}`"
> >
<LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" /> <LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" />

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

@ -167,12 +167,12 @@ function onStopEdit() {
<template> <template>
<a-menu-item <a-menu-item
class="select-none group !flex !items-center !my-0 hover:(bg-primary !bg-opacity-5)" class="select-none group !flex !items-center !my-0 hover:(bg-primary !bg-opacity-5)"
:data-nc="`view-sidebar-view-${vModel.alias || vModel.title}`" :data-testid="`view-sidebar-view-${vModel.alias || vModel.title}`"
@dblclick.stop="onDblClick" @dblclick.stop="onDblClick"
@click.stop="onClick" @click.stop="onClick"
> >
<div v-e="['a:view:open', { view: vModel.type }]" class="text-xs flex items-center w-full gap-2" data-nc="view-item"> <div v-e="['a:view:open', { view: vModel.type }]" class="text-xs flex items-center w-full gap-2" data-testid="view-item">
<div class="flex w-auto" :data-nc="`view-sidebar-drag-handle-${vModel.alias || vModel.title}`"> <div class="flex w-auto" :data-testid="`view-sidebar-drag-handle-${vModel.alias || vModel.title}`">
<MdiDrag <MdiDrag
class="nc-drag-icon hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 !cursor-move" class="nc-drag-icon hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 !cursor-move"
@click.stop.prevent @click.stop.prevent
@ -194,7 +194,7 @@ function onStopEdit() {
<div class="flex-1" /> <div class="flex-1" />
<template v-if="!isEditing && !isLocked && isUIAllowed('virtualViewsCreateOrEdit')"> <template v-if="!isEditing && !isLocked && isUIAllowed('virtualViewsCreateOrEdit')">
<div class="flex items-center gap-1" :data-nc="`view-sidebar-view-actions-${vModel.alias || vModel.title}`"> <div class="flex items-center gap-1" :data-testid="`view-sidebar-view-actions-${vModel.alias || vModel.title}`">
<a-tooltip placement="left"> <a-tooltip placement="left">
<template #title> <template #title>
{{ $t('activity.copyView') }} {{ $t('activity.copyView') }}

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

@ -82,7 +82,7 @@ const filterAutoSaveLoc = computed({
ref="filterComp" ref="filterComp"
class="nc-table-toolbar-menu shadow-lg" class="nc-table-toolbar-menu shadow-lg"
:auto-save="filterAutoSave" :auto-save="filterAutoSave"
data-nc="nc-filter-menu" data-testid="nc-filter-menu"
@update:filters-length="filtersLength = $event" @update:filters-length="filtersLength = $event"
> >
<div v-if="!isPublic" class="flex items-end mt-2 min-h-[30px]" @click.stop> <div v-if="!isPublic" class="flex items-end mt-2 min-h-[30px]" @click.stop>

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

@ -139,7 +139,7 @@ const getIcon = (c: ColumnType) =>
<template #overlay> <template #overlay>
<div <div
class="p-3 min-w-[280px] bg-gray-50 shadow-lg nc-table-toolbar-menu max-h-[max(80vh,500px)] overflow-auto !border" class="p-3 min-w-[280px] bg-gray-50 shadow-lg nc-table-toolbar-menu max-h-[max(80vh,500px)] overflow-auto !border"
data-nc="nc-fields-menu" data-testid="nc-fields-menu"
@click.stop @click.stop
> >
<a-card <a-card
@ -167,7 +167,7 @@ const getIcon = (c: ColumnType) =>
v-show="filteredFieldList.includes(field)" v-show="filteredFieldList.includes(field)"
:key="field.id" :key="field.id"
class="px-2 py-1 flex items-center" class="px-2 py-1 flex items-center"
:data-nc="`nc-fields-menu-${field.title}`" :data-testid="`nc-fields-menu-${field.title}`"
@click.stop @click.stop
> >
<a-checkbox <a-checkbox

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

@ -40,6 +40,14 @@ const passwordProtected = ref(false)
const shared = ref<SharedView>({ id: '', meta: {}, password: undefined }) const shared = ref<SharedView>({ id: '', meta: {}, password: undefined })
const withRTL = computed({
get: () => !!shared.value.meta.rtl,
set: (rtl) => {
shared.value.meta = { ...shared.value.meta, rtl }
updateSharedViewMeta()
},
})
const transitionDuration = computed({ const transitionDuration = computed({
get: () => shared.value.meta.transitionDuration || 250, get: () => shared.value.meta.transitionDuration || 250,
set: (duration) => { set: (duration) => {
@ -105,7 +113,7 @@ const sharedViewUrl = computed(() => {
viewType = 'view' viewType = 'view'
} }
return `${dashboardUrl?.value}#/nc/${viewType}/${shared.value.uuid}` return encodeURI(`${dashboardUrl?.value}#/nc/${viewType}/${shared.value.uuid}`)
}) })
async function saveAllowCSVDownload() { async function saveAllowCSVDownload() {
@ -212,8 +220,7 @@ watch(passwordProtected, (value) => {
wrap-class-name="nc-modal-share-view" wrap-class-name="nc-modal-share-view"
> >
<div <div
data-cy="nc-modal-share-view__link" data-testid="nc-modal-share-view__link"
data-nc="nc-modal-share-view__link"
class="share-link-box !bg-primary !bg-opacity-5 ring-1 ring-accent ring-opacity-100" class="share-link-box !bg-primary !bg-opacity-5 ring-1 ring-accent ring-opacity-100"
> >
<div class="flex-1 h-min text-xs">{{ sharedViewUrl }}</div> <div class="flex-1 h-min text-xs">{{ sharedViewUrl }}</div>
@ -235,8 +242,7 @@ watch(passwordProtected, (value) => {
<a-checkbox <a-checkbox
v-if="shared.type === ViewTypes.FORM" v-if="shared.type === ViewTypes.FORM"
v-model:checked="surveyMode" v-model:checked="surveyMode"
data-cy="nc-modal-share-view__survey-mode" data-testid="nc-modal-share-view__survey-mode"
data-nc="nc-modal-share-view__survey-mode"
class="!text-sm" class="!text-sm"
> >
Use Survey Mode Use Survey Mode
@ -250,7 +256,7 @@ watch(passwordProtected, (value) => {
</template> </template>
<a-input <a-input
v-model:value="transitionDuration" v-model:value="transitionDuration"
data-cy="nc-form-signin__email" data-testid="nc-form-signin__email"
size="small" size="small"
class="!w-32" class="!w-32"
type="number" type="number"
@ -266,8 +272,7 @@ watch(passwordProtected, (value) => {
<a-checkbox <a-checkbox
v-if="shared.type === ViewTypes.FORM" v-if="shared.type === ViewTypes.FORM"
v-model:checked="viewTheme" v-model:checked="viewTheme"
data-cy="nc-modal-share-view__with-theme" data-testid="nc-modal-share-view__with-theme"
data-nc="nc-modal-share-view__with-theme"
class="!text-sm" class="!text-sm"
> >
Use Theme Use Theme
@ -276,26 +281,37 @@ watch(passwordProtected, (value) => {
<Transition name="layout" mode="out-in"> <Transition name="layout" mode="out-in">
<div v-if="viewTheme" class="flex pl-6"> <div v-if="viewTheme" class="flex pl-6">
<LazyGeneralColorPicker <LazyGeneralColorPicker
data-cy="nc-modal-share-view__theme-picker" data-testid="nc-modal-share-view__theme-picker"
class="!p-0" class="!p-0"
:model-value="shared.meta.theme?.primaryColor" :model-value="shared.meta.theme?.primaryColor"
:colors="projectThemeColors" :colors="projectThemeColors"
:row-size="9" :row-size="9"
:advanced="false" :advanced="false"
data-nc="nc-modal-share-view__theme-picker"
@input="onChangeTheme" @input="onChangeTheme"
/> />
</div> </div>
</Transition> </Transition>
</div> </div>
<div>
<!-- use RTL orientation in form - todo: i18n -->
<a-checkbox
v-if="shared.type === ViewTypes.FORM"
v-model:checked="withRTL"
data-testid="nc-modal-share-view__locale"
class="!text-sm"
>
<!-- todo i18n -->
RTL Orientation
</a-checkbox>
</div>
<div> <div>
<!-- Password Protection --> <!-- Password Protection -->
<a-checkbox <a-checkbox
v-model:checked="passwordProtected" v-model:checked="passwordProtected"
data-cy="nc-modal-share-view__with-password" data-testid="nc-modal-share-view__with-password"
class="!text-sm !my-1" class="!text-sm !my-1"
data-nc="nc-modal-share-view__with-password"
> >
{{ $t('msg.info.beforeEnablePwd') }} {{ $t('msg.info.beforeEnablePwd') }}
</a-checkbox> </a-checkbox>
@ -304,8 +320,7 @@ watch(passwordProtected, (value) => {
<div v-if="passwordProtected" class="pl-6 flex gap-2 mt-2 mb-4"> <div v-if="passwordProtected" class="pl-6 flex gap-2 mt-2 mb-4">
<a-input <a-input
v-model:value="shared.password" v-model:value="shared.password"
data-cy="nc-modal-share-view__password" data-testid="nc-modal-share-view__password"
data-nc="nc-modal-share-view__password"
size="small" size="small"
class="!text-xs max-w-[250px]" class="!text-xs max-w-[250px]"
type="password" type="password"
@ -313,8 +328,7 @@ watch(passwordProtected, (value) => {
/> />
<a-button <a-button
data-cy="nc-modal-share-view__save-password" data-testid="nc-modal-share-view__save-password"
data-nc="nc-modal-share-view__save-password"
size="small" size="small"
class="!text-xs" class="!text-xs"
@click="saveShareLinkPassword" @click="saveShareLinkPassword"
@ -333,8 +347,7 @@ watch(passwordProtected, (value) => {
(shared.type === ViewTypes.GRID || shared.type === ViewTypes.KANBAN || shared.type === ViewTypes.GALLERY) (shared.type === ViewTypes.GRID || shared.type === ViewTypes.KANBAN || shared.type === ViewTypes.GALLERY)
" "
v-model:checked="allowCSVDownload" v-model:checked="allowCSVDownload"
data-cy="nc-modal-share-view__with-csv-download" data-testid="nc-modal-share-view__with-csv-download"
data-nc="nc-modal-share-view__with-csv-download"
class="!text-sm" class="!text-sm"
> >
{{ $t('labels.downloadAllowed') }} {{ $t('labels.downloadAllowed') }}

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

@ -55,7 +55,7 @@ watch(
<template #overlay> <template #overlay>
<div <div
class="bg-gray-50 p-6 shadow-lg menu-filter-dropdown min-w-[400px] max-h-[max(80vh,500px)] overflow-auto !border" class="bg-gray-50 p-6 shadow-lg menu-filter-dropdown min-w-[400px] max-h-[max(80vh,500px)] overflow-auto !border"
data-nc="nc-sorts-menu" data-testid="nc-sorts-menu"
> >
<div v-if="sorts?.length" class="sort-grid mb-2" @click.stop> <div v-if="sorts?.length" class="sort-grid mb-2" @click.stop>
<template v-for="(sort, i) in sorts || []" :key="i"> <template v-for="(sort, i) in sorts || []" :key="i">

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

@ -103,7 +103,7 @@ const { isSqlView } = useSmartsheetStoreOrThrow()
</a-button> </a-button>
<template #overlay> <template #overlay>
<a-menu class="ml-6 !text-sm !px-0 !py-2 !rounded" data-nc="toolbar-actions"> <a-menu class="ml-6 !text-sm !px-0 !py-2 !rounded" data-testid="toolbar-actions">
<a-menu-item-group> <a-menu-item-group>
<a-sub-menu <a-sub-menu
v-if="isUIAllowed('view-type')" v-if="isUIAllowed('view-type')"

2
packages/nc-gui/components/tabs/auth/user-management/ShareBase.vue

@ -136,7 +136,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="flex flex-col w-full" data-nc="nc-share-base-sub-modal"> <div class="flex flex-col w-full" data-testid="nc-share-base-sub-modal">
<div class="flex flex-row items-center space-x-0.5 pl-2 h-[0.8rem]"> <div class="flex flex-row items-center space-x-0.5 pl-2 h-[0.8rem]">
<MdiOpenInNew /> <MdiOpenInNew />

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

@ -15,6 +15,7 @@ import {
inject, inject,
ref, ref,
useProvideLTARStore, useProvideLTARStore,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow, useSmartsheetRowStoreOrThrow,
useUIPermission, useUIPermission,
} from '#imports' } from '#imports'
@ -70,6 +71,15 @@ const unlinkRef = async (rec: Record<string, any>) => {
await unlink(rec) await unlink(rec)
} }
} }
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
listItemsDlg.value = true
e.stopPropagation()
break
}
})
</script> </script>
<template> <template>

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

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { CellValueInj, ColumnInj, computed, handleTZ, inject, ref, replaceUrlsWithLink, useProject } from '#imports' import { CellValueInj, ColumnInj, computed, handleTZ, inject, ref, refAutoReset, replaceUrlsWithLink, useProject } from '#imports'
// todo: column type doesn't have required property `error` - throws in typecheck // todo: column type doesn't have required property `error` - throws in typecheck
const column = inject(ColumnInj) as Ref<ColumnType & { colOptions: { error: any } }> const column = inject(ColumnInj) as Ref<ColumnType & { colOptions: { error: any } }>
@ -10,19 +10,25 @@ const cellValue = inject(CellValueInj)
const { isPg } = useProject() const { isPg } = useProject()
const showEditFormulaWarning = ref(false)
const showEditFormulaWarningMessage = () => {
showEditFormulaWarning.value = true
setTimeout(() => {
showEditFormulaWarning.value = false
}, 3000)
}
const result = computed(() => (isPg.value ? handleTZ(cellValue?.value) : cellValue?.value)) const result = computed(() => (isPg.value ? handleTZ(cellValue?.value) : cellValue?.value))
const urls = computed(() => replaceUrlsWithLink(result.value)) 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
}
})
</script> </script>
<template> <template>
@ -35,15 +41,19 @@ const urls = computed(() => replaceUrlsWithLink(result.value))
<span>ERR!</span> <span>ERR!</span>
</a-tooltip> </a-tooltip>
<div class="p-2" @dblclick="showEditFormulaWarningMessage"> <div class="p-2" @dblclick="showEditFormulaWarning = true">
<div v-if="urls" v-html="urls" /> <div v-if="urls" v-html="urls" />
<div v-else>{{ result }}</div> <div v-else>{{ result }}</div>
<div v-if="showEditFormulaWarning" class="text-left text-wrap mt-2 text-[#e65100]"> <div v-if="showEditFormulaWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n --> <!-- TODO: i18n -->
Warning: Formula fields should be configured in the field menu dropdown. Warning: Formula fields should be configured in the field menu dropdown.
</div> </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>
</div> </div>
</div> </div>
</template> </template>

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

@ -13,6 +13,7 @@ import {
inject, inject,
ref, ref,
useProvideLTARStore, useProvideLTARStore,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow, useSmartsheetRowStoreOrThrow,
useUIPermission, useUIPermission,
} from '#imports' } from '#imports'
@ -81,6 +82,15 @@ const onAttachRecord = () => {
childListDlg.value = false childListDlg.value = false
listItemsDlg.value = true listItemsDlg.value = true
} }
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
listItemsDlg.value = true
e.stopPropagation()
break
}
})
</script> </script>
<template> <template>

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

@ -11,6 +11,7 @@ import {
computed, computed,
inject, inject,
provide, provide,
refAutoReset,
useColumn, useColumn,
useMetas, useMetas,
} from '#imports' } from '#imports'
@ -46,45 +47,73 @@ provide(MetaInj, lookupTableMeta)
provide(CellUrlDisableOverlayInj, ref(true)) provide(CellUrlDisableOverlayInj, ref(true))
const lookupColumnMetaProps = useColumn(lookupColumn) const lookupColumnMetaProps = useColumn(lookupColumn)
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
}
})
</script> </script>
<template> <template>
<div class="h-full flex gap-1 overflow-x-auto p-1"> <div class="h-full">
<template v-if="lookupColumn"> <div class="h-full flex gap-1 overflow-x-auto p-1">
<!-- Render virtual cell --> <template v-if="lookupColumn">
<div v-if="isVirtualCol(lookupColumn)"> <!-- Render virtual cell -->
<template <div v-if="isVirtualCol(lookupColumn)">
v-if="lookupColumn.uidt === UITypes.LinkToAnotherRecord && lookupColumn.colOptions.type === RelationTypes.BELONGS_TO" <template
> v-if="lookupColumn.uidt === UITypes.LinkToAnotherRecord && lookupColumn.colOptions.type === RelationTypes.BELONGS_TO"
<LazySmartsheetVirtualCell >
<LazySmartsheetVirtualCell
v-for="(v, i) of arrValue"
:key="i"
:edit-enabled="false"
:model-value="v"
:column="lookupColumn"
/>
</template>
<LazySmartsheetVirtualCell v-else :edit-enabled="false" :model-value="arrValue" :column="lookupColumn" />
</div>
<!-- Render normal cell -->
<template v-else>
<!-- For attachment cell avoid adding chip style -->
<div
v-for="(v, i) of arrValue" v-for="(v, i) of arrValue"
:key="i" :key="i"
:edit-enabled="false" class="min-w-max"
:model-value="v" :class="{
:column="lookupColumn" 'bg-gray-100 px-1 rounded-full flex-1': !lookupColumnMetaProps.isAttachment,
/> ' border-gray-200 rounded border-1': ![UITypes.Attachment, UITypes.MultiSelect, UITypes.SingleSelect].includes(
lookupColumn.uidt,
),
}"
>
<LazySmartsheetCell :model-value="v" :column="lookupColumn" :edit-enabled="false" :virtual="true" />
</div>
</template> </template>
<LazySmartsheetVirtualCell v-else :edit-enabled="false" :model-value="arrValue" :column="lookupColumn" />
</div>
<!-- Render normal cell -->
<template v-else>
<!-- For attachment cell avoid adding chip style -->
<div
v-for="(v, i) of arrValue"
:key="i"
class="min-w-max"
:class="{
'bg-gray-100 px-1 rounded-full flex-1': !lookupColumnMetaProps.isAttachment,
' border-gray-200 rounded border-1': ![UITypes.Attachment, UITypes.MultiSelect, UITypes.SingleSelect].includes(
lookupColumn.uidt,
),
}"
>
<LazySmartsheetCell :model-value="v" :column="lookupColumn" :edit-enabled="false" :virtual="true" />
</div>
</template> </template>
</template> </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.
</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.
</div>
</div>
</div> </div>
</template> </template>

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

@ -2,6 +2,7 @@
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { import {
ActiveCellInj,
CellValueInj, CellValueInj,
ColumnInj, ColumnInj,
IsFormInj, IsFormInj,
@ -14,6 +15,7 @@ import {
inject, inject,
ref, ref,
useProvideLTARStore, useProvideLTARStore,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow, useSmartsheetRowStoreOrThrow,
useUIPermission, useUIPermission,
} from '#imports' } from '#imports'
@ -82,6 +84,15 @@ const onAttachRecord = () => {
childListDlg.value = false childListDlg.value = false
listItemsDlg.value = true listItemsDlg.value = true
} }
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
listItemsDlg.value = true
e.stopPropagation()
break
}
})
</script> </script>
<template> <template>

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

@ -1,11 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
import { CellValueInj, inject } from '#imports' import { CellValueInj, inject, refAutoReset } from '#imports'
const value = inject(CellValueInj) 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
}
})
</script> </script>
<template> <template>
<span class="text-center pl-3"> <div>
{{ value }} <span class="text-center pl-3">
</span> {{ value }}
</span>
<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.
</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.
</div>
</div>
</div>
</template> </template>

13
packages/nc-gui/components/virtual-cell/components/ListChildItems.vue

@ -102,14 +102,19 @@ watch(
<div class="max-h-[max(calc(100vh_-_300px)_,500px)] flex flex-col py-6"> <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"> <div class="flex mb-4 items-center gap-2 px-12">
<div class="flex-1" /> <div class="flex-1" />
<MdiReload v-if="!isForm" class="cursor-pointer text-gray-500" data-cy="nc-child-list-reload" @click="loadChildrenList" /> <MdiReload
v-if="!isForm"
class="cursor-pointer text-gray-500"
data-testid="nc-child-list-reload"
@click="loadChildrenList"
/>
<a-button <a-button
v-if="!readonly" v-if="!readonly"
type="primary" type="primary"
ghost ghost
class="!text-xs" class="!text-xs"
data-cy="nc-child-list-button-link-to" data-testid="nc-child-list-button-link-to"
size="small" size="small"
@click="emit('attachRecord')" @click="emit('attachRecord')"
> >
@ -143,13 +148,13 @@ watch(
<div v-if="!readonly" class="flex gap-2"> <div v-if="!readonly" class="flex gap-2">
<MdiLinkVariantRemove <MdiLinkVariantRemove
class="text-xs text-grey hover:(!text-red-500) cursor-pointer" class="text-xs text-grey hover:(!text-red-500) cursor-pointer"
data-cy="nc-child-list-icon-unlink" data-testid="nc-child-list-icon-unlink"
@click.stop="unlinkRow(row)" @click.stop="unlinkRow(row)"
/> />
<MdiDeleteOutline <MdiDeleteOutline
v-if="!readonly && !isPublic" v-if="!readonly && !isPublic"
class="text-xs text-grey hover:(!text-red-500) cursor-pointer" class="text-xs text-grey hover:(!text-red-500) cursor-pointer"
data-cy="nc-child-list-icon-delete" data-testid="nc-child-list-icon-delete"
@click.stop="deleteRelatedRow(row, unlinkIfNewRow)" @click.stop="deleteRelatedRow(row, unlinkIfNewRow)"
/> />
</div> </div>

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

@ -1,18 +1,20 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Card } from 'ant-design-vue'
import { RelationTypes, UITypes } from 'nocodb-sdk' import { RelationTypes, UITypes } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk' import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { import {
ColumnInj, ColumnInj,
Empty, Empty,
IsPublicInj,
computed, computed,
inject, inject,
ref, ref,
useLTARStoreOrThrow, useLTARStoreOrThrow,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow, useSmartsheetRowStoreOrThrow,
useVModel, useVModel,
watch, watch,
} from '#imports' } from '#imports'
import { IsPublicInj } from '~/context'
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
@ -38,6 +40,8 @@ const { addLTARRef, isNew } = useSmartsheetRowStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
const selectedRowIndex = ref(0)
const linkRow = async (row: Record<string, any>) => { const linkRow = async (row: Record<string, any>) => {
if (isNew.value) { if (isNew.value) {
addLTARRef(row, column?.value as ColumnType) addLTARRef(row, column?.value as ColumnType)
@ -54,6 +58,7 @@ watch(vModel, (nextVal, prevVal) => {
childrenExcludedListPagination.query = '' childrenExcludedListPagination.query = ''
childrenExcludedListPagination.page = 1 childrenExcludedListPagination.page = 1
loadChildrenExcludedList() loadChildrenExcludedList()
selectedRowIndex.value = 0
} }
}) })
@ -98,6 +103,49 @@ const newRowState = computed(() => {
watch(expandedFormDlg, (nexVal) => { watch(expandedFormDlg, (nexVal) => {
if (!nexVal && !isNew.value) vModel.value = false if (!nexVal && !isNew.value) vModel.value = false
}) })
useSelectedCellKeyupListener(vModel, (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowLeft':
e.stopPropagation()
e.preventDefault()
if (childrenExcludedListPagination.page > 1) childrenExcludedListPagination.page--
break
case 'ArrowRight':
e.stopPropagation()
e.preventDefault()
if (
childrenExcludedList.value?.pageInfo &&
childrenExcludedListPagination.page <
(childrenExcludedList.value.pageInfo.totalRows || 1) / childrenExcludedListPagination.size
)
childrenExcludedListPagination.page++
break
case 'ArrowUp':
selectedRowIndex.value = Math.max(0, selectedRowIndex.value - 1)
e.stopPropagation()
e.preventDefault()
break
case 'ArrowDown':
selectedRowIndex.value = Math.min(childrenExcludedList.value?.list?.length - 1, selectedRowIndex.value + 1)
e.stopPropagation()
e.preventDefault()
break
case 'Enter':
{
const selectedRow = childrenExcludedList.value?.list?.[selectedRowIndex.value]
if (selectedRow) {
linkRow(selectedRow)
e.stopPropagation()
e.preventDefault()
}
}
break
}
})
const activeRow = (vNode?: InstanceType<typeof Card>) => {
vNode?.$el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
</script> </script>
<template> <template>
@ -115,6 +163,7 @@ watch(expandedFormDlg, (nexVal) => {
:placeholder="$t('placeholder.filterQuery')" :placeholder="$t('placeholder.filterQuery')"
class="max-w-[200px]" class="max-w-[200px]"
size="small" size="small"
@keydown.capture.stop
/> />
<div class="flex-1" /> <div class="flex-1" />
@ -132,7 +181,9 @@ watch(expandedFormDlg, (nexVal) => {
<a-card <a-card
v-for="(refRow, i) in childrenExcludedList?.list ?? []" v-for="(refRow, i) in childrenExcludedList?.list ?? []"
:key="i" :key="i"
:ref="selectedRowIndex === i ? activeRow : null"
class="!my-4 cursor-pointer hover:(!bg-gray-200/50 shadow-md) group" class="!my-4 cursor-pointer hover:(!bg-gray-200/50 shadow-md) group"
:class="{ 'nc-selected-row': selectedRowIndex === i }"
@click="linkRow(refRow)" @click="linkRow(refRow)"
> >
{{ refRow[relatedTablePrimaryValueProp] }} {{ refRow[relatedTablePrimaryValueProp] }}
@ -175,4 +226,8 @@ watch(expandedFormDlg, (nexVal) => {
:deep(.ant-pagination-item a) { :deep(.ant-pagination-item a) {
line-height: 21px !important; line-height: 21px !important;
} }
:deep(.nc-selected-row) {
@apply !ring;
}
</style> </style>

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

@ -193,6 +193,10 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
formState.value.table_name = meta.value?.table_name formState.value.table_name = meta.value?.table_name
// formState.value.title = formState.value.column_name // formState.value.title = formState.value.column_name
if (column.value) { if (column.value) {
// reset column validation if column is not to be validated
if (!columnToValidate.includes(formState.value.uidt)) {
formState.value.validate = ''
}
await $api.dbTableColumn.update(column.value?.id as string, formState.value) await $api.dbTableColumn.update(column.value?.id as string, formState.value)
// Column updated // Column updated
message.success(t('msg.success.columnUpdated')) message.success(t('msg.success.columnUpdated'))

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

@ -1,6 +1,6 @@
import type { MaybeRef } from '@vueuse/core' import type { MaybeRef } from '@vueuse/core'
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import { message, reactive, unref, useCopy, useEventListener, useI18n } from '#imports' import { message, reactive, ref, unref, useCopy, useEventListener, useI18n } from '#imports'
interface SelectedBlock { interface SelectedBlock {
row: number | null row: number | null
@ -13,16 +13,19 @@ interface SelectedBlock {
export function useMultiSelect( export function useMultiSelect(
fields: MaybeRef<any[]>, fields: MaybeRef<any[]>,
data: MaybeRef<any[]>, data: MaybeRef<any[]>,
editEnabled: MaybeRef<boolean>, _editEnabled: MaybeRef<boolean>,
isPkAvail: MaybeRef<boolean | undefined>, isPkAvail: MaybeRef<boolean | undefined>,
clearCell: Function, clearCell: Function,
makeEditable: Function, makeEditable: Function,
scrollToActiveCell?: (row?: number | null, col?: number | null) => void, scrollToActiveCell?: (row?: number | null, col?: number | null) => void,
keyEventHandler?: Function,
) { ) {
const { t } = useI18n() const { t } = useI18n()
const { copy } = useCopy() const { copy } = useCopy()
const editEnabled = ref(_editEnabled)
const selected = reactive<SelectedBlock>({ row: null, col: null }) const selected = reactive<SelectedBlock>({ row: null, col: null })
// save the first and the last column where the mouse is down while the value isSelectedRow is true // save the first and the last column where the mouse is down while the value isSelectedRow is true
@ -38,6 +41,7 @@ export function useMultiSelect(
function selectCell(row: number, col: number) { function selectCell(row: number, col: number) {
clearRangeRows() clearRangeRows()
editEnabled.value = false
selected.row = row selected.row = row
selected.col = col selected.col = col
} }
@ -126,6 +130,11 @@ export function useMultiSelect(
}) })
const onKeyDown = async (e: KeyboardEvent) => { const onKeyDown = async (e: KeyboardEvent) => {
// invoke the keyEventHandler if provided and return if it returns true
if (await keyEventHandler?.(e)) {
return
}
if ( if (
!isNaN(selectedRows.startRow) && !isNaN(selectedRows.startRow) &&
!isNaN(selectedRows.startCol) && !isNaN(selectedRows.startCol) &&
@ -148,16 +157,20 @@ export function useMultiSelect(
if (e.shiftKey) { if (e.shiftKey) {
if (selected.col > 0) { if (selected.col > 0) {
selected.col-- selected.col--
editEnabled.value = false
} else if (selected.row > 0) { } else if (selected.row > 0) {
selected.row-- selected.row--
selected.col = unref(columnLength) - 1 selected.col = unref(columnLength) - 1
editEnabled.value = false
} }
} else { } else {
if (selected.col < unref(columnLength) - 1) { if (selected.col < unref(columnLength) - 1) {
selected.col++ selected.col++
editEnabled.value = false
} else if (selected.row < unref(data).length - 1) { } else if (selected.row < unref(data).length - 1) {
selected.row++ selected.row++
selected.col = 0 selected.col = 0
editEnabled.value = false
} }
} }
scrollToActiveCell?.() scrollToActiveCell?.()
@ -170,11 +183,9 @@ export function useMultiSelect(
break break
/** on delete key press clear cell */ /** on delete key press clear cell */
case 'Delete': case 'Delete':
if (!unref(editEnabled)) { e.preventDefault()
e.preventDefault() clearRangeRows()
clearRangeRows() await clearCell(selected as { row: number; col: number })
await clearCell(selected as { row: number; col: number })
}
break break
/** on arrow key press navigate through cells */ /** on arrow key press navigate through cells */
case 'ArrowRight': case 'ArrowRight':
@ -183,6 +194,7 @@ export function useMultiSelect(
if (selected.col < unref(columnLength) - 1) { if (selected.col < unref(columnLength) - 1) {
selected.col++ selected.col++
scrollToActiveCell?.() scrollToActiveCell?.()
editEnabled.value = false
} }
break break
case 'ArrowLeft': case 'ArrowLeft':
@ -192,6 +204,7 @@ export function useMultiSelect(
if (selected.col > 0) { if (selected.col > 0) {
selected.col-- selected.col--
scrollToActiveCell?.() scrollToActiveCell?.()
editEnabled.value = false
} }
break break
case 'ArrowUp': case 'ArrowUp':
@ -201,6 +214,7 @@ export function useMultiSelect(
if (selected.row > 0) { if (selected.row > 0) {
selected.row-- selected.row--
scrollToActiveCell?.() scrollToActiveCell?.()
editEnabled.value = false
} }
break break
case 'ArrowDown': case 'ArrowDown':
@ -210,6 +224,7 @@ export function useMultiSelect(
if (selected.row < unref(data).length - 1) { if (selected.row < unref(data).length - 1) {
selected.row++ selected.row++
scrollToActiveCell?.() scrollToActiveCell?.()
editEnabled.value = false
} }
break break
default: default:

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

@ -0,0 +1,21 @@
import { isClient } from '@vueuse/core'
import type { Ref } from 'vue'
export function useSelectedCellKeyupListener(selected: Ref<boolean>, handler: (e: KeyboardEvent) => void) {
if (isClient) {
watch(selected, (nextVal, _, cleanup) => {
// bind listener when `selected` is truthy
if (nextVal) {
document.addEventListener('keydown', handler, true)
// if `selected` is falsy then remove the event handler
} else {
document.removeEventListener('keydown', handler, true)
}
// cleanup is called whenever the watcher is re-evaluated or stopped
cleanup(() => {
document.removeEventListener('keydown', handler, true)
})
})
}
}

1
packages/nc-gui/composables/useTable.ts

@ -79,6 +79,7 @@ export function useTable(onTableCreate?: (tableMeta: TableType) => void) {
okText: t('general.yes'), okText: t('general.yes'),
okType: 'danger', okType: 'danger',
cancelText: t('general.no'), cancelText: t('general.no'),
width: 450,
async onOk() { async onOk() {
try { try {
const meta = (await getMeta(table.id as string, true)) as TableType const meta = (await getMeta(table.id as string, true)) as TableType

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

@ -591,6 +591,7 @@
"tableDeleted": "Deleted table successfully", "tableDeleted": "Deleted table successfully",
"generatePublicShareableReadonlyBase": "Generate publicly shareable readonly base", "generatePublicShareableReadonlyBase": "Generate publicly shareable readonly base",
"deleteViewConfirmation": "Are you sure you want to delete this view?", "deleteViewConfirmation": "Are you sure you want to delete this view?",
"deleteTokenConfirmation": "Are you sure you want to delete this token?",
"deleteTableConfirmation": "Do you want to delete the table", "deleteTableConfirmation": "Do you want to delete the table",
"showM2mTables": "Show M2M Tables", "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."

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

@ -104,7 +104,9 @@
"creator": "Creator", "creator": "Creator",
"editor": "Editor", "editor": "Editor",
"commenter": "Commenter", "commenter": "Commenter",
"viewer": "Viewer" "viewer": "Viewer",
"orgLevelCreator": "Organization Level Creator",
"orgLevelViewer": "Organization Level Viewer"
}, },
"sqlVIew": "SQL View" "sqlVIew": "SQL View"
}, },
@ -200,6 +202,7 @@
"codeSnippet": "Code Snippet" "codeSnippet": "Code Snippet"
}, },
"labels": { "labels": {
"createdBy": "Created By",
"notifyVia": "Notify Via", "notifyVia": "Notify Via",
"projName": "Project name", "projName": "Project name",
"tableName": "Table name", "tableName": "Table name",
@ -297,7 +300,8 @@
"signUpWithGoogle": "Sign up with Google", "signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google", "signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service", "agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!" "welcomeToNc": "Welcome to NocoDB!",
"inviteOnlySignup": "Allow signup only using invite url"
}, },
"activity": { "activity": {
"createProject": "Create Project", "createProject": "Create Project",
@ -342,12 +346,14 @@
"invite": "Invite", "invite": "Invite",
"inviteMore": "Invite more", "inviteMore": "Invite more",
"inviteTeam": "Invite Team", "inviteTeam": "Invite Team",
"inviteUser": "Invite User",
"inviteToken": "Invite Token", "inviteToken": "Invite Token",
"newUser": "New User", "newUser": "New User",
"editUser": "Edit user", "editUser": "Edit user",
"deleteUser": "Remove user from project", "deleteUser": "Remove user from project",
"resendInvite": "Resend invite E-mail", "resendInvite": "Resend invite E-mail",
"copyInviteURL": "Copy invite URL", "copyInviteURL": "Copy invite URL",
"copyPasswordResetURL": "Copy password reset URL",
"newRole": "New role", "newRole": "New role",
"reloadRoles": "Reload roles", "reloadRoles": "Reload roles",
"nextPage": "Next page", "nextPage": "Next page",
@ -481,6 +487,10 @@
}, },
"msg": { "msg": {
"info": { "info": {
"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."
},
"footerInfo": "Rows per page", "footerInfo": "Rows per page",
"upload": "Select file to Upload", "upload": "Select file to Upload",
"upload_sub": "or drag and drop file", "upload_sub": "or drag and drop file",
@ -654,7 +664,8 @@
"theAcceptedFileTypesAreXlsXlsxXlsmOdsOts": "The accepted file types are .xls, .xlsx, .xlsm, .ods, .ots", "theAcceptedFileTypesAreXlsXlsxXlsmOdsOts": "The accepted file types are .xls, .xlsx, .xlsm, .ods, .ots",
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty", "parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed", "duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty." "fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
}, },
"toast": { "toast": {
"exportMetadata": "Project metadata exported successfully", "exportMetadata": "Project metadata exported successfully",
@ -684,13 +695,16 @@
"tableDataExported": "Successfully exported all table data", "tableDataExported": "Successfully exported all table data",
"updated": "Successfully updated", "updated": "Successfully updated",
"sharedViewDeleted": "Deleted shared view successfully", "sharedViewDeleted": "Deleted shared view successfully",
"userDeleted": "User deleted successfully",
"viewRenamed": "View renamed successfully", "viewRenamed": "View renamed successfully",
"tokenGenerated": "Token generated successfully", "tokenGenerated": "Token generated successfully",
"tokenDeleted": "Token deleted successfully", "tokenDeleted": "Token deleted successfully",
"userAddedToProject": "Successfully added user to project", "userAddedToProject": "Successfully added user to project",
"userAdded": "Successfully added user",
"userDeletedFromProject": "Successfully deleted user from project", "userDeletedFromProject": "Successfully deleted user from project",
"inviteEmailSent": "Invite Email sent successfully", "inviteEmailSent": "Invite Email sent successfully",
"inviteURLCopied": "Invite URL copied to clipboard", "inviteURLCopied": "Invite URL copied to clipboard",
"passwordResetURLCopied": "Password reset URL copied to clipboard",
"shareableURLCopied": "Copied shareable base URL to clipboard!", "shareableURLCopied": "Copied shareable base URL to clipboard!",
"embeddableHTMLCodeCopied": "Copied embeddable HTML code!", "embeddableHTMLCodeCopied": "Copied embeddable HTML code!",
"userDetailsUpdated": "Successfully updated the user details", "userDetailsUpdated": "Successfully updated the user details",
@ -700,7 +714,9 @@
"webhookTested": "Webhook tested successfully", "webhookTested": "Webhook tested successfully",
"columnUpdated": "Column updated", "columnUpdated": "Column updated",
"columnCreated": "Column created", "columnCreated": "Column created",
"passwordChanged": "Password changed successfully. Please login again." "passwordChanged": "Password changed successfully. Please login again.",
"settingsSaved": "Settings saved successfully",
"roleUpdated": "Role updated successfully"
} }
} }
} }

42
packages/nc-gui/layouts/base.vue

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, navigateTo, ref, useGlobal, useNuxtApp, useRoute, useSidebar, useUIPermission } from '#imports' import { computed, navigateTo, ref, useGlobal, useNuxtApp, useRoute, useSidebar } from '#imports'
const { signOut, signedIn, isLoading, user, currentVersion } = useGlobal() const { signOut, signedIn, isLoading, user, currentVersion } = useGlobal()
@ -13,8 +13,6 @@ const hasSider = ref(false)
const sidebar = ref<HTMLDivElement>() const sidebar = ref<HTMLDivElement>()
const { isUIAllowed } = useUIPermission()
const logout = () => { const logout = () => {
signOut() signOut()
navigateTo('/signin') navigateTo('/signin')
@ -22,6 +20,8 @@ const logout = () => {
const { hooks } = useNuxtApp() const { hooks } = useNuxtApp()
const isDashboard = computed(() => !!route.params.projectType)
/** when page suspensions have finished, check if a sidebar element was teleported into the layout */ /** when page suspensions have finished, check if a sidebar element was teleported into the layout */
hooks.hook('page:finish', () => { hooks.hook('page:finish', () => {
if (sidebar.value) { if (sidebar.value) {
@ -39,12 +39,12 @@ hooks.hook('page:finish', () => {
<a-layout class="!flex-col"> <a-layout class="!flex-col">
<a-layout-header <a-layout-header
v-if="!route.meta.public && signedIn && !route.meta.hideHeader" v-if="!route.meta.public && signedIn && !route.meta.hideHeader"
class="flex !bg-primary items-center text-white pl-4 pr-5 shadow-lg" class="flex !bg-primary items-center text-white !pl-2 !pr-5"
> >
<div <div
v-if="!route.params.projectType" v-if="!route.params.projectType"
v-e="['c:navbar:home']" v-e="['c:navbar:home']"
data-cy="nc-noco-brand-icon" data-testid="nc-noco-brand-icon"
class="transition-all duration-200 p-2 cursor-pointer transform hover:scale-105 nc-noco-brand-icon" class="transition-all duration-200 p-2 cursor-pointer transform hover:scale-105 nc-noco-brand-icon"
@click="navigateTo('/')" @click="navigateTo('/')"
> >
@ -52,12 +52,15 @@ hooks.hook('page:finish', () => {
<template #title> <template #title>
{{ currentVersion }} {{ currentVersion }}
</template> </template>
<img width="35" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" /> <div class="flex items-center gap-2">
<img width="25" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
<img v-show="!isDashboard" width="90" alt="NocoDB" src="~/assets/img/brand/text.png" />
</div>
</a-tooltip> </a-tooltip>
</div> </div>
<div class="!text-white flex justify-center"> <div class="!text-white flex justify-center">
<div v-show="isLoading" class="flex items-center gap-2 ml-3" data-nc="nc-loading"> <div v-show="isLoading" class="flex items-center gap-2 ml-3" data-testid="nc-loading">
{{ $t('general.loading') }} {{ $t('general.loading') }}
<MdiReload :class="{ 'animate-infinite animate-spin': isLoading }" /> <MdiReload :class="{ 'animate-infinite animate-spin': isLoading }" />
@ -79,35 +82,38 @@ hooks.hook('page:finish', () => {
<template v-if="signedIn"> <template v-if="signedIn">
<a-dropdown :trigger="['click']" overlay-class-name="nc-dropdown-user-accounts-menu"> <a-dropdown :trigger="['click']" overlay-class-name="nc-dropdown-user-accounts-menu">
<MdiDotsVertical <MdiDotsVertical
data-cy="nc-menu-accounts" data-testid="nc-menu-accounts"
class="md:text-xl cursor-pointer hover:text-accent nc-menu-accounts text-white" class="md:text-xl cursor-pointer hover:text-accent nc-menu-accounts text-white"
@click.prevent @click.prevent
/> />
<template #overlay> <template #overlay>
<a-menu class="!py-0 leading-8 !rounded"> <a-menu class="!py-0 leading-8 !rounded">
<a-menu-item key="0" data-cy="nc-menu-accounts__user-settings" class="!rounded-t"> <a-menu-item key="0" data-testid="nc-menu-accounts__user-settings" class="!rounded-t">
<nuxt-link v-e="['c:navbar:user:email']" class="nc-project-menu-item group !no-underline" to="/user"> <nuxt-link v-e="['c:navbar:user:email']" class="nc-project-menu-item group !no-underline" to="/account/users">
<MdiAt class="mt-1 group-hover:text-accent" />&nbsp; <MdiAccountCircleOutline class="mt-1 group-hover:text-accent" />&nbsp;
<div class="prose group-hover:text-primary">
<span class="prose group-hover:text-primary"> {{ email }}</span> <div>Account</div>
<div class="text-xs text-gray-500">{{ email }}</div>
</div>
</nuxt-link> </nuxt-link>
</a-menu-item> </a-menu-item>
<a-menu-divider class="!m-0" /> <a-menu-divider class="!m-0" />
<a-menu-item v-if="isUIAllowed('appStore')" key="0" class="!rounded-t"> <!-- <a-menu-item v-if="isUIAllowed('appStore')" key="0" class="!rounded-t">
<nuxt-link <nuxt-link
v-e="['c:settings:appstore', { page: true }]" v-e="['c:settings:appstore', { page: true }]"
class="nc-project-menu-item group !no-underline" class="nc-project-menu-item group !no-underline"
to="/apps" to="/admin/users"
> >
<MdiStorefrontOutline class="mt-1 group-hover:text-accent" />&nbsp; <MdiShieldAccountOutline class="mt-1 group-hover:text-accent" />&nbsp;
<span class="prose group-hover:text-primary">{{ $t('title.appStore') }}</span> &lt;!&ndash; todo: i18n &ndash;&gt;
<span class="prose group-hover:text-primary">Account management</span>
</nuxt-link> </nuxt-link>
</a-menu-item> </a-menu-item>
<a-menu-divider class="!m-0" /> <a-menu-divider class="!m-0" /> -->
<a-menu-item key="1" class="!rounded-b group"> <a-menu-item key="1" class="!rounded-b group">
<div v-e="['a:navbar:user:sign-out']" class="nc-project-menu-item group" @click="logout"> <div v-e="['a:navbar:user:sign-out']" class="nc-project-menu-item group" @click="logout">

2
packages/nc-gui/layouts/default.vue

@ -20,7 +20,7 @@ export default {
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full">
<Teleport :to="hasSidebar ? '#nc-sidebar-left' : null" :disabled="!hasSidebar"> <Teleport :to="hasSidebar ? '#nc-sidebar-left' : null" :disabled="!hasSidebar">
<slot name="sidebar" /> <slot :key="$route.name" name="sidebar" />
</Teleport> </Teleport>
<a-layout-content> <a-layout-content>

2
packages/nc-gui/layouts/shared-view.vue

@ -53,7 +53,7 @@ export default {
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<div class="flex items-center gap-2 ml-3 text-white"> <div class="flex items-center gap-2 ml-3 text-white">
<template v-if="isLoading"> <template v-if="isLoading">
<span class="text-white" data-nc="nc-loading">{{ $t('general.loading') }}</span> <span class="text-white" data-testid="nc-loading">{{ $t('general.loading') }}</span>
<MdiReload :class="{ 'animate-infinite animate-spin ': isLoading }" /> <MdiReload :class="{ 'animate-infinite animate-spin ': isLoading }" />
</template> </template>

8
packages/nc-gui/lib/constants.ts

@ -18,7 +18,7 @@ export const rolePermissions = {
[Role.Super]: '*', [Role.Super]: '*',
[Role.Admin]: {} as Record<string, boolean>, [Role.Admin]: {} as Record<string, boolean>,
[Role.Guest]: {} as Record<string, boolean>, [Role.Guest]: {} as Record<string, boolean>,
[Role.User]: { [Role.OrgLevelCreator]: {
include: { include: {
projectCreate: true, projectCreate: true,
projectActions: true, projectActions: true,
@ -30,11 +30,17 @@ export const rolePermissions = {
[ProjectRole.Creator]: { [ProjectRole.Creator]: {
exclude: { exclude: {
appStore: true, appStore: true,
superAdminUserManagement: true,
superAdminAppSettings: true,
appLicense: true,
}, },
}, },
[ProjectRole.Owner]: { [ProjectRole.Owner]: {
exclude: { exclude: {
appStore: true, appStore: true,
superAdminUserManagement: true,
superAdminAppSettings: true,
appLicense: true,
}, },
}, },
[ProjectRole.Editor]: { [ProjectRole.Editor]: {

3
packages/nc-gui/lib/enums.ts

@ -1,7 +1,8 @@
export enum Role { export enum Role {
Super = 'super', Super = 'super',
Admin = 'admin', Admin = 'admin',
User = 'user', OrgLevelCreator = 'org-level-creator',
OrgLevelViewer = 'org-level-viewer',
Guest = 'guest', Guest = 'guest',
} }

1
packages/nc-gui/lib/types.ts

@ -86,6 +86,7 @@ export interface SharedViewMeta extends Record<string, any> {
withTheme?: boolean withTheme?: boolean
theme?: Partial<ThemeConfig> theme?: Partial<ThemeConfig>
allowCSVDownload?: boolean allowCSVDownload?: boolean
rtl?: boolean
} }
export interface SharedView { export interface SharedView {

2
packages/nc-gui/middleware/auth.global.ts

@ -37,7 +37,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
const { allRoles } = useRoles() const { allRoles } = useRoles()
/** if user isn't signed in and google auth is enabled, try to check if sign-in data is present */ /** if user isn't signed in and google auth is enabled, try to check if sign-in data is present */
if (!state.signedIn && state.appInfo.value.googleAuthEnabled) await tryGoogleAuth(api, state.signIn) if (!state.signedIn.value && state.appInfo.value.googleAuthEnabled) await tryGoogleAuth(api, state.signIn)
/** if public allow all visitors */ /** if public allow all visitors */
if (to.meta.public) return if (to.meta.public) return

43
packages/nc-gui/pages/[projectType]/[projectId]/index.vue

@ -24,6 +24,7 @@ import {
useUIPermission, useUIPermission,
} from '#imports' } from '#imports'
import { TabType } from '~/lib' import { TabType } from '~/lib'
import { extractSdkResponseErrorMsg } from '~/utils'
definePageMeta({ definePageMeta({
hideHeader: true, hideHeader: true,
@ -152,7 +153,17 @@ onKeyStroke(
clearTabs() clearTabs()
onBeforeMount(async () => { onBeforeMount(async () => {
await loadProject() try {
await loadProject()
} catch (e: any) {
if (e.response?.status === 403) {
// Project is not accessible
message.error(t('msg.error.projectNotAccessible'))
router.replace('/')
return
}
message.error(await extractSdkResponseErrorMsg(e))
}
if (!route.params.type && isUIAllowed('teamAndAuth')) { if (!route.params.type && isUIAllowed('teamAndAuth')) {
addTab({ type: TabType.AUTH, title: t('title.teamAndAuth') }) addTab({ type: TabType.AUTH, title: t('title.teamAndAuth') })
@ -189,20 +200,20 @@ onBeforeUnmount(reset)
<div <div
style="height: var(--header-height)" style="height: var(--header-height)"
:class="isOpen ? 'pl-4' : ''" :class="isOpen ? 'pl-4' : ''"
class="flex items-center !bg-primary text-white px-1 gap-2" class="flex items-center !bg-primary text-white px-1 gap-1"
> >
<div <div
v-if="isOpen && !isSharedBase" v-if="isOpen && !isSharedBase"
v-e="['c:navbar:home']" v-e="['c:navbar:home']"
data-cy="nc-noco-brand-icon" data-testid="nc-noco-brand-icon"
class="w-[40px] min-w-[40px] transition-all duration-200 p-1 cursor-pointer transform hover:scale-105 nc-noco-brand-icon" class="w-[29px] min-w-[29px] transition-all duration-200 py-1 pl-1 cursor-pointer transform hover:scale-105 nc-noco-brand-icon"
@click="navigateTo('/')" @click="navigateTo('/')"
> >
<a-tooltip placement="bottom"> <a-tooltip placement="bottom">
<template #title> <template #title>
{{ currentVersion }} {{ currentVersion }}
</template> </template>
<img width="35" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" /> <img width="25" class="-mr-1" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
</a-tooltip> </a-tooltip>
</div> </div>
@ -216,7 +227,7 @@ onBeforeUnmount(reset)
<template #title> <template #title>
{{ currentVersion }} {{ currentVersion }}
</template> </template>
<img width="35" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" /> <img width="25" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
</a-tooltip> </a-tooltip>
</a> </a>
@ -229,17 +240,17 @@ onBeforeUnmount(reset)
<div <div
:style="{ width: isOpen ? 'calc(100% - 40px) pr-2' : '100%' }" :style="{ width: isOpen ? 'calc(100% - 40px) pr-2' : '100%' }"
:class="[isOpen ? '' : 'justify-center']" :class="[isOpen ? '' : 'justify-center']"
data-nc="nc-project-menu" data-testid="nc-project-menu"
class="group cursor-pointer flex gap-1 items-center nc-project-menu overflow-hidden" class="group cursor-pointer flex gap-1 items-center nc-project-menu overflow-hidden"
> >
<template v-if="isOpen"> <template v-if="isOpen">
<a-tooltip v-if="project.title?.length > 12" placement="bottom"> <a-tooltip v-if="project.title?.length > 12" placement="bottom">
<div class="text-lg font-semibold truncate">{{ project.title }}</div> <div class="text-md font-semibold truncate">{{ project.title }}</div>
<template #title> <template #title>
<div class="text-sm">{{ project.title }}</div> <div class="text-sm">{{ project.title }}</div>
</template> </template>
</a-tooltip> </a-tooltip>
<div v-else class="text-lg font-semibold truncate">{{ project.title }}</div> <div v-else class="text-md font-semibold truncate capitalize">{{ project.title }}</div>
<MdiChevronDown class="min-w-[17px] group-hover:text-accent text-md" /> <MdiChevronDown class="min-w-[17px] group-hover:text-accent text-md" />
</template> </template>
@ -257,7 +268,7 @@ onBeforeUnmount(reset)
<MdiFolder class="group-hover:text-accent text-xl" /> <MdiFolder class="group-hover:text-accent text-xl" />
<div class="flex flex-col"> <div class="flex flex-col">
<div class="text-lg group-hover:(!text-primary) font-semibold"> <div class="text-lg group-hover:(!text-primary) font-semibold capitalize">
<GeneralTruncateText>{{ project.title }}</GeneralTruncateText> <GeneralTruncateText>{{ project.title }}</GeneralTruncateText>
</div> </div>
@ -458,10 +469,16 @@ onBeforeUnmount(reset)
<template #expandIcon></template> <template #expandIcon></template>
<a-menu-item key="0" class="!rounded-t"> <a-menu-item key="0" class="!rounded-t">
<nuxt-link v-e="['c:navbar:user:email']" class="nc-project-menu-item group !no-underline" to="/user"> <nuxt-link
v-e="['c:navbar:user:email']"
class="nc-project-menu-item group !no-underline"
to="/account/users"
>
<MdiAt class="mt-1 group-hover:text-accent" />&nbsp; <MdiAt class="mt-1 group-hover:text-accent" />&nbsp;
<div class="prose group-hover:text-primary">
<span class="prose-sm">{{ email }}</span> <div>Account</div>
<div class="text-xs text-gray-500">{{ email }}</div>
</div>
</nuxt-link> </nuxt-link>
</a-menu-item> </a-menu-item>

6
packages/nc-gui/pages/[projectType]/[projectId]/index/index.vue

@ -52,14 +52,14 @@ function onEdit(targetKey: number, action: 'add' | 'remove' | string) {
</div> </div>
<a-tooltip v-if="tab.title?.length > 12" placement="bottom"> <a-tooltip v-if="tab.title?.length > 12" placement="bottom">
<div class="truncate" :data-nc="`nc-root-tabs-${tab.title}`">{{ tab.title }}</div> <div class="truncate" :data-testid="`nc-root-tabs-${tab.title}`">{{ tab.title }}</div>
<template #title> <template #title>
<div>{{ tab.title }}</div> <div>{{ tab.title }}</div>
</template> </template>
</a-tooltip> </a-tooltip>
<div v-else :data-nc="`nc-root-tabs-${tab.title}`">{{ tab.title }}</div> <div v-else :data-testid="`nc-root-tabs-${tab.title}`">{{ tab.title }}</div>
</div> </div>
</template> </template>
</a-tab-pane> </a-tab-pane>
@ -68,7 +68,7 @@ function onEdit(targetKey: number, action: 'add' | 'remove' | string) {
<span class="flex-1" /> <span class="flex-1" />
<div class="flex justify-center self-center mr-2 min-w-[115px]"> <div class="flex justify-center self-center mr-2 min-w-[115px]">
<div v-show="isLoading" class="flex items-center gap-2 ml-3 text-gray-200" data-nc="nc-loading"> <div v-show="isLoading" class="flex items-center gap-2 ml-3 text-gray-200" data-testid="nc-loading">
{{ $t('general.loading') }} {{ $t('general.loading') }}
<MdiLoading class="animate-infinite animate-spin" /> <MdiLoading class="animate-infinite animate-spin" />

8
packages/nc-gui/pages/[projectType]/form/[viewId].vue

@ -4,6 +4,7 @@ import {
IsPublicInj, IsPublicInj,
MetaInj, MetaInj,
ReloadViewDataHookInj, ReloadViewDataHookInj,
applyLanguageDirection,
createError, createError,
createEventHook, createEventHook,
definePageMeta, definePageMeta,
@ -26,9 +27,8 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
const route = useRoute() const route = useRoute()
const { loadSharedView, sharedView, meta, notFound, password, passwordDlg, passwordError } = useProvideSharedFormStore( const { loadSharedView, sharedView, sharedViewMeta, meta, notFound, password, passwordDlg, passwordError } =
route.params.viewId as string, useProvideSharedFormStore(route.params.viewId as string)
)
await loadSharedView() await loadSharedView()
@ -39,6 +39,8 @@ if (!notFound.value) {
provide(IsFormInj, ref(true)) provide(IsFormInj, ref(true))
useProvideSmartsheetStore(sharedView, meta, true) useProvideSmartsheetStore(sharedView, meta, true)
applyLanguageDirection(sharedViewMeta.value.rtl ? 'rtl' : 'ltr')
} else { } else {
navigateTo('/error/404') navigateTo('/error/404')
throw createError({ statusCode: 404, statusMessage: 'Page Not Found' }) throw createError({ statusCode: 404, statusMessage: 'Page Not Found' })

6
packages/nc-gui/pages/[projectType]/form/[viewId]/index/index.vue

@ -85,7 +85,7 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell
v-if="isVirtualCol(field)" v-if="isVirtualCol(field)"
class="mt-0 nc-input" class="mt-0 nc-input"
:data-nc="`nc-form-input-cell-${field.label || field.title}`" :data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title.replaceAll(' ', '')}`" :class="`nc-form-input-${field.title.replaceAll(' ', '')}`"
:column="field" :column="field"
/> />
@ -94,7 +94,7 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
v-else v-else
v-model="formState[field.title]" v-model="formState[field.title]"
class="nc-input" class="nc-input"
:data-nc="`nc-form-input-cell-${field.label || field.title}`" :data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title.replaceAll(' ', '')}`" :class="`nc-form-input-${field.title.replaceAll(' ', '')}`"
:column="field" :column="field"
:edit-enabled="true" :edit-enabled="true"
@ -115,7 +115,7 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
<button <button
type="submit" type="submit"
class="uppercase scaling-btn prose-sm" class="uppercase scaling-btn prose-sm"
data-nc="shared-form-submit-button" data-testid="shared-form-submit-button"
@click="submitForm" @click="submitForm"
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}

43
packages/nc-gui/pages/[projectType]/form/[viewId]/index/survey.vue

@ -208,15 +208,14 @@ onMounted(() => {
class="max-w-[max(33%,600px)] mx-auto flex flex-col justify-end" class="max-w-[max(33%,600px)] mx-auto flex flex-col justify-end"
> >
<div class="px-4 md:px-0 flex flex-col justify-end"> <div class="px-4 md:px-0 flex flex-col justify-end">
<h1 class="prose-2xl font-bold self-center my-4" data-cy="nc-survey-form__heading" data-nc="nc-survey-form__heading"> <h1 class="prose-2xl font-bold self-center my-4" data-testid="nc-survey-form__heading">
{{ sharedFormView.heading }} {{ sharedFormView.heading }}
</h1> </h1>
<h2 <h2
v-if="sharedFormView.subheading && sharedFormView.subheading !== ''" v-if="sharedFormView.subheading && sharedFormView.subheading !== ''"
class="prose-lg text-slate-500 dark:text-slate-300 self-center mb-4 leading-6" class="prose-lg text-slate-500 dark:text-slate-300 self-center mb-4 leading-6"
data-cy="nc-survey-form__sub-heading" data-testid="nc-survey-form__sub-heading"
data-nc="nc-survey-form__sub-heading"
> >
{{ sharedFormView?.subheading }} {{ sharedFormView?.subheading }}
</h2> </h2>
@ -231,7 +230,7 @@ onMounted(() => {
class="color-transition h-full flex flex-col mt-6 gap-4 w-full max-w-[max(33%,600px)] m-auto" class="color-transition h-full flex flex-col mt-6 gap-4 w-full max-w-[max(33%,600px)] m-auto"
> >
<div v-if="field && !submitted" class="flex flex-col gap-2"> <div v-if="field && !submitted" class="flex flex-col gap-2">
<div class="flex nc-form-column-label" data-nc="nc-form-column-label"> <div class="flex nc-form-column-label" data-testid="nc-form-column-label">
<LazySmartsheetHeaderVirtualCell <LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)" v-if="isVirtualCol(field)"
:column="{ ...field, title: field.label || field.title }" :column="{ ...field, title: field.label || field.title }"
@ -254,8 +253,7 @@ onMounted(() => {
v-model="formState[field.title]" v-model="formState[field.title]"
class="mt-0 nc-input" class="mt-0 nc-input"
:row="{ row: {}, oldRow: {}, rowMeta: {} }" :row="{ row: {}, oldRow: {}, rowMeta: {} }"
:data-cy="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`" :data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:data-nc="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field" :column="field"
/> />
@ -263,8 +261,7 @@ onMounted(() => {
v-else v-else
v-model="formState[field.title]" v-model="formState[field.title]"
class="nc-input" class="nc-input"
:data-cy="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`" :data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:data-nc="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field" :column="field"
:edit-enabled="true" :edit-enabled="true"
/> />
@ -277,8 +274,7 @@ onMounted(() => {
<div <div
class="block text-[14px]" class="block text-[14px]"
:class="field.uidt === UITypes.Checkbox ? 'text-center' : ''" :class="field.uidt === UITypes.Checkbox ? 'text-center' : ''"
data-nc="nc-survey-form__field-description" data-testid="nc-survey-form__field-description"
data-cy="nc-survey-form__field-description"
> >
{{ field.description }} {{ field.description }}
</div> </div>
@ -302,8 +298,7 @@ onMounted(() => {
" "
type="submit" type="submit"
class="uppercase scaling-btn prose-sm" class="uppercase scaling-btn prose-sm"
data-cy="nc-survey-form__btn-submit" data-testid="nc-survey-form__btn-submit"
data-nc="nc-survey-form__btn-submit"
@click="submit" @click="submit"
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
@ -318,8 +313,7 @@ onMounted(() => {
> >
<button <button
class="bg-opacity-100 scaling-btn flex items-center gap-1" class="bg-opacity-100 scaling-btn flex items-center gap-1"
data-cy="nc-survey-form__btn-next" data-testid="nc-survey-form__btn-next"
data-nc="nc-survey-form__btn-next"
:class="[ :class="[
v$.localState[field.title]?.$error ? 'after:!bg-gray-100 after:!ring-red-500' : '', v$.localState[field.title]?.$error ? 'after:!bg-gray-100 after:!ring-red-500' : '',
animationTarget === AnimationTarget.OkButton && isAnimating animationTarget === AnimationTarget.OkButton && isAnimating
@ -349,11 +343,7 @@ onMounted(() => {
<Transition name="slide-left"> <Transition name="slide-left">
<div v-if="submitted" class="flex flex-col justify-center items-center text-center"> <div v-if="submitted" class="flex flex-col justify-center items-center text-center">
<div <div class="text-lg px-6 py-3 bg-green-300 text-gray-700 rounded" data-testid="nc-survey-form__success-msg">
class="text-lg px-6 py-3 bg-green-300 text-gray-700 rounded"
data-cy="nc-survey-form__success-msg"
data-nc="nc-survey-form__success-msg"
>
<template v-if="sharedFormView?.success_msg"> <template v-if="sharedFormView?.success_msg">
{{ sharedFormView?.success_msg }} {{ sharedFormView?.success_msg }}
</template> </template>
@ -376,8 +366,7 @@ onMounted(() => {
<button <button
type="button" type="button"
class="scaling-btn bg-opacity-100" class="scaling-btn bg-opacity-100"
data-cy="nc-survey-form__btn-submit-another-form" data-testid="nc-survey-form__btn-submit-another-form"
data-nc="nc-survey-form__btn-submit-another-form"
@click="resetForm" @click="resetForm"
> >
Submit Another Form Submit Another Form
@ -391,11 +380,7 @@ onMounted(() => {
</div> </div>
<template v-if="!submitted"> <template v-if="!submitted">
<div <div class="mb-24 md:my-4 select-none text-center text-gray-500 dark:text-slate-200" data-testid="nc-survey-form__footer">
class="mb-24 md:my-4 select-none text-center text-gray-500 dark:text-slate-200"
data-cy="nc-survey-form__footer"
data-nc="nc-survey-form__footer"
>
{{ index + 1 }} / {{ formColumns?.length }} {{ index + 1 }} / {{ formColumns?.length }}
</div> </div>
</template> </template>
@ -414,8 +399,7 @@ onMounted(() => {
: '' : ''
" "
class="p-0.5 flex items-center group color-transition" class="p-0.5 flex items-center group color-transition"
data-cy="nc-survey-form__icon-prev" data-testid="nc-survey-form__icon-prev"
data-nc="nc-survey-form__icon-prev"
@click="goPrevious()" @click="goPrevious()"
> >
<MdiChevronLeft :class="isFirst ? 'text-gray-300' : 'group-hover:text-accent'" class="text-2xl md:text-md" /> <MdiChevronLeft :class="isFirst ? 'text-gray-300' : 'group-hover:text-accent'" class="text-2xl md:text-md" />
@ -434,8 +418,7 @@ onMounted(() => {
: '' : ''
" "
class="p-0.5 flex items-center group color-transition" class="p-0.5 flex items-center group color-transition"
data-cy="nc-survey-form__icon-next" data-testid="nc-survey-form__icon-next"
data-nc="nc-survey-form__icon-next"
@click="goNext()" @click="goNext()"
> >
<MdiChevronRight <MdiChevronRight

112
packages/nc-gui/pages/account/index.vue

@ -0,0 +1,112 @@
<script lang="ts" setup>
import { navigateTo, useUIPermission } from '#imports'
const { isUIAllowed } = useUIPermission()
const $route = useRoute()
const selectedKeys = computed(() => [
/^\/account\/users\/?$/.test($route.fullPath)
? isUIAllowed('superAdminUserManagement')
? 'list'
: 'settings'
: $route.params.nestedPage ?? $route.params.page,
])
const openKeys = ref([/^\/account\/users/.test($route.fullPath) && 'users'])
</script>
<template>
<div class="mx-auto h-full">
<a-layout class="h-full overflow-y-auto flex">
<!-- Side tabs -->
<a-layout-sider>
<div class="h-full bg-white nc-user-sidebar">
<a-menu
v-model:openKeys="openKeys"
v-model:selectedKeys="selectedKeys"
:inline-indent="16"
class="tabs-menu h-full"
mode="inline"
>
<div class="text-xs text-gray-500 ml-4 pt-4 pb-2 font-weight-bold">Account Settings</div>
<a-sub-menu key="users" class="!bg-white">
<template #icon>
<MdiAccountSupervisorOutline />
</template>
<template #title>Users</template>
<a-menu-item
v-if="isUIAllowed('superAdminUserManagement')"
key="list"
class="text-xs"
@click="navigateTo('/account/users/list')"
>
<span class="ml-4">User Management</span>
</a-menu-item>
<a-menu-item key="password-reset" class="text-xs" @click="navigateTo('/account/users/password-reset')">
<span class="ml-4">Reset Password</span>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('superAdminAppSettings')"
key="settings"
class="text-xs"
@click="navigateTo('/account/users/settings')"
>
<span class="ml-4">Settings</span>
</a-menu-item>
</a-sub-menu>
<a-menu-item
key="tokens"
class="group active:(!ring-0) hover:(!bg-primary !bg-opacity-25)"
@click="navigateTo('/account/tokens')"
>
<div class="flex items-center space-x-2">
<MdiShieldKeyOutline />
<div class="select-none">Tokens</div>
</div>
</a-menu-item>
<a-menu-item
key="apps"
class="group active:(!ring-0) hover:(!bg-primary !bg-opacity-25)"
@click="navigateTo('/account/apps')"
>
<div class="flex items-center space-x-2">
<MdiStorefrontOutline />
<div class="select-none">App Store</div>
</div>
</a-menu-item>
</a-menu>
</div>
</a-layout-sider>
<!-- Sub Tabs -->
<a-layout-content class="h-auto px-4 scrollbar-thumb-gray-500">
<div class="container mx-auto">
<NuxtPage />
</div>
</a-layout-content>
</a-layout>
</div>
</template>
<style lang="scss" scoped>
:deep(.nc-user-sidebar .ant-menu-sub.ant-menu-inline) {
@apply bg-transparent;
}
:deep(.nc-user-sidebar .ant-menu-item-only-child),
:deep(.ant-menu-submenu-title) {
@apply !h-[30px] !leading-[30px];
}
:deep(.ant-menu-submenu-arrow) {
@apply !text-gray-400;
}
:deep(.ant-menu-submenu-selected .ant-menu-submenu-arrow) {
@apply !text-inherit;
}
</style>

6
packages/nc-gui/pages/account/index/[page].vue

@ -0,0 +1,6 @@
<template>
<AccountUserManagement v-if="$route.params.page === 'users'" />
<AccountToken v-else-if="$route.params.page === 'tokens'" />
<AccountAppStore v-else-if="$route.params.page === 'apps'" />
<span v-else></span>
</template>

5
packages/nc-gui/pages/account/index/users.vue

@ -0,0 +1,5 @@
<template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2">
<NuxtPage />
</div>
</template>

22
packages/nc-gui/pages/account/index/users/[[nestedPage]].vue

@ -0,0 +1,22 @@
<script setup lang="ts">
import { useUIPermission } from '#imports'
const { isUIAllowed } = useUIPermission()
</script>
<template>
<template
v-if="
$route.params.nestedPage === 'password-reset' ||
(!isUIAllowed('superAdminUserManagement') && !isUIAllowed('superAdminAppSettings'))
"
>
<LazyAccountResetPassword />
</template>
<template v-else-if="$route.params.nestedPage === 'settings'">
<LazyAccountSignupSettings />
</template>
<template v-else-if="isUIAllowed('superAdminUserManagement')">
<LazyAccountUserList />
</template>
</template>

7
packages/nc-gui/pages/index/index.vue

@ -12,7 +12,7 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
class="min-h-[calc(100vh_-_var(--header-height))] bg-primary bg-opacity-5 flex flex-wrap justify-between xl:flex-nowrap gap-6 py-6 px-4 md:(px-12 pt-65px)" class="min-h-[calc(100vh_-_var(--header-height))] bg-primary bg-opacity-5 flex flex-wrap justify-between xl:flex-nowrap gap-6 py-6 px-4 md:(px-12 pt-65px)"
> >
<div class="hidden xl:(flex)"> <div class="hidden xl:(flex)">
<div> <div v-if="route.name === 'index-index'">
<LazyGeneralSponsors /> <LazyGeneralSponsors />
</div> </div>
</div> </div>
@ -21,7 +21,10 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
<NuxtPage /> <NuxtPage />
</div> </div>
<div class="flex-1 flex gap-6 flex-col justify-center items-center md:(flex-row justify-between items-start)"> <div
:class="{ 'flex-1': route.name === 'index-index' }"
class="flex gap-6 flex-col justify-center items-center md:(flex-row justify-between items-start)"
>
<template v-if="route.name === 'index-index'"> <template v-if="route.name === 'index-index'">
<TransitionGroup name="page" mode="out-in"> <TransitionGroup name="page" mode="out-in">
<div key="social-card"> <div key="social-card">

6
packages/nc-gui/pages/index/index/index.vue

@ -144,7 +144,7 @@ const copyProjectMeta = async () => {
<template> <template>
<div <div
class="relative flex flex-col justify-center gap-2 w-full p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)" class="relative flex flex-col justify-center gap-2 w-full p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)"
data-nc="projects-container" data-testid="projects-container"
> >
<h1 class="flex items-center justify-center gap-2 leading-8 mb-8 mt-4"> <h1 class="flex items-center justify-center gap-2 leading-8 mb-8 mt-4">
<span class="text-4xl nc-project-page-title" @dblclick="copyProjectMeta">{{ $t('title.myProject') }}</span> <span class="text-4xl nc-project-page-title" @dblclick="copyProjectMeta">{{ $t('title.myProject') }}</span>
@ -166,7 +166,7 @@ const copyProjectMeta = async () => {
v-e="['a:project:refresh']" v-e="['a:project:refresh']"
class="text-xl text-gray-500 group-hover:text-accent cursor-pointer" class="text-xl text-gray-500 group-hover:text-accent cursor-pointer"
:class="isLoading ? '!text-primary' : ''" :class="isLoading ? '!text-primary' : ''"
data-nc="projects-reload-button" data-testid="projects-reload-button"
@click="loadProjects" @click="loadProjects"
/> />
</div> </div>
@ -290,7 +290,7 @@ const copyProjectMeta = async () => {
<MdiDeleteOutline <MdiDeleteOutline
class="nc-action-btn" class="nc-action-btn"
:data-nc="`delete-project-${record.title}`" :data-testid="`delete-project-${record.title}`"
@click.stop="deleteProject(record)" @click.stop="deleteProject(record)"
/> />
</div> </div>

12
packages/nc-gui/pages/index/index/user.vue

@ -82,7 +82,7 @@ const resetError = () => {
<a-form <a-form
ref="formValidator" ref="formValidator"
data-cy="nc-user-settings-form" data-testid="nc-user-settings-form"
layout="vertical" layout="vertical"
class="change-password lg:max-w-3/4 w-full !mx-auto" class="change-password lg:max-w-3/4 w-full !mx-auto"
no-style no-style
@ -91,7 +91,7 @@ const resetError = () => {
> >
<Transition name="layout"> <Transition name="layout">
<div v-if="error" class="mx-auto mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1"> <div v-if="error" class="mx-auto mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1">
<div data-cy="nc-user-settings-form__error" class="flex items-center gap-2 justify-center"> <div data-testid="nc-user-settings-form__error" class="flex items-center gap-2 justify-center">
<MaterialSymbolsWarning /> <MaterialSymbolsWarning />
{{ error }} {{ error }}
</div> </div>
@ -101,7 +101,7 @@ const resetError = () => {
<a-form-item :label="$t('placeholder.password.current')" name="currentPassword" :rules="formRules.currentPassword"> <a-form-item :label="$t('placeholder.password.current')" name="currentPassword" :rules="formRules.currentPassword">
<a-input-password <a-input-password
v-model:value="form.currentPassword" v-model:value="form.currentPassword"
data-cy="nc-user-settings-form__current-password" data-testid="nc-user-settings-form__current-password"
size="large" size="large"
class="password" class="password"
:placeholder="$t('placeholder.password.current')" :placeholder="$t('placeholder.password.current')"
@ -112,7 +112,7 @@ const resetError = () => {
<a-form-item :label="$t('placeholder.password.new')" name="password" :rules="formRules.password"> <a-form-item :label="$t('placeholder.password.new')" name="password" :rules="formRules.password">
<a-input-password <a-input-password
v-model:value="form.password" v-model:value="form.password"
data-cy="nc-user-settings-form__new-password" data-testid="nc-user-settings-form__new-password"
size="large" size="large"
class="password" class="password"
:placeholder="$t('placeholder.password.new')" :placeholder="$t('placeholder.password.new')"
@ -123,7 +123,7 @@ const resetError = () => {
<a-form-item :label="$t('placeholder.password.confirm')" name="passwordRepeat" :rules="formRules.passwordRepeat"> <a-form-item :label="$t('placeholder.password.confirm')" name="passwordRepeat" :rules="formRules.passwordRepeat">
<a-input-password <a-input-password
v-model:value="form.passwordRepeat" v-model:value="form.passwordRepeat"
data-cy="nc-user-settings-form__new-password-repeat" data-testid="nc-user-settings-form__new-password-repeat"
size="large" size="large"
class="password" class="password"
:placeholder="$t('placeholder.password.confirm')" :placeholder="$t('placeholder.password.confirm')"
@ -132,7 +132,7 @@ const resetError = () => {
</a-form-item> </a-form-item>
<div class="text-center"> <div class="text-center">
<button data-cy="nc-user-settings-form__submit" class="scaling-btn bg-opacity-100" type="submit"> <button data-testid="nc-user-settings-form__submit" class="scaling-btn bg-opacity-100" type="submit">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<MdiKeyChange /> <MdiKeyChange />
{{ $t('activity.changePwd') }} {{ $t('activity.changePwd') }}

15
packages/nc-gui/pages/signin.vue

@ -64,7 +64,7 @@ function resetError() {
<template> <template>
<NuxtLayout> <NuxtLayout>
<div <div
data-cy="nc-form-signin" data-testid="nc-form-signin"
class="md:bg-primary bg-opacity-5 signin h-full min-h-[600px] flex flex-col justify-center items-center nc-form-signin" class="md:bg-primary bg-opacity-5 signin h-full min-h-[600px] flex flex-col justify-center items-center nc-form-signin"
> >
<div <div
@ -87,8 +87,7 @@ function resetError() {
<a-form-item :label="$t('labels.email')" name="email" :rules="formRules.email"> <a-form-item :label="$t('labels.email')" name="email" :rules="formRules.email">
<a-input <a-input
v-model:value="form.email" v-model:value="form.email"
data-cy="nc-form-signin__email" data-testid="nc-form-signin__email"
data-nc="nc-form-signin__email"
size="large" size="large"
:placeholder="$t('msg.info.signUp.workEmail')" :placeholder="$t('msg.info.signUp.workEmail')"
@focus="resetError" @focus="resetError"
@ -98,8 +97,7 @@ function resetError() {
<a-form-item :label="$t('labels.password')" name="password" :rules="formRules.password"> <a-form-item :label="$t('labels.password')" name="password" :rules="formRules.password">
<a-input-password <a-input-password
v-model:value="form.password" v-model:value="form.password"
data-cy="nc-form-signin__password" data-testid="nc-form-signin__password"
data-nc="nc-form-signin__password"
size="large" size="large"
class="password" class="password"
:placeholder="$t('msg.info.signUp.enterPassword')" :placeholder="$t('msg.info.signUp.enterPassword')"
@ -114,12 +112,7 @@ function resetError() {
</div> </div>
<div class="self-center flex flex-col flex-wrap gap-4 items-center mt-4 justify-center"> <div class="self-center flex flex-col flex-wrap gap-4 items-center mt-4 justify-center">
<button <button data-testid="nc-form-signin__submit" class="scaling-btn bg-opacity-100" type="submit">
data-cy="nc-form-signin__submit"
data-nc="nc-form-signin__submit"
class="scaling-btn bg-opacity-100"
type="submit"
>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<MdiLogin /> <MdiLogin />
{{ $t('general.signIn') }} {{ $t('general.signIn') }}

6
packages/nc-gui/pages/signup/[[token]].vue

@ -99,7 +99,11 @@ function resetError() {
<a-form ref="formValidator" :model="form" layout="vertical" no-style @finish="signUp"> <a-form ref="formValidator" :model="form" layout="vertical" no-style @finish="signUp">
<Transition name="layout"> <Transition name="layout">
<div v-if="error" class="self-center mb-4 bg-red-500 text-white rounded-lg w-3/4 mx-auto p-1"> <div
v-if="error"
class="self-center mb-4 bg-red-500 text-white rounded-lg w-3/4 mx-auto p-1"
data-testid="nc-signup-error"
>
<div class="flex items-center gap-2 justify-center"> <div class="flex items-center gap-2 justify-center">
<MaterialSymbolsWarning /> <MaterialSymbolsWarning />
<div class="break-words">{{ error }}</div> <div class="break-words">{{ error }}</div>

5
packages/nc-gui/plugins/a.i18n.ts

@ -1,5 +1,6 @@
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import { defineNuxtPlugin, nextTick } from '#imports' import { isClient } from '@vueuse/core'
import { applyLanguageDirection, defineNuxtPlugin, isRtlLang, nextTick } from '#imports'
import type { Language, NocoI18n } from '~/lib' import type { Language, NocoI18n } from '~/lib'
import { LanguageAlias } from '~/lib' import { LanguageAlias } from '~/lib'
@ -24,6 +25,8 @@ export async function setI18nLanguage(locale: keyof typeof Language, i18n = glob
} }
i18n.global.locale.value = locale i18n.global.locale.value = locale
if (isClient) applyLanguageDirection(isRtlLang(locale) ? 'rtl' : 'ltr')
} }
export async function loadLocaleMessages( export async function loadLocaleMessages(

2
packages/nc-gui/plugins/tele.ts

@ -37,6 +37,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
socket.emit('page', { socket.emit('page', {
path: to.matched[0].path + (to.query && to.query.type ? `?type=${to.query.type}` : ''), path: to.matched[0].path + (to.query && to.query.type ? `?type=${to.query.type}` : ''),
pid: route?.params?.projectId,
}) })
}) })
@ -48,6 +49,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
event: evt, event: evt,
...(data || {}), ...(data || {}),
path: route?.matched?.[0]?.path, path: route?.matched?.[0]?.path,
pid: route?.params?.projectId,
}) })
} }
}, },

79
packages/nc-gui/tests/playwright/README.md

@ -1,79 +0,0 @@
# Playwright E2E tests
## Setup
Make sure to install the dependencies(in the playwright folder):
```bash
npm install
npx playwright install chromium --with-deps
```
## Run Test Server
Start the backend test server (in `packages/nocodb` folder):
```bash
npm run watch:run:playwright:quick
```
Start the frontend test server (in `packages/nc-gui` folder):
```bash
NUXT_PAGE_TRANSITION_DISABLE=true npm run dev
```
## Running Tests
### Running all tests
For selecting db type, rename `.env.example` to `.env` and set `E2E_DEV_DB_TYPE` to `sqlite`(default), `mysql` or `pg`.
```bash
npm run test
```
### Running individual tests
Add `.only` to the test you want to run:
```js
test.only('should login', async ({ page }) => {
// ...
})
```
```bash
npm run test
```
## Developing tests
### WebStorm
In Webstorm, you can use the `test-debug` run action to run the tests.
Add `.only` to the test you want to run. This will open the test in a chromium session and you can also add break points.
i.e `test.only('should login', async ({ page }) => {`
### VSCode
In VSCode, use this [https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chromium](extension).
It will have run button beside each test in the file.
### Page Objects
Page object is a class which has methods to interact with a page/component. Methods should be thin and should not do a whole lot. They should also be reusable.
All the action methods i.e click of a page object is also responsible for waiting till the action is completed. This can be done by waiting on an API call or some ui change.
Do not add any logic to the tests. Instead, create a page object for the page you are testing.
All the selection, UI actions and assertions should be in the page object.
Page objects should be in `packages/nc-gui/tests/playwright/pages` folder.
### Verify if tests are not flaky
Add `.only` to the added test and run `npm run test:repeat`. This will run the test multiple times and should show if the test is flaky.

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

@ -0,0 +1,2 @@
// refer - https://stackoverflow.com/a/11752084
export const isMac = () => /Mac/i.test(navigator.platform)

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

Loading…
Cancel
Save