Browse Source

Merge pull request #6464 from nocodb/feat/expanded-view

feat: Redesigned Expanded View
pull/6478/head
Anbarasu 1 year ago committed by GitHub
parent
commit
7b1e65a589
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 12
      packages/nc-gui/assets/nc-icons/table.svg
  2. 2
      packages/nc-gui/assets/style.scss
  3. 8
      packages/nc-gui/components.d.ts
  4. 8
      packages/nc-gui/components/general/UserIcon.vue
  5. 280
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  6. 202
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  7. 232
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  8. 2
      packages/nc-gui/composables/useExpandedFormStore.ts
  9. 2
      packages/nc-gui/utils/iconUtils.ts
  10. 5
      packages/nocodb-sdk/src/lib/Api.ts
  11. 6
      packages/nocodb/src/models/Audit.ts
  12. 8
      packages/nocodb/src/schema/swagger.json
  13. 38
      tests/playwright/pages/Dashboard/ExpandedForm/index.ts
  14. 1
      tests/playwright/pages/Dashboard/Gallery/index.ts
  15. 1
      tests/playwright/pages/Dashboard/Grid/index.ts
  16. 8
      tests/playwright/tests/db/features/expandedFormUrl.spec.ts
  17. 5
      tests/playwright/tests/db/features/keyboardShortcuts.spec.ts

12
packages/nc-gui/assets/nc-icons/table.svg

@ -0,0 +1,12 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1409_68546)">
<path d="M12.3571 5.96842L4.64282 10.4219" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="1.15184" width="9.06208" height="9.06208" rx="1.335" transform="matrix(0.866044 -0.499967 0.866044 0.499967 -0.345705 8.77119)" stroke="#1F293A" stroke-width="1.33"/>
<path d="M4 6.33984L11.7143 10.7933" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1409_68546">
<rect width="16" height="16" fill="white" transform="translate(0.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 712 B

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

