Browse Source

feat: virtual scroll for grid (#8356)

* feat: virtual scroll for grid

* feat: improve virtual scroll

* fix: remove unused expose & ref

* feat: move row ltar helpers to parent level

* fix: use shared composable for useMetas

* fix: column add issue

* fix: reload issue

* feat: move cell state to computed

* chore: lint

* fix: null check for sticky field

* fix: PR requested changes

* fix: shared views

* fix: provide row store calls

* test: avoid all rows selector

* fix: group by

* fix: include isVirtualCol in cellMeta

* fix: split colMeta and cellMeta

* chore: lint

* test: edit column flakiness

* test: renderColumn for dashboard grid

* test: user column test flakiness
fix/src-filter
Mert E 5 months ago committed by GitHub
parent
commit
fed1c7ba5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      packages/nc-gui/components/shared-view/Calendar.vue
  2. 2
      packages/nc-gui/components/shared-view/Gallery.vue
  3. 2
      packages/nc-gui/components/shared-view/Grid.vue
  4. 2
      packages/nc-gui/components/shared-view/Kanban.vue
  5. 2
      packages/nc-gui/components/shared-view/Map.vue
  6. 121
      packages/nc-gui/components/smartsheet/Cell.vue
  7. 1
      packages/nc-gui/components/smartsheet/Form.vue
  8. 17
      packages/nc-gui/components/smartsheet/Row.vue
  9. 5
      packages/nc-gui/components/smartsheet/SharedMapMarkerPopup.vue
  10. 4
      packages/nc-gui/components/smartsheet/TableDataCell.vue
  11. 61
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  12. 4
      packages/nc-gui/components/smartsheet/column/DefaultValue.vue
  13. 2
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  14. 1
      packages/nc-gui/components/smartsheet/grid/GroupByTable.vue
  15. 909
      packages/nc-gui/components/smartsheet/grid/Table.vue
  16. 5
      packages/nc-gui/components/tabs/Smartsheet.vue
  17. 2
      packages/nc-gui/composables/useExpandedFormStore.ts
  18. 4
      packages/nc-gui/composables/useMetas.ts
  19. 50
      packages/nc-gui/composables/useMultiSelect/index.ts
  20. 4
      packages/nc-gui/composables/useSharedFormViewStore.ts
  21. 249
      packages/nc-gui/composables/useSmartsheetLtarHelpers.ts
  22. 266
      packages/nc-gui/composables/useSmartsheetRowStore.ts
  23. 1
      packages/nc-gui/lib/types.ts
  24. 3
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  25. 44
      tests/playwright/pages/Dashboard/Grid/index.ts
  26. 2
      tests/playwright/pages/Dashboard/common/Cell/UserOptionCell.ts
  27. 1
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  28. 1
      tests/playwright/pages/Dashboard/common/Topbar/index.ts
  29. 6
      tests/playwright/tests/db/columns/columnUserSelect.spec.ts
  30. 3
      tests/playwright/tests/db/features/keyboardShortcuts.spec.ts

2
packages/nc-gui/components/shared-view/Calendar.vue

@ -28,6 +28,8 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView)

2
packages/nc-gui/components/shared-view/Gallery.vue

@ -32,6 +32,8 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView)

2
packages/nc-gui/components/shared-view/Grid.vue

