Browse Source

Merge pull request #5184 from nocodb/feat/5178-row-navigation

feat: Add shortcuts to navigate between rows in expanded form
pull/5268/head
Raju Udava 2 years ago committed by GitHub
parent
commit
8ad9d456b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 19
      packages/nc-gui/components/dlg/KeyboardShortcuts.vue
  2. 57
      packages/nc-gui/components/general/ShortcutLabel.vue
  3. 3
      packages/nc-gui/components/smartsheet/Grid.vue
  4. 4
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  5. 120
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  6. 3
      packages/nc-gui/composables/useExpandedFormStore.ts
  7. 6
      packages/nc-gui/composables/useSelectedCellKeyupListener/index.ts
  8. 14
      packages/nc-gui/composables/useViewData.ts

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

@ -15,6 +15,9 @@ const dialogShow = computed({
const renderCmdOrCtrlKey = () => { const renderCmdOrCtrlKey = () => {
return isMac() ? '⌘' : 'CTRL' return isMac() ? '⌘' : 'CTRL'
} }
const renderAltOrOptlKey = () => {
return isMac() ? '⌥' : 'ALT'
}
const shortcutList = [ const shortcutList = [
{ {
@ -197,6 +200,22 @@ const shortcutList = [
keys: [renderCmdOrCtrlKey(), 'Enter'], keys: [renderCmdOrCtrlKey(), 'Enter'],
behaviour: 'Save current expanded form item', behaviour: 'Save current expanded form item',
}, },
{
keys: [renderAltOrOptlKey(), '→'],
behaviour: 'Switch to next row',
},
{
keys: [renderAltOrOptlKey(), '←'],
behaviour: 'Switch to previous row',
},
{
keys: [renderAltOrOptlKey(), 'S'],
behaviour: 'Save current expanded form item',
},
{
keys: [renderAltOrOptlKey(), 'N'],
behaviour: 'Create a new row',
},
], ],
}, },
] ]

57
packages/nc-gui/components/general/ShortcutLabel.vue

@ -0,0 +1,57 @@
<script lang="ts" setup>
import { isMac } from '#imports'
const props = defineProps<{
keys: string[]
}>()
const isMacOs = isMac()
const getLabel = (key: string) => {
if (isMacOs) {
switch (key.toLowerCase()) {
case 'alt':
return '⌥'
case 'shift':
return '⇧'
case 'meta':
return '⌘'
case 'control':
case 'ctrl':
return '⌃'
case 'enter':
return '↩'
}
}
switch (key.toLowerCase()) {
case 'arrowup':
return '↑'
case 'arrowdown':
return '↓'
case 'arrowleft':
return '←'
case 'arrowright':
return '→'
}
return key
}
</script>
<template>
<div class="nc-shortcut-label-wrapper">
<div v-for="(key, index) in props.keys" :key="index" class="nc-shortcut-label">
<span>{{ getLabel(key) }}</span>
</div>
</div>
</template>
<style scoped>
.nc-shortcut-label-wrapper {
@apply flex gap-1;
}
.nc-shortcut-label {
@apply text-[0.7rem] leading-6 min-w-5 min-h-5 text-center relative z-0 after:(content-[''] left-0 top-0 -z-1 bg-current opacity-10 absolute w-full h-full rounded) px-1;
}
</style>

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

