Browse Source

Merge pull request #4482 from nocodb/feat/keyboard-manoeuvre

feat: keyboard manoeuvre
pull/4550/head
Raju Udava 2 years ago committed by GitHub
parent
commit
7d602228a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      packages/nc-gui/app.vue
  2. 36
      packages/nc-gui/assets/css/global.css
  3. 1
      packages/nc-gui/components.d.ts
  4. 1
      packages/nc-gui/components/account/Token.vue
  5. 1
      packages/nc-gui/components/account/UsersModal.vue
  6. 80
      packages/nc-gui/components/cell/DatePicker.vue
  7. 86
      packages/nc-gui/components/cell/DateTimePicker.vue
  8. 32
      packages/nc-gui/components/cell/Json.vue
  9. 1
      packages/nc-gui/components/cell/attachment/Modal.vue
  10. 7
      packages/nc-gui/components/cell/attachment/index.vue
  11. 34
      packages/nc-gui/components/dashboard/TreeView.vue
  12. 2
      packages/nc-gui/components/dashboard/settings/AppStore.vue
  13. 1
      packages/nc-gui/components/dashboard/settings/Modal.vue
  14. 1
      packages/nc-gui/components/dlg/AirtableImport.vue
  15. 245
      packages/nc-gui/components/dlg/KeyboardShortcuts.vue
  16. 1
      packages/nc-gui/components/dlg/QuickImport.vue
  17. 1
      packages/nc-gui/components/dlg/TableCreate.vue
  18. 1
      packages/nc-gui/components/dlg/TableRename.vue
  19. 8
      packages/nc-gui/components/dlg/ViewCreate.vue
  20. 8
      packages/nc-gui/components/dlg/ViewDelete.vue
  21. 17
      packages/nc-gui/components/general/FullScreen.vue
  22. 39
      packages/nc-gui/components/general/ShareBaseButton.vue
  23. 27
      packages/nc-gui/components/general/SocialCard.vue
  24. 7
      packages/nc-gui/components/monaco/Editor.vue
  25. 1
      packages/nc-gui/components/shared-view/AskPassword.vue
  26. 5
      packages/nc-gui/components/smartsheet/Cell.vue
  27. 56
      packages/nc-gui/components/smartsheet/Grid.vue
  28. 7
      packages/nc-gui/components/smartsheet/Kanban.vue
  29. 14
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  30. 2
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  31. 1
      packages/nc-gui/components/smartsheet/toolbar/Erd.vue
  32. 1
      packages/nc-gui/components/smartsheet/toolbar/MoreActions.vue
  33. 1
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  34. 1
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  35. 2
      packages/nc-gui/components/tabs/auth/ApiTokenManagement.vue
  36. 1
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  37. 3
      packages/nc-gui/components/tabs/auth/user-management/UsersModal.vue
  38. 6
      packages/nc-gui/components/virtual-cell/Formula.vue
  39. 12
      packages/nc-gui/components/virtual-cell/Lookup.vue
  40. 18
      packages/nc-gui/components/virtual-cell/Rollup.vue
  41. 10
      packages/nc-gui/components/virtual-cell/components/ListItems.vue
  42. 7
      packages/nc-gui/lang/en.json
  43. 51
      packages/nc-gui/pages/[projectType]/[projectId]/index.vue
  44. 1
      packages/nc-gui/pages/[projectType]/form/[viewId].vue
  45. 2
      packages/nc-gui/pages/index/index/create-external.vue
  46. 1
      packages/nc-gui/utils/browserUtils.ts
  47. 4
      packages/nc-gui/utils/viewUtils.ts
  48. 8
      tests/playwright/pages/Base.ts
  49. 6
      tests/playwright/pages/Dashboard/ExpandedForm/index.ts
  50. 16
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  51. 6
      tests/playwright/pages/Dashboard/Grid/index.ts
  52. 9
      tests/playwright/pages/Dashboard/Settings/Teams.ts
  53. 22
      tests/playwright/pages/Dashboard/TreeView.ts
  54. 12
      tests/playwright/pages/Dashboard/ViewSidebar/index.ts
  55. 11
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  56. 104
      tests/playwright/tests/keyboardShortcuts.spec.ts

4
packages/nc-gui/app.vue

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

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

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

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