@ -43,6 +43,8 @@ provide(IsLockedInj, isLocked)
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideViewGroupBy(sharedView, meta, xWhere, true)
useProvideSmartsheetLtarHelpers(meta)
if (signedIn.value) {
try {
await loadProject()

2
packages/nc-gui/components/shared-view/Kanban.vue

@ -27,6 +27,8 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView, true)

2
packages/nc-gui/components/shared-view/Map.vue

@ -27,6 +27,8 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideMapViewStore(meta, sharedView, true)

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

@ -154,43 +154,10 @@ const onContextmenu = (e: MouseEvent) => {
e.stopPropagation()
}
}
// Todo: move intersection logic to a separate component or a vue directive
const intersected = ref(false)
const intersectionObserver = ref<IntersectionObserver>()
const elementToObserve = ref<Element>()
// load the cell only when it is in the viewport
function initIntersectionObserver() {
intersectionObserver.value = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// if the cell is in the viewport, load the cell and disconnect the observer
if (entry.isIntersecting) {
intersected.value = true
intersectionObserver.value?.disconnect()
intersectionObserver.value = undefined
}
})
})
}
// observe the cell when it is mounted
onMounted(() => {
initIntersectionObserver()
intersectionObserver.value?.observe(elementToObserve.value!)
})
// disconnect the observer when the cell is unmounted
onUnmounted(() => {
intersectionObserver.value?.disconnect()
})
</script>
<template>
<div
ref="elementToObserve"
:class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
{
@ -214,51 +181,49 @@ onUnmounted(() => {
@keydown.shift.enter.exact="navigate(NavigateDir.PREV, $event)"
>
<template v-if="column">
<template v-if="intersected">
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" :virtual="props.virtual" />
<LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellSingleSelect
v-else-if="isSingleSelect(column)"
v-model="vModel"
:disable-option-creation="!!isEditColumnMenu"
:row-index="props.rowIndex"
/>
<LazyCellMultiSelect
v-else-if="isMultiSelect(column)"
v-model="vModel"
:disable-option-creation="!!isEditColumnMenu"
:row-index="props.rowIndex"
/>
<LazyCellDatePicker v-else-if="isDate(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellYearPicker v-else-if="isYear(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellDateTimePicker
v-else-if="isDateTime(column, abstractType)"
v-model="vModel"
:is-pk="isPrimaryKey(column)"
:is-updated-from-copy-n-paste="currentRow.rowMeta.isUpdatedFromCopyNPaste"
/>
<LazyCellTimePicker v-else-if="isTime(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellRating v-else-if="isRating(column)" v-model="vModel" />
<LazyCellDuration v-else-if="isDuration(column)" v-model="vModel" />
<LazyCellEmail v-else-if="isEmail(column)" v-model="vModel" />
<LazyCellUrl v-else-if="isURL(column)" v-model="vModel" />
<LazyCellPhoneNumber v-else-if="isPhoneNumber(column)" v-model="vModel" />
<LazyCellPercent v-else-if="isPercent(column)" v-model="vModel" />
<LazyCellCurrency v-else-if="isCurrency(column)" v-model="vModel" @save="emit('save')" />
<LazyCellUser v-else-if="isUser(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellDecimal v-else-if="isDecimal(column)" v-model="vModel" />
<LazyCellFloat v-else-if="isFloat(column, abstractType)" v-model="vModel" />
<LazyCellText v-else-if="isString(column, abstractType)" v-model="vModel" />
<LazyCellInteger v-else-if="isInt(column, abstractType)" v-model="vModel" />
<LazyCellJson v-else-if="isJSON(column)" v-model="vModel" />
<LazyCellText v-else v-model="vModel" />
<div
v-if="((isPublic && readOnly && !isForm) || (isSystemColumn(column) && !isAttachment(column))) && !isTextArea(column)"
class="nc-locked-overlay"
/>
</template>
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" :virtual="props.virtual" />
<LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellSingleSelect
v-else-if="isSingleSelect(column)"
v-model="vModel"
:disable-option-creation="!!isEditColumnMenu"
:row-index="props.rowIndex"
/>
<LazyCellMultiSelect
v-else-if="isMultiSelect(column)"
v-model="vModel"
:disable-option-creation="!!isEditColumnMenu"
:row-index="props.rowIndex"
/>
<LazyCellDatePicker v-else-if="isDate(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellYearPicker v-else-if="isYear(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellDateTimePicker
v-else-if="isDateTime(column, abstractType)"
v-model="vModel"
:is-pk="isPrimaryKey(column)"
:is-updated-from-copy-n-paste="currentRow.rowMeta.isUpdatedFromCopyNPaste"
/>
<LazyCellTimePicker v-else-if="isTime(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellRating v-else-if="isRating(column)" v-model="vModel" />
<LazyCellDuration v-else-if="isDuration(column)" v-model="vModel" />
<LazyCellEmail v-else-if="isEmail(column)" v-model="vModel" />
<LazyCellUrl v-else-if="isURL(column)" v-model="vModel" />
<LazyCellPhoneNumber v-else-if="isPhoneNumber(column)" v-model="vModel" />
<LazyCellPercent v-else-if="isPercent(column)" v-model="vModel" />
<LazyCellCurrency v-else-if="isCurrency(column)" v-model="vModel" @save="emit('save')" />
<LazyCellUser v-else-if="isUser(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellDecimal v-else-if="isDecimal(column)" v-model="vModel" />
<LazyCellFloat v-else-if="isFloat(column, abstractType)" v-model="vModel" />
<LazyCellText v-else-if="isString(column, abstractType)" v-model="vModel" />
<LazyCellInteger v-else-if="isInt(column, abstractType)" v-model="vModel" />
<LazyCellJson v-else-if="isJSON(column)" v-model="vModel" />
<LazyCellText v-else v-model="vModel" />
<div
v-if="((isPublic && readOnly && !isForm) || (isSystemColumn(column) && !isAttachment(column))) && !isTextArea(column)"
class="nc-locked-overlay"
/>
</template>
</div>
</template>

1
packages/nc-gui/components/smartsheet/Form.vue

@ -120,7 +120,6 @@ reloadEventHook.on(async () => {
const { fields, showAll, hideAll } = useViewColumnsOrThrow()
const { state, row } = useProvideSmartsheetRowStore(
meta,
ref({
row: formState.value,
oldRow: {},

17
packages/nc-gui/components/smartsheet/Row.vue

@ -1,6 +1,4 @@
<script lang="ts" setup>
import type { Ref } from 'vue'
import type { TableType } from 'nocodb-sdk'
import type { Row } from '#imports'
import {
ReloadRowDataHookInj,
@ -10,7 +8,6 @@ import {
provide,
toRef,
useProvideSmartsheetRowStore,
useSmartsheetStoreOrThrow,
} from '#imports'
const props = defineProps<{
@ -19,12 +16,7 @@ const props = defineProps<{
const currentRow = toRef(props, 'row')
const { meta } = useSmartsheetStoreOrThrow()
const { isNew, state, syncLTARRefs, clearLTARCell, addLTARRef, cleaMMCell } = useProvideSmartsheetRowStore(
meta as Ref<TableType>,
currentRow,
)
const { isNew, state } = useProvideSmartsheetRowStore(currentRow)
const reloadViewDataTrigger = inject(ReloadViewDataHookInj)!
@ -39,13 +31,6 @@ reloadHook.on((params) => {
})
provide(ReloadRowDataHookInj, reloadHook)
defineExpose({
syncLTARRefs,
clearLTARCell,
addLTARRef,
cleaMMCell,
})
</script>
<template>

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

@ -1,7 +1,4 @@
<script lang="ts" setup>
import type { ColumnType, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { isVirtualCol } from 'nocodb-sdk'
import {
ActiveViewInj,
@ -52,7 +49,7 @@ provide(ReloadRowDataHookInj, reloadViewDataHook!)
const currentRow = toRef(props, 'row')
useProvideSmartsheetRowStore(meta as Ref<TableType>, currentRow)
useProvideSmartsheetRowStore(currentRow)
</script>
<template>

4
packages/nc-gui/components/smartsheet/TableDataCell.vue

@ -1,15 +1,13 @@
<script lang="ts" setup>
import { CellClickHookInj, CurrentCellInj, createEventHook, ref } from '#imports'
const el = ref<HTMLTableDataCellElement>()
const el = ref<HTMLElement>()
const cellClickHook = createEventHook()
provide(CellClickHookInj, cellClickHook)
provide(CurrentCellInj, el)
defineExpose({ el })
</script>
<template>

61
packages/nc-gui/components/smartsheet/VirtualCell.vue

@ -62,43 +62,10 @@ function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
if (!isForm.value) e.stopImmediatePropagation()
}
// Todo: move intersection logic to a separate component or a vue directive
const intersected = ref(false)
const intersectionObserver = ref<IntersectionObserver>()
const elementToObserve = ref<Element>()
// load the cell only when it is in the viewport
function initIntersectionObserver() {
intersectionObserver.value = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// if the cell is in the viewport, load the cell and disconnect the observer
if (entry.isIntersecting) {
intersected.value = true
intersectionObserver.value?.disconnect()
intersectionObserver.value = undefined
}
})
})
}
// observe the cell when it is mounted
onMounted(() => {
initIntersectionObserver()
intersectionObserver.value?.observe(elementToObserve.value!)
})
// disconnect the observer when the cell is unmounted
onUnmounted(() => {
intersectionObserver.value?.disconnect()
})
</script>
<template>
<div
ref="elementToObserve"
class="nc-virtual-cell w-full flex items-center"
:class="{
'text-right justify-end': isGrid && !isForm && isRollup(column) && !isExpandedForm,
@ -107,21 +74,19 @@ onUnmounted(() => {
@keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)"
>
<template v-if="intersected">
<LazyVirtualCellLinks v-if="isLink(column)" />
<LazyVirtualCellHasMany v-else-if="isHm(column)" />
<LazyVirtualCellManyToMany v-else-if="isMm(column)" />
<LazyVirtualCellBelongsTo v-else-if="isBt(column)" />
<LazyVirtualCellOneToOne v-else-if="isOo(column)" />
<LazyVirtualCellRollup v-else-if="isRollup(column)" />
<LazyVirtualCellFormula v-else-if="isFormula(column)" />
<LazyVirtualCellQrCode v-else-if="isQrCode(column)" />
<LazyVirtualCellBarcode v-else-if="isBarcode(column)" />
<LazyVirtualCellCount v-else-if="isCount(column)" />
<LazyVirtualCellLookup v-else-if="isLookup(column)" />
<LazyCellReadOnlyDateTimePicker v-else-if="isCreatedOrLastModifiedTimeCol(column)" :model-value="modelValue" />
<LazyCellReadOnlyUser v-else-if="isCreatedOrLastModifiedByCol(column)" :model-value="modelValue" />
</template>
<LazyVirtualCellLinks v-if="isLink(column)" />
<LazyVirtualCellHasMany v-else-if="isHm(column)" />
<LazyVirtualCellManyToMany v-else-if="isMm(column)" />
<LazyVirtualCellBelongsTo v-else-if="isBt(column)" />
<LazyVirtualCellOneToOne v-else-if="isOo(column)" />
<LazyVirtualCellRollup v-else-if="isRollup(column)" />
<LazyVirtualCellFormula v-else-if="isFormula(column)" />
<LazyVirtualCellQrCode v-else-if="isQrCode(column)" />
<LazyVirtualCellBarcode v-else-if="isBarcode(column)" />
<LazyVirtualCellCount v-else-if="isCount(column)" />
<LazyVirtualCellLookup v-else-if="isLookup(column)" />
<LazyCellReadOnlyDateTimePicker v-else-if="isCreatedOrLastModifiedTimeCol(column)" :model-value="modelValue" />
<LazyCellReadOnlyUser v-else-if="isCreatedOrLastModifiedByCol(column)" :model-value="modelValue" />
</div>
</template>

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

@ -7,8 +7,6 @@ const props = defineProps<{
}>()
const emits = defineEmits(['update:value'])
const meta = inject(MetaInj, ref())
provide(EditColumnInj, ref(true))
const vModel = useVModel(props, 'value', emits)
@ -20,7 +18,7 @@ const rowRef = ref({
},
})
useProvideSmartsheetRowStore(meta, rowRef)
useProvideSmartsheetRowStore(rowRef)
const cdfValue = ref<string | null>(null)

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

@ -157,6 +157,8 @@ const duplicatingRowInProgress = ref(false)
useProvideSmartsheetStore(ref({}) as Ref<ViewType>, meta)
useProvideSmartsheetLtarHelpers(meta)
watch(
state,
() => {

1
packages/nc-gui/components/smartsheet/grid/GroupByTable.vue

@ -148,6 +148,7 @@ const pagination = computed(() => {
:hide-header="true"
:pagination="pagination"
:disable-skeleton="true"
:disable-virtual-y="true"
/>
</template>

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

File diff suppressed because it is too large Load Diff

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

@ -24,6 +24,7 @@ import {
useMetas,
useProvideCalendarViewStore,
useProvideKanbanViewStore,
useProvideSmartsheetLtarHelpers,
useProvideSmartsheetStore,
useRoles,
useSqlEditor,
@ -58,8 +59,6 @@ const { isGallery, isGrid, isForm, isKanban, isLocked, isMap, isCalendar, xWhere
useSqlEditor()
const { isPanelExpanded } = useExtensions()
const reloadViewDataEventHook = createEventHook()
const reloadViewMetaEventHook = createEventHook<void | boolean>()
@ -88,6 +87,8 @@ provide(
useProvideViewColumns(activeView, meta, () => reloadViewDataEventHook?.trigger())
useProvideViewGroupBy(activeView, meta, xWhere)
useProvideSmartsheetLtarHelpers(meta)
const grid = ref()
const onDrop = async (event: DragEvent) => {

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

@ -56,7 +56,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
: ({ row: {}, oldRow: {}, rowMeta: {} } as Row),
)
const rowStore = useProvideSmartsheetRowStore(meta, row)
const rowStore = useProvideSmartsheetRowStore(row)
const activeView = inject(ActiveViewInj, ref())

4
packages/nc-gui/composables/useMetas.ts

@ -3,7 +3,7 @@ import type { WatchStopHandle } from 'vue'
import type { TableType } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, storeToRefs, useBase, useNuxtApp, useState, watch } from '#imports'
export function useMetas() {
export const useMetas = createSharedComposable(() => {
const { $api } = useNuxtApp()
const { tables: _tables } = storeToRefs(useBase())
@ -118,4 +118,4 @@ export function useMetas() {
}
return { getMeta, clearAllMeta, metas, metasWithIdAsKey, removeMeta, setMeta }
}
})

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

@ -335,25 +335,43 @@ export function useMultiSelect(
}
}
function isCellSelected(row: number, col: number) {
if (activeCell.col === col && activeCell.row === row) {
return true
const fillRangeMap = computed(() => {
/*
`${rowIndex}-${colIndex}`: true | false
*/
const map: Record<string, boolean> = {}
if (fillRange._start === null || fillRange._end === null) {
return map
}
return selectedRange.isCellInRange({ row, col })
}
for (let row = fillRange.start.row; row <= fillRange.end.row; row++) {
for (let col = fillRange.start.col; col <= fillRange.end.col; col++) {
map[`${row}-${col}`] = true
}
}
function isCellInFillRange(row: number, col: number) {
if (fillRange._start === null || fillRange._end === null) {
return false
return map
})
const selectRangeMap = computed(() => {
/*
`${rowIndex}-${colIndex}`: true | false
*/
const map: Record<string, boolean> = {}
if (selectedRange._start === null || selectedRange._end === null) {
return map
}
if (selectedRange.isCellInRange({ row, col })) {
return false
for (let row = selectedRange.start.row; row <= selectedRange.end.row; row++) {
for (let col = selectedRange.start.col; col <= selectedRange.end.col; col++) {
map[`${row}-${col}`] = true
}
}
return fillRange.isCellInRange({ row, col })
}
return map
})
const isPasteable = (row?: Row, col?: ColumnType, showInfo = false) => {
if (!row || !col) {
@ -420,7 +438,7 @@ export function useMultiSelect(
// if there was a right click on selected range, don't restart the selection
if (
(event?.button !== MAIN_MOUSE_PRESSED || (event?.button === MAIN_MOUSE_PRESSED && event.ctrlKey)) &&
isCellSelected(row, col)
selectRangeMap.value[`${row}-${col}`]
) {
return
}
@ -489,7 +507,7 @@ export function useMultiSelect(
fillDirection === 1 ? row <= fillRange._end.row : row >= fillRange._end.row;
row += fillDirection
) {
if (isCellSelected(row, selectedRange.start.col)) {
if (selectRangeMap.value[`${row}-${selectedRange.start.col}`]) {
continue
}
@ -1298,14 +1316,14 @@ export function useMultiSelect(
handleMouseOver,
clearSelectedRange,
copyValue,
isCellSelected,
activeCell,
handleCellClick,
resetSelectedRange,
selectedRange,
makeActive,
isCellInFillRange,
isMouseDown,
isFillMode,
selectRangeMap,
fillRangeMap,
}
}

4
packages/nc-gui/composables/useSharedFormViewStore.ts

@ -1,7 +1,6 @@
import useVuelidate from '@vuelidate/core'
import { helpers, minLength, required, sameAs } from '@vuelidate/validators'
import dayjs from 'dayjs'
import type { Ref } from 'vue'
import type {
BoolType,
ColumnType,
@ -91,8 +90,9 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const preFilledDefaultValueformState = ref<Record<string, any>>({})
useProvideSmartsheetLtarHelpers(meta)
const { state: additionalState } = useProvideSmartsheetRowStore(
meta as Ref<TableType>,
ref({
row: formState,
rowMeta: { new: true },

249
packages/nc-gui/composables/useSmartsheetLtarHelpers.ts

@ -0,0 +1,249 @@
import { RelationTypes, isLinksOrLTAR } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import {
NOCO,
deepCompare,
extractPkFromRow,
extractSdkResponseErrorMsg,
isBt,
isHm,
isMm,
isOo,
message,
storeToRefs,
unref,
useBase,
useI18n,
useInjectionState,
useMetas,
useNuxtApp,
} from '#imports'
import type { Row } from '#imports'
const [useProvideSmartsheetLtarHelpers, useSmartsheetLtarHelpers] = useInjectionState(
(meta: Ref<TableType | undefined> | ComputedRef<TableType | undefined>) => {
const { $api } = useNuxtApp()
const { t } = useI18n()
const { base } = storeToRefs(useBase())
const { metas } = useMetas()
const getRowLtarHelpers = (row: Row) => {
if (!row.rowMeta) {
row.rowMeta = {}
}
if (!row.rowMeta.ltarState) {
row.rowMeta.ltarState = {}
}
return row.rowMeta.ltarState
}
// actions
const addLTARRef = async (row: Row, value: Record<string, any>, column: ColumnType) => {
if (isHm(column) || isMm(column)) {
if (!getRowLtarHelpers(row)[column.title!]) getRowLtarHelpers(row)[column.title!] = []
if (getRowLtarHelpers(row)[column.title!]!.find((ln: Record<string, any>) => deepCompare(ln, value))) {
// This value is already in the list
return message.info(t('msg.info.valueAlreadyInList'))
}
if (Array.isArray(value)) {
getRowLtarHelpers(row)[column.title!]!.push(...value)
} else {
getRowLtarHelpers(row)[column.title!]!.push(value)
}
} else if (isBt(column) || isOo(column)) {
getRowLtarHelpers(row)[column.title!] = value
}
}
// actions
const removeLTARRef = async (row: Row, value: Record<string, any>, column: ColumnType) => {
if (isHm(column) || isMm(column)) {
getRowLtarHelpers(row)[column.title!]?.splice(getRowLtarHelpers(row)[column.title!]?.indexOf(value), 1)
} else if (isBt(column) || isOo(column)) {
getRowLtarHelpers(row)[column.title!] = null
}
}
const linkRecord = async (
rowId: string,
relatedRowId: string,
column: ColumnType,
type: RelationTypes,
{ metaValue = meta.value }: { metaValue?: TableType } = {},
) => {
try {
await $api.dbTableRow.nestedAdd(
NOCO,
base.value.id as string,
metaValue?.id as string,
encodeURIComponent(rowId),
type,
column.id as string,
encodeURIComponent(relatedRowId),
)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
/** sync LTAR relations kept in local state */
const syncLTARRefs = async (
row: Row,
rowData: Record<string, any>,
{ metaValue = meta.value }: { metaValue?: TableType } = {},
) => {
const id = extractPkFromRow(rowData, metaValue?.columns as ColumnType[])
for (const column of metaValue?.columns ?? []) {
if (!isLinksOrLTAR(column)) continue
const colOptions = column.colOptions as LinkToAnotherRecordType
const relatedTableMeta = metas.value?.[colOptions?.fk_related_model_id as string]
if (isHm(column) || isMm(column)) {
const relatedRows = (getRowLtarHelpers(row)?.[column.title!] ?? []) as Record<string, any>[]
for (const relatedRow of relatedRows) {
await linkRecord(
id,
extractPkFromRow(relatedRow, relatedTableMeta.columns as ColumnType[]),
column,
colOptions.type as RelationTypes,
{ metaValue },
)
}
} else if ((isBt(column) || isOo(column)) && getRowLtarHelpers(row)?.[column.title!]) {
await linkRecord(
id,
extractPkFromRow(
getRowLtarHelpers(row)?.[column.title!] as Record<string, any>,
relatedTableMeta.columns as ColumnType[],
),
column,
colOptions.type as RelationTypes,
{ metaValue },
)
}
// clear LTAR refs after sync
getRowLtarHelpers(row)[column.title!] = null
}
}
// clear LTAR cell
const clearLTARCell = async (row: Row, column: ColumnType) => {
try {
if (!column || !isLinksOrLTAR(column)) return
const relatedTableMeta = metas.value?.[(<LinkToAnotherRecordType>column?.colOptions)?.fk_related_model_id as string]
if (row.rowMeta.new) {
getRowLtarHelpers(row)[column.title!] = null
} else {
if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes((<LinkToAnotherRecordType>column.colOptions)?.type)) {
if (!row.row[column.title!]) return
await $api.dbTableRow.nestedRemove(
NOCO,
base.value.id as string,
meta.value?.id as string,
extractPkFromRow(row.row, meta.value?.columns as ColumnType[]),
(<LinkToAnotherRecordType>column.colOptions)?.type as any,
column.id as string,
extractPkFromRow(row.row[column.title!], relatedTableMeta?.columns as ColumnType[]),
)
row.row[column.title!] = null
} else {
for (const link of (row.row[column.title!] as Record<string, any>[]) || []) {
await $api.dbTableRow.nestedRemove(
NOCO,
base.value.id as string,
meta.value?.id as string,
encodeURIComponent(extractPkFromRow(row.row, meta.value?.columns as ColumnType[])),
(<LinkToAnotherRecordType>column?.colOptions).type as 'hm' | 'mm',
column.id as string,
encodeURIComponent(extractPkFromRow(link, relatedTableMeta?.columns as ColumnType[])),
)
}
row.row[column.title!] = []
}
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const loadRow = async (row: Row) => {
const record = await $api.dbTableRow.read(
NOCO,
base.value?.id as string,
meta.value?.title as string,
encodeURIComponent(extractPkFromRow(row.row, meta.value?.columns as ColumnType[])),
)
Object.assign(unref(row), {
row: record,
oldRow: { ...record },
rowMeta: {},
})
}
// clear MM cell
const cleaMMCell = async (row: Row, column: ColumnType) => {
try {
if (!column || !isLinksOrLTAR(column)) return
if (row.rowMeta.new) {
getRowLtarHelpers(row)[column.title!] = null
} else {
if ((<LinkToAnotherRecordType>column.colOptions)?.type === RelationTypes.MANY_TO_MANY) {
if (!row.row[column.title!]) return
const result = await $api.dbDataTableRow.nestedListCopyPasteOrDeleteAll(
meta.value?.id as string,
column.id as string,
[
{
operation: 'deleteAll',
rowId: extractPkFromRow(row.row, meta.value?.columns as ColumnType[]) as string,
columnId: column.id as string,
fk_related_model_id: (column.colOptions as LinkToAnotherRecordType)?.fk_related_model_id as string,
},
],
)
row.row[column.title!] = null
return Array.isArray(result.unlink) ? result.unlink : []
}
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
return {
addLTARRef,
removeLTARRef,
syncLTARRefs,
loadRow,
clearLTARCell,
cleaMMCell,
}
},
'smartsheet-ltar-helpers',
)
export { useProvideSmartsheetLtarHelpers }
export function useSmartsheetLtarHelpersOrThrow() {
const smartsheetLtarHelpers = useSmartsheetLtarHelpers()
if (smartsheetLtarHelpers == null) throw new Error('Please call `useSmartsheetLtarHelpers` on the appropriate parent component')
return smartsheetLtarHelpers
}

266
packages/nc-gui/composables/useSmartsheetRowStore.ts

@ -1,242 +1,42 @@
import { RelationTypes, isLinksOrLTAR } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import type { MaybeRef } from '@vueuse/core'
import {
NOCO,
computed,
deepCompare,
extractPkFromRow,
extractSdkResponseErrorMsg,
isBt,
isHm,
isMm,
isOo,
message,
ref,
storeToRefs,
unref,
useBase,
useI18n,
useInjectionState,
useMetas,
useNuxtApp,
} from '#imports'
import { computed, ref, unref, useInjectionState } from '#imports'
import type { Row } from '#imports'
const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
(meta: Ref<TableType | undefined>, row: MaybeRef<Row>) => {
const { $api } = useNuxtApp()
const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState((row: MaybeRef<Row>) => {
const currentRow = ref(row)
const { t } = useI18n()
const { base } = storeToRefs(useBase())
const { metas } = useMetas()
const currentRow = ref(row)
// state
const state = ref<Record<string, Record<string, any> | Record<string, any>[] | null>>({})
// getters
const isNew = computed(() => unref(row).rowMeta.new ?? false)
// actions
const addLTARRef = async (value: Record<string, any>, column: ColumnType) => {
if (isHm(column) || isMm(column)) {
if (!state.value[column.title!]) state.value[column.title!] = []
if (state.value[column.title!]!.find((ln: Record<string, any>) => deepCompare(ln, value))) {
// This value is already in the list
return message.info(t('msg.info.valueAlreadyInList'))
}
if (Array.isArray(value)) {
state.value[column.title!]!.push(...value)
} else {
state.value[column.title!]!.push(value)
// state
const state = computed({
get: () => currentRow.value?.rowMeta?.ltarState ?? {},
set: (value) => {
if (currentRow.value) {
if (!currentRow.value.rowMeta) {
currentRow.value.rowMeta = {}
}
} else if (isBt(column) || isOo(column)) {
state.value[column.title!] = value
currentRow.value.rowMeta.ltarState = value
}
}
// actions
const removeLTARRef = async (value: Record<string, any>, column: ColumnType) => {
if (isHm(column) || isMm(column)) {
state.value[column.title!]?.splice(state.value[column.title!]?.indexOf(value), 1)
} else if (isBt(column) || isOo(column)) {
state.value[column.title!] = null
}
}
const linkRecord = async (
rowId: string,
relatedRowId: string,
column: ColumnType,
type: RelationTypes,
{ metaValue = meta.value }: { metaValue?: TableType } = {},
) => {
try {
await $api.dbTableRow.nestedAdd(
NOCO,
base.value.id as string,
metaValue?.id as string,
encodeURIComponent(rowId),
type,
column.id as string,
encodeURIComponent(relatedRowId),
)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
/** sync LTAR relations kept in local state */
const syncLTARRefs = async (row: Record<string, any>, { metaValue = meta.value }: { metaValue?: TableType } = {}) => {
const id = extractPkFromRow(row, metaValue?.columns as ColumnType[])
for (const column of metaValue?.columns ?? []) {
if (!isLinksOrLTAR(column)) continue
const colOptions = column.colOptions as LinkToAnotherRecordType
const relatedTableMeta = metas.value?.[colOptions?.fk_related_model_id as string]
if (isHm(column) || isMm(column)) {
const relatedRows = (state.value?.[column.title!] ?? []) as Record<string, any>[]
for (const relatedRow of relatedRows) {
await linkRecord(
id,
extractPkFromRow(relatedRow, relatedTableMeta.columns as ColumnType[]),
column,
colOptions.type as RelationTypes,
{ metaValue },
)
}
} else if ((isBt(column) || isOo(column)) && state.value?.[column.title!]) {
await linkRecord(
id,
extractPkFromRow(state.value?.[column.title!] as Record<string, any>, relatedTableMeta.columns as ColumnType[]),
column,
colOptions.type as RelationTypes,
{ metaValue },
)
}
// clear LTAR refs after sync
state.value[column.title!] = null
}
}
// clear LTAR cell
const clearLTARCell = async (column: ColumnType) => {
try {
if (!column || !isLinksOrLTAR(column)) return
const relatedTableMeta = metas.value?.[(<LinkToAnotherRecordType>column?.colOptions)?.fk_related_model_id as string]
if (isNew.value) {
state.value[column.title!] = null
} else if (currentRow.value) {
if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes((<LinkToAnotherRecordType>column.colOptions)?.type)) {
if (!currentRow.value.row[column.title!]) return
await $api.dbTableRow.nestedRemove(
NOCO,
base.value.id as string,
meta.value?.id as string,
extractPkFromRow(currentRow.value.row, meta.value?.columns as ColumnType[]),
(<LinkToAnotherRecordType>column.colOptions)?.type as any,
column.id as string,
extractPkFromRow(currentRow.value.row[column.title!], relatedTableMeta?.columns as ColumnType[]),
)
currentRow.value.row[column.title!] = null
} else {
for (const link of (currentRow.value.row[column.title!] as Record<string, any>[]) || []) {
await $api.dbTableRow.nestedRemove(
NOCO,
base.value.id as string,
meta.value?.id as string,
encodeURIComponent(extractPkFromRow(currentRow.value.row, meta.value?.columns as ColumnType[])),
(<LinkToAnotherRecordType>column?.colOptions).type as 'hm' | 'mm',
column.id as string,
encodeURIComponent(extractPkFromRow(link, relatedTableMeta?.columns as ColumnType[])),
)
}
currentRow.value.row[column.title!] = []
}
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const loadRow = async () => {
const record = await $api.dbTableRow.read(
NOCO,
base.value?.id as string,
meta.value?.title as string,
encodeURIComponent(extractPkFromRow(unref(row)?.row, meta.value?.columns as ColumnType[])),
)
Object.assign(unref(row), {
row: record,
oldRow: { ...record },
rowMeta: {},
})
}
// clear MM cell
const cleaMMCell = async (column: ColumnType) => {
try {
if (!column || !isLinksOrLTAR(column)) return
if (isNew.value) {
state.value[column.title!] = null
} else if (currentRow.value) {
if ((<LinkToAnotherRecordType>column.colOptions)?.type === RelationTypes.MANY_TO_MANY) {
if (!currentRow.value.row[column.title!]) return
console.log('currentRow.value.row, meta.value?.columns', currentRow.value.row, meta.value?.columns)
const result = await $api.dbDataTableRow.nestedListCopyPasteOrDeleteAll(
meta.value?.id as string,
column.id as string,
[
{
operation: 'deleteAll',
rowId: extractPkFromRow(currentRow.value.row, meta.value?.columns as ColumnType[]) as string,
columnId: column.id as string,
fk_related_model_id: (column.colOptions as LinkToAnotherRecordType)?.fk_related_model_id as string,
},
],
)
currentRow.value.row[column.title!] = null
return Array.isArray(result.unlink) ? result.unlink : []
}
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
return {
row,
state,
isNew,
// todo: use better name
addLTARRef,
removeLTARRef,
syncLTARRefs,
loadRow,
currentRow,
clearLTARCell,
cleaMMCell,
}
},
'smartsheet-row-store',
)
},
})
// getters
const isNew = computed(() => unref(row).rowMeta?.new ?? false)
const { addLTARRef, removeLTARRef, syncLTARRefs, loadRow, clearLTARCell, cleaMMCell } = useSmartsheetLtarHelpersOrThrow()
return {
row,
state,
isNew,
// todo: use better name
addLTARRef: (...args: any) => addLTARRef(currentRow.value, ...args),
removeLTARRef: (...args: any) => removeLTARRef(currentRow.value, ...args),
syncLTARRefs: (...args: any) => syncLTARRefs(currentRow.value, ...args),
loadRow: (...args: any) => loadRow(currentRow.value, ...args),
currentRow,
clearLTARCell: (...args: any) => clearLTARCell(currentRow.value, ...args),
cleaMMCell: (...args: any) => cleaMMCell(currentRow.value, ...args),
}
}, 'smartsheet-row-store')
export { useProvideSmartsheetRowStore }

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

@ -64,6 +64,7 @@ interface Row {
commentCount?: number
changed?: boolean
saving?: boolean
ltarState?: Record<string, Record<string, any> | Record<string, any>[] | null>
// use in datetime picker component
isUpdatedFromCopyNPaste?: Record<string, boolean>
// Used in Calendar view

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

@ -296,7 +296,8 @@ export class ColumnPageObject extends BasePage {
// when clicked on the dropdown cell header
await this.getColumnHeader(title).locator('.nc-ui-dt-dropdown').scrollIntoViewIfNeeded();
await this.getColumnHeader(title).locator('.nc-ui-dt-dropdown').click();
await this.rootPage.locator('li[role="menuitem"]:has-text("Edit")').last().click();
await expect(await this.rootPage.locator('li[role="menuitem"]:has-text("Edit"):visible').last()).toBeVisible();
await this.rootPage.locator('li[role="menuitem"]:has-text("Edit"):visible').last().click();
await this.get().waitFor({ state: 'visible' });

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

@ -79,6 +79,43 @@ export class GridPage extends BasePage {
return this.get().locator(`tr[data-testid="grid-row-${index}"]`);
}
async renderColumn(columnHeader: string) {
// we have virtual grid, so we need to make sure the column is rendered
const headerRow = this.get().locator('.nc-grid-header').first();
let column = headerRow.locator(`[data-title="${columnHeader}"]`);
let lastScrolledColumn: Locator = null;
let direction = 'right';
while (headerRow) {
try {
await column.elementHandle({ timeout: 1000 });
break;
} catch {}
const lastColumn =
direction === 'right'
? headerRow.locator('th.nc-grid-column-header').last()
: headerRow.locator('th.nc-grid-column-header').nth(1);
if (lastScrolledColumn) {
if ((await lastScrolledColumn.innerText()) === (await lastColumn.innerText())) {
if (direction === 'right') {
direction = 'left';
lastScrolledColumn = null;
} else {
throw new Error(`Column with header "${columnHeader}" not found`);
}
}
}
await lastColumn.scrollIntoViewIfNeeded();
lastScrolledColumn = lastColumn;
column = headerRow.locator(`[data-title="${columnHeader}"]`);
}
}
async rowCount() {
return await this.get().locator('.nc-grid-row').count();
}
@ -89,7 +126,8 @@ export class GridPage extends BasePage {
private async _fillRow({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) {
const cell = this.cell.get({ index, columnHeader });
await cell.waitFor({ state: 'visible' });
await expect(cell).toBeVisible();
await this.rootPage.waitForTimeout(500);
await this.cell.dblclick({
index,
columnHeader,
@ -132,7 +170,9 @@ export class GridPage extends BasePage {
// fallback
await this.rootPage.waitForTimeout(400);
await expect(this.get().locator('.nc-grid-row')).toHaveCount(rowCount);
await expect(this.get().locator(`[data-testid="grid-row-${rowCount - 1}"]`)).toHaveCount(1);
await this.rootPage.waitForLoadState('networkidle');
await this._fillRow({ index, columnHeader, value: rowValue });

2
tests/playwright/pages/Dashboard/common/Cell/UserOptionCell.ts

@ -100,7 +100,7 @@ export class UserOptionCellPageObject extends BasePage {
}
const locator = this.cell.get({ index, columnHeader }).locator('.ant-tag');
await locator.waitFor({ state: 'visible' });
await expect(locator).toBeVisible();
return expect(locator).toContainText(option);
}

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

@ -410,6 +410,7 @@ export class CellPageObject extends BasePage {
}
async copyCellToClipboard({ index, columnHeader }: CellProps, ...clickOptions: Parameters<Locator['click']>) {
if (this.parent instanceof GridPage) await this.parent.renderColumn(columnHeader);
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await this.get({ index, columnHeader }).click(...clickOptions);
await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable');

1
tests/playwright/pages/Dashboard/common/Topbar/index.ts

@ -82,5 +82,6 @@ export class TopbarPage extends BasePage {
async clickRefresh() {
await this.get().locator(`.nc-icon-reload`).waitFor({ state: 'visible' });
await this.get().locator(`.nc-icon-reload`).click();
await this.rootPage.waitForLoadState('networkidle');
}
}

6
tests/playwright/tests/db/columns/columnUserSelect.spec.ts

@ -214,9 +214,11 @@ test.describe('User single select', () => {
await grid.cell.click({ index: 3, columnHeader: 'User' });
await dashboard.rootPage.keyboard.press('Shift+ArrowDown');
await dashboard.rootPage.keyboard.press((await grid.isMacOs()) ? 'Meta+c' : 'Control+c');
await dashboard.rootPage.keyboard.press((await grid.isMacOs()) ? 'Meta+C' : 'Control+C');
await grid.cell.click({ index: 0, columnHeader: 'User' });
await dashboard.rootPage.keyboard.press((await grid.isMacOs()) ? 'Meta+v' : 'Control+v');
await dashboard.rootPage.keyboard.press((await grid.isMacOs()) ? 'Meta+V' : 'Control+V');
await dashboard.rootPage.waitForTimeout(500);
// refresh
await topbar.clickRefresh();

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

@ -272,6 +272,8 @@ test.describe('Clipboard support', () => {
// ########################################
await dashboard.grid.renderColumn('Attachment');
await dashboard.grid.cell.attachment.addFile({
index: 0,
columnHeader: 'Attachment',
@ -304,6 +306,7 @@ test.describe('Clipboard support', () => {
];
for (const { type, value } of responseTable) {
await dashboard.grid.renderColumn(type);
if (type === 'Rating') {
await dashboard.grid.cell.rating.verify({
index: rowIndex,

Loading…
Cancel
Save