@ -118,6 +118,7 @@ const {
selectedAllRecords, selectedAllRecords,
removeRowIfNew, removeRowIfNew,
navigateToSiblingRow, navigateToSiblingRow,
getExpandedRowIndex,
} = useViewData(meta, view, xWhere) } = useViewData(meta, view, xWhere)
const { getMeta } = useMetas() const { getMeta } = useMetas()
@ -980,6 +981,8 @@ const closeAddColumnDropdown = () => {
:row-id="routeQuery.rowId" :row-id="routeQuery.rowId"
:view="view" :view="view"
show-next-prev-icons show-next-prev-icons
:first-row="getExpandedRowIndex() === 0"
:last-row="getExpandedRowIndex() === data.length - 1"
@next="navigateToSiblingRow(NavigateDir.NEXT)" @next="navigateToSiblingRow(NavigateDir.NEXT)"
@prev="navigateToSiblingRow(NavigateDir.PREV)" @prev="navigateToSiblingRow(NavigateDir.PREV)"
/> />

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

@ -18,7 +18,7 @@ const route = useRoute()
const { meta, isSqlView } = useSmartsheetStoreOrThrow() const { meta, isSqlView } = useSmartsheetStoreOrThrow()
const { commentsDrawer, displayValue, primaryKey, save: _save, loadRow } = useExpandedFormStoreOrThrow() const { commentsDrawer, displayValue, primaryKey, save: _save, loadRow, saveRowAndStay } = useExpandedFormStoreOrThrow()
const { isNew, syncLTARRefs, state } = useSmartsheetRowStoreOrThrow() const { isNew, syncLTARRefs, state } = useSmartsheetRowStoreOrThrow()
@ -26,8 +26,6 @@ const { isUIAllowed } = useUIPermission()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook()) const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const saveRowAndStay = ref(0)
const save = async () => { const save = async () => {
if (isNew.value) { if (isNew.value) {
const data = await _save(state.value) const data = await _save(state.value)

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

@ -22,6 +22,7 @@ import {
useVModel, useVModel,
watch, watch,
} from '#imports' } from '#imports'
import { useActiveKeyupListener } from '~/composables/useSelectedCellKeyupListener'
import type { Row } from '~/lib' import type { Row } from '~/lib'
interface Props { interface Props {
@ -34,12 +35,16 @@ interface Props {
rowId?: string rowId?: string
view?: ViewType view?: ViewType
showNextPrevIcons?: boolean showNextPrevIcons?: boolean
firstRow?: boolean
lastRow?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue', 'cancel', 'next', 'prev']) const emits = defineEmits(['update:modelValue', 'cancel', 'next', 'prev'])
const key = ref(0)
const { t } = useI18n() const { t } = useI18n()
const row = ref(props.row) const row = ref(props.row)
@ -64,7 +69,16 @@ const isKanban = inject(IsKanbanInj, ref(false))
provide(MetaInj, meta) provide(MetaInj, meta)
const { commentsDrawer, changedColumns, state: rowState, isNew, loadRow, save } = useProvideExpandedFormStore(meta, row) const {
commentsDrawer,
changedColumns,
state: rowState,
isNew,
loadRow,
saveRowAndStay,
syncLTARRefs,
save,
} = useProvideExpandedFormStore(meta, row)
const duplicatingRowInProgress = ref(false) const duplicatingRowInProgress = ref(false)
@ -157,6 +171,92 @@ const cellWrapperEl = ref<HTMLElement>()
onMounted(() => { onMounted(() => {
setTimeout(() => (cellWrapperEl.value?.querySelector('input,select,textarea') as HTMLInputElement)?.focus()) setTimeout(() => (cellWrapperEl.value?.querySelector('input,select,textarea') as HTMLInputElement)?.focus())
}) })
const addNewRow = () => {
setTimeout(async () => {
row.value = {
row: {},
oldRow: {},
rowMeta: { new: true },
}
rowState.value = {}
key.value++
isExpanded.value = true
}, 500)
}
// attach keyboard listeners to switch between rows
// using alt + left/right arrow keys
useActiveKeyupListener(
isExpanded,
async (e: KeyboardEvent) => {
if (!e.altKey) return
if (e.key === 'ArrowLeft') {
e.stopPropagation()
emits('prev')
} else if (e.key === 'ArrowRight') {
e.stopPropagation()
emits('next')
}
// on alt + s save record
else if (e.code === 'KeyS') {
// remove focus from the active input if any
document.activeElement?.blur()
e.stopPropagation()
if (isNew.value) {
const data = await save(rowState.value)
await syncLTARRefs(data)
reloadHook?.trigger(null)
} else {
await save()
reloadHook?.trigger(null)
}
if (!saveRowAndStay.value) {
onClose()
}
// on alt + n create new record
} else if (e.code === 'KeyN') {
// remove focus from the active input if any to avoid unwanted input
;(document.activeElement as HTMLInputElement)?.blur?.()
if (changedColumns.value.size > 0) {
await Modal.confirm({
title: 'Do you want to save the changes?',
okText: 'Save',
cancelText: 'Discard',
onOk: async () => {
await save()
reloadHook?.trigger(null)
addNewRow()
},
onCancel: () => {
addNewRow()
},
})
} else if (isNew.value) {
await Modal.confirm({
title: 'Do you want to save the record?',
okText: 'Save',
cancelText: 'Discard',
onOk: async () => {
const data = await save(rowState.value)
await syncLTARRefs(data)
reloadHook?.trigger(null)
addNewRow()
},
onCancel: () => {
addNewRow()
},
})
} else {
addNewRow()
}
}
},
{ immediate: true },
)
</script> </script>
<script lang="ts"> <script lang="ts">
@ -177,21 +277,25 @@ export default {
> >
<SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" @duplicate-row="onDuplicateRow" /> <SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" @duplicate-row="onDuplicateRow" />
<div class="!bg-gray-100 rounded flex-1"> <div :key="key" class="!bg-gray-100 rounded flex-1">
<div class="flex h-full nc-form-wrapper items-stretch min-h-[max(70vh,100%)]"> <div class="flex h-full nc-form-wrapper items-stretch min-h-[max(70vh,100%)]">
<div class="flex-1 overflow-auto scrollbar-thin-dull nc-form-fields-container relative"> <div class="flex-1 overflow-auto scrollbar-thin-dull nc-form-fields-container relative">
<template v-if="props.showNextPrevIcons"> <template v-if="props.showNextPrevIcons">
<a-tooltip placement="bottom"> <a-tooltip v-if="!props.firstRow" placement="bottom">
<template #title> <template #title>
{{ $t('labels.nextRow') }} {{ $t('labels.prevRow') }}
<GeneralShortcutLabel class="justify-center" :keys="['Alt', '←']" />
</template> </template>
<MdiChevronRight class="cursor-pointer nc-next-arrow" @click="onNext" /> <MdiChevronLeft class="cursor-pointer nc-prev-arrow" @click="$emit('prev')" />
</a-tooltip> </a-tooltip>
<a-tooltip placement="bottom">
<a-tooltip v-if="!props.lastRow" placement="bottom">
<template #title> <template #title>
{{ $t('labels.prevRow') }} {{ $t('labels.nextRow') }}
<GeneralShortcutLabel class="justify-center" :keys="['Alt', '→']" />
</template> </template>
<MdiChevronLeft class="cursor-pointer nc-prev-arrow" @click="$emit('prev')" /> <MdiChevronRight class="cursor-pointer nc-next-arrow" @click="onNext" />
</a-tooltip> </a-tooltip>
</template> </template>
<div class="w-[500px] mx-auto"> <div class="w-[500px] mx-auto">

3
packages/nc-gui/composables/useExpandedFormStore.ts

@ -38,6 +38,8 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const commentsDrawer = ref(false) const commentsDrawer = ref(false)
const saveRowAndStay = ref(0)
const changedColumns = ref(new Set<string>()) const changedColumns = ref(new Set<string>())
const { project } = useProject() const { project } = useProject()
@ -243,6 +245,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
changedColumns, changedColumns,
loadRow, loadRow,
primaryKey, primaryKey,
saveRowAndStay,
} }
}, 'expanded-form-store') }, 'expanded-form-store')

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

@ -1,15 +1,15 @@
import { isClient } from '@vueuse/core' import { isClient } from '@vueuse/core'
import type { Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
function useSelectedCellKeyupListener( function useSelectedCellKeyupListener(
selected: Ref<boolean>, selected: Ref<boolean | undefined> | ComputedRef<boolean | undefined>,
handler: (e: KeyboardEvent) => void, handler: (e: KeyboardEvent) => void,
{ immediate = false }: { immediate?: boolean } = {}, { immediate = false }: { immediate?: boolean } = {},
) { ) {
if (isClient) { if (isClient) {
watch( watch(
selected, selected,
(nextVal: boolean, _: boolean, cleanup) => { (nextVal: boolean | undefined, _: boolean | undefined, cleanup) => {
// bind listener when `selected` is truthy // bind listener when `selected` is truthy
if (nextVal) { if (nextVal) {
document.addEventListener('keydown', handler, true) document.addEventListener('keydown', handler, true)

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

@ -477,15 +477,24 @@ export function useViewData(
} }
} }
const navigateToSiblingRow = async (dir: NavigateDir) => {
// get current expanded row index // get current expanded row index
const expandedRowIndex = formattedData.value.findIndex( function getExpandedRowIndex() {
return formattedData.value.findIndex(
(row: Row) => routeQuery.rowId === extractPkFromRow(row.row, meta.value?.columns as ColumnType[]), (row: Row) => routeQuery.rowId === extractPkFromRow(row.row, meta.value?.columns as ColumnType[]),
) )
}
const navigateToSiblingRow = async (dir: NavigateDir) => {
const expandedRowIndex = getExpandedRowIndex()
// calculate next row index based on direction // calculate next row index based on direction
let siblingRowIndex = expandedRowIndex + (dir === NavigateDir.NEXT ? 1 : -1) let siblingRowIndex = expandedRowIndex + (dir === NavigateDir.NEXT ? 1 : -1)
// if unsaved row skip it
while (formattedData.value[siblingRowIndex]?.rowMeta?.new) {
siblingRowIndex = siblingRowIndex + (dir === NavigateDir.NEXT ? 1 : -1)
}
const currentPage = paginationData?.value?.page || 1 const currentPage = paginationData?.value?.page || 1
// if next row index is less than 0, go to previous page and point to last element // if next row index is less than 0, go to previous page and point to last element
@ -547,5 +556,6 @@ export function useViewData(
removeRowIfNew, removeRowIfNew,
navigateToSiblingRow, navigateToSiblingRow,
deleteRowById, deleteRowById,
getExpandedRowIndex,
} }
} }

Loading…
Cancel
Save