Browse Source

Merge branch 'develop' into fix/dropdown-ui

pull/7181/head
Raju Udava 12 months ago
parent
commit
3650564db6
  1. 58
      packages/nc-gui/components/account/Token.vue
  2. 32
      packages/nc-gui/components/cell/DateTimePicker.vue
  3. 2
      packages/nc-gui/components/cell/MultiSelect.vue
  4. 2
      packages/nc-gui/components/cell/SingleSelect.vue
  5. 18
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  6. 4
      packages/nc-gui/components/smartsheet/column/DefaultValue.vue
  7. 5
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  8. 2
      packages/nc-gui/components/smartsheet/details/Fields.vue
  9. 19
      packages/nc-gui/components/smartsheet/grid/Table.vue
  10. 118
      packages/nc-gui/components/smartsheet/toolbar/CreateGroupBy.vue
  11. 102
      packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue
  12. 2
      packages/nc-gui/components/tabs/Smartsheet.vue
  13. 3
      packages/nc-gui/components/virtual-cell/HasMany.vue
  14. 12
      packages/nc-gui/components/webhook/Editor.vue
  15. 37
      packages/nc-gui/composables/usePaste.ts
  16. 34
      packages/nc-gui/helpers/parsers/parserHelpers.ts
  17. 14
      packages/nc-gui/lang/en.json
  18. 4
      packages/nc-gui/lib/types.ts
  19. 1
      packages/nc-gui/utils/iconUtils.ts
  20. 14
      packages/nocodb/src/services/columns.service.ts
  21. 10
      tests/playwright/pages/Dashboard/Grid/index.ts
  22. 82
      tests/playwright/pages/Dashboard/common/Toolbar/Groupby.ts
  23. 6
      tests/playwright/pages/Dashboard/common/Toolbar/index.ts
  24. 25
      tests/playwright/tests/db/general/cellSelection.spec.ts
  25. 8
      tests/playwright/tests/db/general/groupCRUD.spec.ts
  26. 36
      tests/playwright/tests/db/general/toolbarOperations.spec.ts
  27. 8
      tests/playwright/tests/db/views/viewGridShare.spec.ts

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

