Browse Source

feat: infinite scroll (#9403)

* feat: infinite scroll wip

* feat: implement column create

* feat: improve scroll performance and minor bugs

* fix: optimize cache clear fix: preserver selected items from cache clear

* feat: add keyboard support

* fix: get rid of unwanted data

* feat: infinite scroll

* fix: reload visible data

* fix: rowIndex Sync

* fix: row height fix

* fix: performance issues

* fix: small issues

* fix: stablize scrolling

* fix: scroll fails to load new data

* fix: best part is no part remove bunch of manual handling and move to computedProperty

* fix: load data as chunks instead of offset

* fix: deboucne 500 ms

* feat: safe chunk clearing

* feat: working infinite table(wip)

* fix: handle delete selected range of rows

* fix: update types

* fix: nuxt warnings

* fix: table fixes

* feat: undo-redo support for infiniteTable

* fix: fix addEmptyRow

* fix: groupby fixes

* fix: refactor visibleDtaa computed

* fix: cache clear

* fix: invalid offset error

* fix: add empty row elem

* fix: rows not loading at end

* fix: refactor

* fix: more tests passing

* fix: perf optimizations

* fix: couple tests

* fix: row height tests

* fix: row height tests

* fix: row height tests

* fix: sync row comment count

* fix: fixes

* fix: lot of tests

* fix: update the row placeholder columns size calculation

* fix: update the totalRows on loadData

* fix: tests when count is 0

* fix: hide placeholder if rowHeight is small

* fix: not required imo as infinite scroll is implemented

* fix: links tests

* fix: filter tests

* fix: insert after test fix: Row: Create, Update, Delete fix: Row height fix: Create column tests

* fix: error, timezone bug fix: shared view data not loading after 100

* fix: ignore shifting. this fixes errors in rows, which has some mandatory required cells

* fix: keyboardShortcuts test

* fix: project collaboration test

* fix: increase local cache fix: records empty on switching to full screen mode fix: links issue on new records

* fix: row and col margin for improved data rendering

* fix: addEmptyRow to table bottom

* fix: update gridShare test fix: shared view grid feat: new count endpoint public

* fix: undo-redo test failing

* fix: bulkUpdate chore: disabled bulkUpdate tests for now

* fix: slow searchBtn

* fix: limit max selection

* fix: limit selection to 100

* fix: initial chunk load to 100

* fix: couple fixes

* fix: couple fixes

* fix: row expand

* fix: scrollto Top and scrollTo Bottom on Shift Cmd Down/Up

* fix: drop support for cmd A

* fix: failing tests

* fix: error on clicking resize col

* fix: premature fetching

* fix: deleteSelected not working properly

* fix: test build

* fix: test build

* fix: throttled 500

* fix: scroll related issues fix: added transitions

* fix: scroll related issues

* fix: decrease col margin

* fix: increase local cache and update Buffer

* fix: decrease throttle

* fix: improve scroll performance

* fix: improve scroll performance

* fix: improve scroll performance

* fix: fixes

* feat: beta toggle show

* feat: beta toggle show

* fix: hold scroll action

* fix: sync visible data reloadVisibleDataHook

* fix: refactor useBetaFeatureToggle fix: useMultiSelect in table

* fix: dynamically reduce margin while loading records

* fix: some bugs

* fix: data loading in infinitescroll

* fix: shared view and search issues

* feat: betaToggles menu

* fix: scroll showing up in aggregation

* fix: text

* fix: implement shifting in addEmptyRow

* fix: calculate slices on rowHeight modified

* fix: keep invalid cells until another row selected

* fix: remove row if filter gets failed

* fix: update styles

* fix: move filter handling to nocodb-sdk

* fix: user field filter

* fix: sort order

* fix:user field sorting

* fix: update virtual cols

* fix: updated sort handling

* fix: updated sort handling

* fix: updated sort handling for bulkUpdate and undo-redo

* fix: unit tests

* fix: deleteSelectedRecords fails

* fix: chunkstates errors

* fix: sort bugs

* fix: scroll position

* fix: delete selectedrange of records

* fix: improved chunk management

* fix: sync toggle states across tabs

* fix: sync between tabs

* fix: limit issues

* fix: update issues

* fix: zIndex

* fix: minor fixes

* fix: cmd arrow issue

* fix: bulkdelete index issues

* fix: empty rows at top

* fix: queue add new record behaviour

* fix: resolve rowObj addEmptyRow

* fix: typo

* fix: clear indexes

* fix: reload if width is zero

* fix: manual handling

* fix: remove console

* fix: prefetch when scroll from below

* fix: refactor fixes

* fix:undo-redo with sort

---------

Co-authored-by: mertmit <mertmit99@gmail.com>
pull/9781/head
Anbarasu 2 weeks ago committed by GitHub
parent
commit
04f49c13d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      packages/nc-gui/assets/nc-icons/bulb.svg
  2. 110
      packages/nc-gui/components/dashboard/FeatureExperimentation.vue
  3. 12
      packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue
  4. 5
      packages/nc-gui/components/smartsheet/Topbar.vue
  5. 6
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  6. 4
      packages/nc-gui/components/smartsheet/form/field-settings.vue
  7. 2848
      packages/nc-gui/components/smartsheet/grid/InfiniteTable.vue
  8. 30
      packages/nc-gui/components/smartsheet/grid/PaginationV2.vue
  9. 15
      packages/nc-gui/components/smartsheet/grid/Table.vue
  10. 128
      packages/nc-gui/components/smartsheet/grid/index.vue
  11. 129
      packages/nc-gui/composables/useBetaFeatureToggle.ts
  12. 18
      packages/nc-gui/composables/useExtensions.ts
  13. 303
      packages/nc-gui/composables/useGridViewData.ts
  14. 1371
      packages/nc-gui/composables/useInfiniteData.ts
  15. 383
      packages/nc-gui/composables/useMultiSelect/index.ts
  16. 28
      packages/nc-gui/composables/useSharedView.ts
  17. 1
      packages/nc-gui/composables/useViewData.ts
  18. 1
      packages/nc-gui/context/index.ts
  19. 2
      packages/nc-gui/lang/en.json
  20. 7
      packages/nc-gui/lib/types.ts
  21. 286
      packages/nc-gui/utils/dataUtils.ts
  22. 2
      packages/nc-gui/utils/iconUtils.ts
  23. 185
      packages/nc-gui/utils/sortUtils.ts
  24. 7
      packages/nocodb-sdk/src/lib/errorUtils.ts
  25. 320
      packages/nocodb-sdk/src/lib/filterHelpers.ts
  26. 2
      packages/nocodb-sdk/src/lib/index.ts
  27. 14
      packages/nocodb/src/controllers/public-datas.controller.ts
  28. 202
      packages/nocodb/src/db/BaseModelSqlv2.ts
  29. 5
      packages/nocodb/src/db/conditionV2.ts
  30. 14
      packages/nocodb/src/filters/global-exception/global-exception.filter.ts
  31. 8
      packages/nocodb/src/models/Filter.ts
  32. 75
      packages/nocodb/src/schema/swagger.json
  33. 49
      packages/nocodb/src/services/public-datas.service.ts
  34. 62
      packages/nocodb/src/utils/globals.ts
  35. 8
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  36. 32
      tests/playwright/pages/Dashboard/Grid/index.ts
  37. 2
      tests/playwright/pages/Dashboard/common/Toolbar/SearchData.ts
  38. 7
      tests/playwright/quickTests/commonTest.ts
  39. 7
      tests/playwright/tests/db/columns/columnMenuOperations.spec.ts
  40. 89
      tests/playwright/tests/db/features/keyboardShortcuts.spec.ts
  41. 5
      tests/playwright/tests/db/features/undo-redo.spec.ts
  42. 8
      tests/playwright/tests/db/general/tableColumnOperation.spec.ts
  43. 9
      tests/playwright/tests/db/views/viewGridShare.spec.ts

8
packages/nc-gui/assets/nc-icons/bulb.svg

@ -0,0 +1,8 @@
<svg width="10" height="16" viewBox="0 0 10 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 98">
<path id="Vector" d="M6.07449 13.9231C6.07449 14.5178 5.59234 15 4.99757 15C4.40281 15 3.92065 14.5178 3.92065 13.9231" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M7.15143 13.923H2.84375" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M4.99762 1C2.61855 1 0.689941 2.92861 0.689941 5.30768C0.689941 6.15276 0.93329 6.941 1.35374 7.60615L2.84378 10.6923H7.15146L8.6415 7.60615C9.06195 6.941 9.30529 6.15276 9.30529 5.30768C9.30529 2.92861 7.37668 1 4.99762 1Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_4" d="M7.15143 12.3076H2.84375" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 967 B

110
packages/nc-gui/components/dashboard/FeatureExperimentation.vue

@ -0,0 +1,110 @@
<script setup lang="ts">
interface Props {
value?: boolean
}
const props = defineProps<Props>()
const { toggleFeature, features, isEngineeringModeOn } = useBetaFeatureToggle()
const value = useVModel(props, 'value')
const selectedFeatures = ref<Record<string, boolean>>({})
const saveExperimentalFeatures = () => {
features.value.forEach((feature) => {
if (selectedFeatures.value[feature.id] !== feature.enabled) {
toggleFeature(feature.id)
}
})
}
onMounted(() => {
selectedFeatures.value = Object.fromEntries(features.value.map((feature) => [feature.id, feature.enabled]))
})
const isChanged = computed(() => {
return features.value.some((feature) => selectedFeatures.value[feature.id] !== feature.enabled)
})
watch(value, (val) => {
if (val) {
selectedFeatures.value = Object.fromEntries(features.value.map((feature) => [feature.id, feature.enabled]))
}
})
const clickCount = ref(0)
const clickTimer = ref<NodeJS.Timeout | undefined>(undefined)
const handleClick = () => {
clickCount.value++
if (clickCount.value === 1) {
if (clickTimer.value) clearTimeout(clickTimer.value)
clickTimer.value = setTimeout(() => {
clickCount.value = 0
}, 3000)
}
if (clickCount.value >= 3) {
isEngineeringModeOn.value = !isEngineeringModeOn.value
clickCount.value = 0
if (clickTimer.value) {
clearTimeout(clickTimer.value)
clickTimer.value = undefined
}
}
}
onUnmounted(() => {
if (clickTimer.value) clearTimeout(clickTimer.value)
})
</script>
<template>
<NcModal v-model:visible="value" size="small">
<div class="flex flex-col gap-3">
<div>
<h1 class="text-base text-gray-800 font-semibold">
<component :is="iconMap.bulb" class="text-gray-500 h-5 mr-1 pb-1" @click="handleClick" />
{{ $t('general.featurePreview') }}
</h1>
<div class="text-gray-600 leading-5">
{{ $t('labels.toggleExperimentalFeature') }}
</div>
</div>
<div
ref="contentRef"
class="border-1 rounded-md min-h-[50px] max-h-[234px] nc-scrollbar-md overflow-y-auto border-gray-200"
>
<template v-for="feature in features" :key="feature.id">
<div
v-if="!feature?.isEngineering || isEngineeringModeOn"
class="border-b-1 px-3 flex gap-2 flex-col py-2 border-gray-200 last:border-b-0"
>
<div class="flex items-center justify-between">
<div class="text-gray-800 font-medium">
{{ feature.title }}
</div>
<NcSwitch v-model:checked="selectedFeatures[feature.id]" />
</div>
<div class="text-gray-500 leading-4 text-[13px]">
{{ feature.description }}
</div>
</div>
</template>
</div>
<div class="flex w-full gap-2 justify-end">
<NcButton type="secondary" size="small" @click="value = false">
{{ $t('general.cancel') }}
</NcButton>
<NcButton :disabled="!isChanged" size="small" @click="saveExperimentalFeatures">
{{ $t('general.save') }}
</NcButton>
</div>
</div>
</NcModal>
</template>

12
packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue

@ -52,6 +52,13 @@ onMounted(() => {
isMounted.value = true isMounted.value = true
}) })
const isExperimentalFeatureModalOpen = ref(false)
const openExperimentationMenu = () => {
isMenuOpen.value = false
isExperimentalFeatureModalOpen.value = true
}
const accountUrl = computed(() => { const accountUrl = computed(() => {
return isUIAllowed('superAdminSetup') && !isEeUI ? '/account/setup' : '/account/profile' return isUIAllowed('superAdminSetup') && !isEeUI ? '/account/setup' : '/account/profile'
}) })
@ -183,6 +190,10 @@ const accountUrl = computed(() => {
<NcDivider /> <NcDivider />
<DashboardSidebarEEMenuOption v-if="isEeUI" /> <DashboardSidebarEEMenuOption v-if="isEeUI" />
<NcMenuItem @click="openExperimentationMenu">
<GeneralIcon icon="bulb" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('general.featurePreview') }} </span>
</NcMenuItem>
<nuxt-link v-e="['c:user:settings']" class="!no-underline" :to="accountUrl"> <nuxt-link v-e="['c:user:settings']" class="!no-underline" :to="accountUrl">
<NcMenuItem> <GeneralIcon icon="ncSettings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem> <NcMenuItem> <GeneralIcon icon="ncSettings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem>
@ -191,6 +202,7 @@ const accountUrl = computed(() => {
</NcMenu> </NcMenu>
</template> </template>
</NcDropdown> </NcDropdown>
<DashboardFeatureExperimentation v-model:value="isExperimentalFeatureModalOpen" />
<LazyNotificationMenu /> <LazyNotificationMenu />
</div> </div>

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

@ -14,6 +14,8 @@ const { appInfo } = useGlobal()
const { toggleExtensionPanel, isPanelExpanded, extensionsEgg, onEggClick } = useExtensions() const { toggleExtensionPanel, isPanelExpanded, extensionsEgg, onEggClick } = useExtensions()
const { isFeatureEnabled } = useBetaFeatureToggle()
const isSharedBase = computed(() => route.value.params.typeOrId === 'base') const isSharedBase = computed(() => route.value.params.typeOrId === 'base')
const topbarBreadcrumbItemWidth = computed(() => { const topbarBreadcrumbItemWidth = computed(() => {
@ -54,7 +56,7 @@ const topbarBreadcrumbItemWidth = computed(() => {
<GeneralApiLoader v-if="!isMobileMode" /> <GeneralApiLoader v-if="!isMobileMode" />
<NcButton <NcButton
v-if="!isSharedBase && extensionsEgg && openedViewsTab === 'view'" v-if="!isSharedBase && isFeatureEnabled(FEATURE_FLAG.EXTENSIONS) && openedViewsTab === 'view'"
v-e="['c:extension-toggle']" v-e="['c:extension-toggle']"
type="secondary" type="secondary"
size="small" size="small"
@ -77,7 +79,6 @@ const topbarBreadcrumbItemWidth = computed(() => {
</span> </span>
</div> </div>
</NcButton> </NcButton>
<div v-else-if="!isSharedBase && !extensionsEgg" class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEggClick" />
<div v-if="!isSharedBase"> <div v-if="!isSharedBase">
<LazySmartsheetTopbarCmdK /> <LazySmartsheetTopbarCmdK />

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

@ -59,7 +59,7 @@ const { $e } = useNuxtApp()
const { appInfo } = useGlobal() const { appInfo } = useGlobal()
const { betaFeatureToggleState } = useBetaFeatureToggle() const { isFeatureEnabled } = useBetaFeatureToggle()
const { openedViewsTab } = storeToRefs(useViewsStore()) const { openedViewsTab } = storeToRefs(useViewsStore())
@ -122,7 +122,9 @@ const isColumnTypeOpen = ref(false)
const geoDataToggleCondition = (t: { name: UITypes }) => { const geoDataToggleCondition = (t: { name: UITypes }) => {
if (!appInfo.value.ee) return true if (!appInfo.value.ee) return true
return betaFeatureToggleState.show ? betaFeatureToggleState.show : !t.name.includes(UITypes.GeoData) const isColEnabled = isFeatureEnabled(FEATURE_FLAG.GEODATA_COLUMN)
return isColEnabled || !t.name.includes(UITypes.GeoData)
} }
const showDeprecated = ref(false) const showDeprecated = ref(false)

4
packages/nc-gui/components/smartsheet/form/field-settings.vue

@ -3,7 +3,7 @@ import { UITypes, isSelectTypeCol } from 'nocodb-sdk'
const { formState, activeField, updateColMeta, isRequired } = useFormViewStoreOrThrow() const { formState, activeField, updateColMeta, isRequired } = useFormViewStoreOrThrow()
const { betaFeatureToggleState } = useBetaFeatureToggle() const { isFeatureEnabled } = useBetaFeatureToggle()
const updateSelectFieldLayout = (value: boolean) => { const updateSelectFieldLayout = (value: boolean) => {
if (!activeField.value) return if (!activeField.value) return
@ -13,7 +13,7 @@ const updateSelectFieldLayout = (value: boolean) => {
} }
const columnSupportsScanning = (elementType: UITypes) => const columnSupportsScanning = (elementType: UITypes) =>
betaFeatureToggleState.show && isFeatureEnabled(FEATURE_FLAG.FORM_SUPPORT_COLUMN_SCANNING) &&
[UITypes.SingleLineText, UITypes.Number, UITypes.Email, UITypes.URL, UITypes.LongText].includes(elementType) [UITypes.SingleLineText, UITypes.Number, UITypes.Email, UITypes.URL, UITypes.LongText].includes(elementType)
</script> </script>

2848
packages/nc-gui/components/smartsheet/grid/InfiniteTable.vue

File diff suppressed because it is too large Load Diff

30
packages/nc-gui/components/smartsheet/grid/PaginationV2.vue

@ -4,11 +4,13 @@ import { type PaginatedType, UITypes } from 'nocodb-sdk'
const props = defineProps<{ const props = defineProps<{
scrollLeft?: number scrollLeft?: number
paginationData: PaginatedType paginationData?: PaginatedType
changePage: (page: number) => void changePage?: (page: number) => void
showSizeChanger?: boolean showSizeChanger?: boolean
customLabel?: string customLabel?: string
totalRows?: number
depth?: number depth?: number
disablePagination?: boolean
}>() }>()
const emits = defineEmits(['update:paginationData']) const emits = defineEmits(['update:paginationData'])
@ -23,6 +25,8 @@ const showSizeChanger = toRef(props, 'showSizeChanger')
const vPaginationData = useVModel(props, 'paginationData', emits) const vPaginationData = useVModel(props, 'paginationData', emits)
const disablePagination = toRef(props, 'disablePagination')
const { updateAggregate, getAggregations, visibleFieldsComputed, displayFieldComputed } = useViewAggregateOrThrow() const { updateAggregate, getAggregations, visibleFieldsComputed, displayFieldComputed } = useViewAggregateOrThrow()
const scrollLeft = toRef(props, 'scrollLeft') const scrollLeft = toRef(props, 'scrollLeft')
@ -46,6 +50,9 @@ const count = computed(() => vPaginationData.value?.totalRows ?? Infinity)
const page = computed({ const page = computed({
get: () => vPaginationData?.value?.page ?? 1, get: () => vPaginationData?.value?.page ?? 1,
set: async (p) => { set: async (p) => {
if (disablePagination.value) {
return
}
isPaginationLoading.value = true isPaginationLoading.value = true
try { try {
await changePage?.(p) await changePage?.(p)
@ -118,6 +125,7 @@ const renderAltOrOptlKey = () => {
}" }"
> >
<div class="flex relative justify-between gap-2 w-full"> <div class="flex relative justify-between gap-2 w-full">
<template v-if="!disablePagination">
<div v-if="isViewDataLoading" class="nc-pagination-skeleton flex justify-center item-center min-h-10 min-w-16 w-16"> <div v-if="isViewDataLoading" class="nc-pagination-skeleton flex justify-center item-center min-h-10 min-w-16 w-16">
<a-skeleton :active="true" :title="true" :paragraph="false" class="w-16 max-w-16" /> <a-skeleton :active="true" :title="true" :paragraph="false" class="w-16 max-w-16" />
</div> </div>
@ -133,6 +141,20 @@ const renderAltOrOptlKey = () => {
{{ customLabel ? customLabel : count !== 1 ? $t('objects.records') : $t('objects.record') }} {{ customLabel ? customLabel : count !== 1 ? $t('objects.records') : $t('objects.record') }}
</span> </span>
</NcTooltip> </NcTooltip>
</template>
<template v-else-if="+totalRows >= 0">
<NcTooltip class="flex sticky items-center h-full">
<template #title> {{ totalRows }} {{ totalRows !== 1 ? $t('objects.records') : $t('objects.record') }} </template>
<span
data-testid="grid-pagination"
class="text-gray-500 text-ellipsis overflow-hidden pl-1 truncate nc-grid-row-count caption text-xs text-nowrap"
>
{{ Intl.NumberFormat('en', { notation: 'compact' }).format(totalRows) }}
{{ totalRows !== 1 ? $t('objects.records') : $t('objects.record') }}
</span>
</NcTooltip>
</template>
<template <template
v-if="![UITypes.SpecificDBType, UITypes.ForeignKey, UITypes.Button].includes(displayFieldComputed.column?.uidt!)" v-if="![UITypes.SpecificDBType, UITypes.ForeignKey, UITypes.Button].includes(displayFieldComputed.column?.uidt!)"
@ -222,7 +244,7 @@ const renderAltOrOptlKey = () => {
overlay-class-name="max-h-96 relative scroll-container nc-scrollbar-md overflow-auto" overlay-class-name="max-h-96 relative scroll-container nc-scrollbar-md overflow-auto"
> >
<div <div
class="flex items-center overflow-x-hidden justify-end group hover:bg-gray-100 cursor-pointer text-gray-500 transition-all transition-linear px-3 py-2" class="flex items-center overflow-hidden justify-end group hover:bg-gray-100 cursor-pointer text-gray-500 transition-all transition-linear px-3 py-2"
:style="{ :style="{
'min-width': width, 'min-width': width,
'max-width': width, 'max-width': width,
@ -288,7 +310,7 @@ const renderAltOrOptlKey = () => {
<div class="!pl-8 pr-60 !w-8 h-1"></div> <div class="!pl-8 pr-60 !w-8 h-1"></div>
<div class="absolute h-9 bg-white border-l-1 border-gray-200 px-1 flex items-center right-0"> <div v-if="!disablePagination" class="absolute h-9 bg-white border-l-1 border-gray-200 px-1 flex items-center right-0">
<NcPaginationV2 <NcPaginationV2
v-if="count !== Infinity" v-if="count !== Infinity"
v-model:current="page" v-model:current="page"

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

@ -585,6 +585,7 @@ const {
meta, meta,
fields, fields,
dataRef, dataRef,
undefined,
editEnabled, editEnabled,
isPkAvail, isPkAvail,
contextMenu, contextMenu,
@ -989,7 +990,7 @@ const scrollWrapper = computed(() => scrollParent.value || gridWrapper.value)
const scrollLeft = ref() const scrollLeft = ref()
function scrollToCell(row?: number | null, col?: number | null) { function scrollToCell(row?: number | null, col?: number | null, scrollBehaviour: ScrollBehavior = 'instant') {
row = row ?? activeCell.row row = row ?? activeCell.row
col = col ?? activeCell.col col = col ?? activeCell.col
@ -1020,32 +1021,38 @@ function scrollToCell(row?: number | null, col?: number | null) {
} }
if (row === dataRef.value.length - 1) { if (row === dataRef.value.length - 1) {
requestAnimationFrame(() => {
scrollWrapper.value.scrollTo({ scrollWrapper.value.scrollTo({
top: isGroupBy.value ? scrollWrapper.value.scrollTop : scrollWrapper.value.scrollHeight, top: isGroupBy.value ? scrollWrapper.value.scrollTop : scrollWrapper.value.scrollHeight,
left: left:
col === fields.value.length - 1 // if corner cell col === fields.value.length - 1 // if corner cell
? scrollWrapper.value.scrollWidth ? scrollWrapper.value.scrollWidth
: tdScroll.left, : tdScroll.left,
behavior: 'smooth', behavior: 'instant',
})
}) })
return return
} }
if (col === fields.value.length - 1) { if (col === fields.value.length - 1) {
// if last column make 'Add New Column' visible // if last column make 'Add New Column' visible
requestAnimationFrame(() => {
scrollWrapper.value.scrollTo({ scrollWrapper.value.scrollTo({
top: tdScroll.top, top: tdScroll.top,
left: scrollWrapper.value.scrollWidth, left: scrollWrapper.value.scrollWidth,
behavior: 'smooth', behavior: 'instant',
})
}) })
return return
} }
// scroll into the active cell // scroll into the active cell
requestAnimationFrame(() => {
scrollWrapper.value.scrollTo({ scrollWrapper.value.scrollTo({
top: tdScroll.top, top: tdScroll.top,
left: tdScroll.left, left: tdScroll.left,
behavior: 'smooth', behavior: 'instant',
})
}) })
} }
} }

128
packages/nc-gui/components/smartsheet/grid/index.vue

@ -1,5 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ColumnType, GridType } from 'nocodb-sdk' import type { ColumnType, GridType } from 'nocodb-sdk'
import InfiniteTable from './InfiniteTable.vue'
import Table from './Table.vue' import Table from './Table.vue'
import GroupBy from './GroupBy.vue' import GroupBy from './GroupBy.vue'
@ -9,10 +10,6 @@ const view = inject(ActiveViewInj, ref())
const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook()) const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
// keep a root fields variable and will get modified from
// fields menu and get used in grid and gallery
const _fields = inject(FieldsInj, ref([]))
const router = useRouter() const router = useRouter()
const route = router.currentRoute const route = router.currentRoute
@ -21,6 +18,8 @@ const { xWhere, eventBus } = useSmartsheetStoreOrThrow()
const { t } = useI18n() const { t } = useI18n()
const { isFeatureEnabled } = useBetaFeatureToggle()
const bulkUpdateDlg = ref(false) const bulkUpdateDlg = ref(false)
const routeQuery = computed(() => route.value.query as Record<string, string>) const routeQuery = computed(() => route.value.query as Record<string, string>)
@ -29,31 +28,38 @@ const expandedFormDlg = ref(false)
const expandedFormRow = ref<Row>() const expandedFormRow = ref<Row>()
const expandedFormRowState = ref<Record<string, any>>() const expandedFormRowState = ref<Record<string, any>>()
const tableRef = ref<typeof Table>() const reloadVisibleDataHook = createEventHook()
provide(ReloadVisibleDataHookInj, reloadVisibleDataHook)
const tableRef = ref<typeof InfiniteTable>()
useProvideViewAggregate(view, meta, xWhere) useProvideViewAggregate(view, meta, xWhere)
const { const {
loadData, loadData,
paginationData, selectedRows,
formattedData: data,
updateOrSaveRow, updateOrSaveRow,
changePage,
addEmptyRow: _addEmptyRow, addEmptyRow: _addEmptyRow,
deleteRow, deleteRow,
deleteSelectedRows, deleteSelectedRows,
selectedAllRecords, cachedRows,
clearCache,
removeRowIfNew, removeRowIfNew,
navigateToSiblingRow, navigateToSiblingRow,
getExpandedRowIndex,
deleteRangeOfRows, deleteRangeOfRows,
bulkUpdateRows, bulkUpdateRows,
bulkUpdateView, syncCount,
totalRows,
syncVisibleData,
optimisedQuery, optimisedQuery,
islastRow, isLastRow,
isFirstRow, isFirstRow,
aggCommentCount, chunkStates,
} = useViewData(meta, view, xWhere) clearInvalidRows,
isRowSortRequiredRows,
applySorting,
} = useGridViewData(meta, view, xWhere, reloadVisibleDataHook)
const rowHeight = computed(() => { const rowHeight = computed(() => {
if ((view.value?.view as GridType)?.row_height !== undefined) { if ((view.value?.view as GridType)?.row_height !== undefined) {
@ -84,7 +90,6 @@ provide(RowHeightInj, rowHeight)
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
// reload table data reload hook as fallback to rowdatareload
provide(ReloadRowDataHookInj, reloadViewDataHook) provide(ReloadRowDataHookInj, reloadViewDataHook)
const skipRowRemovalOnCancel = ref(false) const skipRowRemovalOnCancel = ref(false)
@ -173,26 +178,10 @@ eventBus.on((event) => {
}) })
const goToNextRow = () => { const goToNextRow = () => {
const currentIndex = getExpandedRowIndex()
/* when last index of current page is reached we should move to next page */
if (!paginationData.value.isLastPage && currentIndex === paginationData.value.pageSize) {
const nextPage = paginationData.value?.page ? paginationData.value?.page + 1 : 1
changePage(nextPage)
}
navigateToSiblingRow(NavigateDir.NEXT) navigateToSiblingRow(NavigateDir.NEXT)
} }
const goToPreviousRow = () => { const goToPreviousRow = () => {
const currentIndex = getExpandedRowIndex()
/* when first index of current page is reached and then clicked back
previos page should be loaded
*/
if (!paginationData.value.isFirstPage && currentIndex === 1) {
const nextPage = paginationData.value?.page ? paginationData.value?.page - 1 : 1
changePage(nextPage)
}
navigateToSiblingRow(NavigateDir.PREV) navigateToSiblingRow(NavigateDir.PREV)
} }
@ -220,19 +209,18 @@ const baseColor = computed(() => {
const updateRowCommentCount = (count: number) => { const updateRowCommentCount = (count: number) => {
if (!routeQuery.value.rowId) return if (!routeQuery.value.rowId) return
const aggCommentCountIndex = aggCommentCount.value.findIndex((row) => row.row_id === routeQuery.value.rowId) const currentRowIndex = Array.from(cachedRows.value.values()).find(
(row) => extractPkFromRow(row.row, meta.value!.columns!) === routeQuery.value.rowId,
)?.rowMeta.rowIndex
const currentRowIndex = data.value.findIndex( if (currentRowIndex === undefined) return
(row) => extractPkFromRow(row.row, meta.value?.columns as ColumnType[]) === routeQuery.value.rowId,
)
if (aggCommentCountIndex === -1 || currentRowIndex === -1) return const currentRow = cachedRows.value.get(currentRowIndex)
if (!currentRow) return
if (Number(aggCommentCount.value[aggCommentCountIndex].count) === count) return currentRow.rowMeta.commentCount = count
aggCommentCount.value[aggCommentCountIndex].count = count syncVisibleData?.()
data.value[currentRowIndex].rowMeta.commentCount = count
} }
watch([windowSize, leftSidebarWidth], updateViewWidth) watch([windowSize, leftSidebarWidth], updateViewWidth)
@ -240,6 +228,21 @@ watch([windowSize, leftSidebarWidth], updateViewWidth)
onMounted(() => { onMounted(() => {
updateViewWidth() updateViewWidth()
}) })
const {
selectedAllRecords: pSelectedAllRecords,
formattedData: pData,
paginationData: pPaginationData,
loadData: pLoadData,
changePage: pChangePage,
addEmptyRow: pAddEmptyRow,
deleteRow: pDeleteRow,
updateOrSaveRow: pUpdateOrSaveRow,
deleteSelectedRows: pDeleteSelectedRows,
deleteRangeOfRows: pDeleteRangeOfRows,
bulkUpdateRows: pBulkUpdateRows,
removeRowIfNew: pRemoveRowIfNew,
} = useViewData(meta, view, xWhere)
</script> </script>
<template> <template>
@ -249,22 +252,47 @@ onMounted(() => {
:style="`background-color: ${isGroupBy ? `${baseColor}` : 'var(--nc-grid-bg)'};`" :style="`background-color: ${isGroupBy ? `${baseColor}` : 'var(--nc-grid-bg)'};`"
> >
<Table <Table
v-if="!isGroupBy" v-if="!isGroupBy && !isFeatureEnabled(FEATURE_FLAG.INFINITE_SCROLLING)"
ref="tableRef"
v-model:selected-all-records="pSelectedAllRecords"
:data="pData"
:pagination-data="pPaginationData"
:load-data="pLoadData"
:change-page="pChangePage"
:call-add-empty-row="pAddEmptyRow"
:delete-row="pDeleteRow"
:update-or-save-row="pUpdateOrSaveRow"
:delete-selected-rows="pDeleteSelectedRows"
:delete-range-of-rows="pDeleteRangeOfRows"
:bulk-update-rows="pBulkUpdateRows"
:expand-form="expandForm"
:remove-row-if-new="pRemoveRowIfNew"
:row-height="rowHeight"
@toggle-optimised-query="toggleOptimisedQuery"
@bulk-update-dlg="bulkUpdateDlg = true"
/>
<InfiniteTable
v-else-if="!isGroupBy"
ref="tableRef" ref="tableRef"
v-model:selected-all-records="selectedAllRecords"
:data="data"
:pagination-data="paginationData"
:load-data="loadData" :load-data="loadData"
:change-page="changePage"
:call-add-empty-row="_addEmptyRow" :call-add-empty-row="_addEmptyRow"
:delete-row="deleteRow" :delete-row="deleteRow"
:update-or-save-row="updateOrSaveRow" :update-or-save-row="updateOrSaveRow"
:delete-selected-rows="deleteSelectedRows" :delete-selected-rows="deleteSelectedRows"
:delete-range-of-rows="deleteRangeOfRows" :delete-range-of-rows="deleteRangeOfRows"
:apply-sorting="applySorting"
:bulk-update-rows="bulkUpdateRows" :bulk-update-rows="bulkUpdateRows"
:clear-cache="clearCache"
:clear-invalid-rows="clearInvalidRows"
:data="cachedRows"
:total-rows="totalRows"
:sync-count="syncCount"
:chunk-states="chunkStates"
:expand-form="expandForm" :expand-form="expandForm"
:remove-row-if-new="removeRowIfNew" :remove-row-if-new="removeRowIfNew"
:row-height="rowHeight" :row-height-enum="rowHeight"
:selected-rows="selectedRows"
:row-sort-required-rows="isRowSortRequiredRows"
@toggle-optimised-query="toggleOptimisedQuery" @toggle-optimised-query="toggleOptimisedQuery"
@bulk-update-dlg="bulkUpdateDlg = true" @bulk-update-dlg="bulkUpdateDlg = true"
/> />
@ -305,24 +333,20 @@ onMounted(() => {
:view="view" :view="view"
show-next-prev-icons show-next-prev-icons
:first-row="isFirstRow" :first-row="isFirstRow"
:last-row="islastRow" :last-row="isLastRow"
:expand-form="expandForm" :expand-form="expandForm"
@next="goToNextRow()" @next="goToNextRow()"
@prev="goToPreviousRow()" @prev="goToPreviousRow()"
@update-row-comment-count="updateRowCommentCount" @update-row-comment-count="updateRowCommentCount"
/> />
<Suspense> <Suspense>
<LazyDlgBulkUpdate <LazyDlgBulkUpdate
v-if="bulkUpdateDlg" v-if="bulkUpdateDlg"
v-model="bulkUpdateDlg" v-model="bulkUpdateDlg"
:pagination-data="paginationData"
:meta="meta" :meta="meta"
:view="view" :view="view"
:bulk-update-rows="bulkUpdateRows" :bulk-update-rows="bulkUpdateRows"
:bulk-update-view="bulkUpdateView" :rows="selectedRows"
:selected-all-records="selectedAllRecords"
:rows="data.filter((r) => r.rowMeta.selected)"
/> />
</Suspense> </Suspense>
</div> </div>

129
packages/nc-gui/composables/useBetaFeatureToggle.ts

@ -1,22 +1,121 @@
import { reactive } from 'vue' import { onMounted, ref } from 'vue'
import { createSharedComposable } from '@vueuse/core'
const storedValue = localStorage.getItem('betaFeatureToggleState') const FEATURES = [
{
id: 'infinite_scrolling',
title: 'Infinite scrolling',
description: 'Effortlessly browse large datasets with infinite scrolling.',
enabled: true,
},
{
id: 'geodata_column',
title: 'Geodata column',
description: 'Enable the geodata column.',
enabled: false,
isEngineering: true,
},
{
id: 'form_support_column_scanning',
title: 'Scanner for filling data in forms',
description: 'Enable scanner to fill data in forms.',
enabled: false,
isEngineering: true,
},
{
id: 'extensions',
title: 'Extensions',
description: 'Extensions allows you to add new features or functionalities to the NocoDB platform.',
enabled: false,
isEngineering: true,
},
]
const initialToggleState = storedValue ? JSON.parse(storedValue) : false export const FEATURE_FLAG = Object.fromEntries(FEATURES.map((feature) => [feature.id.toUpperCase(), feature.id])) as Record<
Uppercase<(typeof FEATURES)[number]['id']>,
(typeof FEATURES)[number]['id']
>
const betaFeatureToggleState = reactive({ show: initialToggleState }) type FeatureId = (typeof FEATURES)[number]['id']
type Feature = (typeof FEATURES)[number]
const toggleBetaFeature = () => { const STORAGE_KEY = 'featureToggleStates'
betaFeatureToggleState.show = !betaFeatureToggleState.show
localStorage.setItem('betaFeatureToggleState', JSON.stringify(betaFeatureToggleState.show))
}
const _useBetaFeatureToggle = () => { export const useBetaFeatureToggle = createSharedComposable(() => {
return { const features = ref<Feature[]>(structuredClone(FEATURES))
betaFeatureToggleState,
toggleBetaFeature, const featureStates = computed(() => {
return features.value.reduce((acc, feature) => {
acc[feature.id] = feature.enabled
return acc
}, {} as Record<FeatureId, boolean>)
})
const { $e } = useNuxtApp()
const isEngineeringModeOn = ref(false)
const saveFeatures = () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(features.value))
window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEY }))
} catch (error) {
console.error('Failed to save features:', error)
}
} }
}
const useBetaFeatureToggle = createSharedComposable(_useBetaFeatureToggle) const toggleFeature = (id: FeatureId) => {
export { useBetaFeatureToggle } const feature = features.value.find((f) => f.id === id)
if (feature) {
feature.enabled = !feature.enabled
$e(`a:feature-preview:${id}:${feature.enabled ? 'on' : 'off'}`)
saveFeatures()
} else {
console.error(`Feature ${id} not found`)
}
}
const isFeatureEnabled = (id: FeatureId) => featureStates.value[id] ?? false
const initializeFeatures = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsedFeatures = JSON.parse(stored) as Partial<Feature>[]
features.value = FEATURES.map((defaultFeature) => ({
...defaultFeature,
enabled: parsedFeatures.find((f) => f.id === defaultFeature.id)?.enabled ?? defaultFeature.enabled,
}))
}
} catch (error) {
console.error('Failed to initialize features:', error)
}
saveFeatures()
}
const handleStorageEvent = (event: StorageEvent) => {
if (event.key === STORAGE_KEY && event.newValue !== null) {
if (JSON.parse(event.newValue) !== features.value) {
initializeFeatures()
}
}
}
onMounted(() => {
initializeFeatures()
window.addEventListener('storage', handleStorageEvent)
})
onUnmounted(() => {
window.removeEventListener('storage', handleStorageEvent)
})
onMounted(initializeFeatures)
return {
features,
toggleFeature,
isFeatureEnabled,
isEngineeringModeOn,
}
})

18
packages/nc-gui/composables/useExtensions.ts

@ -3,12 +3,7 @@ import { ExtensionsEvents } from '#imports'
const extensionsState = createGlobalState(() => { const extensionsState = createGlobalState(() => {
const baseExtensions = ref<Record<string, any>>({}) const baseExtensions = ref<Record<string, any>>({})
// Egg return { baseExtensions }
const extensionsEgg = ref(false)
const extensionsEggCounter = ref(0)
return { baseExtensions, extensionsEgg, extensionsEggCounter }
}) })
export interface ExtensionManifest { export interface ExtensionManifest {
@ -61,7 +56,7 @@ abstract class ExtensionType {
export { ExtensionType } export { ExtensionType }
export const useExtensions = createSharedComposable(() => { export const useExtensions = createSharedComposable(() => {
const { baseExtensions, extensionsEgg, extensionsEggCounter } = extensionsState() const { baseExtensions } = extensionsState()
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
@ -488,13 +483,6 @@ export const useExtensions = createSharedComposable(() => {
// Extension market modal // Extension market modal
const isMarketVisible = ref(false) const isMarketVisible = ref(false)
const onEggClick = () => {
extensionsEggCounter.value++
if (extensionsEggCounter.value >= 2) {
extensionsEgg.value = true
}
}
return { return {
extensionsLoaded, extensionsLoaded,
availableExtensions, availableExtensions,
@ -514,8 +502,6 @@ export const useExtensions = createSharedComposable(() => {
detailsFrom, detailsFrom,
showExtensionDetails, showExtensionDetails,
isMarketVisible, isMarketVisible,
onEggClick,
extensionsEgg,
extensionPanelSize, extensionPanelSize,
eventBus, eventBus,
} }

303
packages/nc-gui/composables/useGridViewData.ts

@ -0,0 +1,303 @@
import type { Api, ColumnType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue'
import type { EventHook } from '@vueuse/core'
import { NavigateDir, type Row } from '#imports'
const formatData = (list: Record<string, any>[], pageInfo: PaginatedType) =>
list.map((row, index) => ({
row: { ...row },
oldRow: { ...row },
rowMeta: {
// Calculate the rowIndex based on the offset and the index of the row
rowIndex: (pageInfo.page - 1) * pageInfo.pageSize + index,
},
}))
export function useGridViewData(
_meta: Ref<TableType | undefined> | ComputedRef<TableType | undefined>,
viewMeta: Ref<ViewType | undefined> | ComputedRef<(ViewType & { id: string }) | undefined>,
where?: ComputedRef<string | undefined>,
reloadVisibleDataHook?: EventHook<void>,
) {
const tablesStore = useTablesStore()
const { activeTableId, activeTable } = storeToRefs(tablesStore)
const meta = computed(() => _meta.value || activeTable.value)
const metaId = computed(() => _meta.value?.id || activeTableId.value)
const { t } = useI18n()
const optimisedQuery = useState('optimisedQuery', () => true)
const router = useRouter()
const route = router.currentRoute
const { appInfo, gridViewPageSize } = useGlobal()
const appInfoDefaultLimit = gridViewPageSize.value || appInfo.value.defaultLimit || 25
const _paginationData = ref<PaginatedType>({ page: 1, pageSize: appInfoDefaultLimit })
const excludePageInfo = ref(false)
const isPublic = inject(IsPublicInj, ref(false))
const { base } = storeToRefs(useBase())
const { fetchSharedViewData, paginationData: sharedPaginationData } = useSharedView()
const { $api } = useNuxtApp()
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow()
const { isUIAllowed } = useRoles()
const routeQuery = computed(() => route.value.query as Record<string, string>)
const paginationData = computed({
get: () => (isPublic.value ? sharedPaginationData.value : _paginationData.value),
set: (value) => {
if (isPublic.value) {
sharedPaginationData.value = value
} else {
_paginationData.value = value
}
},
})
const {
insertRow,
updateRowProperty,
addEmptyRow,
deleteRow,
deleteRowById,
deleteSelectedRows,
deleteRangeOfRows,
updateOrSaveRow,
cachedRows,
clearCache,
totalRows,
bulkUpdateRows,
bulkUpdateView,
removeRowIfNew,
syncCount,
selectedRows,
chunkStates,
isRowSortRequiredRows,
clearInvalidRows,
applySorting,
} = useInfiniteData({
meta,
viewMeta,
callbacks: {
loadData,
syncVisibleData,
},
where,
})
function syncVisibleData() {
reloadVisibleDataHook?.trigger()
}
function getExpandedRowIndex(): number {
const rowId = routeQuery.value.rowId
if (!rowId) return -1
for (const [_index, row] of cachedRows.value.entries()) {
if (extractPkFromRow(row.row, meta.value?.columns as ColumnType[]) === rowId) {
return row.rowMeta.rowIndex!
}
}
return -1
}
const isLastRow = computed(() => {
const expandedRowIndex = getExpandedRowIndex()
if (expandedRowIndex === -1) return false
return expandedRowIndex === totalRows.value - 1
})
const isFirstRow = computed(() => {
const expandedRowIndex = getExpandedRowIndex()
if (expandedRowIndex === -1) return false
return expandedRowIndex === 0
})
const queryParams = computed(() => ({
offset: ((paginationData.value.page ?? 0) - 1) * (paginationData.value.pageSize ?? appInfoDefaultLimit),
limit: paginationData.value.pageSize ?? appInfoDefaultLimit,
where: where?.value ?? '',
}))
async function loadAggCommentsCount(formattedData: Array<Row>) {
if (!isUIAllowed('commentCount') || isPublic.value) return
const ids = formattedData
.filter(({ rowMeta: { new: isNew } }) => !isNew)
.map(({ row }) => extractPkFromRow(row, meta?.value?.columns as ColumnType[]))
.filter(Boolean)
if (!ids.length) return
try {
const aggCommentCount = await $api.utils.commentCount({
ids,
fk_model_id: metaId.value as string,
})
formattedData.forEach((row) => {
const cachedRow = Array.from(cachedRows.value.values()).find(
(cachedRow) => cachedRow.rowMeta.rowIndex === row.rowMeta.rowIndex,
)
if (!cachedRow) return
const id = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const count = aggCommentCount?.find((c: Record<string, any>) => c.row_id === id)?.count || 0
cachedRow.rowMeta.commentCount = +count
})
} catch (e) {
console.error('Failed to load aggregate comment count:', e)
}
}
async function loadData(
params: Parameters<Api<any>['dbViewRow']['list']>[4] & {
limit?: number
offset?: number
} = {},
): Promise<Row[] | undefined> {
if ((!base?.value?.id || !metaId.value || !viewMeta.value?.id) && !isPublic.value) return
try {
const response = !isPublic.value
? await $api.dbViewRow.list('noco', base.value.id!, metaId.value!, viewMeta.value!.id!, {
...queryParams.value,
...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
where: where?.value,
...(excludePageInfo.value ? { excludeCount: 'true' } : {}),
} as any)
: await fetchSharedViewData(
{
sortsArr: sorts.value,
filtersArr: nestedFilters.value,
where: where?.value,
offset: params.offset,
limit: params.limit,
},
{
isInfiniteScroll: true,
},
)
const data = formatData(response.list, response.pageInfo)
if (response.pageInfo.totalRows) {
totalRows.value = response.pageInfo.totalRows
}
loadAggCommentsCount(data)
return data
} catch (error: any) {
if (error?.response?.data.error === 'INVALID_OFFSET_VALUE') {
return []
}
if (error?.response?.data?.error === 'FORMULA_ERROR') {
await tablesStore.reloadTableMeta(metaId.value as string)
return loadData(params)
}
console.error(error)
message.error(await extractSdkResponseErrorMsg(error))
}
}
const navigateToSiblingRow = async (dir: NavigateDir) => {
const expandedRowIndex = getExpandedRowIndex()
if (expandedRowIndex === -1) return
const sortedIndices = Array.from(cachedRows.value.keys()).sort((a, b) => a - b)
let siblingIndex = sortedIndices.findIndex((index) => index === expandedRowIndex) + (dir === NavigateDir.NEXT ? 1 : -1)
// Skip unsaved rows
while (
siblingIndex >= 0 &&
siblingIndex < sortedIndices.length &&
cachedRows.value.get(sortedIndices[siblingIndex])?.rowMeta?.new
) {
siblingIndex += dir === NavigateDir.NEXT ? 1 : -1
}
// Check if we've gone out of bounds
if (siblingIndex < 0 || siblingIndex >= totalRows.value) {
return message.info(t('msg.info.noMoreRecords'))
}
// If the sibling row is not in cachedRows, load more data
if (siblingIndex >= sortedIndices.length) {
await loadData({
offset: sortedIndices[sortedIndices.length - 1] + 1,
limit: 10,
})
sortedIndices.push(
...Array.from(cachedRows.value.keys())
.filter((key) => !sortedIndices.includes(key))
.sort((a, b) => a - b),
)
}
// Extract the row id of the sibling row
const siblingRow = cachedRows.value.get(sortedIndices[siblingIndex])
if (siblingRow) {
const rowId = extractPkFromRow(siblingRow.row, meta.value?.columns as ColumnType[])
if (rowId) {
await router.push({
query: {
...routeQuery.value,
rowId,
},
})
}
}
}
return {
cachedRows,
loadData,
paginationData,
queryParams,
insertRow,
updateRowProperty,
addEmptyRow,
deleteRow,
deleteRowById,
deleteSelectedRows,
deleteRangeOfRows,
updateOrSaveRow,
bulkUpdateRows,
bulkUpdateView,
loadAggCommentsCount,
syncCount,
removeRowIfNew,
navigateToSiblingRow,
getExpandedRowIndex,
optimisedQuery,
isLastRow,
isFirstRow,
clearCache,
totalRows,
selectedRows,
syncVisibleData,
chunkStates,
clearInvalidRows,
applySorting,
isRowSortRequiredRows,
}
}

1371
packages/nc-gui/composables/useInfiniteData.ts

File diff suppressed because it is too large Load Diff

383
packages/nc-gui/composables/useMultiSelect/index.ts

@ -30,17 +30,19 @@ const MAIN_MOUSE_PRESSED = 0
/** /**
* Utility to help with multi-selecting rows/cells in the smartsheet * Utility to help with multi-selecting rows/cells in the smartsheet
*/ */
export function useMultiSelect( export function useMultiSelect(
_meta: MaybeRef<TableType | undefined>, _meta: MaybeRef<TableType | undefined>,
fields: MaybeRef<ColumnType[]>, fields: MaybeRef<ColumnType[]>,
data: MaybeRef<Row[]>, data: MaybeRef<Row[]> | MaybeRef<Map<number, Row>>,
_totalRows?: MaybeRef<number>,
_editEnabled: MaybeRef<boolean>, _editEnabled: MaybeRef<boolean>,
isPkAvail: MaybeRef<boolean | undefined>, isPkAvail: MaybeRef<boolean | undefined>,
contextMenu: Ref<boolean>, contextMenu: Ref<boolean>,
clearCell: Function, clearCell: Function,
clearSelectedRangeOfCells: Function, clearSelectedRangeOfCells: Function,
makeEditable: Function, makeEditable: Function,
scrollToCell?: (row?: number | null, col?: number | null) => void, scrollToCell?: (row?: number | null, col?: number | null, scrollBehaviour?: ScrollBehavior) => void,
keyEventHandler?: Function, keyEventHandler?: Function,
syncCellData?: Function, syncCellData?: Function,
bulkUpdateRows?: Function, bulkUpdateRows?: Function,
@ -48,9 +50,15 @@ export function useMultiSelect(
view?: MaybeRef<ViewType | undefined>, view?: MaybeRef<ViewType | undefined>,
paginationData?: MaybeRef<PaginatedType | undefined>, paginationData?: MaybeRef<PaginatedType | undefined>,
changePage?: (page: number) => void, changePage?: (page: number) => void,
fetchChunk?: (chunkId: number) => Promise<void>,
onActiveCellChanged?: () => void,
) { ) {
const meta = ref(_meta) const meta = ref(_meta)
const MAX_ROW_SELECTION = 100
const CHUNK_SIZE = 50
const { t } = useI18n() const { t } = useI18n()
const { copy } = useCopy() const { copy } = useCopy()
@ -69,6 +77,10 @@ export function useMultiSelect(
const { isDataReadOnly } = useRoles() const { isDataReadOnly } = useRoles()
const isArrayStructure = typeof unref(data) === 'object' && Array.isArray(unref(data))
const paginationDataRef = ref(paginationData)
const editEnabled = ref(_editEnabled) const editEnabled = ref(_editEnabled)
const isMouseDown = ref(false) const isMouseDown = ref(false)
@ -77,8 +89,6 @@ export function useMultiSelect(
const activeView = ref(view) const activeView = ref(view)
const paginationDataRef = ref(paginationData)
const selectedRange = reactive(new CellRange()) const selectedRange = reactive(new CellRange())
const fillRange = reactive(new CellRange()) const fillRange = reactive(new CellRange())
@ -91,6 +101,16 @@ export function useMultiSelect(
() => !(activeCell.row === null || activeCell.col === null || isNaN(activeCell.row) || isNaN(activeCell.col)), () => !(activeCell.row === null || activeCell.col === null || isNaN(activeCell.row) || isNaN(activeCell.col)),
) )
function limitSelection(anchor: Cell, end: Cell): Cell {
const limitedEnd = { ...end }
const totalRows = Math.abs(end.row - anchor.row) + 1
if (totalRows > MAX_ROW_SELECTION) {
const direction = end.row > anchor.row ? 1 : -1
limitedEnd.row = anchor.row + (MAX_ROW_SELECTION - 1) * direction
}
return limitedEnd
}
function makeActive(row: number, col: number) { function makeActive(row: number, col: number) {
if (activeCell.row === row && activeCell.col === col) { if (activeCell.row === row && activeCell.col === col) {
return return
@ -286,7 +306,24 @@ export function useMultiSelect(
async function copyValue(ctx?: Cell) { async function copyValue(ctx?: Cell) {
try { try {
if (selectedRange.start !== null && selectedRange.end !== null && !selectedRange.isSingleCell()) { if (selectedRange.start !== null && selectedRange.end !== null && !selectedRange.isSingleCell()) {
const cprows = unref(data).slice(selectedRange.start.row, selectedRange.end.row + 1) // slice the selected rows for copy let cprows
if (isArrayStructure) {
cprows = unref(data as Row[]).slice(selectedRange.start.row, selectedRange.end.row + 1) // slice the selected rows for copy
} else {
const startChunkId = Math.floor(selectedRange.start.row / CHUNK_SIZE)
const endChunkId = Math.floor(selectedRange.end.row / CHUNK_SIZE)
const chunksToFetch = new Set()
for (let chunkId = startChunkId; chunkId <= endChunkId; chunkId++) {
chunksToFetch.add(chunkId)
}
// Fetch all required chunks
await Promise.all([...chunksToFetch].map(fetchChunk))
// Make sure all data is loaded before copying
cprows = Array.from(unref(data as Map<number, Row>).values()).slice(selectedRange.start.row, selectedRange.end.row + 1) // slice the selected rows for copy
}
const cpcols = unref(fields).slice(selectedRange.start.col, selectedRange.end.col + 1) // slice the selected cols for copy const cpcols = unref(fields).slice(selectedRange.start.col, selectedRange.end.col + 1) // slice the selected cols for copy
await copyTable(cprows, cpcols) await copyTable(cprows, cpcols)
@ -298,7 +335,8 @@ export function useMultiSelect(
const cpCol = ctx?.col ?? activeCell.col const cpCol = ctx?.col ?? activeCell.col
if (cpRow != null && cpCol != null) { if (cpRow != null && cpCol != null) {
const rowObj = unref(data)[cpRow] const rowObj = isArrayStructure ? unref(data as Row[])[cpRow] : unref(data as Map<number, Row>).get(cpRow)
if (!rowObj) return
const columnObj = unref(fields)[cpCol] const columnObj = unref(fields)[cpCol]
const textToCopy = valueToCopy(rowObj, columnObj) const textToCopy = valueToCopy(rowObj, columnObj)
@ -391,15 +429,23 @@ export function useMultiSelect(
function handleMouseOver(event: MouseEvent, row: number, col: number) { function handleMouseOver(event: MouseEvent, row: number, col: number) {
if (isFillMode.value) { if (isFillMode.value) {
const rw = unref(data)[row] const rw = isArrayStructure ? (unref(data) as Row[])[row] : (unref(data) as Map<number, Row>).get(row)
if (!rw) return
if (!selectedRange._start || !selectedRange._end) return if (!selectedRange._start || !selectedRange._end) return
// fill is not supported for new rows yet // fill is not supported for new rows yet
if (rw.rowMeta.new) return if (rw.rowMeta.new) return
fillRange.endRange({ row, col: selectedRange._end.col }) const endRow = Math.min(selectedRange._start.row + 100, row)
scrollToCell?.(row, col)
fillRange.endRange({
row: endRow,
col: selectedRange._end.col,
})
scrollToCell?.(endRow, col)
return return
} }
@ -407,9 +453,9 @@ export function useMultiSelect(
return return
} }
// extend the selection and scroll to the cell const limitedEnd = limitSelection(selectedRange.start, { row, col })
selectedRange.endRange({ row, col }) selectedRange.endRange(limitedEnd)
scrollToCell?.(row, col) scrollToCell?.(limitedEnd.row, limitedEnd.col)
// avoid selecting text // avoid selecting text
event.preventDefault() event.preventDefault()
@ -448,7 +494,7 @@ export function useMultiSelect(
if (activeCell.row !== row || activeCell.col !== col) { if (activeCell.row !== row || activeCell.col !== col) {
// clear active cell on selection start // clear active cell on selection start
activeCell.row = null // activeCell.row = null
activeCell.col = null activeCell.col = null
} }
} }
@ -471,7 +517,16 @@ export function useMultiSelect(
if (selectedRange._start !== null && selectedRange._end !== null) { if (selectedRange._start !== null && selectedRange._end !== null) {
const tempActiveCell = { row: selectedRange._start.row, col: selectedRange._start.col } const tempActiveCell = { row: selectedRange._start.row, col: selectedRange._start.col }
const cprows = unref(data).slice(selectedRange.start.row, selectedRange.end.row + 1) // slice the selected rows for copy let cprows
if (isArrayStructure) {
cprows = (unref(data) as Row[]).slice(selectedRange.start.row, selectedRange.end.row + 1)
} else {
cprows = Array.from(unref(data) as Map<number, Row>)
.filter(([index]) => index >= selectedRange.start.row && index <= selectedRange.end.row)
.map(([, row]) => row)
}
const cpcols = unref(fields).slice(selectedRange.start.col, selectedRange.end.col + 1) // slice the selected cols for copy const cpcols = unref(fields).slice(selectedRange.start.col, selectedRange.end.col + 1) // slice the selected cols for copy
const rawMatrix = serializeRange(cprows, cpcols).json const rawMatrix = serializeRange(cprows, cpcols).json
@ -492,7 +547,11 @@ export function useMultiSelect(
continue continue
} }
const rowObj = unref(data)[row] const rowObj = isArrayStructure ? (unref(data) as Row[])[row] : (unref(data) as Map<number, Row>).get(row)
if (!rowObj) {
continue
}
let pasteIndex = 0 let pasteIndex = 0
@ -559,26 +618,12 @@ export function useMultiSelect(
} }
} }
const handleKeyDown = async (e: KeyboardEvent) => { const handleKeyDownAction = async (e: KeyboardEvent) => {
// invoke the keyEventHandler if provided and return if it returns true
if (await keyEventHandler?.(e)) {
return true
}
if (isExpandedCellInputExist()) {
return
}
if (!isCellActive.value || activeCell.row === null || activeCell.col === null) {
return
}
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
/** on tab key press navigate through cells */ if (activeCell.row === null || activeCell.col === null) return
switch (e.key) { switch (e.key) {
case 'Tab': case 'Tab':
e.preventDefault()
selectedRange.clear() selectedRange.clear()
if (e.shiftKey) { if (e.shiftKey) {
@ -594,7 +639,7 @@ export function useMultiSelect(
if (activeCell.col < unref(columnLength.value) - 1) { if (activeCell.col < unref(columnLength.value) - 1) {
activeCell.col++ activeCell.col++
editEnabled.value = false editEnabled.value = false
} else if (activeCell.row < unref(data).length - 1) { } else if (activeCell.row < (isArrayStructure ? (unref(data) as Row[]).length : unref(_totalRows!)) - 1) {
activeCell.row++ activeCell.row++
activeCell.col = 0 activeCell.col = 0
editEnabled.value = false editEnabled.value = false
@ -602,32 +647,7 @@ export function useMultiSelect(
} }
scrollToCell?.() scrollToCell?.()
break break
/** on enter key press make cell editable */
case 'Enter':
e.preventDefault()
selectedRange.clear()
makeEditable(unref(data)[activeCell.row], unref(fields)[activeCell.col])
break
/** on delete key press clear cell */
case 'Delete':
case 'Backspace':
e.preventDefault()
if (isDataReadOnly.value) {
return
}
if (selectedRange.isSingleCell()) {
selectedRange.clear()
await clearCell(activeCell as { row: number; col: number })
} else {
await clearSelectedRangeOfCells()
}
break
/** on arrow key press navigate through cells */
case 'ArrowRight': case 'ArrowRight':
e.preventDefault()
if (e.shiftKey) { if (e.shiftKey) {
if (cmdOrCtrl) { if (cmdOrCtrl) {
editEnabled.value = false editEnabled.value = false
@ -656,8 +676,6 @@ export function useMultiSelect(
} }
break break
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault()
if (e.shiftKey) { if (e.shiftKey) {
if (cmdOrCtrl) { if (cmdOrCtrl) {
editEnabled.value = false editEnabled.value = false
@ -686,24 +704,23 @@ export function useMultiSelect(
} }
break break
case 'ArrowUp': case 'ArrowUp':
e.preventDefault()
if (e.shiftKey) { if (e.shiftKey) {
const anchor = selectedRange._start ?? activeCell
let newEnd: Cell
if (cmdOrCtrl) { if (cmdOrCtrl) {
editEnabled.value = false newEnd = { row: 0, col: selectedRange._end?.col ?? activeCell.col }
selectedRange.endRange({ } else {
row: 0, newEnd = {
col: selectedRange._end?.col ?? activeCell.col,
})
scrollToCell?.(selectedRange._end?.row, selectedRange._end?.col)
} else if ((selectedRange._end?.row ?? activeCell.row) > 0) {
editEnabled.value = false
selectedRange.endRange({
row: (selectedRange._end?.row ?? activeCell.row) - 1, row: (selectedRange._end?.row ?? activeCell.row) - 1,
col: selectedRange._end?.col ?? activeCell.col, col: selectedRange._end?.col ?? activeCell.col,
})
scrollToCell?.(selectedRange._end?.row, selectedRange._end?.col)
} }
}
const limitedEnd = limitSelection(anchor, newEnd)
editEnabled.value = false
selectedRange.endRange(limitedEnd)
scrollToCell?.(limitedEnd.row, limitedEnd.col, 'instant')
} else { } else {
selectedRange.clear() selectedRange.clear()
@ -714,40 +731,104 @@ export function useMultiSelect(
editEnabled.value = false editEnabled.value = false
} }
} }
onActiveCellChanged?.()
break break
case 'ArrowDown': case 'ArrowDown':
e.preventDefault()
if (e.shiftKey) { if (e.shiftKey) {
const anchor = selectedRange._start ?? activeCell
let newEnd: Cell
if (cmdOrCtrl) { if (cmdOrCtrl) {
editEnabled.value = false newEnd = {
selectedRange.endRange({ row: (isArrayStructure ? (unref(data) as Row[]).length : unref(_totalRows!)) - 1,
row: unref(data).length - 1,
col: selectedRange._end?.col ?? activeCell.col, col: selectedRange._end?.col ?? activeCell.col,
}) }
scrollToCell?.(selectedRange._end?.row, selectedRange._end?.col) } else {
} else if ((selectedRange._end?.row ?? activeCell.row) < unref(data).length - 1) { newEnd = {
editEnabled.value = false
selectedRange.endRange({
row: (selectedRange._end?.row ?? activeCell.row) + 1, row: (selectedRange._end?.row ?? activeCell.row) + 1,
col: selectedRange._end?.col ?? activeCell.col, col: selectedRange._end?.col ?? activeCell.col,
})
scrollToCell?.(selectedRange._end?.row, selectedRange._end?.col)
} }
}
const limitedEnd = limitSelection(anchor, newEnd)
editEnabled.value = false
selectedRange.endRange(limitedEnd)
scrollToCell?.(limitedEnd.row, limitedEnd.col, 'instant')
} else { } else {
selectedRange.clear() selectedRange.clear()
if (activeCell.row < (isArrayStructure ? (unref(data) as Row[]).length : unref(_totalRows!))) {
if (activeCell.row < unref(data).length - 1) {
activeCell.row++ activeCell.row++
selectedRange.startRange({ row: activeCell.row, col: activeCell.col }) selectedRange.startRange({ row: activeCell.row, col: activeCell.col })
scrollToCell?.() scrollToCell?.()
editEnabled.value = false editEnabled.value = false
} }
} }
onActiveCellChanged?.()
break
case 'Enter':
selectedRange.clear()
let row
if (isArrayStructure) {
row = (unref(data) as Row[])[activeCell.row]
} else {
row = (unref(data) as Map<number, Row>).get(activeCell.row)
}
makeEditable(row, unref(fields)[activeCell.col])
break
case 'Delete':
case 'Backspace':
if (isDataReadOnly.value) {
return
}
if (selectedRange.isSingleCell()) {
selectedRange.clear()
await clearCell(activeCell as { row: number; col: number })
} else {
await clearSelectedRangeOfCells()
}
break
}
}
const handleThrottledKeyDownAction = useThrottleFn(handleKeyDownAction, 60)
const handleKeyDown = async (e: KeyboardEvent) => {
// invoke the keyEventHandler if provided and return if it returns true
if (isArrayStructure ? await keyEventHandler?.(e) : keyEventHandler?.(e)) {
return true
}
if (isExpandedCellInputExist()) {
return
}
if (!isCellActive.value || activeCell.row === null || activeCell.col === null) {
return
}
/** on tab key press navigate through cells */
switch (e.key) {
case 'Tab':
case 'Enter':
case 'Delete':
case 'Backspace':
case 'ArrowRight':
case 'ArrowLeft':
case 'ArrowUp':
case 'ArrowDown':
e.preventDefault()
handleThrottledKeyDownAction(e)
break break
default: default:
{ {
const rowObj = unref(data)[activeCell.row] const rowObj = isArrayStructure
? (unref(data) as Row[])[activeCell.row]
: (unref(data) as Map<number, Row>).get(activeCell.row)
if (!rowObj) return
const columnObj = unref(fields)[activeCell.col] const columnObj = unref(fields)[activeCell.col]
if ( if (
@ -760,11 +841,6 @@ export function useMultiSelect(
case 67: case 67:
await copyValue() await copyValue()
break break
// select all - ctrl/cmd +a
case 65:
selectedRange.startRange({ row: 0, col: 0 })
selectedRange.endRange({ row: unref(data).length - 1, col: unref(columnLength.value) - 1 })
break
} }
} }
@ -850,7 +926,17 @@ export function useMultiSelect(
const pasteMatrixCols = clipboardMatrix[0].length const pasteMatrixCols = clipboardMatrix[0].length
const colsToPaste = unref(fields).slice(activeCell.col, activeCell.col + pasteMatrixCols) const colsToPaste = unref(fields).slice(activeCell.col, activeCell.col + pasteMatrixCols)
const rowsToPaste = unref(data).slice(activeCell.row, activeCell.row + selectionRowCount)
let rowsToPaste
if (isArrayStructure) {
rowsToPaste = (unref(data) as Row[]).slice(activeCell.row, activeCell.row + selectionRowCount)
} else {
rowsToPaste = Array.from(unref(data) as Map<number, Row>)
.filter(([index]) => index >= activeCell.row! && index < activeCell.row! + selectionRowCount)
.map(([, row]) => row)
}
const propsToPaste: string[] = [] const propsToPaste: string[] = []
let pastedRows = 0 let pastedRows = 0
@ -904,7 +990,10 @@ export function useMultiSelect(
} }
} else { } else {
if (selectedRange.isSingleCell()) { if (selectedRange.isSingleCell()) {
const rowObj = unref(data)[activeCell.row] const rowObj = isArrayStructure
? (unref(data) as Row[])[activeCell.row]
: (unref(data) as Map<number, Row>).get(activeCell.row)
if (!rowObj) return
const columnObj = unref(fields)[activeCell.col] const columnObj = unref(fields)[activeCell.col]
// handle belongs to column, skip custom links // handle belongs to column, skip custom links
@ -997,6 +1086,8 @@ export function useMultiSelect(
rowObj.row[columnObj.title!] = oldCellValue rowObj.row[columnObj.title!] = oldCellValue
return return
} }
if (isArrayStructure) {
addUndo({ addUndo({
redo: { redo: {
fn: async ( fn: async (
@ -1012,7 +1103,7 @@ export function useMultiSelect(
await changePage?.(pg?.page) await changePage?.(pg?.page)
} }
const pasteRowPk = extractPkFromRow(row.row, meta.value?.columns as ColumnType[]) const pasteRowPk = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const rowObj = unref(data)[activeCell.row] const rowObj = (unref(data) as Row[])[activeCell.row]
const columnObj = unref(fields)[activeCell.col] const columnObj = unref(fields)[activeCell.col]
if ( if (
pasteRowPk === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) && pasteRowPk === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) &&
@ -1073,7 +1164,7 @@ export function useMultiSelect(
} }
const pasteRowPk = extractPkFromRow(row.row, meta.value?.columns as ColumnType[]) const pasteRowPk = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const rowObj = unref(data)[activeCell.row] const rowObj = (unref(data) as Row[])[activeCell.row]
const columnObj = unref(fields)[activeCell.col] const columnObj = unref(fields)[activeCell.col]
if ( if (
@ -1118,6 +1209,96 @@ export function useMultiSelect(
}, },
scope: defineViewScope({ view: activeView?.value }), scope: defineViewScope({ view: activeView?.value }),
}) })
} else {
addUndo({
redo: {
fn: async (
activeCell: Cell,
col: ColumnType,
row: Row,
value: number,
result: { link: any[]; unlink: any[] },
) => {
const pasteRowPk = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const rowObj = (unref(data) as Map<number, Row>).get(activeCell.row)
if (!rowObj) return
const columnObj = unref(fields)[activeCell.col]
if (
pasteRowPk === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) &&
columnObj.id === col.id
) {
await Promise.all([
result.link.length &&
api.dbDataTableRow.nestedLink(
meta.value?.id as string,
columnObj.id as string,
encodeURIComponent(pasteRowPk),
result.link,
{
viewId: activeView?.value?.id,
},
),
result.unlink.length &&
api.dbDataTableRow.nestedUnlink(
meta.value?.id as string,
columnObj.id as string,
encodeURIComponent(pasteRowPk),
result.unlink,
{ viewId: activeView?.value?.id },
),
])
rowObj.row[columnObj.title!] = value
await syncCellData?.(activeCell)
}
},
args: [clone(activeCell), clone(columnObj), clone(rowObj), clone(pasteVal.value), result],
},
undo: {
fn: async (
activeCell: Cell,
col: ColumnType,
row: Row,
value: number,
result: { link: any[]; unlink: any[] },
) => {
const pasteRowPk = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const rowObj = (unref(data) as Map<number, Row>).get(activeCell.row)
if (!rowObj) return
const columnObj = unref(fields)[activeCell.col]
if (
pasteRowPk === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) &&
columnObj.id === col.id
) {
await Promise.all([
result.unlink.length &&
api.dbDataTableRow.nestedLink(
meta.value?.id as string,
columnObj.id as string,
encodeURIComponent(pasteRowPk),
result.unlink,
),
result.link.length &&
api.dbDataTableRow.nestedUnlink(
meta.value?.id as string,
columnObj.id as string,
encodeURIComponent(pasteRowPk),
result.link,
),
])
rowObj.row[columnObj.title!] = value
await syncCellData?.(activeCell)
}
},
args: [clone(activeCell), clone(columnObj), clone(rowObj), clone(oldCellValue), result],
},
scope: defineViewScope({ view: activeView?.value }),
})
}
} }
return await syncCellData?.(activeCell) return await syncCellData?.(activeCell)
@ -1158,7 +1339,15 @@ export function useMultiSelect(
const endCol = Math.max(start.col, end.col) const endCol = Math.max(start.col, end.col)
const cols = unref(fields).slice(startCol, endCol + 1) const cols = unref(fields).slice(startCol, endCol + 1)
const rows = unref(data).slice(startRow, endRow + 1) let rows
if (isArrayStructure) {
rows = (unref(data) as Row[]).slice(startRow, endRow + 1)
} else {
rows = Array.from(unref(data) as Map<number, Row>)
.filter(([index]) => index >= startRow && index <= endRow)
.map(([, row]) => row)
}
const props = [] const props = []
let pasteValue let pasteValue

28
packages/nc-gui/composables/useSharedView.ts

@ -15,7 +15,7 @@ import { UITypes, ViewTypes } from 'nocodb-sdk'
export function useSharedView() { export function useSharedView() {
const nestedFilters = ref<(FilterType & { status?: 'update' | 'delete' | 'create'; parentId?: string })[]>([]) const nestedFilters = ref<(FilterType & { status?: 'update' | 'delete' | 'create'; parentId?: string })[]>([])
const { appInfo, gridViewPageSize } = useGlobal() const { appInfo } = useGlobal()
const baseStore = useBase() const baseStore = useBase()
@ -25,7 +25,7 @@ export function useSharedView() {
const { base } = storeToRefs(baseStore) const { base } = storeToRefs(baseStore)
const appInfoDefaultLimit = gridViewPageSize.value || appInfo.value.defaultLimit || 25 const appInfoDefaultLimit = appInfo.value.defaultLimit || 50
const paginationData = useState<PaginatedType>('paginationData', () => ({ const paginationData = useState<PaginatedType>('paginationData', () => ({
page: 1, page: 1,
@ -134,9 +134,11 @@ export function useSharedView() {
/** Query params for nested data */ /** Query params for nested data */
nested?: any nested?: any
offset?: number offset?: number
limit?: number
}, },
opts?: { opts?: {
isGroupBy?: boolean isGroupBy?: boolean
isInfiniteScroll?: boolean
}, },
) => { ) => {
if (!sharedView.value) if (!sharedView.value)
@ -147,7 +149,9 @@ export function useSharedView() {
if (!param.offset) { if (!param.offset) {
const page = paginationData.value.page || 1 const page = paginationData.value.page || 1
const pageSize = opts?.isGroupBy const pageSize = opts?.isInfiniteScroll
? param.limit
: opts?.isGroupBy
? appInfo.value.defaultGroupByLimit?.limitRecord || 10 ? appInfo.value.defaultGroupByLimit?.limitRecord || 10
: paginationData.value.pageSize || appInfoDefaultLimit : paginationData.value.pageSize || appInfoDefaultLimit
param.offset = (page - 1) * pageSize param.offset = (page - 1) * pageSize
@ -336,6 +340,23 @@ export function useSharedView() {
) )
} }
const fetchCount = async (param: { filtersArr: FilterType[], where?: string }) => {
const data = await $api.public.dbViewRowCount(
sharedView.value.uuid!,
{
filterArrJson: JSON.stringify(param.filtersArr ?? nestedFilters.value),
where: param.where,
},
{
headers: {
'xc-password': password.value,
},
},
)
return data
}
const fetchSharedViewGroupedData = async ( const fetchSharedViewGroupedData = async (
columnId: string, columnId: string,
{ sortsArr, filtersArr }: { sortsArr: SortType[]; filtersArr: FilterType[] }, { sortsArr, filtersArr }: { sortsArr: SortType[]; filtersArr: FilterType[] },
@ -419,5 +440,6 @@ export function useSharedView() {
exportFile, exportFile,
formColumns, formColumns,
allowCSVDownload, allowCSVDownload,
fetchCount,
} }
} }

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

@ -20,6 +20,7 @@ export function useViewData(
const { activeTableId, activeTable } = storeToRefs(tablesStore) const { activeTableId, activeTable } = storeToRefs(tablesStore)
const meta = computed(() => _meta.value || activeTable.value) const meta = computed(() => _meta.value || activeTable.value)
const metaId = computed(() => _meta.value?.id || activeTableId.value) const metaId = computed(() => _meta.value?.id || activeTableId.value)
const { t } = useI18n() const { t } = useI18n()

1
packages/nc-gui/context/index.ts

@ -30,6 +30,7 @@ export const ReloadViewDataHookInj: InjectionKey<
EventHook<{ shouldShowLoading?: boolean; offset?: number; isFormFieldFilters?: boolean } | void> EventHook<{ shouldShowLoading?: boolean; offset?: number; isFormFieldFilters?: boolean } | void>
> = Symbol('reload-view-data-injection') > = Symbol('reload-view-data-injection')
export const ReloadViewMetaHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-meta-injection') export const ReloadViewMetaHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-meta-injection')
export const ReloadVisibleDataHookInj: InjectionKey<EventHook<void>> = Symbol('reload-visible-data-injection')
export const ReloadRowDataHookInj: InjectionKey<EventHook<{ shouldShowLoading?: boolean; offset?: number } | void>> = export const ReloadRowDataHookInj: InjectionKey<EventHook<{ shouldShowLoading?: boolean; offset?: number } | void>> =
Symbol('reload-row-data-injection') Symbol('reload-row-data-injection')
export const ReloadAggregateHookInj: InjectionKey< export const ReloadAggregateHookInj: InjectionKey<

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

@ -93,6 +93,7 @@
"none": "None" "none": "None"
}, },
"general": { "general": {
"featurePreview": "Feature Preview",
"scripts": "Scripts", "scripts": "Scripts",
"configure": "Configure", "configure": "Configure",
"switch": "Switch", "switch": "Switch",
@ -626,6 +627,7 @@
"noConditionsAdded": "No conditions added" "noConditionsAdded": "No conditions added"
}, },
"labels": { "labels": {
"toggleExperimentalFeature": "Enable or disable experimental features with ease, allowing you to explore and evaluate upcoming functionalities.",
"modifiedOn": "Modified on", "modifiedOn": "Modified on",
"configuration": "Configuration", "configuration": "Configuration",
"setup": "Setup", "setup": "Setup",

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

@ -74,6 +74,12 @@ interface Row {
row: Record<string, any> row: Record<string, any>
oldRow: Record<string, any> oldRow: Record<string, any>
rowMeta: { rowMeta: {
// Used in InfiniteScroll Grid View
rowIndex?: number
isLoading?: boolean
isValidationFailed?: boolean
isRowOrderUpdated?: boolean
new?: boolean new?: boolean
selected?: boolean selected?: boolean
commentCount?: number commentCount?: number
@ -93,7 +99,6 @@ interface Row {
id?: string id?: string
position?: string position?: string
dayIndex?: number dayIndex?: number
overLapIteration?: number overLapIteration?: number
numberOfOverlaps?: number numberOfOverlaps?: number
minutes?: number minutes?: number

286
packages/nc-gui/utils/dataUtils.ts

@ -1,5 +1,6 @@
import { RelationTypes, UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk' import { RelationTypes, UITypes, buildFilterTree, isDateMonthFormat, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk' import type { ColumnType, FilterType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import dayjs from 'dayjs'
import { isColumnRequiredAndNull } from './columnUtils' import { isColumnRequiredAndNull } from './columnUtils'
import type { Row } from '~/lib/types' import type { Row } from '~/lib/types'
@ -148,3 +149,284 @@ export const isRowEmpty = (record: any, col: any) => {
return Array.isArray(val) && val.length === 0 return Array.isArray(val) && val.length === 0
} }
export function validateRowFilters(_filters: FilterType[], data: any, columns: ColumnType[], client: any) {
if (!_filters.length) {
return true
}
const filters = buildFilterTree(_filters)
let isValid = null
for (const filter of filters) {
let res
if (filter.is_group && filter.children?.length) {
res = validateRowFilters(filter.children, data, columns, client)
} else {
const column = columns.find((c) => c.id === filter.fk_column_id)
if (!column) {
continue
}
const field = column.title!
let val = data[field]
if (
[UITypes.Date, UITypes.DateTime, UITypes.CreatedTime, UITypes.LastModifiedTime].includes(column.uidt!) &&
!['empty', 'blank', 'notempty', 'notblank'].includes(filter.comparison_op!)
) {
const dateFormat = client === 'mysql2' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
let now = dayjs(new Date())
const dateFormatFromMeta = column?.meta?.date_format
const dataVal: any = val
let filterVal: any = filter.value
if (dateFormatFromMeta && isDateMonthFormat(dateFormatFromMeta)) {
// reset to 1st
now = dayjs(now).date(1)
if (val) val = dayjs(val).date(1)
}
if (filterVal) res = dayjs(filterVal).isSame(dataVal, 'day')
// handle sub operation
switch (filter.comparison_sub_op) {
case 'today':
filterVal = now
break
case 'tomorrow':
filterVal = now.add(1, 'day')
break
case 'yesterday':
filterVal = now.add(-1, 'day')
break
case 'oneWeekAgo':
filterVal = now.add(-1, 'week')
break
case 'oneWeekFromNow':
filterVal = now.add(1, 'week')
break
case 'oneMonthAgo':
filterVal = now.add(-1, 'month')
break
case 'oneMonthFromNow':
filterVal = now.add(1, 'month')
break
case 'daysAgo':
if (!filterVal) return
filterVal = now.add(-filterVal, 'day')
break
case 'daysFromNow':
if (!filterVal) return
filterVal = now.add(filterVal, 'day')
break
case 'exactDate':
if (!filterVal) return
break
// sub-ops for `isWithin` comparison
case 'pastWeek':
filterVal = now.add(-1, 'week')
break
case 'pastMonth':
filterVal = now.add(-1, 'month')
break
case 'pastYear':
filterVal = now.add(-1, 'year')
break
case 'nextWeek':
filterVal = now.add(1, 'week')
break
case 'nextMonth':
filterVal = now.add(1, 'month')
break
case 'nextYear':
filterVal = now.add(1, 'year')
break
case 'pastNumberOfDays':
if (!filterVal) return
filterVal = now.add(-filterVal, 'day')
break
case 'nextNumberOfDays':
if (!filterVal) return
filterVal = now.add(filterVal, 'day')
break
}
if (dataVal) {
switch (filter.comparison_op) {
case 'eq':
res = dayjs(dataVal).isSame(filterVal, 'day')
break
case 'neq':
res = !dayjs(dataVal).isSame(filterVal, 'day')
break
case 'gt':
res = dayjs(dataVal).isAfter(filterVal, 'day')
break
case 'lt':
res = dayjs(dataVal).isBefore(filterVal, 'day')
break
case 'lte':
case 'le':
res = dayjs(dataVal).isSameOrBefore(filterVal, 'day')
break
case 'gte':
case 'ge':
res = dayjs(dataVal).isSameOrAfter(filterVal, 'day')
break
case 'empty':
case 'blank':
res = dataVal === '' || dataVal === null || dataVal === undefined
break
case 'notempty':
case 'notblank':
res = !(dataVal === '' || dataVal === null || dataVal === undefined)
break
case 'isWithin': {
let now = dayjs(new Date()).format(dateFormat).toString()
now = column.uidt === UITypes.Date ? now.substring(0, 10) : now
switch (filter.comparison_sub_op) {
case 'pastWeek':
case 'pastMonth':
case 'pastYear':
case 'pastNumberOfDays':
res = dayjs(dataVal).isBetween(filterVal, now, 'day')
break
case 'nextWeek':
case 'nextMonth':
case 'nextYear':
case 'nextNumberOfDays':
res = dayjs(dataVal).isBetween(now, filterVal, 'day')
break
}
}
}
}
} else {
switch (typeof filter.value) {
case 'boolean':
val = !!data[field]
break
case 'number':
val = +data[field]
break
}
if ([UITypes.User, UITypes.CreatedBy, UITypes.LastModifiedBy].includes(column.uidt!)) {
const userIds: string[] = Array.isArray(data[field])
? data[field].map((user) => user.id)
: data[field]?.id
? [data[field].id]
: []
const filterValues = filter.value.split(',').map((v) => v.trim())
switch (filter.comparison_op) {
case 'anyof':
res = userIds.some((id) => filterValues.includes(id))
break
case 'nanyof':
res = !userIds.some((id) => filterValues.includes(id))
break
case 'allof':
res = filterValues.every((id) => userIds.includes(id))
break
case 'nallof':
res = !filterValues.every((id) => userIds.includes(id))
break
case 'empty':
case 'blank':
res = userIds.length === 0
break
case 'notempty':
case 'notblank':
res = userIds.length > 0
break
default:
res = false // Unsupported operation for User fields
}
} else {
switch (filter.comparison_op) {
case 'eq':
res = val == filter.value
break
case 'neq':
res = val != filter.value
break
case 'like':
res = data[field]?.toString?.()?.toLowerCase()?.indexOf(filter.value?.toLowerCase()) > -1
break
case 'nlike':
res = data[field]?.toString?.()?.toLowerCase()?.indexOf(filter.value?.toLowerCase()) === -1
break
case 'empty':
case 'blank':
res = data[field] === '' || data[field] === null || data[field] === undefined
break
case 'notempty':
case 'notblank':
res = !(data[field] === '' || data[field] === null || data[field] === undefined)
break
case 'checked':
res = !!data[field]
break
case 'notchecked':
res = !data[field]
break
case 'null':
res = res = data[field] === null
break
case 'notnull':
res = data[field] !== null
break
case 'allof':
res = (filter.value?.split(',').map((item) => item.trim()) ?? []).every((item) =>
(data[field]?.split(',') ?? []).includes(item),
)
break
case 'anyof':
res = (filter.value?.split(',').map((item) => item.trim()) ?? []).some((item) =>
(data[field]?.split(',') ?? []).includes(item),
)
break
case 'nallof':
res = !(filter.value?.split(',').map((item) => item.trim()) ?? []).every((item) =>
(data[field]?.split(',') ?? []).includes(item),
)
break
case 'nanyof':
res = !(filter.value?.split(',').map((item) => item.trim()) ?? []).some((item) =>
(data[field]?.split(',') ?? []).includes(item),
)
break
case 'lt':
res = +data[field] < +filter.value
break
case 'lte':
case 'le':
res = +data[field] <= +filter.value
break
case 'gt':
res = +data[field] > +filter.value
break
case 'gte':
case 'ge':
res = +data[field] >= +filter.value
break
}
}
}
}
switch (filter.logical_op) {
case 'or':
isValid = isValid || !!res
break
case 'not':
isValid = isValid && !res
break
case 'and':
default:
isValid = (isValid ?? true) && res
break
}
}
return isValid
}

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

@ -139,6 +139,7 @@ import NcCheckFill from '~icons/nc-icons/checkFill'
import NcExternalLink from '~icons/nc-icons/external-link' import NcExternalLink from '~icons/nc-icons/external-link'
import NcCamera from '~icons/nc-icons/camera' import NcCamera from '~icons/nc-icons/camera'
import NcRefreshCW from '~icons/nc-icons/refresh-cw' import NcRefreshCW from '~icons/nc-icons/refresh-cw'
import NcBulb from '~icons/nc-icons/bulb'
// import NcProjectGray from '~icons/nc-icons/project-gray' // import NcProjectGray from '~icons/nc-icons/project-gray'
import NcPhoneCall from '~icons/nc-icons/phone-call' import NcPhoneCall from '~icons/nc-icons/phone-call'
import NcItalic from '~icons/nc-icons/italic' import NcItalic from '~icons/nc-icons/italic'
@ -768,6 +769,7 @@ export const iconMap = {
camera: NcCamera, camera: NcCamera,
megaPhone: NcMegaPhone, megaPhone: NcMegaPhone,
nocodb: NcNocoDB, nocodb: NcNocoDB,
bulb: NcBulb,
office: NcOffice, office: NcOffice,
sort: Sort, sort: Sort,

185
packages/nc-gui/utils/sortUtils.ts

@ -1,4 +1,5 @@
import { UITypes } from 'nocodb-sdk' import { type ColumnType, type SortType, UITypes } from 'nocodb-sdk'
import dayjs from 'dayjs'
export const getSortDirectionOptions = (uidt: UITypes | string, isGroupBy?: boolean) => { export const getSortDirectionOptions = (uidt: UITypes | string, isGroupBy?: boolean) => {
const groupByOptions = isGroupBy const groupByOptions = isGroupBy
@ -40,3 +41,185 @@ export const getSortDirectionOptions = (uidt: UITypes | string, isGroupBy?: bool
].concat(groupByOptions) ].concat(groupByOptions)
} }
} }
export const sortByUIType = ({
uidt,
a,
b,
options: { caseSensitive = true, direction },
}: {
uidt: UITypes
a: any
b: any
options: {
caseSensitive?: boolean
direction?: 'asc' | 'desc' | 'count-asc' | 'count-desc'
}
}) => {
let nullsLast = direction !== 'asc'
if ([UITypes.Formula, UITypes.User].includes(uidt)) {
nullsLast = !nullsLast
}
if (a === null || a === undefined) {
return nullsLast ? 1 : -1
}
if (b === null || b === undefined) {
return nullsLast ? -1 : 1
}
if (a === '' && b !== '') return nullsLast ? 1 : -1
if (b === '' && a !== '') return nullsLast ? -1 : 1
let result = 0
switch (uidt) {
case UITypes.Number:
case UITypes.Decimal:
case UITypes.Currency:
case UITypes.Percent:
case UITypes.Rating:
case UITypes.Duration:
case UITypes.ID:
case UITypes.Rollup:
result = Number(a) - Number(b)
break
case UITypes.Links: {
const getLinksValue = (links: any) => {
if (links === null) return null
if (typeof links === 'number') return links
if (links && typeof links === 'object') {
return Object.values(links)[0]
}
return links
}
const valA = getLinksValue(a)
const valB = getLinksValue(b)
if (typeof valA === 'number' && typeof valB === 'number') {
result = valA - valB
} else {
result = String(valA).localeCompare(String(valB))
}
break
}
case UITypes.DateTime:
case UITypes.CreatedTime:
case UITypes.LastModifiedTime:
result = dayjs(a).valueOf() - dayjs(b).valueOf()
break
case UITypes.Time: {
const normalizeTimeValue = (value: any): dayjs.Dayjs => {
// If it's already a dayjs object
if (dayjs.isDayjs(value)) {
return dayjs(`1999-01-01 ${value.format('HH:mm:ss')}`)
}
// If it's a string in HH:mm:ss format (from server)
if (typeof value === 'string' && /^\d{2}:\d{2}:\d{2}$/.test(value)) {
return dayjs(`1999-01-01 ${value}`)
}
// If it's a string in HH:mm format (from local state)
if (typeof value === 'string' && /^\d{2}:\d{2}$/.test(value)) {
return dayjs(`1999-01-01 ${value}:00`)
}
// For any other format, try parsing with dayjs
let parsed = dayjs(value)
// If not valid, try parsing as time only
if (!parsed.isValid()) {
parsed = dayjs(value, 'HH:mm:ss')
}
// If still not valid, try with dummy date
if (!parsed.isValid()) {
parsed = dayjs(`1999-01-01 ${value}`)
}
return parsed
}
const timeA = normalizeTimeValue(a)
const timeB = normalizeTimeValue(b)
result = timeA.valueOf() - timeB.valueOf()
break
}
case UITypes.Year:
result = Number(a) - Number(b)
break
case UITypes.Checkbox:
result = a === b ? 0 : a ? -1 : 1
break
case UITypes.SingleSelect:
case UITypes.MultiSelect:
result = String(a).localeCompare(String(b))
break
case UITypes.Attachment: {
const getAttachmentValue = (att) => {
if (Array.isArray(att) && att.length > 0) {
return att[0].title || att[0].path || ''
}
return ''
}
result = getAttachmentValue(a).localeCompare(getAttachmentValue(b))
break
}
case UITypes.User:
case UITypes.CreatedBy:
case UITypes.LastModifiedBy: {
const getUserValue = (user) => {
if (Array.isArray(user) && user.length > 0) {
return user[0].display_name || user[0].email || ''
}
if (user && typeof user === 'object') {
return user.display_name || user.email || ''
}
return String(user)
}
result = getUserValue(a).localeCompare(getUserValue(b))
break
}
case UITypes.SingleLineText:
case UITypes.LongText:
case UITypes.Email:
case UITypes.URL:
case UITypes.PhoneNumber:
case UITypes.Formula:
if (caseSensitive) {
result = String(a).localeCompare(String(b))
} else {
result = String(a).toLowerCase().localeCompare(String(b).toLowerCase())
}
break
case UITypes.JSON:
result = JSON.stringify(a).localeCompare(JSON.stringify(b))
break
default:
result = String(a).localeCompare(String(b))
}
return direction === 'desc' ? -result : result
}
export const isSortRelevantChange = (
changedFields: string[],
sorts: SortType[],
columnsById: Record<string, ColumnType>,
): boolean => {
const sortColumnTitles = new Set(sorts.map((sort) => columnsById[sort.fk_column_id!]?.title).filter(Boolean))
return changedFields.some((field) => sortColumnTitles.has(field))
}

7
packages/nocodb-sdk/src/lib/errorUtils.ts

@ -0,0 +1,7 @@
export class NcSDKError extends Error {
constructor(message: string) {
super(message);
}
}
export class BadRequest extends NcSDKError {}

320
packages/nocodb-sdk/src/lib/filterHelpers.ts

@ -0,0 +1,320 @@
import { ColumnType, FilterType } from '~/lib/Api';
import { UITypes } from '~/lib/index';
import { BadRequest, NcSDKError } from '~/lib/errorUtils';
/**
* Converts a flat array of filter objects into a nested tree structure
* @param {FilterType[]} items - Array of filter objects
* @returns {FilterType[]} - Nested tree structure
*/
export function buildFilterTree(items: FilterType[]) {
const itemMap = new Map();
const rootItems: FilterType[] = [];
// Map items with IDs and handle items without IDs
items.forEach((item) => {
if (item.id) {
itemMap.set(item.id, { ...item, children: [] });
} else {
// Items without IDs go straight to root level
rootItems.push({ ...item, children: [] });
}
});
// Build parent-child relationships for items with IDs
items.forEach((item) => {
// Skip items without IDs as they're already in rootItems
if (!item.id) return;
const mappedItem = itemMap.get(item.id);
if (item.fk_parent_id === null) {
rootItems.push(mappedItem);
} else {
const parent = itemMap.get(item.fk_parent_id);
if (parent) {
parent.children.push(mappedItem);
} else {
// If parent is not found, treat as root item
rootItems.push(mappedItem);
}
}
});
return rootItems;
}
export const GROUPBY_COMPARISON_OPS = <const>[
// these are used for groupby
'gb_eq',
'gb_null',
];
export const COMPARISON_OPS = <const>[
'eq',
'neq',
'not',
'like',
'nlike',
'empty',
'notempty',
'null',
'notnull',
'checked',
'notchecked',
'blank',
'notblank',
'allof',
'anyof',
'nallof',
'nanyof',
'gt',
'lt',
'gte',
'lte',
'ge',
'le',
'in',
'isnot',
'is',
'isWithin',
'btw',
'nbtw',
];
export const IS_WITHIN_COMPARISON_SUB_OPS = <const>[
'pastWeek',
'pastMonth',
'pastYear',
'nextWeek',
'nextMonth',
'nextYear',
'pastNumberOfDays',
'nextNumberOfDays',
];
export const COMPARISON_SUB_OPS = <const>[
'today',
'tomorrow',
'yesterday',
'oneWeekAgo',
'oneWeekFromNow',
'oneMonthAgo',
'oneMonthFromNow',
'daysAgo',
'daysFromNow',
'exactDate',
...IS_WITHIN_COMPARISON_SUB_OPS,
];
export function extractFilterFromXwhere(
str: string | string[],
aliasColObjMap: { [columnAlias: string]: ColumnType },
throwErrorIfInvalid = false
): FilterType[] {
if (!str) {
return [];
}
// if array treat it as `and` group
if (Array.isArray(str)) {
// calling recursively for nested query
const nestedFilters = [].concat(
...str.map((s) =>
extractFilterFromXwhere(s, aliasColObjMap, throwErrorIfInvalid)
)
);
// If there's only one filter, return it directly
if (nestedFilters.length === 1) {
return nestedFilters;
}
// Otherwise, wrap it in an AND group
return [
{
is_group: true,
logical_op: 'and',
children: nestedFilters,
},
];
} else if (typeof str !== 'string' && throwErrorIfInvalid) {
throw new Error(
'Invalid filter format. Expected string or array of strings.'
);
}
let nestedArrayConditions = [];
let openIndex = str.indexOf('((');
if (openIndex === -1) openIndex = str.indexOf('(~');
let nextOpenIndex = openIndex;
let closingIndex = str.indexOf('))');
// if it's a simple query simply return array of conditions
if (openIndex === -1) {
if (str && str != '~not')
nestedArrayConditions = str.split(
/(?=~(?:or(?:not)?|and(?:not)?|not)\()/
);
return extractCondition(
nestedArrayConditions || [],
aliasColObjMap,
throwErrorIfInvalid
);
}
// iterate until finding right closing
while (
(nextOpenIndex = str
.substring(0, closingIndex)
.indexOf('((', nextOpenIndex + 1)) != -1
) {
closingIndex = str.indexOf('))', closingIndex + 1);
}
if (closingIndex === -1)
throw new Error(
`${str
.substring(0, openIndex + 1)
.slice(-10)} : Closing bracket not found`
);
// getting operand starting index
const operandStartIndex = str.lastIndexOf('~', openIndex);
const operator =
operandStartIndex != -1
? str.substring(operandStartIndex + 1, openIndex)
: '';
const lhsOfNestedQuery = str.substring(0, openIndex);
nestedArrayConditions.push(
...extractFilterFromXwhere(
lhsOfNestedQuery,
aliasColObjMap,
throwErrorIfInvalid
),
// calling recursively for nested query
{
is_group: true,
logical_op: operator,
children: extractFilterFromXwhere(
str.substring(openIndex + 1, closingIndex + 1),
aliasColObjMap
),
},
// RHS of nested query(recursion)
...extractFilterFromXwhere(
str.substring(closingIndex + 2),
aliasColObjMap,
throwErrorIfInvalid
)
);
return nestedArrayConditions;
}
// mark `op` and `sub_op` any for being assignable to parameter of type
export function validateFilterComparison(uidt: UITypes, op: any, sub_op?: any) {
if (!COMPARISON_OPS.includes(op) && !GROUPBY_COMPARISON_OPS.includes(op)) {
throw new BadRequest(`${op} is not supported.`);
}
if (sub_op) {
if (
![
UITypes.Date,
UITypes.DateTime,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
].includes(uidt)
) {
throw new BadRequest(
`'${sub_op}' is not supported for UI Type'${uidt}'.`
);
}
if (!COMPARISON_SUB_OPS.includes(sub_op)) {
throw new BadRequest(`'${sub_op}' is not supported.`);
}
if (
(op === 'isWithin' && !IS_WITHIN_COMPARISON_SUB_OPS.includes(sub_op)) ||
(op !== 'isWithin' && IS_WITHIN_COMPARISON_SUB_OPS.includes(sub_op))
) {
throw new BadRequest(`'${sub_op}' is not supported for '${op}'`);
}
}
}
export function extractCondition(
nestedArrayConditions,
aliasColObjMap,
throwErrorIfInvalid
) {
return nestedArrayConditions?.map((str) => {
let [logicOp, alias, op, value] =
str.match(/(?:~(and|or|not))?\((.*?),(\w+),(.*)\)/)?.slice(1) || [];
if (!alias && !op && !value) {
// try match with blank filter format
[logicOp, alias, op, value] =
str.match(/(?:~(and|or|not))?\((.*?),(\w+)\)/)?.slice(1) || [];
}
// handle isblank and isnotblank filter format
switch (op) {
case 'is':
if (value === 'blank') {
op = 'blank';
value = undefined;
} else if (value === 'notblank') {
op = 'notblank';
value = undefined;
}
break;
case 'isblank':
case 'is_blank':
op = 'blank';
break;
case 'isnotblank':
case 'is_not_blank':
case 'is_notblank':
op = 'notblank';
break;
}
let sub_op = null;
if (aliasColObjMap[alias]) {
if (
[
UITypes.Date,
UITypes.DateTime,
UITypes.LastModifiedTime,
UITypes.CreatedTime,
].includes(aliasColObjMap[alias].uidt)
) {
value = value?.split(',');
// the first element would be sub_op
sub_op = value?.[0];
// remove the first element which is sub_op
value?.shift();
value = value?.[0];
} else if (op === 'in') {
value = value.split(',');
}
validateFilterComparison(aliasColObjMap[alias].uidt, op, sub_op);
} else if (throwErrorIfInvalid) {
throw new NcSDKError('INVALID_FILTER');
}
return {
comparison_op: op,
...(sub_op && { comparison_sub_op: sub_op }),
fk_column_id: aliasColObjMap[alias]?.id,
logical_op: logicOp,
value,
};
});
}

2
packages/nocodb-sdk/src/lib/index.ts

@ -32,3 +32,5 @@ export * from '~/lib/dateTimeHelper';
export * from '~/lib/form'; export * from '~/lib/form';
export * from '~/lib/aggregationHelper'; export * from '~/lib/aggregationHelper';
export * from '~/lib/connectionConfigUtils'; export * from '~/lib/connectionConfigUtils';
export * from '~/lib/filterHelpers';
export * from '~/lib/errorUtils';

14
packages/nocodb/src/controllers/public-datas.controller.ts

@ -43,6 +43,20 @@ export class PublicDatasController {
return pagedResponse; return pagedResponse;
} }
@Get(['/api/v2/public/shared-view/:sharedViewUuid/count'])
async dataCount(
@TenantContext() context: NcContext,
@Req() req: NcRequest,
@Param('sharedViewUuid') sharedViewUuid: string,
) {
const pagedResponse = await this.publicDatasService.dataCount(context, {
query: req.query,
password: req.headers?.['xc-password'] as string,
sharedViewUuid,
});
return pagedResponse;
}
@Get([ @Get([
'/api/v1/db/public/shared-view/:sharedViewUuid/aggregate', '/api/v1/db/public/shared-view/:sharedViewUuid/aggregate',
'/api/v2/public/shared-view/:sharedViewUuid/aggregate', '/api/v2/public/shared-view/:sharedViewUuid/aggregate',

202
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -8,6 +8,7 @@ import equal from 'fast-deep-equal';
import { import {
AuditOperationSubTypes, AuditOperationSubTypes,
AuditOperationTypes, AuditOperationTypes,
extractFilterFromXwhere,
isCreatedOrLastModifiedByCol, isCreatedOrLastModifiedByCol,
isCreatedOrLastModifiedTimeCol, isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR, isLinksOrLTAR,
@ -66,12 +67,6 @@ import getAst from '~/helpers/getAst';
import { sanitize, unsanitize } from '~/helpers/sqlSanitize'; import { sanitize, unsanitize } from '~/helpers/sqlSanitize';
import Noco from '~/Noco'; import Noco from '~/Noco';
import { HANDLE_WEBHOOK } from '~/services/hook-handler.service'; import { HANDLE_WEBHOOK } from '~/services/hook-handler.service';
import {
COMPARISON_OPS,
COMPARISON_SUB_OPS,
GROUPBY_COMPARISON_OPS,
IS_WITHIN_COMPARISON_SUB_OPS,
} from '~/utils/globals';
import { extractProps } from '~/helpers/extractProps'; import { extractProps } from '~/helpers/extractProps';
import { defaultLimitConfig } from '~/helpers/extractLimitAndOffset'; import { defaultLimitConfig } from '~/helpers/extractLimitAndOffset';
import generateLookupSelectQuery from '~/db/generateLookupSelectQuery'; import generateLookupSelectQuery from '~/db/generateLookupSelectQuery';
@ -9932,201 +9927,6 @@ export function extractSortsObject(
}); });
} }
export function extractFilterFromXwhere(
str,
aliasColObjMap: { [columnAlias: string]: Column },
throwErrorIfInvalid = false,
) {
if (!str) {
return [];
}
// if array treat it as `and` group
if (Array.isArray(str)) {
// calling recursively for nested query
return str.map((s) =>
extractFilterFromXwhere(s, aliasColObjMap, throwErrorIfInvalid),
);
} else if (typeof str !== 'string' && throwErrorIfInvalid) {
throw new Error(
'Invalid filter format. Expected string or array of strings.',
);
}
let nestedArrayConditions = [];
let openIndex = str.indexOf('((');
if (openIndex === -1) openIndex = str.indexOf('(~');
let nextOpenIndex = openIndex;
let closingIndex = str.indexOf('))');
// if it's a simple query simply return array of conditions
if (openIndex === -1) {
if (str && str != '~not')
nestedArrayConditions = str.split(
/(?=~(?:or(?:not)?|and(?:not)?|not)\()/,
);
return extractCondition(
nestedArrayConditions || [],
aliasColObjMap,
throwErrorIfInvalid,
);
}
// iterate until finding right closing
while (
(nextOpenIndex = str
.substring(0, closingIndex)
.indexOf('((', nextOpenIndex + 1)) != -1
) {
closingIndex = str.indexOf('))', closingIndex + 1);
}
if (closingIndex === -1)
throw new Error(
`${str
.substring(0, openIndex + 1)
.slice(-10)} : Closing bracket not found`,
);
// getting operand starting index
const operandStartIndex = str.lastIndexOf('~', openIndex);
const operator =
operandStartIndex != -1
? str.substring(operandStartIndex + 1, openIndex)
: '';
const lhsOfNestedQuery = str.substring(0, openIndex);
nestedArrayConditions.push(
...extractFilterFromXwhere(
lhsOfNestedQuery,
aliasColObjMap,
throwErrorIfInvalid,
),
// calling recursively for nested query
new Filter({
is_group: true,
logical_op: operator,
children: extractFilterFromXwhere(
str.substring(openIndex + 1, closingIndex + 1),
aliasColObjMap,
),
}),
// RHS of nested query(recursion)
...extractFilterFromXwhere(
str.substring(closingIndex + 2),
aliasColObjMap,
throwErrorIfInvalid,
),
);
return nestedArrayConditions;
}
// mark `op` and `sub_op` any for being assignable to parameter of type
function validateFilterComparison(uidt: UITypes, op: any, sub_op?: any) {
if (!COMPARISON_OPS.includes(op) && !GROUPBY_COMPARISON_OPS.includes(op)) {
NcError.badRequest(`${op} is not supported.`);
}
if (sub_op) {
if (
![
UITypes.Date,
UITypes.DateTime,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
].includes(uidt)
) {
NcError.badRequest(`'${sub_op}' is not supported for UI Type'${uidt}'.`);
}
if (!COMPARISON_SUB_OPS.includes(sub_op)) {
NcError.badRequest(`'${sub_op}' is not supported.`);
}
if (
(op === 'isWithin' && !IS_WITHIN_COMPARISON_SUB_OPS.includes(sub_op)) ||
(op !== 'isWithin' && IS_WITHIN_COMPARISON_SUB_OPS.includes(sub_op))
) {
NcError.badRequest(`'${sub_op}' is not supported for '${op}'`);
}
}
}
export function extractCondition(
nestedArrayConditions,
aliasColObjMap,
throwErrorIfInvalid,
) {
return nestedArrayConditions?.map((str) => {
let [logicOp, alias, op, value] =
str.match(/(?:~(and|or|not))?\((.*?),(\w+),(.*)\)/)?.slice(1) || [];
if (!alias && !op && !value) {
// try match with blank filter format
[logicOp, alias, op, value] =
str.match(/(?:~(and|or|not))?\((.*?),(\w+)\)/)?.slice(1) || [];
}
// handle isblank and isnotblank filter format
switch (op) {
case 'is':
if (value === 'blank') {
op = 'blank';
value = undefined;
} else if (value === 'notblank') {
op = 'notblank';
value = undefined;
}
break;
case 'isblank':
case 'is_blank':
op = 'blank';
break;
case 'isnotblank':
case 'is_not_blank':
case 'is_notblank':
op = 'notblank';
break;
}
let sub_op = null;
if (aliasColObjMap[alias]) {
if (
[
UITypes.Date,
UITypes.DateTime,
UITypes.LastModifiedTime,
UITypes.CreatedTime,
].includes(aliasColObjMap[alias].uidt)
) {
value = value?.split(',');
// the first element would be sub_op
sub_op = value?.[0];
// remove the first element which is sub_op
value?.shift();
value = value?.[0];
} else if (op === 'in') {
value = value.split(',');
}
validateFilterComparison(aliasColObjMap[alias].uidt, op, sub_op);
} else if (throwErrorIfInvalid) {
NcError.invalidFilter(str);
}
return new Filter({
comparison_op: op,
...(sub_op && { comparison_sub_op: sub_op }),
fk_column_id: aliasColObjMap[alias]?.id,
logical_op: logicOp,
value,
});
});
}
function applyPaginate( function applyPaginate(
query, query,
{ {

5
packages/nocodb/src/db/conditionV2.ts

@ -7,6 +7,7 @@ import {
UITypes, UITypes,
} from 'nocodb-sdk'; } from 'nocodb-sdk';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { FilterType } from 'nocodb-sdk';
// import customParseFormat from 'dayjs/plugin/customParseFormat.js'; // import customParseFormat from 'dayjs/plugin/customParseFormat.js';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2'; import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type LinkToAnotherRecordColumn from '~/models/LinkToAnotherRecordColumn'; import type LinkToAnotherRecordColumn from '~/models/LinkToAnotherRecordColumn';
@ -31,7 +32,7 @@ import { type BarcodeColumn, BaseUser, type QrCodeColumn } from '~/models';
export default async function conditionV2( export default async function conditionV2(
baseModelSqlv2: BaseModelSqlv2, baseModelSqlv2: BaseModelSqlv2,
conditionObj: Filter | Filter[], conditionObj: Filter | FilterType | FilterType[] | Filter[],
qb: Knex.QueryBuilder, qb: Knex.QueryBuilder,
alias?: string, alias?: string,
throwErrorIfInvalid = false, throwErrorIfInvalid = false,
@ -66,7 +67,7 @@ function getLogicalOpMethod(filter: Filter) {
const parseConditionV2 = async ( const parseConditionV2 = async (
baseModelSqlv2: BaseModelSqlv2, baseModelSqlv2: BaseModelSqlv2,
_filter: Filter | Filter[], _filter: Filter | FilterType | FilterType[] | Filter[],
aliasCount = { count: 0 }, aliasCount = { count: 0 },
alias?, alias?,
customWhereClause?, customWhereClause?,

14
packages/nocodb/src/filters/global-exception/global-exception.filter.ts

@ -2,7 +2,11 @@ import { Catch, Logger, NotFoundException, Optional } from '@nestjs/common';
import { InjectSentry, SentryService } from '@ntegral/nestjs-sentry'; import { InjectSentry, SentryService } from '@ntegral/nestjs-sentry';
import { ThrottlerException } from '@nestjs/throttler'; import { ThrottlerException } from '@nestjs/throttler';
import hash from 'object-hash'; import hash from 'object-hash';
import { NcErrorType } from 'nocodb-sdk'; import {
NcErrorType,
NcSDKError,
BadRequest as SdkBadRequest,
} from 'nocodb-sdk';
import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common'; import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import NocoCache from '~/cache/NocoCache'; import NocoCache from '~/cache/NocoCache';
@ -64,6 +68,8 @@ export class GlobalExceptionFilter implements ExceptionFilter {
exception instanceof NotFoundException || exception instanceof NotFoundException ||
exception instanceof ThrottlerException || exception instanceof ThrottlerException ||
exception instanceof ExternalError || exception instanceof ExternalError ||
exception instanceof SdkBadRequest ||
exception instanceof NcSDKError ||
(exception instanceof NcBaseErrorv2 && (exception instanceof NcBaseErrorv2 &&
![ ![
NcErrorType.INTERNAL_SERVER_ERROR, NcErrorType.INTERNAL_SERVER_ERROR,
@ -185,7 +191,11 @@ export class GlobalExceptionFilter implements ExceptionFilter {
return response return response
.status(400) .status(400)
.json({ msg: exception.message, errors: exception.errors }); .json({ msg: exception.message, errors: exception.errors });
} else if (exception instanceof UnprocessableEntity) { } else if (
exception instanceof UnprocessableEntity ||
exception instanceof SdkBadRequest ||
exception instanceof NcSDKError
) {
return response.status(422).json({ msg: exception.message }); return response.status(422).json({ msg: exception.message });
} else if (exception instanceof TestConnectionError) { } else if (exception instanceof TestConnectionError) {
return response return response

8
packages/nocodb/src/models/Filter.ts

@ -1,6 +1,10 @@
import { UITypes } from 'nocodb-sdk'; import { UITypes } from 'nocodb-sdk';
import type { BoolType, FilterType } from 'nocodb-sdk'; import type {
import type { COMPARISON_OPS, COMPARISON_SUB_OPS } from '~/utils/globals'; BoolType,
COMPARISON_OPS,
COMPARISON_SUB_OPS,
FilterType,
} from 'nocodb-sdk';
import type { NcContext } from '~/interface/config'; import type { NcContext } from '~/interface/config';
import Model from '~/models/Model'; import Model from '~/models/Model';
import Column from '~/models/Column'; import Column from '~/models/Column';

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

@ -12568,6 +12568,81 @@
"description": "Get the table rows but exculding the current record's children and parent" "description": "Get the table rows but exculding the current record's children and parent"
} }
}, },
"/api/v2/public/shared-view/{sharedViewUuid}/count": {
"parameters": [
{
"schema": {
"type": "string",
"example": "24a6d0bb-e45d-4b1a-bfef-f492d870de9f"
},
"name": "sharedViewUuid",
"in": "path",
"required": true,
"description": "Shared View UUID"
},
{
"schema": {
"type": "string"
},
"in": "header",
"name": "xc-password",
"description": "Shared view password"
}
],
"get": {
"summary": "Count Table View Rows",
"operationId": "public-db-view-row-count",
"description": "Count how many rows in the given Table View",
"tags": [
"Public"
],
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where"
},
{
"schema": {},
"in": "query",
"name": "nested",
"description": "Query params for nested data"
},
{
"$ref": "#/components/parameters/xc-auth"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"count": {
"type": "number",
"x-stoplight": {
"id": "hwq29x70rcipi"
}
}
}
},
"examples": {
"Example 1": {
"value": {
"count": 25
}
}
}
}
}
}
}
}
},
"/api/v2/public/shared-view/{sharedViewUuid}/bulk/dataList": { "/api/v2/public/shared-view/{sharedViewUuid}/bulk/dataList": {
"parameters": [ "parameters": [
{ {

49
packages/nocodb/src/services/public-datas.service.ts

@ -100,6 +100,55 @@ export class PublicDatasService {
return new PagedResponseImpl(data, { ...param.query, count }); return new PagedResponseImpl(data, { ...param.query, count });
} }
async dataCount(
context: NcContext,
param: {
sharedViewUuid: string;
password?: string;
query: any;
},
) {
const { sharedViewUuid, password, query = {} } = param;
const view = await View.getByUUID(context, sharedViewUuid);
if (!view) NcError.viewNotFound(sharedViewUuid);
if (
view.type !== ViewTypes.GRID &&
view.type !== ViewTypes.KANBAN &&
view.type !== ViewTypes.GALLERY &&
view.type !== ViewTypes.MAP &&
view.type !== ViewTypes.CALENDAR
) {
NcError.notFound('Not found');
}
if (view.password && view.password !== password) {
return NcError.invalidSharedViewPassword();
}
const model = await Model.getByIdOrName(context, {
id: view?.fk_model_id,
});
const source = await Source.get(context, model.source_id);
const baseModel = await Model.getBaseModelSQL(context, {
id: model.id,
viewId: view?.id,
dbDriver: await NcConnectionMgrv2.get(source),
source,
});
const countArgs: any = { ...param.query, throwErrorIfInvalidParams: true };
try {
countArgs.filterArr = JSON.parse(countArgs.filterArrJson);
} catch (e) {}
const count: number = await baseModel.count(countArgs);
return { count };
}
async dataAggregate( async dataAggregate(
context: NcContext, context: NcContext,
param: { param: {

62
packages/nocodb/src/utils/globals.ts

@ -201,68 +201,6 @@ export enum CacheDelDirection {
CHILD_TO_PARENT = 'CHILD_TO_PARENT', CHILD_TO_PARENT = 'CHILD_TO_PARENT',
} }
export const GROUPBY_COMPARISON_OPS = <const>[
// these are used for groupby
'gb_eq',
'gb_null',
];
export const COMPARISON_OPS = <const>[
'eq',
'neq',
'not',
'like',
'nlike',
'empty',
'notempty',
'null',
'notnull',
'checked',
'notchecked',
'blank',
'notblank',
'allof',
'anyof',
'nallof',
'nanyof',
'gt',
'lt',
'gte',
'lte',
'ge',
'le',
'in',
'isnot',
'is',
'isWithin',
'btw',
'nbtw',
];
export const IS_WITHIN_COMPARISON_SUB_OPS = <const>[
'pastWeek',
'pastMonth',
'pastYear',
'nextWeek',
'nextMonth',
'nextYear',
'pastNumberOfDays',
'nextNumberOfDays',
];
export const COMPARISON_SUB_OPS = <const>[
'today',
'tomorrow',
'yesterday',
'oneWeekAgo',
'oneWeekFromNow',
'oneMonthAgo',
'oneMonthFromNow',
'daysAgo',
'daysFromNow',
'exactDate',
...IS_WITHIN_COMPARISON_SUB_OPS,
];
export const DB_TYPES = <const>[ export const DB_TYPES = <const>[
'mysql2', 'mysql2',
'sqlite3', 'sqlite3',

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

@ -547,13 +547,9 @@ export class ColumnPageObject extends BasePage {
await columnHdr.locator('.nc-ui-dt-dropdown:visible').click(); await columnHdr.locator('.nc-ui-dt-dropdown:visible').click();
} }
// select all menu access
await expect(
await this.grid.get().locator('[data-testid="nc-check-all"]').locator('input[type="checkbox"]')
).toHaveCount(role === 'creator' || role === 'owner' || role === 'editor' ? 1 : 0);
if (role === 'creator' || role === 'owner' || role === 'editor') { if (role === 'creator' || role === 'owner' || role === 'editor') {
await this.grid.selectAll(); await this.grid.selectRow(0);
await this.grid.selectRow(1);
await this.grid.openAllRowContextMenu(); await this.grid.openAllRowContextMenu();
await this.rootPage.locator('.nc-dropdown-grid-context-menu').waitFor({ state: 'visible' }); await this.rootPage.locator('.nc-dropdown-grid-context-menu').waitFor({ state: 'visible' });
await expect(this.rootPage.locator('.nc-dropdown-grid-context-menu')).toHaveCount(1); await expect(this.rootPage.locator('.nc-dropdown-grid-context-menu')).toHaveCount(1);

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

@ -375,37 +375,13 @@ export class GridPage extends BasePage {
await expect(this.get().locator(`.nc-pagination .total`)).toHaveText(count); await expect(this.get().locator(`.nc-pagination .total`)).toHaveText(count);
} }
async clickPagination({ async clickPagination(_params: { type: 'first-page' | 'last-page' | 'next-page' | 'prev-page'; skipWait?: boolean }) {
type, // No longer required due to implementation of InfiniteScroll
skipWait = false,
}: {
type: 'first-page' | 'last-page' | 'next-page' | 'prev-page';
skipWait?: boolean;
}) {
if (await this.get().locator('.nc-pagination').isHidden()) return;
if (!skipWait) {
await this.get().locator(`.nc-pagination .${type}`).click();
await this.waitLoading();
} else {
await this.waitForResponse({
uiAction: async () => (await this.get().locator(`.nc-pagination .${type}`)).click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: '/views/',
responseJsonMatcher: resJson => resJson?.pageInfo,
});
await this.waitLoading();
}
}
async verifyActivePage({ pageNumber }: { pageNumber: string }) {
if (await this.get().locator('.nc-pagination').isHidden()) {
expect(1).toBe(+pageNumber);
return; return;
} }
await expect(this.get().locator(`.nc-pagination .nc-current-page`).first()).toHaveText(pageNumber); async verifyActivePage(_params: { pageNumber: string }) {
return;
} }
async waitLoading() { async waitLoading() {

2
tests/playwright/pages/Dashboard/common/Toolbar/SearchData.ts

@ -16,7 +16,7 @@ export class ToolbarSearchDataPage extends BasePage {
async verify(query: string) { async verify(query: string) {
const searchEnableBtn = await this.rootPage const searchEnableBtn = await this.rootPage
.waitForSelector('[data-testid="nc-global-search-show-input"]', { timeout: 100 }) .waitForSelector('[data-testid="nc-global-search-show-input"]', { timeout: 1000 })
.catch(() => null); .catch(() => null);
if (searchEnableBtn) { if (searchEnableBtn) {

7
tests/playwright/quickTests/commonTest.ts

@ -193,13 +193,6 @@ const quickVerify = async ({
await dashboard.webhookForm.close(); await dashboard.webhookForm.close();
} }
// Verify pagination
await dashboard.grid.verifyActivePage({ pageNumber: '1' });
await dashboard.grid.clickPagination({ type: 'next-page', skipWait: true });
await dashboard.grid.verifyActivePage({ pageNumber: '2' });
await dashboard.grid.clickPagination({ type: 'prev-page', skipWait: true });
await dashboard.grid.verifyActivePage({ pageNumber: '1' });
await dashboard.viewSidebar.openView({ title: 'Filter&Sort' }); await dashboard.viewSidebar.openView({ title: 'Filter&Sort' });
// Verify Fields, Filter & Sort // Verify Fields, Filter & Sort

7
tests/playwright/tests/db/columns/columnMenuOperations.spec.ts

@ -85,11 +85,10 @@ test.describe('Column menu operations', () => {
insertAfterColumnTitle: 'Title', insertAfterColumnTitle: 'Title',
}); });
await dashboard.grid.toolbar.fields.toggle({ title: 'Actors', isLocallySaved: false, checked: true });
await dashboard.grid.column.create({ await dashboard.grid.column.create({
title: 'InsertAfterColumn1', title: 'InsertAfterColumn1',
type: 'SingleLineText', type: 'SingleLineText',
insertAfterColumnTitle: 'Actors', insertAfterColumnTitle: 'Title',
}); });
await dashboard.closeTab({ title: 'Film' }); await dashboard.closeTab({ title: 'Film' });
@ -109,7 +108,7 @@ test.describe('Column menu operations', () => {
await dashboard.grid.column.create({ await dashboard.grid.column.create({
title: 'InsertBeforeColumn1', title: 'InsertBeforeColumn1',
type: 'SingleLineText', type: 'SingleLineText',
insertBeforeColumnTitle: 'Actors', insertBeforeColumnTitle: 'ReleaseYear',
}); });
await dashboard.closeTab({ title: 'Film' }); await dashboard.closeTab({ title: 'Film' });
@ -125,7 +124,7 @@ test.describe('Column menu operations', () => {
await dashboard.grid.toolbar.fields.toggle({ title: 'Actors', isLocallySaved: false, checked: true }); await dashboard.grid.toolbar.fields.toggle({ title: 'Actors', isLocallySaved: false, checked: true });
await dashboard.grid.column.hideColumn({ await dashboard.grid.column.hideColumn({
title: 'Actors', title: 'RentalDuration',
}); });
await dashboard.closeTab({ title: 'Film' }); await dashboard.closeTab({ title: 'Film' });

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

@ -113,13 +113,16 @@ test.describe('Verify shortcuts', () => {
await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'Country' }); await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'Country' });
// Cmd + up arrow // Cmd + up arrow
await grid.cell.click({ index: 24, columnHeader: 'Country' }); await grid.cell.click({ index: 10, columnHeader: 'Country' });
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+ArrowUp' : 'Control+ArrowUp'); await page.keyboard.press((await grid.isMacOs()) ? 'Meta+ArrowUp' : 'Control+ArrowUp');
await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'Country' }); await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'Country' });
// Cmd + down arrow // Cmd + down arrow
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+ArrowDown' : 'Control+ArrowDown'); await page.keyboard.press((await grid.isMacOs()) ? 'Meta+ArrowDown' : 'Control+ArrowDown');
await grid.cell.verifyCellActiveSelected({ index: 24, columnHeader: 'Country' });
await grid.cell.verifyCellActiveSelected({ index: 108, columnHeader: 'Country' });
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+ArrowUp' : 'Control+ArrowUp');
// Enter to edit and Esc to cancel // Enter to edit and Esc to cancel
await grid.cell.click({ index: 0, columnHeader: 'Country' }); await grid.cell.click({ index: 0, columnHeader: 'Country' });
@ -139,60 +142,20 @@ test.describe('Verify shortcuts', () => {
await page.reload(); await page.reload();
await grid.cell.verify({ index: 1, columnHeader: 'Country', value: 'NewAlgeria' }); await grid.cell.verify({ index: 1, columnHeader: 'Country', value: 'NewAlgeria' });
// Tab: await grid.cell.click({ index: 14, columnHeader: 'Cities' });
// If current page is not last page and and current cell is last column of last row and user press `Tab` then current page will be incremented by 1
await grid.cell.click({ index: 24, columnHeader: 'Cities' });
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
await grid.verifyActivePage({ pageNumber: '2' });
await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'Country' });
// If current page is last page and and current cell is last column of last row and user press `Tab` then new empty row will be added await grid.cell.click({ index: 15, columnHeader: 'Cities' });
await grid.clickPagination({ type: 'last-page' });
await grid.cell.click({ index: 8, columnHeader: 'Cities' });
await page.keyboard.press('Tab'); await page.keyboard.press('Tab');
await grid.verifyRowCount({ count: 10 }); await grid.cell.verifyCellActiveSelected({ index: 16, columnHeader: 'Country' });
await grid.cell.verifyCellActiveSelected({ index: 9, columnHeader: 'Country' });
// If current page is not first page and and current cell is first column of first row and user press `Shift+Tab` then current page will be decremented by 1 await grid.cell.click({ index: 15, columnHeader: 'Country' });
await grid.cell.click({ index: 0, columnHeader: 'Country' });
await page.keyboard.press('Shift+Tab'); await page.keyboard.press('Shift+Tab');
await grid.verifyActivePage({ pageNumber: '4' }); await grid.cell.verifyCellActiveSelected({ index: 14, columnHeader: 'Cities' });
await grid.cell.verifyCellActiveSelected({ index: 24, columnHeader: 'Cities' });
// If current page is first page and and current cell is first column of first row and user press `Shift+Tab` then current page will not change
await grid.clickPagination({ type: 'first-page' });
await grid.cell.click({ index: 0, columnHeader: 'Country' }); await grid.cell.click({ index: 0, columnHeader: 'Country' });
await page.keyboard.press('Shift+Tab'); await page.keyboard.press('Shift+Tab');
await grid.verifyActivePage({ pageNumber: '1' });
await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'Country' }); await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'Country' });
// ArrowDown:
// If current page is not last page and and current cell is in last row and user press `ArrowDown` then current page will be incremented by 1
await grid.cell.click({ index: 24, columnHeader: 'Cities' });
await page.keyboard.press('ArrowDown');
await grid.verifyActivePage({ pageNumber: '2' });
await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'Cities' });
// If current page is last page and and current cell is in last row and user press `ArrowDown` then new empty row will be added
await grid.clickPagination({ type: 'last-page' });
await grid.cell.click({ index: 8, columnHeader: 'Cities' });
await page.keyboard.press('ArrowDown');
await grid.verifyRowCount({ count: 10 });
await grid.cell.verifyCellActiveSelected({ index: 9, columnHeader: 'Country' });
// ArrowUp:
// If current page is not first page and and current cell is in first row and user press `ArrwoUp` then current page will be decremented by 1
await grid.cell.click({ index: 0, columnHeader: 'Cities' });
await page.keyboard.press('ArrowUp');
await grid.verifyActivePage({ pageNumber: '4' });
await grid.cell.verifyCellActiveSelected({ index: 24, columnHeader: 'Cities' });
// If current page is first page and and current cell is in first row and user press `ArrwoUp` then current page will not change
await grid.clickPagination({ type: 'first-page' });
await grid.cell.click({ index: 0, columnHeader: 'Cities' });
await page.keyboard.press('ArrowUp');
await grid.verifyActivePage({ pageNumber: '1' });
await grid.cell.verifyCellActiveSelected({ index: 0, columnHeader: 'Cities' });
}); });
}); });
@ -386,6 +349,8 @@ test.describe('Clipboard support', () => {
test('multiple cells - horizontal, all data types', async ({ page }) => { test('multiple cells - horizontal, all data types', async ({ page }) => {
// skip for local run (clipboard access issue in headless mode) // skip for local run (clipboard access issue in headless mode)
// Cmd A or Control A support is removed
test.skip();
if (!process.env.CI && config.use.headless) { if (!process.env.CI && config.use.headless) {
test.skip(); test.skip();
} }
@ -423,18 +388,30 @@ test.describe('Clipboard support', () => {
} }
await grid.cell.click({ index: 1, columnHeader: 'SingleLineText' }); await grid.cell.click({ index: 1, columnHeader: 'SingleLineText' });
await page.waitForTimeout(500);
await page.keyboard.press('Shift+ArrowDown'); await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(500);
await page.keyboard.press('Shift+ArrowDown'); await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(500);
await page.keyboard.press('Shift+ArrowDown'); await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(500);
await page.keyboard.press('Shift+ArrowDown'); await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(500);
await page.keyboard.press('Shift+ArrowDown'); await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(500);
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+c' : 'Control+c'); await page.keyboard.press((await grid.isMacOs()) ? 'Meta+c' : 'Control+c');
await page.waitForTimeout(500);
await grid.cell.click({ index: 1, columnHeader: 'LongText' }); await grid.cell.click({ index: 1, columnHeader: 'LongText' });
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+v' : 'Control+v'); await page.waitForTimeout(500);
// reload page await page.keyboard.press((await grid.isMacOs()) ? 'Meta+v' : 'Control+v');
await dashboard.rootPage.reload();
// verify copied data // verify copied data
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
@ -443,11 +420,23 @@ test.describe('Clipboard support', () => {
// Block selection // Block selection
await grid.cell.click({ index: 1, columnHeader: 'SingleLineText' }); await grid.cell.click({ index: 1, columnHeader: 'SingleLineText' });
await page.waitForTimeout(500);
await page.keyboard.press('Shift+ArrowDown'); await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(500);
await page.keyboard.press('Shift+ArrowDown'); await page.keyboard.press('Shift+ArrowDown');
await page.waitForTimeout(500);
await page.keyboard.press('Shift+ArrowRight'); await page.keyboard.press('Shift+ArrowRight');
await page.waitForTimeout(500);
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+c' : 'Control+c'); await page.keyboard.press((await grid.isMacOs()) ? 'Meta+c' : 'Control+c');
await page.waitForTimeout(500);
await grid.cell.click({ index: 4, columnHeader: 'SingleLineText' }); await grid.cell.click({ index: 4, columnHeader: 'SingleLineText' });
await page.waitForTimeout(500);
await page.keyboard.press((await grid.isMacOs()) ? 'Meta+v' : 'Control+v'); await page.keyboard.press((await grid.isMacOs()) ? 'Meta+v' : 'Control+v');
// reload page // reload page

5
tests/playwright/tests/db/features/undo-redo.spec.ts

@ -299,7 +299,10 @@ test.describe('Undo Redo', () => {
test('Row height', async ({ page }) => { test('Row height', async ({ page }) => {
async function verifyRowHeight({ height }: { height: string }) { async function verifyRowHeight({ height }: { height: string }) {
await expect(dashboard.grid.rowPage.getRecord(0)).toHaveAttribute('style', `height: ${height};`); await expect(dashboard.grid.rowPage.getRecord(0)).toHaveAttribute(
'style',
expect.stringMatching(new RegExp(`height:\\s*${height}px;`))
);
} }
// close 'Team & Auth' tab // close 'Team & Auth' tab

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

@ -51,7 +51,13 @@ test.describe('Table Column Operations', () => {
await grid.addNewRow({ index: 2, value: `Row 2` }); await grid.addNewRow({ index: 2, value: `Row 2` });
await grid.addNewRow({ index: 3, value: `Row 3` }); await grid.addNewRow({ index: 3, value: `Row 3` });
await grid.addNewRow({ index: 4, value: `Row 4` }); await grid.addNewRow({ index: 4, value: `Row 4` });
await grid.deleteAll();
// Delete all rows is not supported in Infinite Scroll
await grid.deleteRow(4);
await grid.deleteRow(3);
await grid.deleteRow(2);
await grid.deleteRow(1);
await grid.deleteRow(0);
await grid.verifyRowDoesNotExist({ index: 0 }); await grid.verifyRowDoesNotExist({ index: 0 });

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

@ -281,11 +281,6 @@ test.describe('Shared view', () => {
await dashboard.grid.column.create({ await dashboard.grid.column.create({
title: 'New Column', title: 'New Column',
}); });
await dashboard.grid.addNewRow({
index: 25,
columnHeader: 'Country',
value: 'New Country',
});
await dashboard.signOut(); await dashboard.signOut();
@ -313,7 +308,7 @@ test.describe('Shared view', () => {
await sharedPage2.grid.toolbar.clickFilter(); await sharedPage2.grid.toolbar.clickFilter();
await sharedPage2.grid.toolbar.filter.add({ await sharedPage2.grid.toolbar.filter.add({
title: 'Country', title: 'Country',
value: 'New Country', value: 'China',
operation: 'is like', operation: 'is like',
locallySaved: true, locallySaved: true,
}); });
@ -322,7 +317,7 @@ test.describe('Shared view', () => {
await sharedPage2.grid.cell.verify({ await sharedPage2.grid.cell.verify({
index: 0, index: 0,
columnHeader: 'Country', columnHeader: 'Country',
value: 'New Country', value: 'China',
}); });
}); });
}); });

Loading…
Cancel
Save