@ -180,6 +180,7 @@ declare module '@vue/runtime-core' {
MdiHook: typeof import('~icons/mdi/hook')['default']
MdiInformation: typeof import('~icons/mdi/information')['default']
MdiJson: typeof import('~icons/mdi/json')['default']
MdiKeyboard: typeof import('~icons/mdi/keyboard')['default']
MdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
MdiKeyChange: typeof import('~icons/mdi/key-change')['default']
MdiKeyStar: typeof import('~icons/mdi/key-star')['default']

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -284,6 +284,7 @@ onBeforeUnmount(() => {
<template>
<a-modal
v-model:visible="dialogShow"
:class="{ active: dialogShow }"
width="max(30vw, 600px)"
class="p-2"
wrap-class-name="nc-modal-airtable-import"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -24,6 +24,7 @@ import {
extractPkFromRow,
inject,
isColumnRequiredAndNull,
isDrawerOrModalExist,
isMac,
message,
onBeforeUnmount,
@ -96,6 +97,10 @@ const tbodyEl = ref<HTMLElement>()
const gridWrapper = ref<HTMLElement>()
const tableHead = ref<HTMLElement>()
const isAddingColumnAllowed = !readOnly.value && !isLocked.value && isUIAllowed('add-column') && !isSqlView.value
const isAddingEmptyRowAllowed = !isView && !isLocked.value && hasEditPermission && !isSqlView.value
const {
isLoading,
loadData,
@ -175,12 +180,13 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
return true
}
// if expanded form is active skip keyboard event handling
if (document.querySelector('.nc-drawer-expanded-form.active')) {
// skip keyboard event handling if there is a drawer / modal
if (isDrawerOrModalExist()) {
return true
}
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
const altOrOptionKey = e.altKey
if (e.key === ' ') {
if (selectedCell.row !== null && !editEnabled) {
e.preventDefault()
@ -194,32 +200,41 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
return true
}
} else if (e.key === 'Enter') {
if (e.shiftKey) {
// add a line break for types like LongText / JSON
return true
}
if (editEnabled) {
editEnabled = false
return true
}
}
if (cmdOrCtrl) {
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
selectedCell.row = 0
selectedCell.col = selectedCell.col ?? 0
scrollToCell?.()
editEnabled = false
return true
case 'ArrowDown':
e.preventDefault()
selectedCell.row = data.value.length - 1
selectedCell.col = selectedCell.col ?? 0
scrollToCell?.()
editEnabled = false
return true
case 'ArrowRight':
e.preventDefault()
selectedCell.row = selectedCell.row ?? 0
selectedCell.col = fields.value?.length - 1
scrollToCell?.()
editEnabled = false
return true
case 'ArrowLeft':
e.preventDefault()
selectedCell.row = selectedCell.row ?? 0
selectedCell.col = 0
scrollToCell?.()
@ -227,6 +242,25 @@ const { selectCell, startSelectRange, endSelectRange, clearSelectedRange, copyVa
return true
}
}
if (altOrOptionKey) {
switch (e.keyCode) {
case 82: {
// ALT + R
if (isAddingEmptyRowAllowed) {
addEmptyRow()
}
break
}
case 67: {
// ALT + C
if (isAddingColumnAllowed) {
addColumnDropdown.value = true
}
break
}
}
}
})
function scrollToCell(row?: number | null, col?: number | null) {
@ -336,7 +370,12 @@ watch(contextMenu, () => {
const rowRefs = $ref<any[]>()
async function clearCell(ctx: { row: number; col: number } | null) {
if (!ctx) return
if (
!ctx ||
!hasEditPermission ||
(fields.value[ctx.col].uidt !== UITypes.LinkToAnotherRecord && isVirtualCol(fields.value[ctx.col]))
)
return
const rowObj = data.value[ctx.row]
const columnObj = fields.value[ctx.col]
@ -408,8 +447,8 @@ onClickOutside(smartTable, (e) => {
)
return
// if expanded form is active skip resetting the active cell
if (document.querySelector('.nc-drawer-expanded-form.active')) {
// skip if drawer / modal is active
if (isDrawerOrModalExist()) {
return
}
@ -617,7 +656,7 @@ const closeAddColumnDropdown = () => {
</div>
</th>
<th
v-if="!readOnly && !isLocked && isUIAllowed('add-column') && !isSqlView"
v-if="isAddingColumnAllowed"
v-e="['c:column:add']"
class="cursor-pointer"
@click.stop="addColumnDropdown = true"
@ -645,8 +684,7 @@ const closeAddColumnDropdown = () => {
</th>
</tr>
</thead>
<!-- this prevent select text from field if not in edit mode -->
<tbody ref="tbodyEl" @selectstart.prevent>
<tbody ref="tbodyEl">
<LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row">
<template #default="{ state }">
<tr class="nc-grid-row" :data-testid="`grid-row-${rowIndex}`">
@ -750,7 +788,7 @@ const closeAddColumnDropdown = () => {
</template>
</LazySmartsheetRow>
<tr v-if="!isView && !isLocked && hasEditPermission && !isSqlView">
<tr v-if="isAddingEmptyRowAllowed">
<td
v-e="['c:row:add:grid-bottom']"
:colspan="visibleColLength + 1"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -34,12 +34,10 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
<div v-else>{{ result }}</div>
<div v-if="showEditNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Formula fields should be configured in the field menu dropdown.
{{ $t('msg.info.computedFieldEditWarning') }}
</div>
<div v-if="showClearNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Computed field - unable to clear text.
{{ $t('msg.info.computedFieldDeleteWarning') }}
</div>
</div>
</div>

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

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

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

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

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

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

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

@ -199,7 +199,8 @@
"quickImportFrom": "Quick Import From",
"quickImport": "Quick Import",
"advancedSettings": "Advanced Settings",
"codeSnippet": "Code Snippet"
"codeSnippet": "Code Snippet",
"keyboardShortcut": "Keyboard Shortcuts"
},
"labels": {
"createdBy": "Created By",
@ -613,7 +614,9 @@
"deleteViewConfirmation": "Are you sure you want to delete this view?",
"deleteTableConfirmation": "Do you want to delete the table",
"showM2mTables": "Show M2M Tables",
"deleteKanbanStackConfirmation": "Deleting this stack will also remove the select option `{stackToBeDeleted}` from the `{groupingField}`. The records will move to the uncategorized stack."
"deleteKanbanStackConfirmation": "Deleting this stack will also remove the select option `{stackToBeDeleted}` from the `{groupingField}`. The records will move to the uncategorized stack.",
"computedFieldEditWarning": "Computed field: contents are read-only. Use column edit menu to reconfigure",
"computedFieldDeleteWarning": "Computed field: contents are read-only. Unable to clear content."
},
"error": {
"searchProject": "Your search for {search} found no results",

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

@ -1,8 +1,12 @@
<script setup lang="ts">
import tinycolor from 'tinycolor2'
import {
TabType,
computed,
definePageMeta,
extractSdkResponseErrorMsg,
isDrawerOrModalExist,
isMac,
message,
navigateTo,
onBeforeMount,
@ -12,7 +16,9 @@ import {
openLink,
projectThemeColors,
ref,
resolveComponent,
useCopy,
useDialog,
useGlobal,
useI18n,
useProject,
@ -23,8 +29,6 @@ import {
useTheme,
useUIPermission,
} from '#imports'
import { TabType } from '~/lib'
import { extractSdkResponseErrorMsg } from '~/utils'
definePageMeta({
hideHeader: true,
@ -34,6 +38,8 @@ const { theme, defaultTheme } = useTheme()
const { t } = useI18n()
const { $e } = useNuxtApp()
const route = useRoute()
const router = useRouter()
@ -182,6 +188,47 @@ onMounted(() => {
})
onBeforeUnmount(reset)
function openKeyboardShortcutDialog() {
$e('a:actions:keyboard-shortcut')
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgKeyboardShortcuts'), {
'modelValue': isOpen,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (e.altKey && !e.shiftKey && !cmdOrCtrl) {
switch (e.keyCode) {
case 188: {
// ALT + ,
if (isUIAllowed('settings') && !isDrawerOrModalExist()) {
e.preventDefault()
toggleDialog(true, 'teamAndAuth')
}
break
}
}
}
if (cmdOrCtrl) {
switch (e.key) {
case '/':
if (!isDrawerOrModalExist()) {
openKeyboardShortcutDialog()
}
break
}
}
})
</script>
<template>

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

@ -64,6 +64,7 @@ watch(
<a-modal
v-model:visible="passwordDlg"
:class="{ active: passwordDlg }"
:closable="false"
width="min(100%, 450px)"
centered

2
packages/nc-gui/pages/index/index/create-external.vue

@ -551,6 +551,7 @@ onMounted(async () => {
<a-modal
v-model:visible="configEditDlg"
:class="{ active: configEditDlg }"
:title="$t('activity.editConnJson')"
width="600px"
wrap-class-name="nc-modal-edit-connection-json"
@ -562,6 +563,7 @@ onMounted(async () => {
<!-- Use Connection URL -->
<a-modal
v-model:visible="importURLDlg"
:class="{ active: importURLDlg }"
:title="$t('activity.useConnectionUrl')"
width="600px"
:ok-text="$t('general.ok')"

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

@ -1,2 +1,3 @@
// refer - https://stackoverflow.com/a/11752084
export const isMac = () => /Mac/i.test(navigator.platform)
export const isDrawerOrModalExist = () => document.querySelector('.ant-modal.active, .ant-drawer-open')

4
packages/nc-gui/utils/viewUtils.ts

@ -37,3 +37,7 @@ export function applyLanguageDirection(dir: typeof rtl | typeof ltr) {
document.body.classList.add(dir)
document.body.style.direction = dir
}
export function applyNonSelectable() {
document.body.classList.add('non-selectable')
}

8
tests/playwright/pages/Base.ts

@ -84,4 +84,12 @@ export default abstract class BasePage {
async getClipboardText() {
return await this.rootPage.evaluate(() => navigator.clipboard.readText());
}
async os() {
return await this.rootPage.evaluate(() => navigator.platform);
}
async isMacOs() {
return (await this.os()).includes('Mac');
}
}

6
tests/playwright/pages/Dashboard/ExpandedForm/index.ts

@ -93,9 +93,11 @@ export class ExpandedFormPage extends BasePage {
await this.rootPage.locator('[data-testid="grid-load-spinner"]').waitFor({ state: 'hidden' });
}
async verify({ header, url }: { header: string; url: string }) {
async verify({ header, url }: { header: string; url?: string }) {
await expect(this.get().locator(`.nc-expanded-form-header`).last()).toContainText(header);
await expect.poll(() => this.rootPage.url()).toContain(url);
if (url) {
await expect.poll(() => this.rootPage.url()).toContain(url);
}
}
async escape() {

16
tests/playwright/pages/Dashboard/Grid/Column/index.ts

@ -17,6 +17,14 @@ export class ColumnPageObject extends BasePage {
return this.rootPage.locator('form[data-testid="add-or-edit-column"]');
}
private getColumnHeader(title: string) {
return this.grid.get().locator(`th[data-title="${title}"]`);
}
async clickColumnHeader({ title }: { title: string }) {
await this.getColumnHeader(title).click();
}
async create({
title,
type = 'SingleLineText',
@ -162,7 +170,7 @@ export class ColumnPageObject extends BasePage {
}
async delete({ title }: { title: string }) {
await this.grid.get().locator(`th[data-title="${title}"] >> svg.ant-dropdown-trigger`).click();
await this.getColumnHeader(title).locator('svg.ant-dropdown-trigger').click();
// await this.rootPage.locator('li[role="menuitem"]:has-text("Delete")').waitFor();
await this.rootPage.locator('li[role="menuitem"]:has-text("Delete")').click();
@ -183,7 +191,7 @@ export class ColumnPageObject extends BasePage {
formula?: string;
format?: string;
}) {
await this.grid.get().locator(`th[data-title="${title}"] .nc-ui-dt-dropdown`).click();
await this.getColumnHeader(title).locator('.nc-ui-dt-dropdown').click();
await this.rootPage.locator('li[role="menuitem"]:has-text("Edit")').click();
await this.get().waitFor({ state: 'visible' });
@ -222,9 +230,9 @@ export class ColumnPageObject extends BasePage {
async verify({ title, isVisible = true }: { title: string; isVisible?: boolean }) {
if (!isVisible) {
return await expect(await this.rootPage.locator(`th[data-title="${title}"]`)).not.toBeVisible();
return await expect(this.getColumnHeader(title)).not.toBeVisible();
}
await await expect(this.rootPage.locator(`th[data-title="${title}"]`)).toContainText(title);
await expect(this.getColumnHeader(title)).toContainText(title);
}
async verifyRoleAccess(param: { role: string }) {

6
tests/playwright/pages/Dashboard/Grid/index.ts

@ -114,7 +114,11 @@ export class GridPage extends BasePage {
await this.waitForResponse({
uiAction: clickOnColumnHeaderToSave,
requestUrlPathToMatch: 'api/v1/db/data/noco',
httpMethodsToMatch: ['PATCH'],
httpMethodsToMatch: [
'PATCH',
// since edit row on an empty row will emit POST request
'POST',
],
responseJsonMatcher: resJson => resJson?.[columnHeader] === value,
});
} else {

9
tests/playwright/pages/Dashboard/Settings/Teams.ts

@ -28,10 +28,11 @@ export class TeamsPage extends BasePage {
return this.rootPage.getByTestId('nc-share-base-sub-modal');
}
async invite({ email, role }: { email: string; role: string }) {
async invite({ email, role, skipOpeningModal }: { email: string; role: string; skipOpeningModal?: boolean }) {
email = this.prefixEmail(email);
await this.inviteTeamBtn.click();
if (!skipOpeningModal) await this.inviteTeamBtn.click();
await this.inviteTeamModal.locator(`input[placeholder="E-mail"]`).fill(email);
await this.inviteTeamModal.locator(`.nc-user-roles`).click();
const userRoleModal = this.rootPage.locator(`.nc-dropdown-user-role`);
@ -78,6 +79,10 @@ export class TeamsPage extends BasePage {
return await this.getSharedBaseSubModal().locator(`.nc-url:visible`).textContent();
}
async getInvitationUrl() {
return await this.rootPage.getByTestId('invite-modal-invitation-url').textContent();
}
async sharedBaseActions({ action }: { action: string }) {
const actionMenu = ['reload', 'copy url', 'open tab', 'copy embed code'];
const index = actionMenu.indexOf(action);

22
tests/playwright/pages/Dashboard/TreeView.ts

@ -20,6 +20,18 @@ export class TreeViewPage extends BasePage {
return this.dashboard.get().locator('.nc-treeview-container');
}
async isVisible() {
return await this.get().isVisible();
}
async verifyVisibility({ isVisible }: { isVisible: boolean }) {
if (isVisible) {
await expect(this.get()).toBeVisible();
} else {
await expect(this.get()).not.toBeVisible();
}
}
async focusTable({ title }: { title: string }) {
await this.get().locator(`.nc-project-tree-tbl-${title}`).focus();
}
@ -43,8 +55,8 @@ export class TreeViewPage extends BasePage {
await this.dashboard.waitForTabRender({ title, mode });
}
async createTable({ title }: { title: string }) {
await this.get().locator('.nc-add-new-table').click();
async createTable({ title, skipOpeningModal }: { title: string; skipOpeningModal?: boolean }) {
if (!skipOpeningModal) await this.get().locator('.nc-add-new-table').click();
await this.dashboard.get().locator('.nc-modal-table-create').locator('.ant-modal-body').waitFor();
@ -63,13 +75,13 @@ export class TreeViewPage extends BasePage {
async verifyTable({ title, index, exists = true }: { title: string; index?: number; exists?: boolean }) {
if (exists) {
await expect(this.get().locator(`.nc-project-tree-tbl-${title}`)).toBeVisible();
await expect(this.get().getByTestId(`tree-view-table-${title}`)).toHaveCount(1);
if (index) {
await expect(await this.get().locator('.nc-tbl-title').nth(index)).toHaveText(title);
await expect(this.get().locator('.nc-tbl-title').nth(index)).toHaveText(title);
}
} else {
await expect(this.get().locator(`.nc-project-tree-tbl-${title}`)).toHaveCount(0);
await expect(this.get().getByTestId(`tree-view-table-${title}`)).toHaveCount(0);
}
}

12
tests/playwright/pages/Dashboard/ViewSidebar/index.ts

@ -23,6 +23,18 @@ export class ViewSidebarPage extends BasePage {
return this.dashboard.get().locator('.nc-view-sidebar');
}
async isVisible() {
return await this.get().isVisible();
}
async verifyVisibility({ isVisible }: { isVisible: boolean }) {
if (isVisible) {
await expect(this.get()).toBeVisible();
} else {
await expect(this.get()).not.toBeVisible();
}
}
private async createView({ title, locator }: { title: string; locator: Locator }) {
await locator.click();
await this.rootPage.locator('input[id="form_item_title"]:visible').fill(title);

11
tests/playwright/pages/Dashboard/common/Cell/index.ts

@ -31,7 +31,8 @@ export class CellPageObject extends BasePage {
}
async click({ index, columnHeader }: { index: number; columnHeader: string }) {
return await this.get({ index, columnHeader }).click();
await this.get({ index, columnHeader }).click();
await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable');
}
async dblclick({ index, columnHeader }: { index?: number; columnHeader: string }) {
@ -60,6 +61,14 @@ export class CellPageObject extends BasePage {
await this.get({ index, columnHeader }).locator('.nc-action-icon.nc-plus').click();
}
async verifyCellActiveSelected({ index, columnHeader }: { index: number; columnHeader: string }) {
await expect(this.get({ index, columnHeader })).toHaveClass(/active/);
}
async verifyCellEditable({ index, columnHeader }: { index: number; columnHeader: string }) {
await this.get({ index, columnHeader }).isEditable();
}
async verify({ index, columnHeader, value }: { index: number; columnHeader: string; value: string | string[] }) {
const _verify = async text => {
await expect

104
tests/playwright/tests/keyboardShortcuts.spec.ts

@ -0,0 +1,104 @@
import { expect, test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard';
import { GridPage } from '../pages/Dashboard/Grid';
import setup from '../setup';
test.describe('Verify shortcuts', () => {
let dashboard: DashboardPage, grid: GridPage;
let context: any;
test.beforeEach(async ({ page }) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
grid = dashboard.grid;
});
test('Verify shortcuts', async ({ page }) => {
await dashboard.treeView.openTable({ title: 'Country' });
// create new table
await page.keyboard.press('Alt+t');
await dashboard.treeView.createTable({ title: 'New Table', skipOpeningModal: true });
await dashboard.treeView.verifyTable({ title: 'New Table' });
// create new row
await grid.column.clickColumnHeader({ title: 'Title' });
await page.waitForTimeout(2000);
await page.keyboard.press('Alt+r');
await grid.editRow({ index: 0, value: 'New Row' });
await grid.verifyRowCount({ count: 1 });
// create new column
await page.keyboard.press('Alt+c');
await grid.column.fillTitle({ title: 'New Column' });
await grid.column.save();
await grid.column.verify({ title: 'New Column' });
// fullscreen
await page.keyboard.press('Alt+f');
await dashboard.treeView.verifyVisibility({
isVisible: false,
});
await dashboard.viewSidebar.verifyVisibility({
isVisible: false,
});
await page.keyboard.press('Alt+f');
await dashboard.treeView.verifyVisibility({
isVisible: true,
});
await dashboard.viewSidebar.verifyVisibility({
isVisible: true,
});
// invite team member
await page.keyboard.press('Alt+i');
await dashboard.settings.teams.invite({
email: 'new@example.com',
role: 'editor',
skipOpeningModal: true,
});
const url = await dashboard.settings.teams.getInvitationUrl();
// await dashboard.settings.teams.closeInvite();
expect(url).toContain('signup');
await page.waitForTimeout(1000);
await dashboard.settings.teams.closeInvite();
// Cmd + Right arrow
await dashboard.treeView.openTable({ title: 'Country' });
await page.waitForTimeout(1500);
await grid.cell.click({ index: 0, columnHeader: 'Country' });
await page.waitForTimeout(1500);
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+ArrowRight' : 'Control+ArrowRight');
await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'City List' });
// Cmd + Right arrow
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+ArrowLeft' : 'Control+ArrowLeft');
await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'Country' });
// Cmd + up arrow
await grid.cell.click({ index: 24, columnHeader: 'Country' });
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+ArrowUp' : 'Control+ArrowUp');
await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'Country' });
// Cmd + down arrow
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+ArrowDown' : 'Control+ArrowDown');
await grid.cell.verifyCellActiveSelected({ index: 24, columnHeader: 'Country' });
// Enter to edit and Esc to cancel
await grid.cell.click({ index: 0, columnHeader: 'Country' });
await page.keyboard.press('Enter');
await page.keyboard.type('New');
await page.keyboard.press('Escape');
await grid.cell.verify({ index: 0, columnHeader: 'Country', value: 'AfghanistanNew' });
// Space to open expanded row and Meta + Space to save
await grid.cell.click({ index: 1, columnHeader: 'Country' });
await page.keyboard.press('Space');
await dashboard.expandedForm.verify({
header: 'Algeria',
});
await dashboard.expandedForm.fillField({ columnTitle: 'Country', value: 'NewAlgeria' });
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+Enter' : 'Control+Enter');
await page.waitForTimeout(2000);
await grid.cell.verify({ index: 1, columnHeader: 'Country', value: 'NewAlgeria' });
});
});
Loading…
Cancel
Save