@ -435,7 +435,7 @@ a {
}
.ant-modal {
@apply !top-[50px];
@apply !top-[30px];
}
.ant-modal-content {
@apply !p-6;

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

@ -81,6 +81,7 @@ declare module '@vue/runtime-core' {
CilFullscreenExit: typeof import('~icons/cil/fullscreen-exit')['default']
ClaritySuccessLine: typeof import('~icons/clarity/success-line')['default']
IcBaselineMoreVert: typeof import('~icons/ic/baseline-more-vert')['default']
Icon: typeof import('~icons/ic/on')['default']
IcOutlineInsertDriveFile: typeof import('~icons/ic/outline-insert-drive-file')['default']
IcRoundEdit: typeof import('~icons/ic/round-edit')['default']
IcRoundKeyboardArrowDown: typeof import('~icons/ic/round-keyboard-arrow-down')['default']
@ -104,8 +105,10 @@ declare module '@vue/runtime-core' {
MaterialSymbolsVisibility: typeof import('~icons/material-symbols/visibility')['default']
MaterialSymbolsVisibilityOff: typeof import('~icons/material-symbols/visibility-off')['default']
MaterialSymbolsWarning: typeof import('~icons/material-symbols/warning')['default']
MdiAccordionUp: typeof import('~icons/mdi/accordion-up')['default']
MdiAccount: typeof import('~icons/mdi/account')['default']
MdiAccountCircleOutline: typeof import('~icons/mdi/account-circle-outline')['default']
MdiAccountCircleOutlines: typeof import('~icons/mdi/account-circle-outlines')['default']
MdiAccountSupervisorOutline: typeof import('~icons/mdi/account-supervisor-outline')['default']
MdiAlpha: typeof import('~icons/mdi/alpha')['default']
MdiAppleKeyboardShift: typeof import('~icons/mdi/apple-keyboard-shift')['default']
@ -123,6 +126,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']
MdiChevronUp: typeof import('~icons/mdi/chevron-up')['default']
MdiCircleMedium: typeof import('~icons/mdi/circle-medium')['default']
MdiClose: typeof import('~icons/mdi/close')['default']
MdiCodeTags: typeof import('~icons/mdi/code-tags')['default']
@ -132,8 +136,10 @@ declare module '@vue/runtime-core' {
MdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
MdiEye: typeof import('~icons/mdi/eye')['default']
MdiFileDocumentMultipleOutline: typeof import('~icons/mdi/file-document-multiple-outline')['default']
MdiFileDocumentOutline: typeof import('~icons/mdi/file-document-outline')['default']
MdiFlag: typeof import('~icons/mdi/flag')['default']
MdiHeart: typeof import('~icons/mdi/heart')['default']
MdiHistory: typeof import('~icons/mdi/history')['default']
MdiKeyStar: typeof import('~icons/mdi/key-star')['default']
MdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
MdiLoading: typeof import('~icons/mdi/loading')['default']
@ -142,8 +148,10 @@ declare module '@vue/runtime-core' {
MdiMagnify: typeof import('~icons/mdi/magnify')['default']
MdiMapMarkerOutline: typeof import('~icons/mdi/map-marker-outline')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
MdiMessageOutline: typeof import('~icons/mdi/message-outline')['default']
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['default']
MdiMoonFull: typeof import('~icons/mdi/moon-full')['default']
MdiMoreVert: typeof import('~icons/mdi/more-vert')['default']
MdiPlus: typeof import('~icons/mdi/plus')['default']
MdiReload: typeof import('~icons/mdi/reload')['default']
MdiRocketLaunchOutline: typeof import('~icons/mdi/rocket-launch-outline')['default']

8
packages/nc-gui/components/general/UserIcon.vue

@ -1,6 +1,7 @@
<script lang="ts" setup>
const props = defineProps<{
size?: 'small' | 'medium' | 'large' | 'xlarge'
size?: 'small' | 'medium' | 'base' | 'large' | 'xlarge'
name?: string
}>()
const { user } = useGlobal()
@ -9,9 +10,9 @@ const backgroundColor = computed(() => (user.value?.id ? stringToColour(user.val
const size = computed(() => props.size || 'medium')
const displayName = computed(() => user.value?.display_name ?? '')
const displayName = computed(() => props.name ?? user.value?.display_name ?? '')
const email = computed(() => user.value?.email ?? '')
const email = computed(() => props.name ?? user.value?.email ?? '')
const usernameInitials = computed(() => {
const displayNameSplit = displayName.value?.split(' ').filter((name) => name) ?? []
@ -34,6 +35,7 @@ const usernameInitials = computed(() => {
:class="{
'min-w-4 min-h-4': size === 'small',
'min-w-6 min-h-6': size === 'medium',
'w-8 h-8 !text-md': size === 'base',
'min-w-20 min-h-20 !text-3xl': size === 'large',
'min-w-26 min-h-26 !text-4xl': size === 'xlarge',
}"

280
packages/nc-gui/components/smartsheet/expanded-form/Comments.vue

@ -1,43 +1,23 @@
<script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import type { AuditType } from 'nocodb-sdk'
import {
enumColor,
iconMap,
ref,
timeAgo,
useCopy,
useExpandedFormStoreOrThrow,
useGlobal,
useI18n,
useRoles,
watch,
} from '#imports'
const { loadCommentsAndLogs, commentsAndLogs, isCommentsLoading, commentsOnly, saveComment, isYou, comment, updateComment } =
useExpandedFormStoreOrThrow()
import { Icon } from '@iconify/vue'
import { ref, timeAgo, useExpandedFormStoreOrThrow, useGlobal, useRoles, watch } from '#imports'
const { loadCommentsAndLogs, commentsAndLogs, saveComment, comment, updateComment } = useExpandedFormStoreOrThrow()
const commentsWrapperEl = ref<HTMLDivElement>()
await loadCommentsAndLogs()
const showBorder = ref(false)
const { copy } = useCopy()
const { t } = useI18n()
const { user } = useGlobal()
const tab = ref<'comments' | 'audits'>('comments')
const { isUIAllowed } = useRoles()
const hasEditPermission = computed(() => isUIAllowed('commentEdit'))
// currently, edit option is disable on purpose
// since the current update wouldn't keep track of the previous values
// need history of edit feature in order to enable it back
const disableEditOption = ref(true)
const editLog = ref<AuditType>()
const isEditing = ref<boolean>(false)
@ -79,6 +59,7 @@ function onCancel() {
}
function onStopEdit() {
loadCommentsAndLogs()
isEditing.value = false
editLog.value = undefined
}
@ -89,36 +70,27 @@ onKeyStroke('Enter', (event) => {
}
})
const _contextMenu = ref(false)
const contextMenu = computed({
get: () => _contextMenu.value,
set: (val) => {
if (hasEditPermission.value) {
_contextMenu.value = val
}
},
})
async function copyComment(val: string) {
if (!val) return
try {
await copy(val)
message.success(t('msg.success.commentCopied'))
} catch (e: any) {
message.error(e.message)
}
}
const comments = computed(() => commentsAndLogs.value.filter((log) => log.op_type === 'COMMENT'))
const audits = computed(() => commentsAndLogs.value.filter((log) => log.op_type !== 'COMMENT'))
function editComment(log: AuditType) {
editLog.value = log
isEditing.value = true
}
const value = computed({
get() {
return editLog.value.description.substring(editLog.value.description.indexOf(':') + 1) ?? ''
},
set(val) {
if (!editLog.value) return
editLog.value.description = val
},
})
watch(
commentsAndLogs,
() => {
// todo: replace setTimeout
setTimeout(() => {
if (commentsWrapperEl.value) commentsWrapperEl.value.scrollTop = commentsWrapperEl.value?.scrollHeight
}, 200)
@ -128,128 +100,150 @@ watch(
</script>
<template>
<div class="h-full flex flex-col w-full bg-gray-100 p-2">
<div ref="commentsWrapperEl" class="flex-1 min-h-[100px] overflow-y-auto scrollbar-thin-dull p-2 space-y-2">
<a-skeleton v-if="isCommentsLoading" type="list-item-avatar-two-line@8" />
<template v-else-if="commentsAndLogs.length === 0">
<div class="flex flex-col text-center justify-center h-full">
<div class="text-center text-3xl text-gray-300">
<div class="flex flex-col h-full w-full bg-gray-50 rounded-lg">
<div class="bg-white rounded-t-lg border-gray-200 border-b-1">
<div class="flex flex-row gap-2 m-2 p-1 bg-gray-100 rounded-lg">
<div
class="tab flex-1 px-4 py-2 transition-all text-gray-600 cursor-pointer rounded-lg"
:class="{
'bg-white shadow !text-brand-500 !hover:text-brand-500': tab === 'comments',
}"
@click="tab = 'comments'"
>
<div class="tab-title nc-tab">
<MdiMessageOutline class="h-4 w-4" />
Comments
</div>
</div>
<div
class="tab flex-1 px-4 py-2 transition-all text-gray-600 cursor-pointer rounded-lg"
:class="{
'bg-white shadow !text-brand-500 !hover:text-brand-500': tab === 'audits',
}"
@click="tab = 'audits'"
>
<div class="tab-title nc-tab">
<MdiFileDocumentOutline class="h-4 w-4" />
Audits
</div>
</div>
</div>
</div>
<div>
<div v-if="tab === 'comments'" ref="commentsWrapperEl" class="flex flex-col h-[74vh] max-h-[680px]">
<div v-if="comments.length === 0" class="flex flex-col my-1 text-center justify-center h-full">
<div class="text-center text-3xl text-gray-700">
<MdiChatProcessingOutline />
</div>
<div class="font-bold text-center my-1 text-gray-400">Start a conversation</div>
<div class="text-gray-400">
NocoDB allows you to inquire, monitor progress updates, and collaborate with your team members.
<div class="font-bold text-center my-1 text-gray-700">Start a conversation</div>
</div>
<div v-else class="flex-grow-1 my-2 px-2 space-y-2 overflow-y-scroll nc-scrollbar-md">
<div v-for="log of comments" :key="log.id" class="!last:mb-11">
<div class="bg-white rounded-xl group border-1 gap-2 border-gray-200">
<div class="flex flex-col p-4 gap-3">
<div class="flex justify-between">
<div class="flex items-center gap-2">
<GeneralUserIcon size="base" :name="log.display_name ?? log.user" />
<div class="flex flex-col">
<span class="truncate font-bold max-w-42">
{{ log.display_name ?? log.user.split('@')[0].slice(0, 2) ?? 'Shared base' }}
</span>
<div v-if="log.id !== editLog?.id" class="text-xs text-gray-500">
{{ log.created_at !== log.updated_at ? `Edited ${timeAgo(log.updated_at)}` : timeAgo(log.created_at) }}
</div>
</template>
<template v-else>
<div v-for="(log, idx) of commentsAndLogs" :key="log.id">
<a-dropdown :trigger="['contextmenu']" :overlay-class-name="`nc-dropdown-comment-context-menu-${idx}`">
<div class="flex gap-1 text-xs">
<component
:is="iconMap.accountCircle"
class="row-span-2"
:class="isYou(log.user) ? 'text-pink-600' : 'text-blue-600 '"
/>
<div class="flex-1">
<p class="mb-1 caption edited-text text-[10px] text-gray-500">
{{ isYou(log.user) ? 'You' : log.user == null ? 'Shared base' : log.user }}
{{ log.op_type === 'COMMENT' ? 'commented' : log.op_sub_type === 'INSERT' ? 'created' : 'edited' }}
</p>
<div v-if="log.op_type === 'COMMENT'">
<a-input
</div>
</div>
<NcButton
v-if="log.user === user.email && !editLog"
type="secondary"
class="!px-2 opacity-0 group-hover:opacity-100 transition-all"
size="sm"
@click="editComment(log)"
>
<Icon class="iconify text-gray-800" icon="lucide:pen" />
</NcButton>
</div>
<textarea
v-if="log.id === editLog?.id"
:ref="focusInput"
v-model:value="editLog.description"
@blur="onCancel"
v-model="value"
rows="6"
class="px-2 py-1 rounded-lg border-none nc-scrollbar-md bg-white outline-gray-200"
@keydown.stop="onKeyDown($event)"
/>
<p
v-else
class="block caption my-2 nc-chip w-full min-h-20px p-2 rounded"
:style="{ backgroundColor: enumColor.light[2] }"
>
<!--
retrieve the comment part from the audit description
`The following comment has been created: foo` -> `foo`
-->
<div v-else class="text-sm text-gray-700">
{{ log.description.substring(log.description.indexOf(':') + 1) }}
</p>
</div>
<p v-else-if="log.details" v-dompurify-html="log.details" class="caption my-3" style="word-break: break-all" />
<p v-else>{{ log.description }}</p>
<p class="time text-right text-[10px] mb-0 mt-1 text-gray-500">
{{ timeAgo(log.created_at) }}
</p>
<div v-if="log.id === editLog?.id" class="flex justify-end gap-1">
<NcButton type="secondary" size="sm" @click="onCancel"> Cancel </NcButton>
<NcButton size="sm" @click="onEditComment"> Save </NcButton>
</div>
</div>
<template #overlay>
<a-menu v-if="log.op_type === 'COMMENT'" @click="contextMenu = false">
<a-menu-item key="copy-comment" @click="copyComment(log.description)">
<div v-e="['a:comment:copy']" class="nc-project-menu-item">
{{ t('general.copy') }}
</div>
</a-menu-item>
<a-menu-item v-if="log.user === user.email && !disableEditOption" key="edit-comment" @click="editComment(log)">
<div v-e="['a:comment:edit']" class="nc-project-menu-item">
{{ t('general.edit') }}
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
</div>
<div class="border-1 my-2 w-full" />
<div class="p-0">
<div class="flex justify-center">
<!-- Comments only -->
<a-checkbox v-model:checked="commentsOnly" v-e="['c:row-expand:comment-only']" @change="loadCommentsAndLogs">
{{ $t('labels.commentsOnly') }}
<span class="text-[11px] text-gray-500" />
</a-checkbox>
</div>
<div v-if="hasEditPermission" class="shrink mt-2 flex">
<div
v-if="hasEditPermission"
class="mt-1 absolute bottom-0 left-0 right-0 w-[285px] p-2 rounded-b-xl border-t-1 bg-white gap-2 flex"
>
<a-input
v-model:value="comment"
class="!text-xs nc-comment-box"
ghost
:class="{ focus: showBorder }"
@focusin="showBorder = true"
@focusout="showBorder = false"
class="!rounded-lg border-1 bg-white !px-4 !py-2 !border-gray-200 nc-comment-box"
placeholder="Start typing..."
@keyup.enter.prevent="saveComment"
>
<template #addonBefore>
<div class="flex items-center">
<component :is="iconMap.accountCircle" class="text-lg text-pink-700" small @click="saveComment" />
</a-input>
<NcButton type="secondary" size="medium" @click="saveComment">
<Icon class="iconify text-gray-800" icon="lucide:send" />
</NcButton>
</div>
</div>
<div
v-else
ref="commentsWrapperEl"
class="flex flex-col m-1 p-1 h-[74vh] max-h-[680px] overflow-y-scroll nc-scrollbar-md space-y-2"
>
<template v-if="audits.length === 0">
<div class="flex flex-col text-center justify-center h-full">
<div class="text-center text-3xl text-gray-600">
<MdiHistory />
</div>
<div class="font-bold text-center my-1 text-gray-600">See changes to this record</div>
</div>
</template>
<template #suffix>
<component :is="iconMap.returnKey" v-if="comment" class="text-sm" small @click="saveComment" />
</template>
</a-input>
<div v-for="log of audits" :key="log.id">
<div class="bg-white rounded-xl border-1 gap-3 border-gray-200">
<div class="flex flex-col p-4 gap-3">
<div class="flex justify-between">
<div class="flex font-bold items-center gap-2">
<GeneralUserIcon size="base" :name="log.display_name ?? log.user" />
<div class="flex flex-col">
<span class="truncate max-w-50">
{{ log.display_name ?? log.user.split('@')[0].slice(0, 2) ?? 'Shared base' }}
</span>
<div v-if="log.id !== editLog?.id" class="text-xs text-gray-500">
{{ timeAgo(log.created_at) }}
</div>
</div>
</div>
</div>
<div class="text-sm font-medium text-gray-700">
{{ log.description.split('.')[1] }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.red.lighten-4) {
@apply bg-red-100;
}
:deep(.green.lighten-4) {
@apply bg-green-100;
.tab .tab-title {
@apply min-w-0 flex justify-center gap-2 font-semibold items-center;
word-break: 'keep-all';
white-space: 'nowrap';
display: 'inline';
}
</style>

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

@ -1,202 +0,0 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import type { ViewType } from 'nocodb-sdk'
import {
IsPublicInj,
ReloadRowDataHookInj,
iconMap,
isMac,
useCopy,
useExpandedFormStoreOrThrow,
useRoles,
useSmartsheetRowStoreOrThrow,
useSmartsheetStoreOrThrow,
} from '#imports'
const props = defineProps<{ view?: ViewType }>()
const emit = defineEmits(['cancel', 'duplicateRow'])
const route = useRoute()
const { meta, isSqlView } = useSmartsheetStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false))
const { commentsDrawer, displayValue, primaryKey, save: _save, loadRow, deleteRowById } = useExpandedFormStoreOrThrow()
const { isNew, syncLTARRefs, state } = useSmartsheetRowStoreOrThrow()
const { isUIAllowed } = useRoles()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const save = async () => {
if (isNew.value) {
const data = await _save(state.value)
await syncLTARRefs(data)
reloadTrigger?.trigger()
} else {
await _save()
reloadTrigger?.trigger()
}
}
// todo: accept as a prop / inject
const iconColor = '#1890ff'
const { dashboardUrl } = useDashboard()
const { copy } = useCopy()
const copyRecordUrl = () => {
copy(
encodeURI(
`${dashboardUrl?.value}#/${route.params.typeOrId}/${route.params.projectId}/${meta.value?.id}${
props.view ? `/${props.view.title}` : ''
}?rowId=${primaryKey.value}`,
),
)
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('dataEdit')) {
await save()
}
}
}
}
})
const showDeleteRowModal = ref(false)
const onDeleteRowClick = () => {
showDeleteRowModal.value = true
}
const onConfirmDeleteRowClick = async () => {
showDeleteRowModal.value = false
await deleteRowById(primaryKey.value)
await reloadTrigger.trigger()
emit('cancel')
message.success('Row deleted')
}
</script>
<template>
<div class="flex p-2 items-center gap-2 p-4 nc-expanded-form-header">
<h5 class="text-lg font-weight-medium flex items-center gap-1 mb-0 min-w-0 overflow-x-hidden truncate">
<GeneralTableIcon :style="{ color: iconColor }" :meta="meta" class="mx-2" />
<template v-if="meta">
{{ meta.title }}
</template>
<template v-if="displayValue">: {{ displayValue }}</template>
</h5>
<div class="flex-1" />
<a-tooltip v-if="!isPublic" placement="bottom">
<template #title>
<!-- todo: i18n -->
<div class="text-center w-full">Copy record URL</div>
</template>
<component
:is="iconMap.link"
v-if="!isNew"
class="nc-icon-transition cursor-pointer select-none text-gray-500 mx-1 nc-copy-row-url min-w-4"
@click="copyRecordUrl"
/>
</a-tooltip>
<a-tooltip v-if="!isSqlView" placement="bottom">
<!-- Toggle comments draw -->
<template #title>
<div class="text-center w-full">{{ $t('activity.toggleCommentsDraw') }}</div>
</template>
<component
:is="iconMap.comment"
v-if="isUIAllowed('commentList') && !isNew"
v-e="['c:row-expand:comment-toggle']"
class="nc-icon-transition cursor-pointer select-none nc-toggle-comments text-gray-500 mx-1 min-w-4"
@click="commentsDrawer = !commentsDrawer"
/>
</a-tooltip>
<NcButton
class="nc-expand-form-save-btn !w-[60px]"
type="primary"
size="small"
:disabled="!isUIAllowed('dataEdit')"
@click="save"
>
{{ $t('general.save') }}
</NcButton>
<a-dropdown>
<component :is="iconMap.threeDotVertical" class="nc-icon-transition nc-expand-form-more-actions hover:cursor-pointer" />
<template #overlay>
<a-menu>
<a-menu-item v-if="!isNew" @click="loadRow()">
<div v-e="['c:row-expand:reload']" class="py-2 flex gap-2 items-center">
<component
:is="iconMap.reload"
class="nc-icon-transition cursor-pointer select-none text-gray-500 mx-1 min-w-4 text-primary"
/>
{{ $t('general.reload') }}
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('dataEdit') && !isNew" @click="!isNew && emit('duplicateRow')">
<div v-e="['c:row-expand:duplicate']" class="py-2 flex gap-2 a">
<component
:is="iconMap.copy"
class="nc-icon-transition cursor-pointer select-none nc-duplicate-row text-gray-500 mx-1 min-w-4 text-primary"
/>
{{ $t('activity.duplicateRow') }}
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('dataEdit') && !isNew" @click="!isNew && onDeleteRowClick()">
<div v-e="['c:row-expand:delete']" class="py-2 flex gap-2 items-center">
<component
:is="iconMap.delete"
class="nc-icon-transition cursor-pointer select-none nc-delete-row text-gray-500 mx-1 min-w-4 text-primary"
/>
{{ $t('activity.deleteRow') }}
</div>
</a-menu-item>
<a-menu-item @click="emit('cancel')">
<div v-e="['c:row-expand:delete']" class="py-2 flex gap-2 items-center">
<component
:is="iconMap.close"
class="nc-icon-transition cursor-pointer select-none nc-delete-row text-gray-500 mx-1 min-w-4 text-primary"
/>
{{ $t('general.close') }}
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<GeneralModal v-model:visible="showDeleteRowModal" class="!w-[25rem]">
<div class="p-4">
<div class="prose-xl font-bold self-center">Delete row ?</div>
<div class="mt-4">Are you sure you want to delete this row?</div>
</div>
<div class="flex flex-row gap-x-2 mt-1 pt-1.5 justify-end p-4">
<NcButton type="secondary" @click="showDeleteRowModal = false">{{ $t('general.cancel') }}</NcButton>
<NcButton @click="onConfirmDeleteRowClick">{{ $t('general.confirm') }} </NcButton>
</div>
</GeneralModal>
</div>
</template>
<style scoped>
:deep(svg) {
@apply outline-none;
}
</style>

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

@ -3,6 +3,8 @@ import type { TableType, ViewType } from 'nocodb-sdk'
import { isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue'
import MdiChevronDown from '~icons/mdi/chevron-down'
import TableIcon from '~icons/nc-icons/table'
import {
CellClickHookInj,
FieldsInj,
@ -62,6 +64,8 @@ const isPublic = inject(IsPublicInj, ref(false))
const { isUIAllowed } = useRoles()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
// override cell click hook to avoid unexpected behavior at form fields
provide(CellClickHookInj, undefined)
@ -89,12 +93,15 @@ provide(MetaInj, meta)
const {
commentsDrawer,
changedColumns,
deleteRowById,
displayValue,
state: rowState,
isNew,
loadRow: _loadRow,
primaryKey,
saveRowAndStay,
syncLTARRefs,
save,
save: _save,
} = useProvideExpandedFormStore(meta, row)
const duplicatingRowInProgress = ref(false)
@ -220,6 +227,17 @@ const addNewRow = () => {
}, 500)
}
const save = async () => {
if (isNew.value) {
const data = await _save(rowState.value)
await syncLTARRefs(data)
reloadTrigger?.trigger()
} else {
await _save()
reloadTrigger?.trigger()
}
}
// attach keyboard listeners to switch between rows
// using alt + left/right arrow keys
useActiveKeyupListener(
@ -241,7 +259,7 @@ useActiveKeyupListener(
e.stopPropagation()
if (isNew.value) {
const data = await save(rowState.value)
const data = await _save(rowState.value)
await syncLTARRefs(data)
reloadHook?.trigger(null)
} else {
@ -276,7 +294,7 @@ useActiveKeyupListener(
okText: 'Save',
cancelText: 'Discard',
onOk: async () => {
const data = await save(rowState.value)
const data = await _save(rowState.value)
await syncLTARRefs(data)
reloadHook?.trigger(null)
addNewRow()
@ -292,6 +310,25 @@ useActiveKeyupListener(
},
{ immediate: true },
)
const showDeleteRowModal = ref(false)
const onDeleteRowClick = () => {
showDeleteRowModal.value = true
}
const onConfirmDeleteRowClick = async () => {
showDeleteRowModal.value = false
await deleteRowById(primaryKey.value)
message.success('Row deleted')
if (!props.lastRow) {
await onNext()
} else if (!props.firstRow) {
emits('prev')
} else {
onClose()
}
}
</script>
<script lang="ts">
@ -301,59 +338,99 @@ export default {
</script>
<template>
<a-drawer
<a-modal
:key="key"
v-model:visible="isExpanded"
:footer="null"
:width="commentsDrawer && isUIAllowed('commentList') ? 'min(90vw,900px)' : 'min(90vw,700px)'"
:body-style="{ 'padding': 0, 'display': 'flex', 'flex-direction': 'column' }"
:width="commentsDrawer && isUIAllowed('commentList') ? 'min(90vw,1280px)' : 'min(90vw,1280px)'"
:body-style="{ padding: 0 }"
:closable="false"
class="nc-drawer-expanded-form"
class="nc-drawer-expanded-form max-h-[856px]"
:class="{ active: isExpanded }"
>
<SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" @duplicate-row="onDuplicateRow" />
<div :key="key" class="!bg-gray-50 rounded flex-1">
<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">
<template v-if="props.showNextPrevIcons">
<a-tooltip v-if="!props.firstRow" placement="bottom">
<template #title>
{{ $t('labels.prevRow') }}
<GeneralShortcutLabel class="justify-center" :keys="['Alt', '←']" />
</template>
<GeneralIcon icon="chevronLeft" class="cursor-pointer nc-prev-arrow" @click="$emit('prev')" />
</a-tooltip>
<a-tooltip v-if="!props.lastRow" placement="bottom">
<template #title>
{{ $t('labels.nextRow') }}
<GeneralShortcutLabel class="justify-center" :keys="['Alt', '→']" />
</template>
<GeneralIcon icon="chevronRight" class="cursor-pointer nc-next-arrow" @click="onNext" />
</a-tooltip>
<div class="flex flex-shrink-0 w-full items-center nc-expanded-form-header relative pb-2 justify-between">
<div class="flex gap-3">
<div class="flex gap-1">
<NcButton v-if="props.showNextPrevIcons" type="secondary" size="small" class="nc-prev-arrow" @click="$emit('prev')">
<MdiChevronUp class="text-md text-gray-700" />
</NcButton>
<NcButton v-if="!props.lastRow" type="secondary" size="small" class="nc-next-arrow" @click="onNext">
<MdiChevronDown class="text-md text-gray-700" />
</NcButton>
</div>
<div v-if="displayValue" class="flex items-center truncate max-w-32 font-bold text-gray-800 text-xl">
{{ displayValue }}
</div>
<div class="bg-gray-100 px-2 gap-1 flex items-center rounded-md text-gray-800">
<TableIcon class="w-6 h-6 text-sm" />
All {{ meta.title }}
</div>
</div>
<div class="flex gap-1">
<NcDropdown v-if="!isNew">
<NcButton type="secondary" size="small" class="nc-expand-form-more-actions">
<MdiMoreVert class="text-md text-gray-700" />
</NcButton>
<template #overlay>
<NcMenu>
<NcMenuItem v-if="!isNew" class="text-gray-700" @click="_loadRow()">
<div v-e="['c:row-expand:reload']" class="flex gap-2 items-center">
<component :is="iconMap.reload" class="cursor-pointer" />
{{ $t('general.reload') }}
</div>
</NcMenuItem>
<NcMenuItem
v-if="isUIAllowed('dataEdit') && !isNew"
class="text-gray-700"
@click="!isNew ? onDuplicateRow() : () => {}"
>
<div v-e="['c:row-expand:duplicate']" class="flex gap-2 items-center">
<component :is="iconMap.copy" class="cursor-pointer nc-duplicate-row" />
Duplicate record
</div>
</NcMenuItem>
<a-menu-divider class="my-1" />
<NcMenuItem
v-if="isUIAllowed('dataEdit') && !isNew"
v-e="['c:row-expand:delete']"
class="!text-red-500"
@click="!isNew && onDeleteRowClick()"
>
<component :is="iconMap.delete" class="cursor-pointer nc-delete-row" />
Delete record
</NcMenuItem>
</NcMenu>
</template>
<div class="w-[500px] mx-auto">
<div v-if="duplicatingRowInProgress" class="flex items-center justify-center h-[100px]">
<a-spin size="large" />
</NcDropdown>
<NcButton type="secondary" size="small" class="nc-expand-form-close-btn" @click="onClose">
<MdiClose class="text-md text-gray-700" />
</NcButton>
</div>
</div>
<div class="flex flex-row w-full gap-4">
<div class="flex w-full flex-col h-[85vh] max-h-[770px] border-1 rounded-xl border-gray-200">
<div
class="flex flex-grow-1 h-full flex-col !pb-12 nc-scrollbar-md overflow-y-scroll items-center w-full rounded-xl bg-white p-4"
>
<div
v-for="(col, i) of fields"
v-else
v-show="isFormula(col) || !isVirtualCol(col) || !isNew || isLinksOrLTAR(col)"
:key="col.title"
class="mt-2 py-2"
:class="`nc-expand-col-${col.title}`"
:data-testid="`nc-expand-col-${col.title}`"
>
<div class="flex items-start flex-row">
<div class="w-[12rem] mt-2.5 scale-110">
<LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" />
<LazySmartsheetHeaderCell v-else :column="col" />
</div>
<LazySmartsheetDivDataCell
v-if="col.title"
:ref="i ? null : (el: any) => (cellWrapperEl = el)"
class="!bg-white rounded px-1 min-h-[35px] flex items-center mt-2 relative"
class="!bg-white rounded-lg !w-[20rem] border-1 border-gray-200 px-1 min-h-[35px] flex items-center relative"
>
<LazySmartsheetVirtualCell v-if="isVirtualCol(col)" v-model="row.row[col.title]" :row="row" :column="col" />
@ -368,34 +445,36 @@ export default {
/>
</LazySmartsheetDivDataCell>
</div>
<div v-if="hiddenFields.length > 0" class="my-4">
<div class="flex items-center py-4">
</div>
<div v-if="hiddenFields.length > 0" class="flex w-full px-12 items-center py-3">
<div class="flex-grow h-px mr-1 bg-gray-100"></div>
<a-button
class="!rounded-md flex items-center flex-shrink-1 focus:!border-[#d9d9d9] focus:!text-gray-500 hover:text-blue"
@click="toggleHiddenFields"
>
<NcButton type="secondary" size="small" class="flex-shrink-1 !text-sm" @click="toggleHiddenFields">
{{ showHiddenFields ? `Hide ${hiddenFields.length} hidden` : `Show ${hiddenFields.length} hidden` }}
{{ hiddenFields.length > 1 ? `fields` : `field` }}
<MdiChevronDown class="ml-1" :class="showHiddenFields ? 'transform rotate-180' : ''" />
</a-button>
<div class="flex-grow ml-1 h-px bg-gray-100"></div>
</NcButton>
<div class="flex-grow h-px ml-1 bg-gray-100"></div>
</div>
<div v-if="hiddenFields.length > 0 && showHiddenFields" class="mb-3">
<div
v-for="(col, i) of hiddenFields"
v-show="(isFormula(col) || !isVirtualCol(col) || !isNew || isLinksOrLTAR(col)) && showHiddenFields"
v-show="isFormula(col) || !isVirtualCol(col) || !isNew || isLinksOrLTAR(col)"
:key="col.title"
class="mt-2 py-2"
:class="`nc-expand-col-${col.title}`"
:data-testid="`nc-expand-col-${col.title}`"
>
<div class="flex flex-row items-start">
<div class="w-[12rem] scale-110 mt-2.5">
<LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" />
<LazySmartsheetHeaderCell v-else :column="col" />
</div>
<LazySmartsheetDivDataCell
:ref="i ? null : (el) => (cellWrapperEl = el)"
class="!bg-white rounded px-1 min-h-[35px] flex items-center mt-2 relative"
v-if="col.title"
:ref="i ? null : (el: any) => (cellWrapperEl = el)"
class="!bg-white rounded-lg !w-[20rem] border-1 border-gray-200 px-1 min-h-[35px] flex items-center relative"
>
<LazySmartsheetVirtualCell v-if="isVirtualCol(col)" v-model="row.row[col.title]" :row="row" :column="col" />
@ -413,54 +492,33 @@ export default {
</div>
</div>
</div>
<div
v-if="!isNew"
class="nc-comments-drawer min-w-0 min-h-full max-h-full"
:class="{ active: commentsDrawer && isUIAllowed('commentList') }"
v-if="isUIAllowed('dataEdit')"
class="w-full flex-shrink-1 rounded-xl border-t-1 border-gray-200 bottom-0 z-10 bg-white flex justify-end p-2"
>
<div class="h-full">
<LazySmartsheetExpandedFormComments v-if="commentsDrawer && isUIAllowed('commentList')" />
<NcButton type="primary" size="medium" class="nc-expand-form-save-btn" @click="save"> Save </NcButton>
</div>
</div>
<div
v-if="!isNew && commentsDrawer && isUIAllowed('commentList')"
class="nc-comments-drawer border-1 relative border-gray-200 w-[380px] bg-gray-50 rounded-lg min-w-0"
:class="{ active: commentsDrawer && isUIAllowed('commentList') }"
>
<LazySmartsheetExpandedFormComments />
</div>
</div>
</a-drawer>
</template>
</a-modal>
<style scoped lang="scss">
:deep(input, select, textarea) {
@apply !bg-white;
}
<GeneralModal v-model:visible="showDeleteRowModal" class="!w-[25rem]">
<div class="p-4">
<div class="prose-xl font-bold self-center">Delete row ?</div>
:deep(.ant-modal-body) {
@apply !bg-gray-50;
}
.nc-comments-drawer {
@apply w-0 transition-width ease-in-out duration-200;
overflow: hidden;
&.active {
@apply w-[250px] border-left-1;
}
}
.nc-form-wrapper {
max-height: max(calc(100vh - 65px), 600px);
height: max-content !important;
}
.nc-prev-arrow,
.nc-next-arrow {
@apply w-7 h-7 flex items-center justify-center absolute opacity-70 rounded-full transition-transform transition-background transition-opacity transform bg-white hover:(bg-gray-200) active:(scale-125 opacity-100) !text-xl;
}
.nc-prev-arrow {
@apply left-4 top-4;
}
<div class="mt-4">Are you sure you want to delete this row?</div>
</div>
<div class="flex flex-row gap-x-2 mt-1 pt-1.5 justify-end p-4">
<NcButton @click="onConfirmDeleteRowClick">{{ $t('general.confirm') }} </NcButton>
</div>
</GeneralModal>
</template>
.nc-next-arrow {
@apply right-4 top-4;
}
</style>
<style scoped lang="scss"></style>

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

@ -31,7 +31,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const { t } = useI18n()
const commentsOnly = ref(true)
const commentsOnly = ref(false)
const commentsAndLogs = ref<any[]>([])

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

@ -328,7 +328,7 @@ export const iconMap = {
edit: MaterialSymbolsEdit,
lookup: h('span', { class: 'material-symbols' }, 'search'),
text: h('span', { class: 'material-symbols' }, 'text_fields'),
longText: h('span', { class: 'material-symbols' }, 'text_format'),
longText: h('span', { class: 'material-symbols' }, 'view_headline'),
clock: h('span', { class: 'material-symbols' }, 'access_time'),
web: h('span', { class: 'material-symbols' }, 'web'),
webhook: h('span', { class: 'material-symbols' }, 'webhook'),

5
packages/nocodb-sdk/src/lib/Api.ts

@ -98,6 +98,11 @@ export interface AuditType {
* @example w@nocodb.com
*/
user?: string;
/**
* The display name of user performing the action
* @example NocoDB
*/
display_name?: string;
/**
* IP address from the user
* @example ::ffff:127.0.0.1

6
packages/nocodb/src/models/Audit.ts

@ -140,6 +140,12 @@ export default class Audit implements AuditType {
public static async commentsList(args) {
const query = Noco.ncMeta
.knex(MetaTable.AUDIT)
.join(
MetaTable.USERS,
`${MetaTable.USERS}.email`,
`${MetaTable.AUDIT}.user`,
)
.select(`${MetaTable.AUDIT}.*`, `${MetaTable.USERS}.display_name`)
.where('row_id', args.row_id)
.where('fk_model_id', args.fk_model_id)
.orderBy('created_at', 'desc');

8
packages/nocodb/src/schema/swagger.json

@ -12673,6 +12673,7 @@
{
"id": "adt_3sii7erfwrlegb",
"user": "w@nocodb.com",
"display_name": "NocoDB",
"ip": null,
"base_id": null,
"project_id": "p_63b4q0qengen1x",
@ -12689,6 +12690,7 @@
{
"id": "adt_fezs668qbxj8gc",
"user": "w@nocodb.com",
"display_name": "NocoDB",
"ip": null,
"base_id": null,
"project_id": "p_63b4q0qengen1x",
@ -15554,6 +15556,7 @@
{
"id": "adt_l5we7pkx70vaao",
"user": "w@nocodb.com",
"display_name": "NocoDB",
"ip": "::ffff:127.0.0.1",
"base_id": "ds_3l9qx8xqksenrl",
"project_id": "p_9sx43moxhqtjm3",
@ -15578,6 +15581,11 @@
"description": "The user name performing the action",
"example": "w@nocodb.com"
},
"display_name": {
"type": "string",
"description": "The display name of user performing the action",
"example": "NocoDB"
},
"ip": {
"type": "string",
"example": "::ffff:127.0.0.1",

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

@ -10,9 +10,7 @@ export class ExpandedFormPage extends BasePage {
readonly duplicateRowButton: Locator;
readonly deleteRowButton: Locator;
readonly btn_copyUrl: Locator;
readonly btn_save: Locator;
readonly btn_toggleComments: Locator;
readonly btn_moreActions: Locator;
constructor(dashboard: DashboardPage) {
@ -22,10 +20,8 @@ export class ExpandedFormPage extends BasePage {
this.duplicateRowButton = this.dashboard.get().locator('.nc-duplicate-row:visible');
this.deleteRowButton = this.dashboard.get().locator('.nc-delete-row:visible');
this.btn_copyUrl = this.dashboard.get().locator('.nc-copy-row-url:visible');
this.btn_toggleComments = this.dashboard.get().locator('.nc-toggle-comments:visible');
this.btn_save = this.dashboard.get().locator('button.nc-expand-form-save-btn');
this.btn_moreActions = this.dashboard.get().locator('.nc-expand-form-more-actions');
this.btn_moreActions = this.get().locator('.nc-expand-form-more-actions');
}
get() {
@ -33,7 +29,7 @@ export class ExpandedFormPage extends BasePage {
}
async click3DotsMenu(menuItem: string) {
await this.get().locator('.nc-icon-transition.ant-dropdown-trigger').last().click();
await this.get().locator('.nc-expand-form-more-actions').last().click();
// add delay; wait for the menu to appear
await this.rootPage.waitForTimeout(500);
@ -43,14 +39,14 @@ export class ExpandedFormPage extends BasePage {
}
async clickDuplicateRow() {
await this.click3DotsMenu('Duplicate Row');
await this.click3DotsMenu('Duplicate Record');
// wait for loader to disappear
// await this.dashboard.waitForLoaderToDisappear();
await this.rootPage.waitForTimeout(2000);
}
async clickDeleteRow() {
await this.click3DotsMenu('Delete Row');
await this.click3DotsMenu('Delete Record');
await this.rootPage.locator('.ant-btn-primary:has-text("Confirm")').click();
}
@ -64,12 +60,6 @@ export class ExpandedFormPage extends BasePage {
return await isDisabled.count();
}
async getShareRowUrl() {
await this.btn_copyUrl.click();
await this.verifyToast({ message: 'Copied to clipboard' });
return await this.getClipboardText();
}
async gotoUsingUrlAndRowId({ rowId }: { rowId: string }) {
const url = this.dashboard.rootPage.url();
const expandedFormUrl = '/' + url.split('/').slice(3).join('/').split('?')[0] + `?rowId=${rowId}`;
@ -150,11 +140,11 @@ export class ExpandedFormPage extends BasePage {
async escape() {
await this.rootPage.keyboard.press('Escape');
await this.get().waitFor({ state: 'hidden' });
await this.get().locator('.nc-drawer-expanded-form').waitFor({ state: 'hidden' });
}
async close() {
await this.click3DotsMenu('Close');
await this.get().locator('.nc-expand-form-close-btn').last().click();
}
async openChildCard(param: { column: string; title: string }) {
@ -167,22 +157,20 @@ export class ExpandedFormPage extends BasePage {
}
async verifyCount({ count }: { count: number }) {
return await expect(this.rootPage.locator(`.nc-drawer-expanded-form .ant-drawer-content`)).toHaveCount(count);
return await expect(this.rootPage.locator(`.nc-drawer-expanded-form`)).toHaveCount(count);
}
async verifyRoleAccess(param: { role: string }) {
const role = param.role.toLowerCase();
expect(await this.btn_copyUrl.count()).toBe(1);
expect(await this.btn_moreActions.count()).toBe(1);
// expect(await this.btn_moreActions.count()).toBe(1);
await this.btn_moreActions.click();
const menu = this.rootPage.locator('.ant-dropdown:visible');
await menu.waitFor({ state: 'visible' });
const menuItems = menu.locator('.ant-dropdown-menu-item');
for (let i = 0; i < (await menuItems.count()); i++) {
if (role === 'owner' || role === 'editor' || role === 'creator') {
const menuText = ['Reload', 'Duplicate row', 'Delete row', 'Close'];
const menuText = ['Reload', 'Duplicate record', 'Delete record'];
expect(await getTextExcludeIconText(menuItems.nth(i))).toBe(menuText[i]);
} else {
const menuText = ['Reload', 'Close'];
@ -191,15 +179,15 @@ export class ExpandedFormPage extends BasePage {
}
if (role === 'owner' || role === 'editor' || role === 'creator') {
expect(await this.btn_save.isEnabled()).toBeTruthy();
expect(await this.btn_save.count()).toBe(1);
} else {
expect(await this.btn_save.isEnabled()).toBeFalsy();
expect(await this.btn_save.count()).toBe(0);
}
if (role === 'viewer') {
expect(await this.btn_toggleComments.count()).toBe(0);
expect(await this.get().locator('.nc-comments-drawer').count()).toBe(0);
} else {
expect(await this.btn_toggleComments.count()).toBe(1);
expect(await this.get().locator('.nc-comments-drawer').count()).toBe(1);
}
// press escape to close the expanded form

1
tests/playwright/pages/Dashboard/Gallery/index.ts

@ -30,7 +30,6 @@ export class GalleryPage extends BasePage {
y: 1,
},
});
await (await this.rootPage.locator('.ant-drawer-body').elementHandle())?.waitForElementState('stable');
}
// todo: Wait for render to complete

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

@ -224,7 +224,6 @@ export class GridPage extends BasePage {
async openExpandedRow({ index }: { index: number }) {
await this.row(index).locator(`td[data-testid="cell-Id-${index}"]`).hover();
await this.row(index).locator(`div[data-testid="nc-expand-${index}"]`).click();
await (await this.rootPage.locator('.ant-drawer-body').elementHandle())?.waitForElementState('stable');
}
async selectRow(index: number) {

8
tests/playwright/tests/db/features/expandedFormUrl.spec.ts

@ -41,13 +41,15 @@ test.describe('Expanded form URL', () => {
}
// expand row & verify URL
// New Expanded Modal don't have functionality to copy URL. Hence gettting URL from root page
await viewObj.openExpandedRow({ index: 0 });
const url = await dashboard.expandedForm.getShareRowUrl();
const url = await dashboard.rootPage.url();
await dashboard.expandedForm.escape();
await dashboard.rootPage.goto(url);
await dashboard.expandedForm.verify({
header: 'Test Table: Row 0',
header: 'Row 0 All Test Table',
url,
});
}
@ -180,6 +182,8 @@ test.describe('Expanded record duplicate & delete options', () => {
// expand row & delete
await dashboard.grid.openExpandedRow({ index: 3 });
await dashboard.expandedForm.clickDeleteRow();
await dashboard.expandedForm.escape();
await dashboard.rootPage.reload();
await dashboard.grid.verifyRowCount({ count: 3 });
// expand row, duplicate & verify menu

5
tests/playwright/tests/db/features/keyboardShortcuts.spec.ts

@ -69,6 +69,7 @@ test.describe('Verify shortcuts', () => {
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();
@ -133,8 +134,10 @@ test.describe('Verify shortcuts', () => {
header: 'Algeria',
});
await dashboard.expandedForm.fillField({ columnTitle: 'Country', value: 'NewAlgeria' });
await dashboard.expandedForm.save();
await dashboard.expandedForm.escape();
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+Enter' : 'Control+Enter');
await page.waitForTimeout(2000);
await page.reload();
await grid.cell.verify({ index: 1, columnHeader: 'Country', value: 'NewAlgeria' });
});
});

Loading…
Cancel
Save