@ -3,6 +3,7 @@ import type { VNodeRef } from '@vue/runtime-core'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import type { ApiTokenType, RequestParams } from 'nocodb-sdk' import type { ApiTokenType, RequestParams } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, isEeUI, ref, useApi, useCopy, useNuxtApp } from '#imports' import { extractSdkResponseErrorMsg, isEeUI, ref, useApi, useCopy, useNuxtApp } from '#imports'
import { extractNextDefaultName } from '~/helpers/parsers/parserHelpers'
const { api, isLoading } = useApi() const { api, isLoading } = useApi()
@ -18,6 +19,8 @@ interface IApiTokenInfo extends ApiTokenType {
const tokens = ref<IApiTokenInfo[]>([]) const tokens = ref<IApiTokenInfo[]>([])
const allTokens = ref<IApiTokenInfo[]>([])
const selectedToken = reactive({ const selectedToken = reactive({
isShow: false, isShow: false,
id: '', id: '',
@ -29,7 +32,7 @@ const showNewTokenModal = ref(false)
const currentLimit = ref(10) const currentLimit = ref(10)
const defaultTokenName = t('labels.untitledToken') const defaultTokenName = t('labels.token')
const selectedTokenData = ref<ApiTokenType>({ const selectedTokenData = ref<ApiTokenType>({
description: defaultTokenName, description: defaultTokenName,
@ -42,6 +45,13 @@ const pagination = reactive({
pageSize: 10, pageSize: 10,
}) })
const setDefaultTokenName = () => {
selectedTokenData.value.description = extractNextDefaultName(
[...allTokens.value.map((el) => el?.description || '')],
defaultTokenName,
)
}
const hideOrShowToken = (tokenId: string) => { const hideOrShowToken = (tokenId: string) => {
if (selectedToken.isShow && selectedToken.id === tokenId) { if (selectedToken.isShow && selectedToken.id === tokenId) {
selectedToken.isShow = false selectedToken.isShow = false
@ -52,6 +62,38 @@ const hideOrShowToken = (tokenId: string) => {
} }
} }
// To set default next token name we should need to fetch all token first
const loadAllTokens = async (limit = pagination.total) => {
try {
const response: any = await api.orgTokens.list({
query: {
limit,
},
} as RequestParams)
if (!response) return
allTokens.value = response.list as IApiTokenInfo[]
setDefaultTokenName()
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
// This will update allTokens local value instead of fetching all tokens on each operation (add|delete)
const updateAllTokens = (type: 'delete' | 'add', token: IApiTokenInfo) => {
switch (type) {
case 'add': {
allTokens.value = [...allTokens.value, token]
break
}
case 'delete': {
allTokens.value = [...allTokens.value.filter((t) => t.token !== token.token)]
break
}
}
setDefaultTokenName()
}
const loadTokens = async (page = currentPage.value, limit = currentLimit.value) => { const loadTokens = async (page = currentPage.value, limit = currentLimit.value) => {
currentPage.value = page currentPage.value = page
try { try {
@ -67,6 +109,10 @@ const loadTokens = async (page = currentPage.value, limit = currentLimit.value)
pagination.pageSize = 10 pagination.pageSize = 10
tokens.value = response.list as IApiTokenInfo[] tokens.value = response.list as IApiTokenInfo[]
if (!allTokens.value.length) {
await loadAllTokens(pagination.total)
}
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
@ -84,6 +130,11 @@ const deleteToken = async (token: string): Promise<void> => {
await api.orgTokens.delete(token) await api.orgTokens.delete(token)
// message.success(t('msg.success.tokenDeleted')) // message.success(t('msg.success.tokenDeleted'))
await loadTokens() await loadTokens()
updateAllTokens('delete', {
token,
} as IApiTokenInfo)
if (!tokens.value.length && currentPage.value !== 1) { if (!tokens.value.length && currentPage.value !== 1) {
currentPage.value-- currentPage.value--
loadTokens(currentPage.value) loadTokens(currentPage.value)
@ -107,16 +158,17 @@ const generateToken = async () => {
if (!isValidTokenName.value) return if (!isValidTokenName.value) return
try { try {
await api.orgTokens.create(selectedTokenData.value) const token = await api.orgTokens.create(selectedTokenData.value)
showNewTokenModal.value = false showNewTokenModal.value = false
// Token generated successfully // Token generated successfully
// message.success(t('msg.success.tokenGenerated')) // message.success(t('msg.success.tokenGenerated'))
selectedTokenData.value = {} selectedTokenData.value = {}
await loadTokens() await loadTokens()
updateAllTokens('add', token as IApiTokenInfo)
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} finally { } finally {
selectedTokenData.value.description = defaultTokenName
$e('a:api-token:generate') $e('a:api-token:generate')
} }
} }

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

@ -51,8 +51,13 @@ const dateTimeFormat = computed(() => {
let localModelValue = modelValue ? dayjs(modelValue).utc().local() : undefined let localModelValue = modelValue ? dayjs(modelValue).utc().local() : undefined
const tempLocalValue = ref<dayjs.Dayjs>()
const localState = computed({ const localState = computed({
get() { get() {
if (!modelValue && tempLocalValue.value) {
return tempLocalValue.value
}
if (!modelValue) { if (!modelValue) {
return undefined return undefined
} }
@ -131,8 +136,15 @@ watch(
(next) => { (next) => {
if (next) { if (next) {
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, () => (open.value = false)) onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, () => (open.value = false))
if (!modelValue) {
tempLocalValue.value = dayjs(new Date()).utc().local()
} else {
tempLocalValue.value = undefined
}
} else { } else {
editable.value = false editable.value = false
tempLocalValue.value = undefined
} }
}, },
{ flush: 'post' }, { flush: 'post' },
@ -240,6 +252,22 @@ const cellClickHook = inject(CellClickHookInj, null)
const cellClickHandler = () => { const cellClickHandler = () => {
open.value = (active.value || editable.value) && !open.value open.value = (active.value || editable.value) && !open.value
} }
function okHandler(val: dayjs.Dayjs | string) {
if (!val) {
emit('update:modelValue', null)
return
}
if (dayjs(val).isValid()) {
// setting localModelValue to cater NOW function in date picker
localModelValue = dayjs(val)
// send the payload in UTC format
emit('update:modelValue', dayjs(val).utc().format('YYYY-MM-DD HH:mm:ssZ'))
}
open.value = !open.value
}
onMounted(() => { onMounted(() => {
cellClickHook?.on(cellClickHandler) cellClickHook?.on(cellClickHandler)
}) })
@ -261,7 +289,7 @@ const isColDisabled = computed(() => {
<template> <template>
<a-date-picker <a-date-picker
v-model:value="localState" :value="localState"
:disabled="isColDisabled" :disabled="isColDisabled"
:show-time="true" :show-time="true"
:bordered="false" :bordered="false"
@ -274,7 +302,7 @@ const isColDisabled = computed(() => {
:dropdown-class-name="`${randomClass} nc-picker-datetime children:border-1 children:border-gray-200 ${open ? 'active' : ''}`" :dropdown-class-name="`${randomClass} nc-picker-datetime children:border-1 children:border-gray-200 ${open ? 'active' : ''}`"
:open="isOpen" :open="isOpen"
@click="clickHandler" @click="clickHandler"
@ok="open = !open" @ok="okHandler"
> >
<template #suffixIcon></template> <template #suffixIcon></template>
</a-date-picker> </a-date-picker>

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

@ -387,7 +387,7 @@ const selectedOpts = computed(() => {
:open="isOpen && editAllowed" :open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed" :disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }" :class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-multi-select-cell ${isOpen ? 'active' : ''}`" :dropdown-class-name="`nc-dropdown-multi-select-cell !min-w-200px ${isOpen ? 'active' : ''}`"
@search="search" @search="search"
@keydown.stop @keydown.stop
> >

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

@ -291,7 +291,7 @@ const selectedOpt = computed(() => {
:open="isOpen && editAllowed" :open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed" :disabled="readOnly || !editAllowed"
:show-arrow="hasEditRoles && !readOnly && active && vModel === null" :show-arrow="hasEditRoles && !readOnly && active && vModel === null"
:dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen && active ? 'active' : ''}`" :dropdown-class-name="`nc-dropdown-single-select-cell !min-w-200px ${isOpen && active ? 'active' : ''}`"
:show-search="!isMobileMode && isOpen && active" :show-search="!isMobileMode && isOpen && active"
@select="onSelect" @select="onSelect"
@keydown="onKeydown($event)" @keydown="onKeydown($event)"

18
packages/nc-gui/components/dashboard/settings/DataSources.vue

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import type { SourceType } from 'nocodb-sdk' import type { SourceType } from 'nocodb-sdk'
import { ClientType, DataSourcesSubTab, storeToRefs, useBase, useCommandPalette, useNuxtApp } from '#imports' import { ClientType, DataSourcesSubTab, isEeUI, storeToRefs, useBase, useCommandPalette, useNuxtApp } from '#imports'
interface Props { interface Props {
state: string state: string
@ -493,6 +493,22 @@ const isEditBaseModalOpen = computed({
</div> </div>
</NcButton> </NcButton>
</NcTooltip> </NcTooltip>
<NcTooltip v-if="!isEeUI">
<template #title>
{{ $t('title.audit') }}
</template>
<NcButton
size="small"
class="nc-action-btn cursor-pointer outline-0"
type="text"
data-testid="nc-data-sources-view-audit"
@click="baseAction(source.id, DataSourcesSubTab.Audit)"
>
<div class="flex items-center gap-2 text-gray-600">
<GeneralIcon icon="book" class="group-hover:text-accent" />
</div>
</NcButton>
</NcTooltip>
<NcTooltip> <NcTooltip>
<template #title> <template #title>
{{ $t('tooltip.metaSync') }} {{ $t('tooltip.metaSync') }}

4
packages/nc-gui/components/smartsheet/column/DefaultValue.vue

@ -27,8 +27,8 @@ const cdfValue = ref<string | null>(null)
const editEnabled = ref(false) const editEnabled = ref(false)
const updateCdfValue = (cdf: string | null) => { const updateCdfValue = (cdf: string | null) => {
vModel.value.cdf = cdf vModel.value = { ...vModel.value, cdf }
cdfValue.value = vModel.value.cdf cdfValue.value = cdf
} }
onMounted(() => { onMounted(() => {

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

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ColumnReqType, ColumnType } from 'nocodb-sdk' import type { ColumnReqType, ColumnType } from 'nocodb-sdk'
import { UITypes, isLinksOrLTAR, isVirtualCol } from 'nocodb-sdk' import { UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { import {
IsFormInj, IsFormInj,
IsKanbanInj, IsKanbanInj,
@ -159,6 +159,9 @@ onMounted(() => {
if (formState.value.pk) { if (formState.value.pk) {
message.info(t('msg.info.editingPKnotSupported')) message.info(t('msg.info.editingPKnotSupported'))
emit('cancel') emit('cancel')
} else if (isSystemColumn(formState.value)) {
message.info(t('msg.info.editingSystemKeyNotSupported'))
emit('cancel')
} }
} }

2
packages/nc-gui/components/smartsheet/details/Fields.vue

@ -1081,7 +1081,7 @@ const onFieldOptionUpdate = () => {
</Draggable> </Draggable>
</div> </div>
<Transition v-if="!changingField" name="slide-fade"> <Transition v-if="!changingField" name="slide-fade">
<div class="border-gray-200 border-l-1 nc-scrollbar-md nc-fields-height !overflow-y-auto"> <div v-if="!changingField" class="border-gray-200 border-l-1 nc-scrollbar-md nc-fields-height !overflow-y-auto">
<SmartsheetColumnEditOrAddProvider <SmartsheetColumnEditOrAddProvider
v-if="activeField" v-if="activeField"
class="p-4 w-[25rem]" class="p-4 w-[25rem]"

19
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -34,6 +34,7 @@ import {
useI18n, useI18n,
useMultiSelect, useMultiSelect,
useNuxtApp, useNuxtApp,
usePaste,
useRoles, useRoles,
useRoute, useRoute,
useSmartsheetStoreOrThrow, useSmartsheetStoreOrThrow,
@ -168,6 +169,8 @@ const predictNextFormulas = async () => {
await _predictNextFormulas(meta) await _predictNextFormulas(meta)
} }
const { paste } = usePaste()
// #Refs // #Refs
const rowRefs = ref<any[]>() const rowRefs = ref<any[]>()
@ -1694,6 +1697,20 @@ onKeyStroke('ArrowDown', onDown)
</div> </div>
</NcMenuItem> </NcMenuItem>
<NcMenuItem
v-if="contextMenuTarget"
class="nc-base-menu-item"
data-testid="context-menu-item-paste"
:disabled="isSystemColumn(fields[contextMenuTarget.col])"
@click="paste"
>
<div v-e="['a:row:paste']" class="flex gap-2 items-center">
<GeneralIcon icon="paste" />
<!-- Paste -->
{{ $t('general.paste') }}
</div>
</NcMenuItem>
<!-- Clear cell --> <!-- Clear cell -->
<NcMenuItem <NcMenuItem
v-if=" v-if="
@ -1703,6 +1720,7 @@ onKeyStroke('ArrowDown', onDown)
(isLinksOrLTAR(fields[contextMenuTarget.col]) || !isVirtualCol(fields[contextMenuTarget.col])) (isLinksOrLTAR(fields[contextMenuTarget.col]) || !isVirtualCol(fields[contextMenuTarget.col]))
" "
class="nc-base-menu-item" class="nc-base-menu-item"
:disabled="isSystemColumn(fields[contextMenuTarget.col])"
@click="clearCell(contextMenuTarget)" @click="clearCell(contextMenuTarget)"
> >
<div v-e="['a:row:clear']" class="flex gap-2 items-center"> <div v-e="['a:row:clear']" class="flex gap-2 items-center">
@ -1715,6 +1733,7 @@ onKeyStroke('ArrowDown', onDown)
<NcMenuItem <NcMenuItem
v-else-if="contextMenuTarget && hasEditPermission" v-else-if="contextMenuTarget && hasEditPermission"
class="nc-base-menu-item" class="nc-base-menu-item"
:disabled="isSystemColumn(fields[contextMenuTarget.col])"
@click="clearSelectedRangeOfCells()" @click="clearSelectedRangeOfCells()"
> >
<div v-e="['a:row:clear-range']" class="flex gap-2 items-center"> <div v-e="['a:row:clear-range']" class="flex gap-2 items-center">

118
packages/nc-gui/components/smartsheet/toolbar/CreateGroupBy.vue

@ -0,0 +1,118 @@
<script lang="ts" setup>
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
const props = defineProps<{
// As we need to focus search box when the parent is opened
isParentOpen: boolean
columns?: ColumnType[]
}>()
const emits = defineEmits(['created'])
const { isParentOpen, columns } = toRefs(props)
const inputRef = ref()
const search = ref('')
const activeFieldIndex = ref(-1)
const activeView = inject(ActiveViewInj, ref())
const meta = inject(MetaInj, ref())
const { showSystemFields, metaColumnById } = useViewColumnsOrThrow()
const { groupBy } = useViewGroupBy(activeView)
const options = computed<ColumnType[]>(
() =>
(columns.value || meta.value?.columns)
?.filter((c: ColumnType) => {
if (c.uidt === UITypes.Links) {
return true
}
if (isSystemColumn(metaColumnById?.value?.[c.id!])) {
/** hide system columns if not enabled */
if (c?.colOptions) {
/** ignore virtual fields which are system fields ( mm relation ) and qr code fields */
return false
}
return showSystemFields.value
} else if (c.uidt === UITypes.QrCode || c.uidt === UITypes.Barcode || c.uidt === UITypes.ID) {
return false
} else {
/** ignore hasmany and manytomany relations if it's using within group menu */
return !(isLinksOrLTAR(c) && (c.colOptions as LinkToAnotherRecordType).type !== RelationTypes.BELONGS_TO)
}
})
.filter((c: ColumnType) => !groupBy.value.find((g) => g.column?.id === c.id))
.filter((c: ColumnType) => c.title?.toLowerCase().includes(search.value.toLowerCase())) ?? [],
)
const onClick = (column: ColumnType) => {
emits('created', column)
}
watch(
isParentOpen,
() => {
if (!isParentOpen.value) return
setTimeout(() => {
inputRef.value?.focus()
}, 100)
},
{
immediate: true,
},
)
onMounted(() => {
search.value = ''
activeFieldIndex.value = -1
})
const onArrowDown = () => {
activeFieldIndex.value = Math.min(activeFieldIndex.value + 1, options.value.length - 1)
}
const onArrowUp = () => {
activeFieldIndex.value = Math.max(activeFieldIndex.value - 1, 0)
}
</script>
<template>
<div
class="flex flex-col w-full pt-4 pb-2 min-w-64 nc-group-by-create-modal"
@keydown.arrow-down.prevent="onArrowDown"
@keydown.arrow-up.prevent="onArrowUp"
@keydown.enter.prevent="onClick(options[activeFieldIndex])"
>
<div class="flex pb-3 px-4 border-b-1 border-gray-100">
<input ref="inputRef" v-model="search" class="w-full focus:outline-none" :placeholder="$t('msg.selectFieldToGroup')" />
</div>
<div class="flex-col w-full max-h-100 max-w-76 nc-scrollbar-md !overflow-y-auto">
<div v-if="!options.length" class="flex text-gray-500 px-4 py-2.25">{{ $t('general.empty') }}</div>
<div
v-for="(option, index) in options"
:key="index"
v-e="['c:group-by:add:column:select']"
class="flex flex-row h-10 items-center gap-x-1.5 px-2.5 hover:bg-gray-100 cursor-pointer nc-group-by-column-search-item"
:class="{
'bg-gray-100': activeFieldIndex === index,
}"
@click="onClick(option)"
>
<SmartsheetHeaderIcon :column="option" />
<NcTooltip class="truncate">
<template #title> {{ option.title }}</template>
<span>
{{ option.title }}
</span>
</NcTooltip>
</div>
</div>
</div>
</template>

102
packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType, LinkToAnotherRecordType, LookupType } from 'nocodb-sdk' import type { ColumnType, LinkToAnotherRecordType, LookupType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk' import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import { import {
ActiveViewInj, ActiveViewInj,
IsLockedInj, IsLockedInj,
@ -24,7 +24,7 @@ const meta = inject(MetaInj, ref())
const view = inject(ActiveViewInj, ref()) const view = inject(ActiveViewInj, ref())
const isLocked = inject(IsLockedInj, ref(false)) const isLocked = inject(IsLockedInj, ref(false))
const { gridViewCols, updateGridViewColumn } = useViewColumnsOrThrow() const { gridViewCols, updateGridViewColumn, metaColumnById, showSystemFields } = useViewColumnsOrThrow()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
@ -52,7 +52,10 @@ const groupedByColumnIds = computed(() => groupBy.value.map((g) => g.fk_column_i
const { eventBus } = useSmartsheetStoreOrThrow() const { eventBus } = useSmartsheetStoreOrThrow()
const { isMobileMode } = useGlobal() const { isMobileMode } = useGlobal()
const supportedLookups = ref([])
const supportedLookups = ref<string[]>([])
const showCreateGroupBy = ref(false)
const fieldsToGroupBy = computed(() => { const fieldsToGroupBy = computed(() => {
const fields = meta.value?.columns || [] const fields = meta.value?.columns || []
@ -61,7 +64,7 @@ const fieldsToGroupBy = computed(() => {
if (excludedGroupingUidt.includes(field.uidt as UITypes)) return false if (excludedGroupingUidt.includes(field.uidt as UITypes)) return false
if (field.uidt === UITypes.Lookup) { if (field.uidt === UITypes.Lookup) {
return supportedLookups.value.includes(field.id) return field.id && supportedLookups.value.includes(field.id)
} }
return true return true
@ -78,6 +81,28 @@ const columnByID = computed(() =>
}, {} as Record<string, ColumnType>), }, {} as Record<string, ColumnType>),
) )
const availableColumns = computed(() => {
return columns.value
?.filter((c: ColumnType) => {
if (c.uidt === UITypes.Links) {
return true
}
if (isSystemColumn(metaColumnById?.value?.[c.id!])) {
return (
/** hide system columns if not enabled */
showSystemFields.value
)
} else if (c.uidt === UITypes.QrCode || c.uidt === UITypes.Barcode || c.uidt === UITypes.ID) {
return false
} else {
/** ignore hasmany and manytomany relations if it's using within sort menu */
return !(isLinksOrLTAR(c) && (c.colOptions as LinkToAnotherRecordType).type !== RelationTypes.BELONGS_TO)
/** ignore virtual fields which are system fields ( mm relation ) and qr code fields */
}
})
.filter((c) => !groupBy.value.find((g) => g.fk_column_id === c.id))
})
const getColumnUidtByID = (key?: string) => { const getColumnUidtByID = (key?: string) => {
if (!key) return '' if (!key) return ''
return columnByID.value[key]?.uidt || '' return columnByID.value[key]?.uidt || ''
@ -126,8 +151,10 @@ const saveGroupBy = async () => {
} }
} }
const addFieldToGroupBy = async () => { const addFieldToGroupBy = (column: ColumnType) => {
_groupBy.value.push({ fk_column_id: undefined, sort: 'asc', order: _groupBy.value.length + 1 }) _groupBy.value.push({ fk_column_id: column.id, sort: 'asc', order: _groupBy.value.length + 1 })
saveGroupBy()
showCreateGroupBy.value = false
} }
const removeFieldFromGroupBy = async (index: string | number) => { const removeFieldFromGroupBy = async (index: string | number) => {
@ -137,17 +164,13 @@ const removeFieldFromGroupBy = async (index: string | number) => {
} }
_groupBy.value.splice(+index, 1) _groupBy.value.splice(+index, 1)
await saveGroupBy() await saveGroupBy()
if (_groupBy.value.length === 0) {
addFieldToGroupBy()
}
} }
watch(open, () => { watch(open, () => {
if (open.value) { if (open.value) {
_groupBy.value = [...groupBy.value] _groupBy.value = [...groupBy.value]
if (_groupBy.value.length === 0) { } else {
addFieldToGroupBy() showCreateGroupBy.value = false
}
} }
}) })
@ -157,27 +180,29 @@ const loadAllowedLookups = async () => {
for (const col of meta.value?.columns || []) { for (const col of meta.value?.columns || []) {
if (col.uidt !== UITypes.Lookup) continue if (col.uidt !== UITypes.Lookup) continue
let nextCol = col let nextCol: ColumnType = col
// check the lookup column is supported type or not // check the lookup column is supported type or not
while (nextCol && nextCol.uidt === UITypes.Lookup) { while (nextCol && nextCol.uidt === UITypes.Lookup) {
const lookupRelation = (await getMeta(nextCol.fk_model_id))?.columns?.find( const lookupRelation = (await getMeta(nextCol.fk_model_id as string))?.columns?.find(
(c) => c.id === (nextCol.colOptions as LookupType).fk_relation_column_id, (c) => c.id === (nextCol?.colOptions as LookupType).fk_relation_column_id,
) )
const relatedTableMeta = await getMeta((lookupRelation.colOptions as LinkToAnotherRecordType).fk_related_model_id) const relatedTableMeta = await getMeta(
(lookupRelation?.colOptions as LinkToAnotherRecordType).fk_related_model_id as string,
)
nextCol = relatedTableMeta?.columns?.find( nextCol = relatedTableMeta?.columns?.find(
(c) => c.id === (nextCol.colOptions as LinkToAnotherRecordType).fk_lookup_column_id, (c) => c.id === ((nextCol?.colOptions as LookupType).fk_lookup_column_id as string),
) ) as ColumnType
// if next column is same as root lookup column then break the loop // if next column is same as root lookup column then break the loop
// since it's going to be a circular loop, and ignore the column // since it's going to be a circular loop, and ignore the column
if (nextCol.id === col.id) { if (nextCol?.id === col.id) {
break break
} }
} }
if (nextCol.uidt !== UITypes.Attachment) filteredLookupCols.push(col.id) if (nextCol?.uidt !== UITypes.Attachment && col.id) filteredLookupCols.push(col.id)
} }
supportedLookups.value = filteredLookupCols supportedLookups.value = filteredLookupCols
@ -218,12 +243,23 @@ watch(meta, async () => {
</a-button> </a-button>
</div> </div>
<template #overlay> <template #overlay>
<SmartsheetToolbarCreateGroupBy
v-if="!_groupBy.length"
:is-parent-open="open"
:columns="fieldsToGroupBy"
@created="addFieldToGroupBy"
/>
<div <div
v-else
:class="{ ' min-w-[400px]': _groupBy.length }" :class="{ ' min-w-[400px]': _groupBy.length }"
class="flex flex-col bg-white overflow-auto menu-filter-dropdown max-h-[max(80vh,500px)] py-6 pl-6" class="flex flex-col bg-white overflow-auto nc-group-by-list menu-filter-dropdown max-h-[max(80vh,500px)] py-6 pl-6"
data-testid="nc-group-by-menu" data-testid="nc-group-by-menu"
> >
<div class="group-by-grid pb-1 mb-2 max-h-100 nc-scrollbar-md pr-5" @click.stop> <div
class="group-by-grid pb-1 max-h-100 nc-scrollbar-md pr-5"
:class="{ 'mb-2': availableColumns.length && fieldsToGroupBy.length > _groupBy.length && _groupBy.length < 3 }"
@click.stop
>
<template v-for="[i, group] of Object.entries(_groupBy)" :key="`grouped-by-${group.fk_column_id}`"> <template v-for="[i, group] of Object.entries(_groupBy)" :key="`grouped-by-${group.fk_column_id}`">
<LazySmartsheetToolbarFieldListAutoCompleteDropdown <LazySmartsheetToolbarFieldListAutoCompleteDropdown
v-model="group.fk_column_id" v-model="group.fk_column_id"
@ -273,23 +309,35 @@ watch(meta, async () => {
</a-tooltip> </a-tooltip>
</template> </template>
</div> </div>
<NcDropdown
v-if="availableColumns.length && fieldsToGroupBy.length > _groupBy.length && _groupBy.length < 3"
v-model:visible="showCreateGroupBy"
:trigger="['click']"
overlay-class-name="nc-toolbar-dropdown"
>
<NcButton <NcButton
v-if="fieldsToGroupBy.length > _groupBy.length && _groupBy.length < 3"
v-e="['c:group-by:add']" v-e="['c:group-by:add']"
class="nc-add-group-btn" class="nc-add-group-by-btn !text-brand-500"
style="width: fit-content" style="width: fit-content"
size="small" size="small"
type="text" type="text"
:disabled="groupedByColumnIds.length < _groupBy.length" @click.stop="showCreateGroupBy = true"
@click.stop="addFieldToGroupBy()"
> >
<div class="flex gap-1 items-center" :class="{ 'text-brand-500': groupedByColumnIds.length >= _groupBy.length }"> <div class="flex gap-1 items-center">
<div class="flex"> <div class="flex">
{{ $t('activity.addSubGroup') }} {{ $t('activity.addSubGroup') }}
</div> </div>
<GeneralIcon icon="plus" /> <GeneralIcon icon="plus" />
</div> </div>
</NcButton> </NcButton>
<template #overlay>
<SmartsheetToolbarCreateGroupBy
:is-parent-open="showCreateGroupBy"
:columns="fieldsToGroupBy"
@created="addFieldToGroupBy"
/>
</template>
</NcDropdown>
</div> </div>
</template> </template>
</NcDropdown> </NcDropdown>

2
packages/nc-gui/components/tabs/Smartsheet.vue

@ -171,7 +171,7 @@ watch([activeViewTitleOrId, activeTableId], () => {
<LazySmartsheetToolbar v-if="!isForm" /> <LazySmartsheetToolbar v-if="!isForm" />
<div class="flex flex-row w-full" style="height: calc(100% - var(--topbar-height))"> <div class="flex flex-row w-full" style="height: calc(100% - var(--topbar-height))">
<Transition name="layout" mode="out-in"> <Transition name="layout" mode="out-in">
<div class="flex flex-1 min-h-0 w-3/4"> <div v-if="openedViewsTab === 'view'" class="flex flex-1 min-h-0 w-3/4">
<div class="h-full flex-1 min-w-0 min-h-0 bg-white"> <div class="h-full flex-1 min-w-0 min-h-0 bg-white">
<LazySmartsheetGrid v-if="isGrid || !meta || !activeView" ref="grid" /> <LazySmartsheetGrid v-if="isGrid || !meta || !activeView" ref="grid" />

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

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import { isSystemColumn } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { import {
CellValueInj, CellValueInj,
@ -118,7 +119,7 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
</template> </template>
</div> </div>
<div v-if="!isUnderLookup" class="flex justify-end gap-1 min-h-[30px] items-center"> <div v-if="!isUnderLookup && !isSystemColumn(column)" class="flex justify-end gap-1 min-h-[30px] items-center">
<GeneralIcon <GeneralIcon
icon="expand" icon="expand"
class="select-none transform text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand" class="select-none transform text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"

12
packages/nc-gui/components/webhook/Editor.vue

@ -20,6 +20,7 @@ import {
useNuxtApp, useNuxtApp,
watch, watch,
} from '#imports' } from '#imports'
import { extractNextDefaultName } from '~/helpers/parsers/parserHelpers'
interface Props { interface Props {
hook?: HookType hook?: HookType
@ -49,11 +50,13 @@ const titleDomRef = ref<HTMLInputElement | undefined>()
const useForm = Form.useForm const useForm = Form.useForm
const defaultHookName = t('labels.webhook')
let hookRef = reactive< let hookRef = reactive<
Omit<HookType, 'notification'> & { notification: Record<string, any>; eventOperation?: string; condition: boolean } Omit<HookType, 'notification'> & { notification: Record<string, any>; eventOperation?: string; condition: boolean }
>({ >({
id: '', id: '',
title: 'Untitled Webhook', title: defaultHookName,
event: undefined, event: undefined,
operation: undefined, operation: undefined,
eventOperation: undefined, eventOperation: undefined,
@ -472,6 +475,10 @@ async function testWebhook() {
await webhookTestRef.value.testWebhook() await webhookTestRef.value.testWebhook()
} }
const getDefaultHookName = (hooks: HookType[]) => {
return extractNextDefaultName([...hooks.map((el) => el?.title || '')], defaultHookName)
}
watch( watch(
() => hookRef.eventOperation, () => hookRef.eventOperation,
() => { () => {
@ -499,7 +506,10 @@ onMounted(async () => {
if (hookRef.event && hookRef.operation) { if (hookRef.event && hookRef.operation) {
hookRef.eventOperation = `${hookRef.event} ${hookRef.operation}` hookRef.eventOperation = `${hookRef.event} ${hookRef.operation}`
} else {
hookRef.eventOperation = eventList.value[0].value.join(' ')
} }
hookRef.title = getDefaultHookName(hooks.value)
onNotificationTypeChange() onNotificationTypeChange()

37
packages/nc-gui/composables/usePaste.ts

@ -0,0 +1,37 @@
import { message, useI18n } from '#imports'
export const usePaste = () => {
const { t } = useI18n()
const paste = async (): Promise<boolean> => {
try {
// Check if the Clipboard API is supported
if (!navigator.clipboard) throw new Error(t('msg.error.pasteFromClipboardError'))
// Read text from the clipboard
const clipboardText = await navigator.clipboard.readText()
// Create a new paste event
const pasteEvent = new Event('paste', {
bubbles: false,
cancelable: true,
})
// Attach clipboard data to the event
const clipboardData = {
getData: () => clipboardText || '',
}
Object.defineProperty(pasteEvent, 'clipboardData', { value: clipboardData })
// Dispatch the event on the document
document.dispatchEvent(pasteEvent)
return true
} catch (e) {
message.error(t('msg.error.pasteFromClipboardError'))
return false
}
}
return { paste }
}

34
packages/nc-gui/helpers/parsers/parserHelpers.ts

@ -191,3 +191,37 @@ export const filterNullOrUndefinedObjectProperties = <T extends Record<string, a
return result return result
}, {} as Record<string, any>) as T }, {} as Record<string, any>) as T
} }
/**
* Extracts the next default name based on the provided namesData, defaultName, and splitOperator.
*
* @param namesData - An array of strings containing existing names data.
* @param defaultName - The default name to extract and generate the next name from.
* @param splitOperator - The separator used to split the defaultName and numbers in existing namesData.
* Defaults to '-'. Example: If defaultName is 'Token' and splitOperator is '-',
* existing names like 'Token-1', 'Token-2', etc., will be considered.
* @returns The next default name with an incremented number based on existing namesData.
*/
export const extractNextDefaultName = (namesData: string[], defaultName: string, splitOperator: string = '-'): string => {
// Extract and sort numbers associated with the provided defaultName
const extractedSortedNumbers =
(namesData
.map((name) => {
const [_defaultName, number] = name.split(splitOperator)
if (_defaultName === defaultName && !isNaN(Number(number?.trim()))) {
return Number(number?.trim())
}
return undefined
})
.filter((e) => e)
.sort((a, b) => {
if (a !== undefined && b !== undefined) {
return a - b
}
return 0
}) as number[]) || []
return extractedSortedNumbers.length
? `${defaultName}${splitOperator}${extractedSortedNumbers[extractedSortedNumbers.length - 1] + 1}`
: `${defaultName}${splitOperator}1`
}

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

@ -188,7 +188,8 @@
"useSurveyMode": "Use Survey Mode", "useSurveyMode": "Use Survey Mode",
"shift": "Shift", "shift": "Shift",
"enter": "Enter", "enter": "Enter",
"seconds": "Seconds" "seconds": "Seconds",
"paste": "Paste"
}, },
"objects": { "objects": {
"workspace": "Workspace", "workspace": "Workspace",
@ -640,7 +641,8 @@
"sourceNameRequired": "Source name is required", "sourceNameRequired": "Source name is required",
"changeWsName": "Change Workspace Name", "changeWsName": "Change Workspace Name",
"pressEnter": "Press Enter", "pressEnter": "Press Enter",
"newFormLoaded": "New form will be loaded after" "newFormLoaded": "New form will be loaded after",
"webhook": "Webhook"
}, },
"activity": { "activity": {
"openInANewTab": "Open in a new tab", "openInANewTab": "Open in a new tab",
@ -1020,6 +1022,7 @@
"acceptOnlyValid": "Accepts only", "acceptOnlyValid": "Accepts only",
"apiTokenCreate": "Create personal API tokens to use in automation or external apps.", "apiTokenCreate": "Create personal API tokens to use in automation or external apps.",
"selectFieldToSort": "Select Field to Sort", "selectFieldToSort": "Select Field to Sort",
"selectFieldToGroup": "Select Field to Group",
"thereAreNoRecordsInTable": "There are no records in table", "thereAreNoRecordsInTable": "There are no records in table",
"createWebhookMsg1": "Get started with web-hooks!", "createWebhookMsg1": "Get started with web-hooks!",
"createWebhookMsg2": "Create web-hooks to power you automations,", "createWebhookMsg2": "Create web-hooks to power you automations,",
@ -1207,7 +1210,9 @@
"goToPrevious": "Go to previous", "goToPrevious": "Go to previous",
"goToNext": "Go to next", "goToNext": "Go to next",
"thankYou": "Thank you!", "thankYou": "Thank you!",
"submittedFormData": "You have successfully submitted the form data." "submittedFormData": "You have successfully submitted the form data.",
"editingSystemKeyNotSupported": "Editing system key not supported"
}, },
"error": { "error": {
"nameRequired": "Name Required", "nameRequired": "Name Required",
@ -1283,7 +1288,8 @@
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed", "duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.", "fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Base not accessible", "projectNotAccessible": "Base not accessible",
"copyToClipboardError": "Failed to copy to clipboard" "copyToClipboardError": "Failed to copy to clipboard",
"pasteFromClipboardError": "Failed to paste from clipboard"
}, },
"toast": { "toast": {
"exportMetadata": "Base metadata exported successfully", "exportMetadata": "Base metadata exported successfully",

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

@ -3,7 +3,7 @@ import type { I18n } from 'vue-i18n'
import type { Theme as AntTheme } from 'ant-design-vue/es/config-provider' import type { Theme as AntTheme } from 'ant-design-vue/es/config-provider'
import type { UploadFile } from 'ant-design-vue' import type { UploadFile } from 'ant-design-vue'
import type { ImportSource, ImportType, TabType } from './enums' import type { ImportSource, ImportType, TabType } from './enums'
import type { rolePermissions } from './constants' import type { rolePermissions } from './acl'
interface User { interface User {
id: string id: string
@ -176,7 +176,7 @@ interface SidebarTableNode extends TableType {
isViewsLoading?: boolean isViewsLoading?: boolean
} }
export { export type {
User, User,
ProjectMetaInfo, ProjectMetaInfo,
Field, Field,

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

@ -452,6 +452,7 @@ export const iconMap = {
role_no_access: NoAccess, role_no_access: NoAccess,
commentHere: NcCommentHere, commentHere: NcCommentHere,
fileImage: FileImageIcon, fileImage: FileImageIcon,
paste: h('span', { class: 'material-symbols' }, 'content_paste'),
} }
export const getMdiIcon = (type: string): any => { export const getMdiIcon = (type: string): any => {

14
packages/nocodb/src/services/columns.service.ts

@ -444,12 +444,7 @@ export class ColumnsService {
} }
// handle single quote for default value // handle single quote for default value
if ( if (driverType === 'pg' || driverType === 'sqlite3') {
driverType === 'mysql' ||
driverType === 'mysql2' ||
driverType === 'pg' ||
driverType === 'sqlite3'
) {
colBody.cdf = colBody.cdf.replace(/'/g, "'"); colBody.cdf = colBody.cdf.replace(/'/g, "'");
} else { } else {
colBody.cdf = colBody.cdf.replace(/'/g, "''"); colBody.cdf = colBody.cdf.replace(/'/g, "''");
@ -1300,12 +1295,7 @@ export class ColumnsService {
} }
// handle single quote for default value // handle single quote for default value
if ( if (driverType === 'pg' || driverType === 'sqlite3') {
driverType === 'mysql' ||
driverType === 'mysql2' ||
driverType === 'pg' ||
driverType === 'sqlite3'
) {
colBody.cdf = colBody.cdf.replace(/'/g, "'"); colBody.cdf = colBody.cdf.replace(/'/g, "'");
} else { } else {
colBody.cdf = colBody.cdf.replace(/'/g, "''"); colBody.cdf = colBody.cdf.replace(/'/g, "''");

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

@ -450,4 +450,14 @@ export class GridPage extends BasePage {
} }
return text; return text;
} }
async pasteWithMouse({ index, columnHeader }: CellProps) {
await this.cell.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await this.cell.get({ index, columnHeader }).click({ button: 'right' });
await this.get().page().getByTestId('context-menu-item-paste').click();
// kludge: wait for paste to complete
await this.rootPage.waitForTimeout(1000);
}
} }

82
tests/playwright/pages/Dashboard/common/Toolbar/Groupby.ts

@ -12,7 +12,7 @@ export class ToolbarGroupByPage extends BasePage {
} }
get() { get() {
return this.rootPage.locator(`.nc-group-by-menu-btn`); return this.rootPage.locator(`[data-testid="nc-group-by-menu"]`);
} }
async verify({ index, column, direction }: { index: number; column: string; direction: string }) { async verify({ index, column, direction }: { index: number; column: string; direction: string }) {
@ -26,13 +26,22 @@ export class ToolbarGroupByPage extends BasePage {
} }
async reset() { async reset() {
// open group-by menu
await this.toolbar.clickGroupBy();
const groupByCount = await this.rootPage.locator('.nc-group-by-item-remove-btn').count(); const groupByCount = await this.rootPage.locator('.nc-group-by-item-remove-btn').count();
for (let i = groupByCount - 1; i > -1; i--) { for (let i = groupByCount - 1; i > -1; i--) {
await this.rootPage.locator('.nc-group-by-item-remove-btn').nth(i).click(); await this.rootPage.locator('.nc-group-by-item-remove-btn').nth(i).click();
} }
// close group-by menu
await this.toolbar.clickGroupBy();
await this.toolbar.parent.waitLoading();
} }
async update({ index, title, ascending }: { index: number; title: string; ascending: boolean }) { async update({ index, title, ascending }: { index: number; title: string; ascending: boolean }) {
await this.toolbar.clickGroupBy();
// Update the Column and Direction of the Group By at the given index // Update the Column and Direction of the Group By at the given index
await this.rootPage.locator('.nc-sort-field-select').nth(index).click(); await this.rootPage.locator('.nc-sort-field-select').nth(index).click();
await this.rootPage await this.rootPage
@ -47,24 +56,59 @@ export class ToolbarGroupByPage extends BasePage {
.locator('.ant-select-item') .locator('.ant-select-item')
.nth(ascending ? 0 : 1) .nth(ascending ? 0 : 1)
.click(); .click();
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
// close group-by menu
await this.toolbar.clickGroupBy();
await this.toolbar.parent.waitLoading();
} }
async add({ title, ascending, locallySaved }: { title: string; ascending: boolean; locallySaved: boolean }) { async add({ title, ascending, locallySaved }: { title: string; ascending: boolean; locallySaved: boolean }) {
const addGroupBtn = this.toolbar.rootPage.locator(`.nc-add-group-btn`); // open group-by menu
if (!(await addGroupBtn.isDisabled())) { await this.toolbar.clickGroupBy();
await addGroupBtn.click();
// Check if create group-by modal is open or group-by list is open
let isGroupByListOpen = false;
for (let i = 0; i < 3; i++) {
const groupByList = this.rootPage.locator('.nc-group-by-list');
if (await groupByList.isVisible()) {
isGroupByListOpen = true;
break;
} }
// read content of the dropdown
const col = await this.rootPage.locator('.nc-sort-field-select').last().textContent(); const searchInput = this.rootPage.locator('.nc-group-by-create-modal');
if (col !== title) { if (await searchInput.isVisible()) {
await this.rootPage.locator('.nc-sort-field-select').last().click(); isGroupByListOpen = false;
await this.rootPage break;
.locator('div.ant-select-dropdown.nc-dropdown-toolbar-field-list') }
.locator(`div[label="${title}"]`)
.last() await this.rootPage.waitForTimeout(150);
.click(); }
if (isGroupByListOpen) {
await this.get().locator('button:has-text("Add subgroup")').click();
} }
const regexTitle = new RegExp(`^${title}`);
await this.rootPage
.locator('.nc-group-by-create-modal')
.locator('.nc-group-by-column-search-item >> div', { hasText: regexTitle })
.scrollIntoViewIfNeeded();
// select column
const selectColumn = async () =>
await this.rootPage
.locator('.nc-group-by-create-modal')
.locator('.nc-group-by-column-search-item >> div', { hasText: regexTitle })
.click({ force: true });
await this.waitForResponse({
uiAction: selectColumn,
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
});
await this.rootPage.locator('.nc-sort-dir-select').last().click(); await this.rootPage.locator('.nc-sort-dir-select').last().click();
const selectSortDirection = () => const selectSortDirection = () =>
this.rootPage this.rootPage
@ -79,9 +123,21 @@ export class ToolbarGroupByPage extends BasePage {
httpMethodsToMatch: ['GET'], httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`, requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
}); });
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
// close group-by menu
await this.toolbar.clickGroupBy();
await this.toolbar.parent.waitLoading();
} }
async remove({ index }: { index: number }) { async remove({ index }: { index: number }) {
// open group-by menu
await this.toolbar.clickGroupBy();
await this.rootPage.locator('.nc-group-by-item-remove-btn').nth(index).click(); await this.rootPage.locator('.nc-group-by-item-remove-btn').nth(index).click();
// close group-by menu
await this.toolbar.clickGroupBy();
await this.toolbar.parent.waitLoading();
} }
} }

6
tests/playwright/pages/Dashboard/common/Toolbar/index.ts

@ -119,9 +119,11 @@ export class ToolbarPage extends BasePage {
} }
async clickGroupBy() { async clickGroupBy() {
const menuOpen = this.groupBy.get().isVisible(); const menuOpen = await this.groupBy.get().isVisible();
await this.get().locator(`button.nc-group-by-menu-btn`).click(); await this.get().locator(`button.nc-group-by-menu-btn`).click();
if (!menuOpen) {
// Wait for the menu to close
if (menuOpen) {
await this.groupBy.get().waitFor({ state: 'hidden' }); await this.groupBy.get().waitFor({ state: 'hidden' });
} }
} }

25
tests/playwright/tests/db/general/cellSelection.spec.ts

@ -28,16 +28,34 @@ test.describe('Verify cell selection', () => {
expect(await grid.selectedCount()).toBe(9); expect(await grid.selectedCount()).toBe(9);
await dashboard.closeAllTabs(); await dashboard.closeAllTabs();
// #2 when copied with clipboard, it copies correct text // #2 when copied with clipboard, it copies correct text and paste
const verifyPastedData = async ({ index }: { index: number }): Promise<void> => {
// FirstName column
let cellText: string[] = ['MARY', 'PATRICIA'];
for (let i = index; i <= index + 1; i++) {
await grid.cell.verify({ index: i, columnHeader: 'FirstName', value: cellText[i - index] });
}
// LastName column
cellText = ['SMITH', 'JOHNSON'];
for (let i = index; i <= index + 1; i++) {
await grid.cell.verify({ index: i, columnHeader: 'LastName', value: cellText[i - index] });
}
};
await dashboard.treeView.openTable({ title: 'Customer' }); await dashboard.treeView.openTable({ title: 'Customer' });
await grid.selectRange({ await grid.selectRange({
start: { index: 0, columnHeader: 'FirstName' }, start: { index: 0, columnHeader: 'FirstName' },
end: { index: 1, columnHeader: 'LastName' }, end: { index: 1, columnHeader: 'LastName' },
}); });
expect(await grid.copyWithKeyboard()).toBe('MARY\tSMITH\n' + 'PATRICIA\tJOHNSON'); expect(await grid.copyWithKeyboard()).toBe('MARY\tSMITH\n' + 'PATRICIA\tJOHNSON');
await grid.pasteWithMouse({ index: 2, columnHeader: 'FirstName' });
await verifyPastedData({ index: 2 });
await dashboard.closeAllTabs(); await dashboard.closeAllTabs();
// #3 when copied with mouse, it copies correct text // #3 when copied with mouse, it copies correct text and paste
await dashboard.treeView.openTable({ title: 'Customer' }); await dashboard.treeView.openTable({ title: 'Customer' });
await grid.selectRange({ await grid.selectRange({
start: { index: 0, columnHeader: 'FirstName' }, start: { index: 0, columnHeader: 'FirstName' },
@ -46,6 +64,9 @@ test.describe('Verify cell selection', () => {
expect(await grid.copyWithMouse({ index: 0, columnHeader: 'FirstName' })).toBe( expect(await grid.copyWithMouse({ index: 0, columnHeader: 'FirstName' })).toBe(
'MARY\tSMITH\n' + 'PATRICIA\tJOHNSON' 'MARY\tSMITH\n' + 'PATRICIA\tJOHNSON'
); );
await grid.pasteWithMouse({ index: 4, columnHeader: 'FirstName' });
await verifyPastedData({ index: 4 });
await dashboard.closeAllTabs(); await dashboard.closeAllTabs();
}); });

8
tests/playwright/tests/db/general/groupCRUD.spec.ts

@ -47,8 +47,6 @@ test.describe('GroupBy CRUD Operations', () => {
await toolbar.sort.add({ title: 'Sub_Group', ascending: true, locallySaved: false }); await toolbar.sort.add({ title: 'Sub_Group', ascending: true, locallySaved: false });
await toolbar.clickGroupBy();
await toolbar.groupBy.add({ title: 'Category', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Category', ascending: false, locallySaved: false });
await dashboard.grid.groupPage.openGroup({ indexMap: [2] }); await dashboard.grid.groupPage.openGroup({ indexMap: [2] });
@ -116,8 +114,6 @@ test.describe('GroupBy CRUD Operations', () => {
await toolbar.sort.add({ title: 'Sub_Category', ascending: true, locallySaved: false }); await toolbar.sort.add({ title: 'Sub_Category', ascending: true, locallySaved: false });
await toolbar.clickGroupBy();
await toolbar.groupBy.add({ title: 'Category', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Category', ascending: false, locallySaved: false });
await toolbar.groupBy.add({ title: 'Sub_Group', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Sub_Group', ascending: false, locallySaved: false });
@ -186,8 +182,6 @@ test.describe('GroupBy CRUD Operations', () => {
await toolbar.sort.add({ title: 'Item', ascending: true, locallySaved: false }); await toolbar.sort.add({ title: 'Item', ascending: true, locallySaved: false });
await toolbar.clickGroupBy();
await toolbar.groupBy.add({ title: 'Category', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Category', ascending: false, locallySaved: false });
await toolbar.groupBy.add({ title: 'Sub_Group', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Sub_Group', ascending: false, locallySaved: false });
await toolbar.groupBy.add({ title: 'Sub_Category', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Sub_Category', ascending: false, locallySaved: false });
@ -255,8 +249,6 @@ test.describe('GroupBy CRUD Operations', () => {
test('Single GroupBy CRUD Operations - Links', async ({ page }) => { test('Single GroupBy CRUD Operations - Links', async ({ page }) => {
await dashboard.treeView.openTable({ title: 'Film' }); await dashboard.treeView.openTable({ title: 'Film' });
await toolbar.clickGroupBy();
await toolbar.groupBy.add({ title: 'Actors', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Actors', ascending: false, locallySaved: false });
await dashboard.grid.groupPage.openGroup({ indexMap: [2] }); await dashboard.grid.groupPage.openGroup({ indexMap: [2] });

36
tests/playwright/tests/db/general/toolbarOperations.spec.ts

@ -32,14 +32,9 @@ test.describe('Toolbar operations (GRID)', () => {
// Open Table // Open Table
await dashboard.treeView.openTable({ title: 'Film' }); await dashboard.treeView.openTable({ title: 'Film' });
// Open GroupBy Menu
await toolbar.clickGroupBy();
// GroupBy Category Descending Order // GroupBy Category Descending Order
await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false });
await toolbar.clickGroupBy();
// Hide Field and Verify // Hide Field and Verify
await toolbar.fields.toggle({ title: 'Description' }); await toolbar.fields.toggle({ title: 'Description' });
await dashboard.grid.column.verify({ await dashboard.grid.column.verify({
@ -165,16 +160,10 @@ test.describe('Toolbar operations (GRID)', () => {
// Open Table // Open Table
await dashboard.treeView.openTable({ title: 'Film' }); await dashboard.treeView.openTable({ title: 'Film' });
// Open GroupBy Menu
await toolbar.clickGroupBy();
// GroupBy Category Descending Order // GroupBy Category Descending Order
await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false });
await toolbar.groupBy.add({ title: 'RentalDuration', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'RentalDuration', ascending: false, locallySaved: false });
// Close GroupBy Menu
await toolbar.clickGroupBy();
// Hide Field and Verify // Hide Field and Verify
await toolbar.fields.toggle({ title: 'Description' }); await toolbar.fields.toggle({ title: 'Description' });
await dashboard.grid.column.verify({ await dashboard.grid.column.verify({
@ -315,9 +304,6 @@ test.describe('Toolbar operations (GRID)', () => {
await dashboard.grid.column.save({ isUpdated: true }); await dashboard.grid.column.save({ isUpdated: true });
} }
// Open GroupBy Menu
await toolbar.clickGroupBy();
// GroupBy Category Descending Order // GroupBy Category Descending Order
await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false });
await toolbar.groupBy.add({ title: 'RentalDuration', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'RentalDuration', ascending: false, locallySaved: false });
@ -464,11 +450,8 @@ test.describe('Toolbar operations (GRID)', () => {
await dashboard.grid.column.save({ isUpdated: true }); await dashboard.grid.column.save({ isUpdated: true });
} }
// Open GroupBy Menu
await toolbar.clickGroupBy();
await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false });
await toolbar.groupBy.add({ title: 'RentalDuration', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'RentalDuration', ascending: false, locallySaved: false });
await toolbar.clickGroupBy();
await dashboard.grid.groupPage.openGroup({ indexMap: [5, 0] }); await dashboard.grid.groupPage.openGroup({ indexMap: [5, 0] });
@ -479,12 +462,8 @@ test.describe('Toolbar operations (GRID)', () => {
value: 'ALLEY EVOLUTION', value: 'ALLEY EVOLUTION',
}); });
await toolbar.clickGroupBy();
await toolbar.groupBy.update({ index: 0, title: 'ReleaseYear', ascending: false }); await toolbar.groupBy.update({ index: 0, title: 'ReleaseYear', ascending: false });
await toolbar.clickGroupBy();
await dashboard.grid.groupPage.openGroup({ indexMap: [0, 1] }); await dashboard.grid.groupPage.openGroup({ indexMap: [0, 1] });
await dashboard.grid.groupPage.validateFirstRow({ await dashboard.grid.groupPage.validateFirstRow({
@ -494,8 +473,6 @@ test.describe('Toolbar operations (GRID)', () => {
value: 'ACADEMY DINOSAUR', value: 'ACADEMY DINOSAUR',
}); });
await toolbar.clickGroupBy();
await toolbar.groupBy.update({ index: 1, title: 'Length', ascending: false }); await toolbar.groupBy.update({ index: 1, title: 'Length', ascending: false });
await dashboard.grid.groupPage.openGroup({ indexMap: [0, 5] }); await dashboard.grid.groupPage.openGroup({ indexMap: [0, 5] });
@ -511,10 +488,7 @@ test.describe('Toolbar operations (GRID)', () => {
if (enableQuickRun()) test.skip(); if (enableQuickRun()) test.skip();
await dashboard.treeView.openTable({ title: 'Film' }); await dashboard.treeView.openTable({ title: 'Film' });
// Open GroupBy Menu
await toolbar.clickGroupBy();
await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false });
await toolbar.clickGroupBy();
await dashboard.viewSidebar.createGridView({ title: 'Test' }); await dashboard.viewSidebar.createGridView({ title: 'Test' });
await dashboard.rootPage.waitForTimeout(500); await dashboard.rootPage.waitForTimeout(500);
@ -538,10 +512,7 @@ test.describe('Toolbar operations (GRID)', () => {
await dashboard.treeView.openTable({ title: 'Film' }); await dashboard.treeView.openTable({ title: 'Film' });
await dashboard.viewSidebar.createGridView({ title: 'Film Grid' }); await dashboard.viewSidebar.createGridView({ title: 'Film Grid' });
// Open GroupBy Menu
await toolbar.clickGroupBy();
await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false });
await toolbar.clickGroupBy();
await dashboard.viewSidebar.copyView({ title: 'Film Grid' }); await dashboard.viewSidebar.copyView({ title: 'Film Grid' });
@ -561,11 +532,8 @@ test.describe('Toolbar operations (GRID)', () => {
if (enableQuickRun()) test.skip(); if (enableQuickRun()) test.skip();
await dashboard.treeView.openTable({ title: 'Film' }); await dashboard.treeView.openTable({ title: 'Film' });
// Open GroupBy Menu
await toolbar.clickGroupBy();
await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false });
await toolbar.groupBy.add({ title: 'RentalDuration', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'RentalDuration', ascending: false, locallySaved: false });
await toolbar.clickGroupBy();
await dashboard.grid.groupPage.openGroup({ indexMap: [0, 0] }); await dashboard.grid.groupPage.openGroup({ indexMap: [0, 0] });
@ -576,9 +544,7 @@ test.describe('Toolbar operations (GRID)', () => {
value: 'CONTROL ANTHEM', value: 'CONTROL ANTHEM',
}); });
await toolbar.clickGroupBy();
await toolbar.groupBy.remove({ index: 1 }); await toolbar.groupBy.remove({ index: 1 });
await toolbar.clickGroupBy();
await dashboard.grid.groupPage.validateFirstRow({ await dashboard.grid.groupPage.validateFirstRow({
indexMap: [0], indexMap: [0],
@ -587,9 +553,7 @@ test.describe('Toolbar operations (GRID)', () => {
value: 'CHICAGO NORTH', value: 'CHICAGO NORTH',
}); });
await toolbar.clickGroupBy();
await toolbar.groupBy.remove({ index: 0 }); await toolbar.groupBy.remove({ index: 0 });
await toolbar.clickGroupBy();
await dashboard.grid.cell.verify({ await dashboard.grid.cell.verify({
index: 0, index: 0,

8
tests/playwright/tests/db/views/viewGridShare.spec.ts

@ -16,14 +16,13 @@ test.describe('Shared view', () => {
test('Grid Share with GroupBy', async ({ page }) => { test('Grid Share with GroupBy', async ({ page }) => {
await dashboard.treeView.openTable({ title: 'Film' }); await dashboard.treeView.openTable({ title: 'Film' });
await dashboard.grid.toolbar.clickGroupBy();
await dashboard.grid.toolbar.groupBy.add({ await dashboard.grid.toolbar.groupBy.add({
title: 'Title', title: 'Title',
ascending: false, ascending: false,
locallySaved: false, locallySaved: false,
}); });
await dashboard.grid.toolbar.clickGroupBy();
await dashboard.grid.toolbar.sort.add({ await dashboard.grid.toolbar.sort.add({
title: 'Title', title: 'Title',
ascending: false, ascending: false,
@ -76,7 +75,7 @@ test.describe('Shared view', () => {
await page.reload(); await page.reload();
await dashboard.treeView.openTable({ title: 'Film' }); await dashboard.treeView.openTable({ title: 'Film' });
await dashboard.grid.toolbar.clickGroupBy();
await dashboard.grid.toolbar.groupBy.update({ await dashboard.grid.toolbar.groupBy.update({
index: 0, index: 0,
title: 'Length', title: 'Length',
@ -102,9 +101,8 @@ test.describe('Shared view', () => {
await page.waitForTimeout(5000); await page.waitForTimeout(5000);
await dashboard.treeView.openTable({ title: 'Film' }); await dashboard.treeView.openTable({ title: 'Film' });
await dashboard.grid.toolbar.clickGroupBy();
await dashboard.grid.toolbar.groupBy.remove({ index: 0 }); await dashboard.grid.toolbar.groupBy.remove({ index: 0 });
await dashboard.grid.toolbar.clickGroupBy();
await page.goto(sharedLink); await page.goto(sharedLink);
await page.reload(); await page.reload();

Loading…
Cancel
Save