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. 54
      .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

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

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

2
package.json

@ -36,7 +36,7 @@
]
},
"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",
"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",

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']
MdiAccount: typeof import('~icons/mdi/account')['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']
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']
MdiAlphaA: typeof import('~icons/mdi/alpha-a')['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']
MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default']
MdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
MdiClipboard: typeof import('~icons/mdi/clipboard')['default']
MdiClose: typeof import('~icons/mdi/close')['default']
MdiCloseBox: typeof import('~icons/mdi/close-box')['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']
MdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
MdiContentSave: typeof import('~icons/mdi/content-save')['default']
MdiCopy: typeof import('~icons/mdi/copy')['default']
MdiCurrencyUsd: typeof import('~icons/mdi/currency-usd')['default']
MdiDatabaseOutline: typeof import('~icons/mdi/database-outline')['default']
MdiDatabaseSync: typeof import('~icons/mdi/database-sync')['default']
MdiDelete: typeof import('~icons/mdi/delete')['default']
MdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
MdiDiscord: typeof import('~icons/mdi/discord')['default']
MdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
MdiDotsVertical: typeof import('~icons/mdi/dots-vertical')['default']
MdiDownload: typeof import('~icons/mdi/download')['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']
MdiJson: typeof import('~icons/mdi/json')['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']
MdiKeyStar: typeof import('~icons/mdi/key-star')['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']
MdiScriptTextKeyOutline: typeof import('~icons/mdi/script-text-key-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']
MdiSort: typeof import('~icons/mdi/sort')['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">
import { ColumnInj, IsFormInj, ReadonlyInj, getMdiIcon, inject } from '#imports'
import { ActiveCellInj, ColumnInj, IsFormInj, ReadonlyInj, getMdiIcon, inject, useSelectedCellKeyupListener } from '#imports'
interface Props {
// 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 active = inject(ActiveCellInj, ref(false))
let vModel = $computed({
get: () => !!props.modelValue && props.modelValue !== '0' && props.modelValue !== 0,
set: (val) => emits('update:modelValue', val),
@ -37,11 +39,20 @@ const checkboxMeta = $computed(() => {
}
})
function onClick() {
if (!readOnly?.value) {
function onClick(force?: boolean) {
if (!readOnly?.value && (force || active.value)) {
vModel = !vModel
}
}
useSelectedCellKeyupListener(active, (e) => {
switch (e.key) {
case 'Enter':
onClick()
e.stopPropagation()
break
}
})
</script>
<template>
@ -53,7 +64,7 @@ function onClick() {
'nc-cell-hover-show': !vModel && !readOnly,
'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 }">
<Transition name="layout" mode="out-in" :duration="100">
@ -62,6 +73,7 @@ function onClick() {
:style="{
color: checkboxMeta.color,
}"
@click.stop="onClick(true)"
/>
</Transition>
</div>
@ -72,6 +84,7 @@ function onClick() {
.nc-cell-hover-show {
opacity: 0;
transition: 0.3s opacity;
&:hover {
opacity: 0.7;
}

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

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

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

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

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

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

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

@ -84,6 +84,11 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@blur="submitDuration"
@keypress="checkDurationFormat($event)"
@keydown.enter="submitDuration"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/>
<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>
<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">
{{ vModel }}

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

@ -30,6 +30,11 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
type="number"
step="0.1"
@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>
</template>

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

@ -34,6 +34,11 @@ function onKeyDown(evt: KeyboardEvent) {
type="number"
@blur="editEnabled = false"
@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>
</template>

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

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

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

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ColumnInj, EditModeInj, computed, inject } from '#imports'
import { ActiveCellInj, ColumnInj, computed, inject, useSelectedCellKeyupListener } from '#imports'
interface Props {
modelValue?: number | null | undefined
@ -11,8 +11,6 @@ const emits = defineEmits(['update:modelValue'])
const column = inject(ColumnInj)!
const editEnabled = inject(EditModeInj)
const ratingMeta = computed(() => {
return {
icon: {
@ -29,16 +27,17 @@ const vModel = computed({
get: () => modelValue ?? NaN,
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>
<template>
<a-rate
v-model:value="vModel"
:count="ratingMeta.max"
:style="`color: ${ratingMeta.color}; padding: 0px 5px`"
:class="{ '!ml-[-8px]': !editEnabled }"
:disabled="!editEnabled"
>
<a-rate v-model:value="vModel" :count="ratingMeta.max" :style="`color: ${ratingMeta.color}; padding: 0px 5px`">
<template #character>
<MdiStar v-if="ratingMeta.icon.full === 'mdi-star'" 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 { SelectOptionType } from 'nocodb-sdk'
import { ActiveCellInj, ColumnInj, IsKanbanInj, ReadonlyInj, computed, inject, ref, useEventListener, watch } from '#imports'
import { useSelectedCellKeyupListener } from '~/composables/useSelectedCellKeyupListener'
interface Props {
modelValue?: string | undefined
@ -44,15 +45,6 @@ const options = computed<SelectOptionType[]>(() => {
return []
})
const handleKeys = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
e.preventDefault()
isOpen.value = false
break
}
}
const handleClose = (e: MouseEvent) => {
if (aselect.value && !aselect.value.$el.contains(e.target)) {
isOpen.value = false
@ -63,7 +55,24 @@ const handleClose = (e: MouseEvent) => {
useEventListener(document, 'click', handleClose)
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>
@ -77,16 +86,16 @@ watch(isOpen, (n, _o) => {
:open="isOpen"
:disabled="readOnly"
: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"
@keydown="handleKeys"
@click="isOpen = !isOpen"
@keydown.enter.stop
@click="isOpen = active && !isOpen"
>
<a-select-option
v-for="op of options"
:key="op.title"
:value="op.title"
:data-nc="`select-option-${column.title}-${rowIndex}`"
:data-testid="`select-option-${column.title}-${rowIndex}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color">
@ -110,9 +119,11 @@ watch(isOpen, (n, _o) => {
.rounded-tag {
@apply py-0 px-[12px] rounded-[12px];
}
:deep(.ant-tag) {
@apply "rounded-tag";
}
:deep(.ant-select-clear) {
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="{ '!px-2': editEnabled }"
@blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/>
<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"
@keydown.alt.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>

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

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

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

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

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

@ -1,6 +1,6 @@
<script setup lang="ts">
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 {
modelValue?: number | string | null
@ -13,6 +13,10 @@ const emit = defineEmits(['update:modelValue'])
const readOnly = inject(ReadonlyInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
let isYearInvalid = $ref(false)
const localState = $computed({
@ -55,6 +59,21 @@ watch(
)
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>
<template>
@ -66,10 +85,10 @@ const placeholder = computed(() => (isYearInvalid ? 'Invalid year' : ''))
:placeholder="placeholder"
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"
:open="readOnly || (localState && isPk) ? false : open"
:dropdown-class-name="`${randomClass} nc-picker-year`"
@click="open = !open"
@change="open = !open"
:open="(readOnly || (localState && isPk)) && !active && !editable ? false : open"
:dropdown-class-name="`${randomClass} nc-picker-year ${open ? 'active' : ''}`"
@click="open = (active || editable) && !open"
@change="open = (active || editable) && !open"
>
<template #suffixIcon></template>
</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 { useSortable } from './sort'
import {
ActiveCellInj,
DropZoneRef,
IsGalleryInj,
IsKanbanInj,
@ -12,6 +13,7 @@ import {
openLink,
ref,
useDropZone,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow,
useSmartsheetStoreOrThrow,
watch,
@ -113,9 +115,17 @@ watch(
attachments.value = []
}
}
} else {
if (isPublic.value && isForm.value) {
storedFiles.value = []
} else {
attachments.value = []
}
}
},
{ immediate: true },
{
immediate: true,
},
)
/** updates attachments array for autosave */
@ -136,6 +146,13 @@ watch(
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>
<template>
@ -161,7 +178,7 @@ watch(
v-if="!isReadonly"
: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)"
data-nc="attachment-cell-file-picker-button"
data-testid="attachment-cell-file-picker-button"
@click.stop="open"
>
<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"
:data-order="table.order"
:data-id="table.id"
:data-nc="`tree-view-table-${table.title}`"
:data-testid="`tree-view-table-${table.title}`"
@click="addTableTab(table)"
>
<GeneralTooltip class="pl-5 pr-3 py-2" modifier-key="Alt">
<template #title>{{ table.table_name }}</template>
<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
v-if="isUIAllowed('treeview-drag-n-drop')"
:class="`nc-child-draggable-icon-${table.title}`"
@ -366,14 +366,14 @@ const onSearchCloseIconClick = () => {
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
<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') }}
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('table-delete')"
:data-nc="`sidebar-table-delete-${table.title}`"
:data-testid="`sidebar-table-delete-${table.title}`"
@click="deleteTable(table)"
>
<div class="nc-project-menu-item">

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

@ -112,7 +112,7 @@ const columns = [
:columns="columns"
:pagination="false"
:loading="isLoading"
data-nc="audit-tab-table"
data-testid="audit-tab-table"
>
<template #emptyText>
<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
type="text"
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"
>
<template #icon>
@ -220,7 +220,7 @@ watch(
</a-menu-item>
</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>
</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 validateDuplicateAlias = (v: string) => (tables.value || []).every((t) => t.title !== (v || '')) || 'Duplicate table alias'
const validators = computed(() => {
return {
title: [
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) => {
return new Promise<void>((resolve, reject) => {
@ -62,13 +70,23 @@ const validators = computed(() => {
table_name: [validateTableName],
}
})
const { validateInfos } = useForm(table, validators)
const { validate, validateInfos } = useForm(table, validators)
const systemColumnsCheckboxInfo = SYSTEM_COLUMNS.map((c, index) => ({
value: c,
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(() => {
generateUniqueTitle()
@ -87,11 +105,11 @@ onMounted(() => {
<template #footer>
<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>
<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 -->
<div class="prose-xl font-bold self-center my-4">{{ $t('activity.createTable') }}</div>
@ -105,7 +123,7 @@ onMounted(() => {
v-model:value="table.title"
size="large"
hide-details
data-nc="create-table-title-input"
data-testid="create-table-title-input"
:placeholder="$t('msg.info.enterTableName')"
/>
</a-form-item>

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

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

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

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { Language } from '~/lib'
import { onMounted, useGlobal, useI18n, useNuxtApp } from '#imports'
import { useGlobal, useI18n, useNuxtApp } from '#imports'
import { setI18nLanguage } from '~/plugins/a.i18n'
const { $e } = useNuxtApp()
@ -11,31 +11,14 @@ const { locale } = useI18n()
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) {
const nextLang = lang as keyof typeof Language
await setI18nLanguage(nextLang)
currentLang.value = nextLang
applyDirection()
$e('c:navbar:lang', { lang })
}
onMounted(() => {
applyDirection()
})
</script>
<template>

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

@ -111,6 +111,7 @@ const vModel = computed({
})
const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
console.log('syncAndNavigate', e.target)
if (isJSON.value) return
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>
<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">
<div v-if="formViewData" class="items-center justify-center text-center mt-2">
<a-alert type="success">
@ -398,7 +398,7 @@ watch(view, (nextView) => {
</a-col>
</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">
<div class="flex flex-wrap gap-2">
<div class="flex-1 text-lg">
@ -410,7 +410,7 @@ watch(view, (nextView) => {
v-if="hiddenColumns.length"
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"
data-nc="nc-form-add-all"
data-testid="nc-form-add-all"
@click="addAllColumns"
>
<!-- Add all -->
@ -421,7 +421,7 @@ watch(view, (nextView) => {
v-if="localColumns.length"
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"
data-nc="nc-form-remove-all"
data-testid="nc-form-remove-all"
@click="removeAllColumns"
>
<!-- Remove all -->
@ -443,7 +443,7 @@ watch(view, (nextView) => {
<a-card
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"
:data-nc="`nc-form-hidden-column-${element.label || element.title}`"
:data-testid="`nc-form-hidden-column-${element.label || element.title}`"
@mousedown="moved = false"
@mousemove="moved = false"
@mouseup="handleMouseUp(element, index)"
@ -471,7 +471,7 @@ watch(view, (nextView) => {
<template #footer>
<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"
data-nc="nc-drag-n-drop-to-hide"
data-testid="nc-drag-n-drop-to-hide"
>
<!-- Drag and drop fields here to hide -->
{{ $t('msg.info.dragDropHide') }}
@ -529,7 +529,7 @@ watch(view, (nextView) => {
hide-details
placeholder="Form Title"
:bordered="false"
data-nc="nc-form-heading"
data-testid="nc-form-heading"
@blur="updateView"
@keydown.enter="updateView"
/>
@ -549,7 +549,7 @@ watch(view, (nextView) => {
:placeholder="$t('msg.info.formDesc')"
:bordered="false"
:disabled="!isEditable"
data-nc="nc-form-sub-heading"
data-testid="nc-form-sub-heading"
@blur="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,
},
]"
data-nc="nc-form-fields"
data-testid="nc-form-fields"
@click="activeRow = element.title"
>
<div
@ -588,7 +588,7 @@ watch(view, (nextView) => {
>
<MdiEyeOffOutline
class="opacity-0 nc-field-remove-icon"
data-nc="nc-field-remove-icon"
data-testid="nc-field-remove-icon"
@click.stop="hideColumn(index)"
/>
</div>
@ -597,7 +597,7 @@ watch(view, (nextView) => {
<div class="flex gap-2 items-center">
<span
class="text-gray-500 mr-2 nc-form-input-required"
data-nc="nc-form-input-required"
data-testid="nc-form-input-required"
@click="
() => {
element.required = !element.required
@ -621,7 +621,7 @@ watch(view, (nextView) => {
v-model:value="element.label"
type="text"
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')"
@change="updateColMeta(element)"
>
@ -633,7 +633,7 @@ watch(view, (nextView) => {
v-model:value="element.description"
type="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')"
@change="updateColMeta(element)"
/>
@ -646,7 +646,7 @@ watch(view, (nextView) => {
:column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)"
:hide-menu="true"
data-nc="nc-form-input-label"
data-testid="nc-form-input-label"
/>
<LazySmartsheetHeaderCell
@ -654,7 +654,7 @@ watch(view, (nextView) => {
:column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)"
:hide-menu="true"
data-nc="nc-form-input-label"
data-testid="nc-form-input-label"
/>
</div>
@ -669,7 +669,7 @@ watch(view, (nextView) => {
:row="row"
class="nc-input"
: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"
@click.stop.prevent
/>
@ -685,14 +685,14 @@ watch(view, (nextView) => {
v-model="formState[element.title]"
class="nc-input"
: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"
:edit-enabled="true"
@click.stop.prevent
/>
</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>
</template>
@ -707,7 +707,7 @@ watch(view, (nextView) => {
</Draggable>
<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') }}
</button>
</div>
@ -729,7 +729,7 @@ watch(view, (nextView) => {
:rows="3"
hide-details
class="nc-form-after-submit-msg"
data-nc="nc-form-after-submit-msg"
data-testid="nc-form-after-submit-msg"
@change="updateView"
/>
@ -742,7 +742,7 @@ watch(view, (nextView) => {
v-e="[`a:form-view:submit-another-form`]"
size="small"
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"
/>
<span class="ml-4">{{ $t('msg.info.submitAnotherForm') }}</span>
@ -755,7 +755,7 @@ watch(view, (nextView) => {
v-e="[`a:form-view:show-blank-form`]"
size="small"
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"
/>
@ -768,7 +768,7 @@ watch(view, (nextView) => {
v-e="[`a:form-view:email-me`]"
size="small"
class="nc-form-checkbox-send-email"
data-nc="nc-form-checkbox-send-email"
data-testid="nc-form-checkbox-send-email"
@change="onEmailChange"
/>

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

@ -163,14 +163,14 @@ watch(view, async (nextView) => {
</script>
<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 v-for="record in data" :key="`record-${record.row.id}`">
<LazySmartsheetRow :row="record">
<a-card
hoverable
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)"
>
<template v-if="galleryData?.fk_cover_image_col_id" #cover>

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

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

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

@ -309,7 +309,7 @@ watch(view, async (nextView) => {
</script>
<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">
<a-dropdown v-model:visible="contextMenu" :trigger="['contextmenu']" overlay-class-name="nc-dropdown-kanban-context-menu">
<!-- Draggable Stack -->

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

@ -19,7 +19,7 @@ const page = computed({
<template>
<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') }}
</span>

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

@ -124,7 +124,7 @@ onMounted(() => {
:class="{ '!w-[600px]': formState.uidt === UITypes.Formula }"
@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">
<a-form-item :label="$t('labels.columnName')" v-bind="validateInfos.title">
<a-input

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

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

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

@ -45,9 +45,11 @@ const { copy } = useClipboard()
const copyRecordUrl = () => {
copy(
`${dashboardUrl?.value}#/${route.params.projectType}/${route.params.projectId}/${route.params.type}/${meta.value?.title}${
props.view ? `/${props.view.title}` : ''
}?rowId=${primaryKey.value}`,
encodeURI(
`${dashboardUrl?.value}#/${route.params.projectType}/${route.params.projectId}/${route.params.type}/${meta.value?.title}${
props.view ? `/${props.view.title}` : ''
}?rowId=${primaryKey.value}`,
),
)
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' }"
:closable="false"
class="nc-drawer-expanded-form"
:class="{ 'active': isExpanded }"
>
<SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" />
@ -150,7 +151,7 @@ export default {
:key="col.title"
class="mt-2 py-2"
: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" />

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

@ -167,12 +167,12 @@ function onStopEdit() {
<template>
<a-menu-item
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"
@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 class="flex w-auto" :data-nc="`view-sidebar-drag-handle-${vModel.alias || vModel.title}`">
<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-testid="`view-sidebar-drag-handle-${vModel.alias || vModel.title}`">
<MdiDrag
class="nc-drag-icon hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 !cursor-move"
@click.stop.prevent
@ -194,7 +194,7 @@ function onStopEdit() {
<div class="flex-1" />
<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">
<template #title>
{{ $t('activity.copyView') }}

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

@ -82,7 +82,7 @@ const filterAutoSaveLoc = computed({
ref="filterComp"
class="nc-table-toolbar-menu shadow-lg"
:auto-save="filterAutoSave"
data-nc="nc-filter-menu"
data-testid="nc-filter-menu"
@update:filters-length="filtersLength = $event"
>
<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>
<div
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
>
<a-card
@ -167,7 +167,7 @@ const getIcon = (c: ColumnType) =>
v-show="filteredFieldList.includes(field)"
:key="field.id"
class="px-2 py-1 flex items-center"
:data-nc="`nc-fields-menu-${field.title}`"
:data-testid="`nc-fields-menu-${field.title}`"
@click.stop
>
<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 withRTL = computed({
get: () => !!shared.value.meta.rtl,
set: (rtl) => {
shared.value.meta = { ...shared.value.meta, rtl }
updateSharedViewMeta()
},
})
const transitionDuration = computed({
get: () => shared.value.meta.transitionDuration || 250,
set: (duration) => {
@ -105,7 +113,7 @@ const sharedViewUrl = computed(() => {
viewType = 'view'
}
return `${dashboardUrl?.value}#/nc/${viewType}/${shared.value.uuid}`
return encodeURI(`${dashboardUrl?.value}#/nc/${viewType}/${shared.value.uuid}`)
})
async function saveAllowCSVDownload() {
@ -212,8 +220,7 @@ watch(passwordProtected, (value) => {
wrap-class-name="nc-modal-share-view"
>
<div
data-cy="nc-modal-share-view__link"
data-nc="nc-modal-share-view__link"
data-testid="nc-modal-share-view__link"
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>
@ -235,8 +242,7 @@ watch(passwordProtected, (value) => {
<a-checkbox
v-if="shared.type === ViewTypes.FORM"
v-model:checked="surveyMode"
data-cy="nc-modal-share-view__survey-mode"
data-nc="nc-modal-share-view__survey-mode"
data-testid="nc-modal-share-view__survey-mode"
class="!text-sm"
>
Use Survey Mode
@ -250,7 +256,7 @@ watch(passwordProtected, (value) => {
</template>
<a-input
v-model:value="transitionDuration"
data-cy="nc-form-signin__email"
data-testid="nc-form-signin__email"
size="small"
class="!w-32"
type="number"
@ -266,8 +272,7 @@ watch(passwordProtected, (value) => {
<a-checkbox
v-if="shared.type === ViewTypes.FORM"
v-model:checked="viewTheme"
data-cy="nc-modal-share-view__with-theme"
data-nc="nc-modal-share-view__with-theme"
data-testid="nc-modal-share-view__with-theme"
class="!text-sm"
>
Use Theme
@ -276,26 +281,37 @@ watch(passwordProtected, (value) => {
<Transition name="layout" mode="out-in">
<div v-if="viewTheme" class="flex pl-6">
<LazyGeneralColorPicker
data-cy="nc-modal-share-view__theme-picker"
data-testid="nc-modal-share-view__theme-picker"
class="!p-0"
:model-value="shared.meta.theme?.primaryColor"
:colors="projectThemeColors"
:row-size="9"
:advanced="false"
data-nc="nc-modal-share-view__theme-picker"
@input="onChangeTheme"
/>
</div>
</Transition>
</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>
<!-- Password Protection -->
<a-checkbox
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"
data-nc="nc-modal-share-view__with-password"
>
{{ $t('msg.info.beforeEnablePwd') }}
</a-checkbox>
@ -304,8 +320,7 @@ watch(passwordProtected, (value) => {
<div v-if="passwordProtected" class="pl-6 flex gap-2 mt-2 mb-4">
<a-input
v-model:value="shared.password"
data-cy="nc-modal-share-view__password"
data-nc="nc-modal-share-view__password"
data-testid="nc-modal-share-view__password"
size="small"
class="!text-xs max-w-[250px]"
type="password"
@ -313,8 +328,7 @@ watch(passwordProtected, (value) => {
/>
<a-button
data-cy="nc-modal-share-view__save-password"
data-nc="nc-modal-share-view__save-password"
data-testid="nc-modal-share-view__save-password"
size="small"
class="!text-xs"
@click="saveShareLinkPassword"
@ -333,8 +347,7 @@ watch(passwordProtected, (value) => {
(shared.type === ViewTypes.GRID || shared.type === ViewTypes.KANBAN || shared.type === ViewTypes.GALLERY)
"
v-model:checked="allowCSVDownload"
data-cy="nc-modal-share-view__with-csv-download"
data-nc="nc-modal-share-view__with-csv-download"
data-testid="nc-modal-share-view__with-csv-download"
class="!text-sm"
>
{{ $t('labels.downloadAllowed') }}

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

@ -55,7 +55,7 @@ watch(
<template #overlay>
<div
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>
<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>
<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-sub-menu
v-if="isUIAllowed('view-type')"

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

@ -136,7 +136,7 @@ onMounted(() => {
</script>
<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]">
<MdiOpenInNew />

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

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

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

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

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

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

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

@ -11,6 +11,7 @@ import {
computed,
inject,
provide,
refAutoReset,
useColumn,
useMetas,
} from '#imports'
@ -46,45 +47,73 @@ provide(MetaInj, lookupTableMeta)
provide(CellUrlDisableOverlayInj, ref(true))
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>
<template>
<div class="h-full flex gap-1 overflow-x-auto p-1">
<template v-if="lookupColumn">
<!-- Render virtual cell -->
<div v-if="isVirtualCol(lookupColumn)">
<template
v-if="lookupColumn.uidt === UITypes.LinkToAnotherRecord && lookupColumn.colOptions.type === RelationTypes.BELONGS_TO"
>
<LazySmartsheetVirtualCell
<div class="h-full">
<div class="h-full flex gap-1 overflow-x-auto p-1">
<template v-if="lookupColumn">
<!-- Render virtual cell -->
<div v-if="isVirtualCol(lookupColumn)">
<template
v-if="lookupColumn.uidt === UITypes.LinkToAnotherRecord && lookupColumn.colOptions.type === RelationTypes.BELONGS_TO"
>
<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"
:key="i"
:edit-enabled="false"
:model-value="v"
:column="lookupColumn"
/>
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>
<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>
</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>
</template>

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

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

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

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

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="flex mb-4 items-center gap-2 px-12">
<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
v-if="!readonly"
type="primary"
ghost
class="!text-xs"
data-cy="nc-child-list-button-link-to"
data-testid="nc-child-list-button-link-to"
size="small"
@click="emit('attachRecord')"
>
@ -143,13 +148,13 @@ watch(
<div v-if="!readonly" class="flex gap-2">
<MdiLinkVariantRemove
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)"
/>
<MdiDeleteOutline
v-if="!readonly && !isPublic"
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)"
/>
</div>

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

@ -1,18 +1,20 @@
<script lang="ts" setup>
import type { Card } from 'ant-design-vue'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import {
ColumnInj,
Empty,
IsPublicInj,
computed,
inject,
ref,
useLTARStoreOrThrow,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow,
useVModel,
watch,
} from '#imports'
import { IsPublicInj } from '~/context'
const props = defineProps<{ modelValue: boolean }>()
@ -38,6 +40,8 @@ const { addLTARRef, isNew } = useSmartsheetRowStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false))
const selectedRowIndex = ref(0)
const linkRow = async (row: Record<string, any>) => {
if (isNew.value) {
addLTARRef(row, column?.value as ColumnType)
@ -54,6 +58,7 @@ watch(vModel, (nextVal, prevVal) => {
childrenExcludedListPagination.query = ''
childrenExcludedListPagination.page = 1
loadChildrenExcludedList()
selectedRowIndex.value = 0
}
})
@ -98,6 +103,49 @@ const newRowState = computed(() => {
watch(expandedFormDlg, (nexVal) => {
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>
<template>
@ -115,6 +163,7 @@ watch(expandedFormDlg, (nexVal) => {
:placeholder="$t('placeholder.filterQuery')"
class="max-w-[200px]"
size="small"
@keydown.capture.stop
/>
<div class="flex-1" />
@ -132,7 +181,9 @@ watch(expandedFormDlg, (nexVal) => {
<a-card
v-for="(refRow, i) in childrenExcludedList?.list ?? []"
:key="i"
:ref="selectedRowIndex === i ? activeRow : null"
class="!my-4 cursor-pointer hover:(!bg-gray-200/50 shadow-md) group"
:class="{ 'nc-selected-row': selectedRowIndex === i }"
@click="linkRow(refRow)"
>
{{ refRow[relatedTablePrimaryValueProp] }}
@ -175,4 +226,8 @@ watch(expandedFormDlg, (nexVal) => {
:deep(.ant-pagination-item a) {
line-height: 21px !important;
}
:deep(.nc-selected-row) {
@apply !ring;
}
</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.title = formState.value.column_name
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)
// Column updated
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 { 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 {
row: number | null
@ -13,16 +13,19 @@ interface SelectedBlock {
export function useMultiSelect(
fields: MaybeRef<any[]>,
data: MaybeRef<any[]>,
editEnabled: MaybeRef<boolean>,
_editEnabled: MaybeRef<boolean>,
isPkAvail: MaybeRef<boolean | undefined>,
clearCell: Function,
makeEditable: Function,
scrollToActiveCell?: (row?: number | null, col?: number | null) => void,
keyEventHandler?: Function,
) {
const { t } = useI18n()
const { copy } = useCopy()
const editEnabled = ref(_editEnabled)
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
@ -38,6 +41,7 @@ export function useMultiSelect(
function selectCell(row: number, col: number) {
clearRangeRows()
editEnabled.value = false
selected.row = row
selected.col = col
}
@ -126,6 +130,11 @@ export function useMultiSelect(
})
const onKeyDown = async (e: KeyboardEvent) => {
// invoke the keyEventHandler if provided and return if it returns true
if (await keyEventHandler?.(e)) {
return
}
if (
!isNaN(selectedRows.startRow) &&
!isNaN(selectedRows.startCol) &&
@ -148,16 +157,20 @@ export function useMultiSelect(
if (e.shiftKey) {
if (selected.col > 0) {
selected.col--
editEnabled.value = false
} else if (selected.row > 0) {
selected.row--
selected.col = unref(columnLength) - 1
editEnabled.value = false
}
} else {
if (selected.col < unref(columnLength) - 1) {
selected.col++
editEnabled.value = false
} else if (selected.row < unref(data).length - 1) {
selected.row++
selected.col = 0
editEnabled.value = false
}
}
scrollToActiveCell?.()
@ -170,11 +183,9 @@ export function useMultiSelect(
break
/** on delete key press clear cell */
case 'Delete':
if (!unref(editEnabled)) {
e.preventDefault()
clearRangeRows()
await clearCell(selected as { row: number; col: number })
}
e.preventDefault()
clearRangeRows()
await clearCell(selected as { row: number; col: number })
break
/** on arrow key press navigate through cells */
case 'ArrowRight':
@ -183,6 +194,7 @@ export function useMultiSelect(
if (selected.col < unref(columnLength) - 1) {
selected.col++
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowLeft':
@ -192,6 +204,7 @@ export function useMultiSelect(
if (selected.col > 0) {
selected.col--
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowUp':
@ -201,6 +214,7 @@ export function useMultiSelect(
if (selected.row > 0) {
selected.row--
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowDown':
@ -210,6 +224,7 @@ export function useMultiSelect(
if (selected.row < unref(data).length - 1) {
selected.row++
scrollToActiveCell?.()
editEnabled.value = false
}
break
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'),
okType: 'danger',
cancelText: t('general.no'),
width: 450,
async onOk() {
try {
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",
"generatePublicShareableReadonlyBase": "Generate publicly shareable readonly base",
"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",
"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."

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

@ -104,7 +104,9 @@
"creator": "Creator",
"editor": "Editor",
"commenter": "Commenter",
"viewer": "Viewer"
"viewer": "Viewer",
"orgLevelCreator": "Organization Level Creator",
"orgLevelViewer": "Organization Level Viewer"
},
"sqlVIew": "SQL View"
},
@ -200,6 +202,7 @@
"codeSnippet": "Code Snippet"
},
"labels": {
"createdBy": "Created By",
"notifyVia": "Notify Via",
"projName": "Project name",
"tableName": "Table name",
@ -297,7 +300,8 @@
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"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": {
"createProject": "Create Project",
@ -342,12 +346,14 @@
"invite": "Invite",
"inviteMore": "Invite more",
"inviteTeam": "Invite Team",
"inviteUser": "Invite User",
"inviteToken": "Invite Token",
"newUser": "New User",
"editUser": "Edit user",
"deleteUser": "Remove user from project",
"resendInvite": "Resend invite E-mail",
"copyInviteURL": "Copy invite URL",
"copyPasswordResetURL": "Copy password reset URL",
"newRole": "New role",
"reloadRoles": "Reload roles",
"nextPage": "Next page",
@ -481,6 +487,10 @@
},
"msg": {
"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",
"upload": "Select file to Upload",
"upload_sub": "or drag and drop file",
@ -654,7 +664,8 @@
"theAcceptedFileTypesAreXlsXlsxXlsmOdsOts": "The accepted file types are .xls, .xlsx, .xlsm, .ods, .ots",
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty."
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
},
"toast": {
"exportMetadata": "Project metadata exported successfully",
@ -684,13 +695,16 @@
"tableDataExported": "Successfully exported all table data",
"updated": "Successfully updated",
"sharedViewDeleted": "Deleted shared view successfully",
"userDeleted": "User deleted successfully",
"viewRenamed": "View renamed successfully",
"tokenGenerated": "Token generated successfully",
"tokenDeleted": "Token deleted successfully",
"userAddedToProject": "Successfully added user to project",
"userAdded": "Successfully added user",
"userDeletedFromProject": "Successfully deleted user from project",
"inviteEmailSent": "Invite Email sent successfully",
"inviteURLCopied": "Invite URL copied to clipboard",
"passwordResetURLCopied": "Password reset URL copied to clipboard",
"shareableURLCopied": "Copied shareable base URL to clipboard!",
"embeddableHTMLCodeCopied": "Copied embeddable HTML code!",
"userDetailsUpdated": "Successfully updated the user details",
@ -700,7 +714,9 @@
"webhookTested": "Webhook tested successfully",
"columnUpdated": "Column updated",
"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>
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()
@ -13,8 +13,6 @@ const hasSider = ref(false)
const sidebar = ref<HTMLDivElement>()
const { isUIAllowed } = useUIPermission()
const logout = () => {
signOut()
navigateTo('/signin')
@ -22,6 +20,8 @@ const logout = () => {
const { hooks } = useNuxtApp()
const isDashboard = computed(() => !!route.params.projectType)
/** when page suspensions have finished, check if a sidebar element was teleported into the layout */
hooks.hook('page:finish', () => {
if (sidebar.value) {
@ -39,12 +39,12 @@ hooks.hook('page:finish', () => {
<a-layout class="!flex-col">
<a-layout-header
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
v-if="!route.params.projectType"
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"
@click="navigateTo('/')"
>
@ -52,12 +52,15 @@ hooks.hook('page:finish', () => {
<template #title>
{{ currentVersion }}
</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>
</div>
<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') }}
<MdiReload :class="{ 'animate-infinite animate-spin': isLoading }" />
@ -79,35 +82,38 @@ hooks.hook('page:finish', () => {
<template v-if="signedIn">
<a-dropdown :trigger="['click']" overlay-class-name="nc-dropdown-user-accounts-menu">
<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"
@click.prevent
/>
<template #overlay>
<a-menu class="!py-0 leading-8 !rounded">
<a-menu-item key="0" data-cy="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">
<MdiAt class="mt-1 group-hover:text-accent" />&nbsp;
<span class="prose group-hover:text-primary"> {{ email }}</span>
<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="/account/users">
<MdiAccountCircleOutline class="mt-1 group-hover:text-accent" />&nbsp;
<div class="prose group-hover:text-primary">
<div>Account</div>
<div class="text-xs text-gray-500">{{ email }}</div>
</div>
</nuxt-link>
</a-menu-item>
<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
v-e="['c:settings:appstore', { page: true }]"
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>
</a-menu-item>
<a-menu-divider class="!m-0" />
<a-menu-divider class="!m-0" /> -->
<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">

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

@ -20,7 +20,7 @@ export default {
<template>
<div class="w-full h-full">
<Teleport :to="hasSidebar ? '#nc-sidebar-left' : null" :disabled="!hasSidebar">
<slot name="sidebar" />
<slot :key="$route.name" name="sidebar" />
</Teleport>
<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 items-center gap-2 ml-3 text-white">
<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 }" />
</template>

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

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

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

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

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

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

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

@ -37,7 +37,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
const { allRoles } = useRoles()
/** 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 (to.meta.public) return

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

@ -24,6 +24,7 @@ import {
useUIPermission,
} from '#imports'
import { TabType } from '~/lib'
import { extractSdkResponseErrorMsg } from '~/utils'
definePageMeta({
hideHeader: true,
@ -152,7 +153,17 @@ onKeyStroke(
clearTabs()
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')) {
addTab({ type: TabType.AUTH, title: t('title.teamAndAuth') })
@ -189,20 +200,20 @@ onBeforeUnmount(reset)
<div
style="height: var(--header-height)"
: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
v-if="isOpen && !isSharedBase"
v-e="['c:navbar:home']"
data-cy="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"
data-testid="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('/')"
>
<a-tooltip placement="bottom">
<template #title>
{{ currentVersion }}
</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>
</div>
@ -216,7 +227,7 @@ onBeforeUnmount(reset)
<template #title>
{{ currentVersion }}
</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>
@ -229,17 +240,17 @@ onBeforeUnmount(reset)
<div
:style="{ width: isOpen ? 'calc(100% - 40px) pr-2' : '100%' }"
: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"
>
<template v-if="isOpen">
<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>
<div class="text-sm">{{ project.title }}</div>
</template>
</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" />
</template>
@ -257,7 +268,7 @@ onBeforeUnmount(reset)
<MdiFolder class="group-hover:text-accent text-xl" />
<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>
</div>
@ -458,10 +469,16 @@ onBeforeUnmount(reset)
<template #expandIcon></template>
<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;
<span class="prose-sm">{{ email }}</span>
<div class="prose group-hover:text-primary">
<div>Account</div>
<div class="text-xs text-gray-500">{{ email }}</div>
</div>
</nuxt-link>
</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>
<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>
<div>{{ tab.title }}</div>
</template>
</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>
</template>
</a-tab-pane>
@ -68,7 +68,7 @@ function onEdit(targetKey: number, action: 'add' | 'remove' | string) {
<span class="flex-1" />
<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') }}
<MdiLoading class="animate-infinite animate-spin" />

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

@ -4,6 +4,7 @@ import {
IsPublicInj,
MetaInj,
ReloadViewDataHookInj,
applyLanguageDirection,
createError,
createEventHook,
definePageMeta,
@ -26,9 +27,8 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
const route = useRoute()
const { loadSharedView, sharedView, meta, notFound, password, passwordDlg, passwordError } = useProvideSharedFormStore(
route.params.viewId as string,
)
const { loadSharedView, sharedView, sharedViewMeta, meta, notFound, password, passwordDlg, passwordError } =
useProvideSharedFormStore(route.params.viewId as string)
await loadSharedView()
@ -39,6 +39,8 @@ if (!notFound.value) {
provide(IsFormInj, ref(true))
useProvideSmartsheetStore(sharedView, meta, true)
applyLanguageDirection(sharedViewMeta.value.rtl ? 'rtl' : 'ltr')
} else {
navigateTo('/error/404')
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
v-if="isVirtualCol(field)"
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(' ', '')}`"
:column="field"
/>
@ -94,7 +94,7 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
v-else
v-model="formState[field.title]"
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(' ', '')}`"
:column="field"
:edit-enabled="true"
@ -115,7 +115,7 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
<button
type="submit"
class="uppercase scaling-btn prose-sm"
data-nc="shared-form-submit-button"
data-testid="shared-form-submit-button"
@click="submitForm"
>
{{ $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"
>
<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 }}
</h1>
<h2
v-if="sharedFormView.subheading && sharedFormView.subheading !== ''"
class="prose-lg text-slate-500 dark:text-slate-300 self-center mb-4 leading-6"
data-cy="nc-survey-form__sub-heading"
data-nc="nc-survey-form__sub-heading"
data-testid="nc-survey-form__sub-heading"
>
{{ sharedFormView?.subheading }}
</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"
>
<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
v-if="isVirtualCol(field)"
:column="{ ...field, title: field.label || field.title }"
@ -254,8 +253,7 @@ onMounted(() => {
v-model="formState[field.title]"
class="mt-0 nc-input"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:data-cy="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:data-nc="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
/>
@ -263,8 +261,7 @@ onMounted(() => {
v-else
v-model="formState[field.title]"
class="nc-input"
:data-cy="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:data-nc="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="true"
/>
@ -277,8 +274,7 @@ onMounted(() => {
<div
class="block text-[14px]"
:class="field.uidt === UITypes.Checkbox ? 'text-center' : ''"
data-nc="nc-survey-form__field-description"
data-cy="nc-survey-form__field-description"
data-testid="nc-survey-form__field-description"
>
{{ field.description }}
</div>
@ -302,8 +298,7 @@ onMounted(() => {
"
type="submit"
class="uppercase scaling-btn prose-sm"
data-cy="nc-survey-form__btn-submit"
data-nc="nc-survey-form__btn-submit"
data-testid="nc-survey-form__btn-submit"
@click="submit"
>
{{ $t('general.submit') }}
@ -318,8 +313,7 @@ onMounted(() => {
>
<button
class="bg-opacity-100 scaling-btn flex items-center gap-1"
data-cy="nc-survey-form__btn-next"
data-nc="nc-survey-form__btn-next"
data-testid="nc-survey-form__btn-next"
:class="[
v$.localState[field.title]?.$error ? 'after:!bg-gray-100 after:!ring-red-500' : '',
animationTarget === AnimationTarget.OkButton && isAnimating
@ -349,11 +343,7 @@ onMounted(() => {
<Transition name="slide-left">
<div v-if="submitted" class="flex flex-col justify-center items-center text-center">
<div
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"
>
<div class="text-lg px-6 py-3 bg-green-300 text-gray-700 rounded" data-testid="nc-survey-form__success-msg">
<template v-if="sharedFormView?.success_msg">
{{ sharedFormView?.success_msg }}
</template>
@ -376,8 +366,7 @@ onMounted(() => {
<button
type="button"
class="scaling-btn bg-opacity-100"
data-cy="nc-survey-form__btn-submit-another-form"
data-nc="nc-survey-form__btn-submit-another-form"
data-testid="nc-survey-form__btn-submit-another-form"
@click="resetForm"
>
Submit Another Form
@ -391,11 +380,7 @@ onMounted(() => {
</div>
<template v-if="!submitted">
<div
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"
>
<div class="mb-24 md:my-4 select-none text-center text-gray-500 dark:text-slate-200" data-testid="nc-survey-form__footer">
{{ index + 1 }} / {{ formColumns?.length }}
</div>
</template>
@ -414,8 +399,7 @@ onMounted(() => {
: ''
"
class="p-0.5 flex items-center group color-transition"
data-cy="nc-survey-form__icon-prev"
data-nc="nc-survey-form__icon-prev"
data-testid="nc-survey-form__icon-prev"
@click="goPrevious()"
>
<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"
data-cy="nc-survey-form__icon-next"
data-nc="nc-survey-form__icon-next"
data-testid="nc-survey-form__icon-next"
@click="goNext()"
>
<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)"
>
<div class="hidden xl:(flex)">
<div>
<div v-if="route.name === 'index-index'">
<LazyGeneralSponsors />
</div>
</div>
@ -21,7 +21,10 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
<NuxtPage />
</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'">
<TransitionGroup name="page" mode="out-in">
<div key="social-card">

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

@ -144,7 +144,7 @@ const copyProjectMeta = async () => {
<template>
<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)"
data-nc="projects-container"
data-testid="projects-container"
>
<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>
@ -166,7 +166,7 @@ const copyProjectMeta = async () => {
v-e="['a:project:refresh']"
class="text-xl text-gray-500 group-hover:text-accent cursor-pointer"
:class="isLoading ? '!text-primary' : ''"
data-nc="projects-reload-button"
data-testid="projects-reload-button"
@click="loadProjects"
/>
</div>
@ -290,7 +290,7 @@ const copyProjectMeta = async () => {
<MdiDeleteOutline
class="nc-action-btn"
:data-nc="`delete-project-${record.title}`"
:data-testid="`delete-project-${record.title}`"
@click.stop="deleteProject(record)"
/>
</div>

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

@ -82,7 +82,7 @@ const resetError = () => {
<a-form
ref="formValidator"
data-cy="nc-user-settings-form"
data-testid="nc-user-settings-form"
layout="vertical"
class="change-password lg:max-w-3/4 w-full !mx-auto"
no-style
@ -91,7 +91,7 @@ const resetError = () => {
>
<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-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 />
{{ error }}
</div>
@ -101,7 +101,7 @@ const resetError = () => {
<a-form-item :label="$t('placeholder.password.current')" name="currentPassword" :rules="formRules.currentPassword">
<a-input-password
v-model:value="form.currentPassword"
data-cy="nc-user-settings-form__current-password"
data-testid="nc-user-settings-form__current-password"
size="large"
class="password"
: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-input-password
v-model:value="form.password"
data-cy="nc-user-settings-form__new-password"
data-testid="nc-user-settings-form__new-password"
size="large"
class="password"
: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-input-password
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"
class="password"
:placeholder="$t('placeholder.password.confirm')"
@ -132,7 +132,7 @@ const resetError = () => {
</a-form-item>
<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">
<MdiKeyChange />
{{ $t('activity.changePwd') }}

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

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

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

@ -1,5 +1,6 @@
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 { LanguageAlias } from '~/lib'
@ -24,6 +25,8 @@ export async function setI18nLanguage(locale: keyof typeof Language, i18n = glob
}
i18n.global.locale.value = locale
if (isClient) applyLanguageDirection(isRtlLang(locale) ? 'rtl' : 'ltr')
}
export async function loadLocaleMessages(

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

@ -37,6 +37,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
socket.emit('page', {
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,
...(data || {}),
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