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 7 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. 35
      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. 35
      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. 531
      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. 48
      packages/nc-gui/composables/useMultiSelect/index.ts
  20. 4
      packages/nc-gui/composables/useSharedFormViewStore.ts
  21. 249
      packages/nc-gui/composables/useSmartsheetLtarHelpers.ts
  22. 240
      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) useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters) useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView) 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) useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters) useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView) 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) useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideViewGroupBy(sharedView, meta, xWhere, true) useProvideViewGroupBy(sharedView, meta, xWhere, true)
useProvideSmartsheetLtarHelpers(meta)
if (signedIn.value) { if (signedIn.value) {
try { try {
await loadProject() 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) useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters) useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView, true) 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) useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters) useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideMapViewStore(meta, sharedView, true) useProvideMapViewStore(meta, sharedView, true)

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

@ -154,43 +154,10 @@ const onContextmenu = (e: MouseEvent) => {
e.stopPropagation() 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> </script>
<template> <template>
<div <div
ref="elementToObserve"
:class="[ :class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`, `nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
{ {
@ -214,7 +181,6 @@ onUnmounted(() => {
@keydown.shift.enter.exact="navigate(NavigateDir.PREV, $event)" @keydown.shift.enter.exact="navigate(NavigateDir.PREV, $event)"
> >
<template v-if="column"> <template v-if="column">
<template v-if="intersected">
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" :virtual="props.virtual" /> <LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" :virtual="props.virtual" />
<LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" /> <LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" /> <LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
@ -259,7 +225,6 @@ onUnmounted(() => {
class="nc-locked-overlay" class="nc-locked-overlay"
/> />
</template> </template>
</template>
</div> </div>
</template> </template>

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

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

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

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

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

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

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

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

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

@ -62,43 +62,10 @@ function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
if (!isForm.value) e.stopImmediatePropagation() 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> </script>
<template> <template>
<div <div
ref="elementToObserve"
class="nc-virtual-cell w-full flex items-center" class="nc-virtual-cell w-full flex items-center"
:class="{ :class="{
'text-right justify-end': isGrid && !isForm && isRollup(column) && !isExpandedForm, 'text-right justify-end': isGrid && !isForm && isRollup(column) && !isExpandedForm,
@ -107,7 +74,6 @@ onUnmounted(() => {
@keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)" @keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)" @keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)"
> >
<template v-if="intersected">
<LazyVirtualCellLinks v-if="isLink(column)" /> <LazyVirtualCellLinks v-if="isLink(column)" />
<LazyVirtualCellHasMany v-else-if="isHm(column)" /> <LazyVirtualCellHasMany v-else-if="isHm(column)" />
<LazyVirtualCellManyToMany v-else-if="isMm(column)" /> <LazyVirtualCellManyToMany v-else-if="isMm(column)" />
@ -121,7 +87,6 @@ onUnmounted(() => {
<LazyVirtualCellLookup v-else-if="isLookup(column)" /> <LazyVirtualCellLookup v-else-if="isLookup(column)" />
<LazyCellReadOnlyDateTimePicker v-else-if="isCreatedOrLastModifiedTimeCol(column)" :model-value="modelValue" /> <LazyCellReadOnlyDateTimePicker v-else-if="isCreatedOrLastModifiedTimeCol(column)" :model-value="modelValue" />
<LazyCellReadOnlyUser v-else-if="isCreatedOrLastModifiedByCol(column)" :model-value="modelValue" /> <LazyCellReadOnlyUser v-else-if="isCreatedOrLastModifiedByCol(column)" :model-value="modelValue" />
</template>
</div> </div>
</template> </template>

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

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

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

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

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

@ -90,6 +90,8 @@ const props = defineProps<{
extraStyle?: string extraStyle?: string
} }
disableSkeleton?: boolean disableSkeleton?: boolean
disableVirtualX?: boolean
disableVirtualY?: boolean
}>() }>()
const emits = defineEmits(['update:selectedAllRecords', 'bulkUpdateDlg', 'toggleOptimisedQuery']) const emits = defineEmits(['update:selectedAllRecords', 'bulkUpdateDlg', 'toggleOptimisedQuery'])
@ -102,6 +104,16 @@ const dataRef = toRef(props, 'data')
const paginationStyleRef = toRef(props, 'pagination') const paginationStyleRef = toRef(props, 'pagination')
const headerOnly = toRef(props, 'headerOnly')
const hideHeader = toRef(props, 'hideHeader')
const disableSkeleton = toRef(props, 'disableSkeleton')
const disableVirtualX = toRef(props, 'disableVirtualX')
const disableVirtualY = toRef(props, 'disableVirtualY')
const { api } = useApi() const { api } = useApi()
const { const {
@ -115,9 +127,6 @@ const {
deleteRangeOfRows, deleteRangeOfRows,
removeRowIfNew, removeRowIfNew,
bulkUpdateRows, bulkUpdateRows,
headerOnly,
hideHeader,
disableSkeleton,
} = props } = props
// #Injections // #Injections
@ -171,23 +180,15 @@ const {
predictedNextColumn, predictedNextColumn,
predictingNextFormulas, predictingNextFormulas,
predictedNextFormulas, predictedNextFormulas,
predictNextColumn: _predictNextColumn, predictNextColumn,
predictNextFormulas: _predictNextFormulas, predictNextFormulas,
} = useNocoEe().table } = useNocoEe().table
const predictNextColumn = async () => {
await _predictNextColumn(meta)
}
const predictNextFormulas = async () => {
await _predictNextFormulas(meta)
}
const { paste } = usePaste() const { paste } = usePaste()
// #Refs const { addLTARRef, syncLTARRefs, clearLTARCell, cleaMMCell } = useSmartsheetLtarHelpersOrThrow()
const rowRefs = ref<any[]>() // #Refs
const smartTable = ref(null) const smartTable = ref(null)
@ -199,7 +200,7 @@ const tableBodyEl = ref<HTMLElement>()
const fillHandle = ref<HTMLElement>() const fillHandle = ref<HTMLElement>()
const gridRect = useElementBounding(gridWrapper) const { height: tableHeadHeight, width: _tableHeadWidth } = useElementBounding(tableHeadEl)
const isViewColumnsLoading = computed(() => _isViewColumnsLoading.value || !meta.value) const isViewColumnsLoading = computed(() => _isViewColumnsLoading.value || !meta.value)
@ -279,8 +280,8 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
if (isVirtualCol(columnObj)) { if (isVirtualCol(columnObj)) {
let mmClearResult let mmClearResult
if (isMm(columnObj) && rowRefs.value) { if (isMm(columnObj) && rowObj) {
mmClearResult = await rowRefs.value[ctx.row]!.cleaMMCell(columnObj) mmClearResult = await cleaMMCell(rowObj, columnObj)
} }
addUndo({ addUndo({
@ -298,12 +299,11 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) && rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) &&
columnObj.id === col.id columnObj.id === col.id
) { ) {
if (rowRefs.value) {
if (isBt(columnObj) || isOo(columnObj)) { if (isBt(columnObj) || isOo(columnObj)) {
rowObj.row[columnObj.title] = row.row[columnObj.title] rowObj.row[columnObj.title] = row.row[columnObj.title]
await rowRefs.value[ctx.row]!.addLTARRef(rowObj.row[columnObj.title], columnObj) await addLTARRef(rowObj, rowObj.row[columnObj.title], columnObj)
await rowRefs.value[ctx.row]!.syncLTARRefs(rowObj.row) await syncLTARRefs(rowObj, rowObj.row)
} else if (isMm(columnObj)) { } else if (isMm(columnObj)) {
await api.dbDataTableRow.nestedLink( await api.dbDataTableRow.nestedLink(
meta.value?.id as string, meta.value?.id as string,
@ -313,7 +313,6 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
) )
rowObj.row[columnObj.title] = mmClearResult?.length ? mmClearResult?.length : null rowObj.row[columnObj.title] = mmClearResult?.length ? mmClearResult?.length : null
} }
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define // eslint-disable-next-line @typescript-eslint/no-use-before-define
activeCell.col = ctx.col activeCell.col = ctx.col
@ -339,12 +338,10 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
const rowObj = dataRef.value[ctx.row] const rowObj = dataRef.value[ctx.row]
const columnObj = fields.value[ctx.col] const columnObj = fields.value[ctx.col]
if (rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) && columnObj.id === col.id) { if (rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) && columnObj.id === col.id) {
if (rowRefs.value) {
if (isBt(columnObj) || isOo(columnObj)) { if (isBt(columnObj) || isOo(columnObj)) {
await rowRefs.value[ctx.row]!.clearLTARCell(columnObj) await clearLTARCell(rowObj, columnObj)
} else if (isMm(columnObj)) { } else if (isMm(columnObj)) {
await rowRefs.value[ctx.row]!.cleaMMCell(columnObj) await cleaMMCell(rowObj, columnObj)
}
} }
// eslint-disable-next-line @typescript-eslint/no-use-before-define // eslint-disable-next-line @typescript-eslint/no-use-before-define
activeCell.col = ctx.col activeCell.col = ctx.col
@ -362,7 +359,7 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
}, },
scope: defineViewScope({ view: view.value }), scope: defineViewScope({ view: view.value }),
}) })
if ((isBt(columnObj) || isOo(columnObj)) && rowRefs.value) await rowRefs.value[ctx.row]!.clearLTARCell(columnObj) if (isBt(columnObj) || isOo(columnObj)) await clearLTARCell(rowObj, columnObj)
return return
} }
@ -426,7 +423,7 @@ const visibleColLength = computed(() => fields.value?.length)
const gridWrapperClass = computed<string>(() => { const gridWrapperClass = computed<string>(() => {
const classes = [] const classes = []
if (headerOnly !== true) { if (headerOnly.value !== true) {
if (!scrollParent.value) { if (!scrollParent.value) {
classes.push('nc-scrollbar-x-lg !overflow-auto') classes.push('nc-scrollbar-x-lg !overflow-auto')
} }
@ -453,10 +450,33 @@ const dummyRowDataForLoading = computed(() => {
const showSkeleton = computed( const showSkeleton = computed(
() => () =>
(disableSkeleton !== true && (isViewDataLoading.value || isPaginationLoading.value || isViewColumnsLoading.value)) || (disableSkeleton.value !== true && (isViewDataLoading.value || isPaginationLoading.value || isViewColumnsLoading.value)) ||
!meta.value, !meta.value,
) )
const cellMeta = computed(() => {
return dataRef.value.map((row) => {
return fields.value.map((col) => {
return {
isColumnRequiredAndNull: isColumnRequiredAndNull(col, row.row),
}
})
})
})
const colMeta = computed(() => {
return fields.value.map((col) => {
return {
isLookup: isLinksOrLTAR(col),
isRollup: isBt(col),
isFormula: isFormula(col),
isCreatedOrLastModifiedTimeCol: isCreatedOrLastModifiedTimeCol(col),
isCreatedOrLastModifiedByCol: isCreatedOrLastModifiedByCol(col),
isVirtualCol: isVirtualCol(col),
}
})
})
// #Grid // #Grid
function openColumnCreate(data: any) { function openColumnCreate(data: any) {
@ -503,7 +523,12 @@ const onNewRecordToFormClick = () => {
} }
const getContainerScrollForElement = ( const getContainerScrollForElement = (
el: HTMLElement, childPos: {
top: number
right: number
bottom: number
left: number
},
container: HTMLElement, container: HTMLElement,
offset?: { offset?: {
top?: number top?: number
@ -512,7 +537,6 @@ const getContainerScrollForElement = (
right?: number right?: number
}, },
) => { ) => {
const childPos = el.getBoundingClientRect()
const parentPos = container.getBoundingClientRect() const parentPos = container.getBoundingClientRect()
// provide an extra offset to show the prev/next/up/bottom cell // provide an extra offset to show the prev/next/up/bottom cell
@ -524,10 +548,10 @@ const getContainerScrollForElement = (
const stickyColsWidth = numColWidth + primaryColWidth const stickyColsWidth = numColWidth + primaryColWidth
const relativePos = { const relativePos = {
top: childPos.top - parentPos.top, right: childPos.right + numColWidth - parentPos.width - container.scrollLeft,
right: childPos.right - parentPos.right, left: childPos.left + numColWidth - container.scrollLeft - stickyColsWidth,
bottom: childPos.bottom - parentPos.bottom, bottom: childPos.bottom - parentPos.height - container.scrollTop,
left: childPos.left - parentPos.left - stickyColsWidth, top: childPos.top - container.scrollTop,
} }
const scroll = { const scroll = {
@ -561,7 +585,8 @@ const getContainerScrollForElement = (
} }
const { const {
isCellSelected, selectRangeMap,
fillRangeMap,
activeCell, activeCell,
handleMouseDown, handleMouseDown,
handleMouseOver, handleMouseOver,
@ -572,7 +597,6 @@ const {
resetSelectedRange, resetSelectedRange,
makeActive, makeActive,
selectedRange, selectedRange,
isCellInFillRange,
isFillMode, isFillMode,
} = useMultiSelect( } = useMultiSelect(
meta, meta,
@ -947,22 +971,46 @@ async function clearSelectedRangeOfCells() {
await bulkUpdateRows?.(rows, props) await bulkUpdateRows?.(rows, props)
} }
const colPositions = computed(() => {
return fields.value
.filter((col) => col.id && gridViewCols.value[col.id] && gridViewCols.value[col.id].width && gridViewCols.value[col.id].show)
.map((col) => {
return +gridViewCols.value[col.id!]!.width!.replace('px', '') || 200
})
.reduce(
(acc, width, i) => {
acc.push(acc[i] + width)
return acc
},
[0],
)
})
const scrollWrapper = computed(() => scrollParent.value || gridWrapper.value) const scrollWrapper = computed(() => scrollParent.value || gridWrapper.value)
function scrollToCell(row?: number | null, col?: number | null) { function scrollToCell(row?: number | null, col?: number | null) {
row = row ?? activeCell.row row = row ?? activeCell.row
col = col ?? activeCell.col col = col ?? activeCell.col
if (row !== null && col !== null) { if (row !== null && col !== null && scrollWrapper.value) {
// get active cell // calculate cell position
const rows = tableBodyEl.value?.querySelectorAll('tr') const td = {
const cols = rows?.[row]?.querySelectorAll('td') top: row * rowHeightInPx[`${props.rowHeight}`],
const td = cols?.[col === 0 ? 0 : col + 1] left: colPositions.value[col],
right:
col === fields.value.length - 1 ? colPositions.value[colPositions.value.length - 1] + 200 : colPositions.value[col + 1],
bottom: (row + 1) * rowHeightInPx[`${props.rowHeight}`],
}
if (!td || !scrollWrapper.value) return const tdScroll = getContainerScrollForElement(td, scrollWrapper.value, {
top: 9,
bottom: (tableHeadHeight.value || 40) + 9,
right: 9,
})
const { height: headerHeight } = tableHeadEl.value!.getBoundingClientRect() if (isGroupBy.value) {
const tdScroll = getContainerScrollForElement(td, scrollWrapper.value, { top: headerHeight || 40, bottom: 9, right: 9 }) tdScroll.top = scrollWrapper.value.scrollTop
}
// if first column set left to 0 since it's sticky it will be visible and calculated value will be wrong // if first column set left to 0 since it's sticky it will be visible and calculated value will be wrong
// setting left to 0 will make it scroll to the left // setting left to 0 will make it scroll to the left
@ -970,20 +1018,11 @@ function scrollToCell(row?: number | null, col?: number | null) {
tdScroll.left = 0 tdScroll.left = 0
} }
if (rows && row === rows.length - 2) { if (row === dataRef.value.length - 1) {
// if last row make 'Add New Row' visible
const lastRow = rows[rows.length - 1] || rows[rows.length - 2]
const lastRowScroll = getContainerScrollForElement(lastRow, scrollWrapper.value, {
top: headerHeight || 40,
bottom: 9,
right: 9,
})
scrollWrapper.value.scrollTo({ scrollWrapper.value.scrollTo({
top: lastRowScroll.top, top: isGroupBy.value ? scrollWrapper.value.scrollTop : scrollWrapper.value.scrollHeight,
left: left:
cols && col === cols.length - 2 // if corner cell col === fields.value.length - 1 // if corner cell
? scrollWrapper.value.scrollWidth ? scrollWrapper.value.scrollWidth
: tdScroll.left, : tdScroll.left,
behavior: 'smooth', behavior: 'smooth',
@ -991,7 +1030,7 @@ function scrollToCell(row?: number | null, col?: number | null) {
return return
} }
if (cols && col === cols.length - 2) { if (col === fields.value.length - 1) {
// if last column make 'Add New Column' visible // if last column make 'Add New Column' visible
scrollWrapper.value.scrollTo({ scrollWrapper.value.scrollTo({
top: tdScroll.top, top: tdScroll.top,
@ -1026,14 +1065,11 @@ async function resetAndChangePage(row: number, col: number, pageChange?: number)
} }
const saveOrUpdateRecords = async (args: { metaValue?: TableType; viewMetaValue?: ViewType; data?: any } = {}) => { const saveOrUpdateRecords = async (args: { metaValue?: TableType; viewMetaValue?: ViewType; data?: any } = {}) => {
let index = -1
for (const currentRow of args.data || dataRef.value) { for (const currentRow of args.data || dataRef.value) {
index++
/** if new record save row and save the LTAR cells */ /** if new record save row and save the LTAR cells */
if (currentRow.rowMeta.new) { if (currentRow.rowMeta.new) {
const syncLTARRefs = rowRefs.value?.[index]?.syncLTARRefs
const savedRow = await updateOrSaveRow?.(currentRow, '', {}, args) const savedRow = await updateOrSaveRow?.(currentRow, '', {}, args)
await syncLTARRefs?.(savedRow, args) await syncLTARRefs?.(currentRow, savedRow, args)
currentRow.rowMeta.changed = false currentRow.rowMeta.changed = false
continue continue
} }
@ -1078,6 +1114,133 @@ const loadColumn = (title: string, tp: string, colOptions?: any) => {
persistMenu.value = false persistMenu.value = false
} }
// Virtual scroll
const maxGridWidth = computed(() => {
// 64 for the row number column
// count first column twice because it's sticky
// 100 for add new column
return colPositions.value[colPositions.value.length - 1] + colPositions.value[1] + 64 + 100
})
const maxGridHeight = computed(() => {
// 2 extra rows for the add new row and the sticky header
return dataRef.value.length * rowHeightInPx[`${props.rowHeight}`] + 2 * rowHeightInPx[`${props.rowHeight}`]
})
const colSlice = ref({
start: 0,
end: 0,
})
const rowSlice = ref({
start: 0,
end: 0,
})
const VIRTUAL_MARGIN = 10
const calculateSlices = () => {
// if the grid is not rendered yet
if (!scrollWrapper.value || !gridWrapper.value) {
colSlice.value = {
start: 0,
end: 0,
}
rowSlice.value = {
start: 0,
end: 0,
}
return
}
let renderStart = 0
if (disableVirtualX.value !== true) {
// use binary search to find the start and end columns
let startRange = 0
let endRange = colPositions.value.length - 1
while (endRange !== startRange) {
const middle = Math.floor((endRange - startRange) / 2 + startRange)
if (
colPositions.value[middle] <= scrollWrapper.value.scrollLeft &&
colPositions.value[middle + 1] > scrollWrapper.value.scrollLeft
) {
renderStart = middle
break
}
if (middle === startRange) {
renderStart = endRange
break
} else {
if (colPositions.value[middle] <= scrollWrapper.value.scrollLeft) {
startRange = middle
} else {
endRange = middle
}
}
}
let renderEnd = 0
let renderEndFound = false
for (let i = renderStart; i < colPositions.value.length; i++) {
if (colPositions.value[i] > gridWrapper.value.clientWidth + scrollWrapper.value.scrollLeft) {
renderEnd = i
renderEndFound = true
break
}
}
colSlice.value = {
start: Math.max(0, renderStart - VIRTUAL_MARGIN),
end: renderEndFound ? Math.min(fields.value.length, renderEnd + VIRTUAL_MARGIN) : fields.value.length,
}
}
if (disableVirtualY.value !== true) {
const rowHeight = rowHeightInPx[`${props.rowHeight}`]
const rowRenderStart = Math.max(0, Math.floor(scrollWrapper.value.scrollTop / rowHeight) - VIRTUAL_MARGIN)
const rowRenderEnd = Math.min(
dataRef.value.length,
rowRenderStart + Math.ceil(gridWrapper.value.clientHeight / rowHeight) + VIRTUAL_MARGIN,
)
rowSlice.value = {
start: rowRenderStart,
end: rowRenderEnd,
}
}
}
const visibleFields = computed(() => {
if (disableVirtualX.value) return fields.value.map((field, index) => ({ field, index })).filter((f) => f.index !== 0)
// return data as { field, index } to keep track of the index
const vFields = fields.value.slice(colSlice.value.start, colSlice.value.end)
return vFields.map((field, index) => ({ field, index: index + colSlice.value.start })).filter((f) => f.index !== 0)
})
const visibleData = computed(() => {
if (disableVirtualY.value) return dataRef.value.map((row, index) => ({ row, index }))
// return data as { row, index } to keep track of the index
return dataRef.value.slice(rowSlice.value.start, rowSlice.value.end).map((row, index) => ({
row,
index: index + rowSlice.value.start,
}))
})
const leftOffset = computed(() => {
return colSlice.value.start > 0 ? colPositions.value[colSlice.value.start] - colPositions.value[1] : 0
})
const topOffset = computed(() => {
return rowHeightInPx[`${props.rowHeight}`] * rowSlice.value.start
})
// #Fill Handle // #Fill Handle
const fillHandleTop = ref() const fillHandleTop = ref()
@ -1085,12 +1248,18 @@ const fillHandleLeft = ref()
const refreshFillHandle = () => { const refreshFillHandle = () => {
nextTick(() => { nextTick(() => {
const cellRef = document.querySelector('.last-cell') const rowIndex = isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row
if (cellRef) { const colIndex = isNaN(selectedRange.end.col) ? activeCell.col : selectedRange.end.col
const cellRect = cellRef.getBoundingClientRect() if (rowIndex !== null && colIndex !== null) {
if (!cellRect || !gridWrapper.value) return if (!scrollWrapper.value || !gridWrapper.value) return
fillHandleTop.value = cellRect.top + cellRect.height - gridRect.top.value + gridWrapper.value.scrollTop
fillHandleLeft.value = cellRect.left + cellRect.width - gridRect.left.value + gridWrapper.value.scrollLeft // 32 for the header
fillHandleTop.value = (rowIndex + 1) * rowHeightInPx[`${props.rowHeight}`] + (hideHeader.value ? 0 : 32)
// 64 for the row number column
fillHandleLeft.value =
64 +
colPositions.value[colIndex + 1] +
(colIndex === 0 ? Math.max(0, scrollWrapper.value.scrollLeft - gridWrapper.value.offsetLeft) : 0)
} }
}) })
} }
@ -1115,6 +1284,7 @@ watch(
[() => selectedRange.end.row, () => selectedRange.end.col, () => activeCell.row, () => activeCell.col], [() => selectedRange.end.row, () => selectedRange.end.col, () => activeCell.row, () => activeCell.col],
([sr, sc, ar, ac], [osr, osc, oar, oac]) => { ([sr, sc, ar, ac], [osr, osc, oar, oac]) => {
if (sr !== osr || sc !== osc || ar !== oar || ac !== oac) { if (sr !== osr || sc !== osc || ar !== oar || ac !== oac) {
calculateSlices()
refreshFillHandle() refreshFillHandle()
} }
}, },
@ -1130,6 +1300,9 @@ onMounted(() => {
} }
}) })
if (smartTable.value) resizeObserver.observe(smartTable.value) if (smartTable.value) resizeObserver.observe(smartTable.value)
until(scrollWrapper)
.toBeTruthy()
.then(() => calculateSlices())
}) })
// #Listeners // #Listeners
@ -1160,12 +1333,22 @@ async function reloadViewDataHandler(params: void | { shouldShowLoading?: boolea
await loadData?.({ ...(params?.offset !== undefined ? { offset: params.offset } : {}) }) await loadData?.({ ...(params?.offset !== undefined ? { offset: params.offset } : {}) })
calculateSlices()
isViewDataLoading.value = false isViewDataLoading.value = false
} }
let frame: number | null = null
useEventListener(scrollWrapper, 'scroll', () => { useEventListener(scrollWrapper, 'scroll', () => {
if (frame) {
cancelAnimationFrame(frame)
}
frame = requestAnimationFrame(() => {
calculateSlices()
refreshFillHandle() refreshFillHandle()
}) })
})
useEventListener(document, 'mousedown', (e) => { useEventListener(document, 'mousedown', (e) => {
if (e.offsetX > (e.target as HTMLElement)?.clientWidth || e.offsetY > (e.target as HTMLElement)?.clientHeight) { if (e.offsetX > (e.target as HTMLElement)?.clientWidth || e.offsetY > (e.target as HTMLElement)?.clientHeight) {
@ -1310,6 +1493,7 @@ watch(
isViewDataLoading.value = true isViewDataLoading.value = true
try { try {
await loadData?.() await loadData?.()
calculateSlices()
} catch (e) { } catch (e) {
if (!axios.isCancel(e)) { if (!axios.isCancel(e)) {
console.log(e) console.log(e)
@ -1328,6 +1512,10 @@ watch(
{ immediate: true }, { immediate: true },
) )
watch([() => fields.value.length, () => dataRef.value.length], () => {
calculateSlices()
})
// #Providers // #Providers
provide(CellUrlDisableOverlayInj, disableUrlOverlay) provide(CellUrlDisableOverlayInj, disableUrlOverlay)
@ -1414,16 +1602,16 @@ onKeyStroke('ArrowDown', onDown)
:trigger="isSqlView ? [] : ['contextmenu']" :trigger="isSqlView ? [] : ['contextmenu']"
overlay-class-name="nc-dropdown-grid-context-menu" overlay-class-name="nc-dropdown-grid-context-menu"
> >
<div class="table-overlay" :class="{ 'nc-grid-skeleton-loader': showSkeleton }"> <div>
<table <table
ref="smartTable" class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white sticky top-0 z-5 bg-white"
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white relative"
:class="{ :class="{
'mobile': isMobileMode, mobile: isMobileMode,
'desktop': !isMobileMode, desktop: !isMobileMode,
'pr-60 pb-12': !headerOnly, }"
:style="{
transform: `translateX(${leftOffset}px)`,
}" }"
@contextmenu="showContextMenu"
> >
<thead v-show="hideHeader !== true" ref="tableHeadEl"> <thead v-show="hideHeader !== true" ref="tableHeadEl">
<tr v-if="isViewColumnsLoading"> <tr v-if="isViewColumnsLoading">
@ -1446,7 +1634,13 @@ onKeyStroke('ArrowDown', onDown)
</td> </td>
</tr> </tr>
<tr v-show="!isViewColumnsLoading" class="nc-grid-header"> <tr v-show="!isViewColumnsLoading" class="nc-grid-header">
<th class="w-[64px] min-w-[64px]" data-testid="grid-id-column" @dblclick="() => {}"> <th
class="w-[64px] min-w-[64px]"
data-testid="grid-id-column"
:style="{
left: `-${leftOffset}px`,
}"
>
<div class="w-full h-full flex pl-2 pr-1 items-center" data-testid="nc-check-all"> <div class="w-full h-full flex pl-2 pr-1 items-center" data-testid="nc-check-all">
<template v-if="!readOnly"> <template v-if="!readOnly">
<div class="nc-no-label text-gray-500" :class="{ hidden: vSelectedAllRecords }">#</div> <div class="nc-no-label text-gray-500" :class="{ hidden: vSelectedAllRecords }">#</div>
@ -1468,8 +1662,47 @@ onKeyStroke('ArrowDown', onDown)
</div> </div>
</th> </th>
<th <th
v-for="(col, index) in fields" v-if="fields[0]"
:key="col.title" v-xc-ver-resize
:data-col="fields[0].id"
:data-title="fields[0].title"
:style="{
'min-width': gridViewCols[fields[0].id]?.width || '180px',
'max-width': gridViewCols[fields[0].id]?.width || '180px',
'width': gridViewCols[fields[0].id]?.width || '180px',
...(leftOffset > 0
? {
left: `-${leftOffset - 64}px`,
}
: {}),
}"
class="nc-grid-column-header"
:class="{
'!border-r-blue-400 !border-r-3': toBeDroppedColId === fields[0].id,
}"
@xcstartresizing="onXcStartResizing(fields[0].id, $event)"
@xcresize="onresize(fields[0].id, $event)"
@xcresizing="onXcResizing(fields[0].id, $event)"
@click="selectColumn(fields[0].id!)"
>
<div
class="w-full h-full flex items-center text-gray-500 pl-2 pr-1"
draggable="false"
@dragstart.stop="onDragStart(fields[0].id!, $event)"
@drag.stop="onDrag($event)"
@dragend.stop="onDragEnd($event)"
>
<LazySmartsheetHeaderVirtualCell
v-if="fields[0] && colMeta[0].isVirtualCol"
:column="fields[0]"
:hide-menu="readOnly || isMobileMode"
/>
<LazySmartsheetHeaderCell v-else :column="fields[0]" :hide-menu="readOnly || isMobileMode" />
</div>
</th>
<th
v-for="{ field: col, index } in visibleFields"
:key="col.id"
v-xc-ver-resize v-xc-ver-resize
:data-col="col.id" :data-col="col.id"
:data-title="col.title" :data-title="col.title"
@ -1495,7 +1728,7 @@ onKeyStroke('ArrowDown', onDown)
@dragend.stop="onDragEnd($event)" @dragend.stop="onDragEnd($event)"
> >
<LazySmartsheetHeaderVirtualCell <LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(col)" v-if="colMeta[index].isVirtualCol"
:column="col" :column="col"
:hide-menu="readOnly || isMobileMode" :hide-menu="readOnly || isMobileMode"
/> />
@ -1623,6 +1856,29 @@ onKeyStroke('ArrowDown', onDown)
</th> </th>
</tr> </tr>
</thead> </thead>
</table>
<div
class="table-overlay"
:class="{ 'nc-grid-skeleton-loader': showSkeleton }"
:style="{
height: `${maxGridHeight}px`,
width: `${maxGridWidth}px`,
}"
>
<table
ref="smartTable"
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white relative"
:class="{
'mobile': isMobileMode,
'desktop': !isMobileMode,
'pr-60 pb-12': !headerOnly,
'w-full': dataRef.length === 0,
}"
:style="{
transform: `translateY(${topOffset}px) translateX(${leftOffset}px)`,
}"
@contextmenu="showContextMenu"
>
<tbody v-if="headerOnly !== true" ref="tableBodyEl"> <tbody v-if="headerOnly !== true" ref="tableBodyEl">
<template v-if="showSkeleton"> <template v-if="showSkeleton">
<tr v-for="(row, rowIndex) of dummyRowDataForLoading" :key="rowIndex"> <tr v-for="(row, rowIndex) of dummyRowDataForLoading" :key="rowIndex">
@ -1634,7 +1890,7 @@ onKeyStroke('ArrowDown', onDown)
></td> ></td>
</tr> </tr>
</template> </template>
<LazySmartsheetRow v-for="(row, rowIndex) of dataRef" ref="rowRefs" :key="rowIndex" :row="row"> <LazySmartsheetRow v-for="{ row, index: rowIndex } in visibleData" :key="rowIndex" :row="row">
<template #default="{ state }"> <template #default="{ state }">
<tr <tr
v-show="!showSkeleton" v-show="!showSkeleton"
@ -1649,6 +1905,9 @@ onKeyStroke('ArrowDown', onDown)
<td <td
key="row-index" key="row-index"
class="caption nc-grid-cell w-[64px] min-w-[64px]" class="caption nc-grid-cell w-[64px] min-w-[64px]"
:style="{
left: `-${leftOffset}px`,
}"
:data-testid="`cell-Id-${rowIndex}`" :data-testid="`cell-Id-${rowIndex}`"
@contextmenu="contextMenuTarget = null" @contextmenu="contextMenuTarget = null"
> >
@ -1710,29 +1969,100 @@ onKeyStroke('ArrowDown', onDown)
</div> </div>
</td> </td>
<SmartsheetTableDataCell <SmartsheetTableDataCell
v-for="(columnObj, colIndex) of fields" v-if="fields[0]"
:key="columnObj.id" :key="fields[0].id"
class="cell relative nc-grid-cell cursor-pointer" class="cell relative nc-grid-cell cursor-pointer"
:class="{ :class="{
'active': isCellSelected(rowIndex, colIndex), 'active': selectRangeMap[`${rowIndex}-0`],
'active-cell':
(activeCell.row === rowIndex && activeCell.col === 0) ||
(selectedRange._start?.row === rowIndex && selectedRange._start?.col === 0),
'nc-required-cell': cellMeta[rowIndex][0].isColumnRequiredAndNull && !isPublicView,
'align-middle': !rowHeight || rowHeight === 1,
'align-top': rowHeight && rowHeight !== 1,
'filling': fillRangeMap[`${rowIndex}-0`],
'readonly':
(colMeta[0].isLookup ||
colMeta[0].isRollup ||
colMeta[0].isFormula ||
colMeta[0].isCreatedOrLastModifiedTimeCol ||
colMeta[0].isCreatedOrLastModifiedByCol) &&
hasEditPermission &&
selectRangeMap[`${rowIndex}-0`],
'!border-r-blue-400 !border-r-3': toBeDroppedColId === fields[0].id,
}"
:style="{
'min-width': gridViewCols[fields[0].id]?.width || '180px',
'max-width': gridViewCols[fields[0].id]?.width || '180px',
'width': gridViewCols[fields[0].id]?.width || '180px',
...(leftOffset > 0
? {
left: `-${leftOffset - 64}px`,
}
: {}),
}"
:data-testid="`cell-${fields[0].title}-${rowIndex}`"
:data-key="`data-key-${rowIndex}-${fields[0].id}`"
:data-col="fields[0].id"
:data-title="fields[0].title"
:data-row-index="rowIndex"
:data-col-index="0"
@mousedown="handleMouseDown($event, rowIndex, 0)"
@mouseover="handleMouseOver($event, rowIndex, 0)"
@click="handleCellClick($event, rowIndex, 0)"
@dblclick="makeEditable(row, fields[0])"
@contextmenu="showContextMenu($event, { row: rowIndex, col: 0 })"
>
<div v-if="!switchingTab" class="w-full">
<LazySmartsheetVirtualCell
v-if="fields[0] && colMeta[0].isVirtualCol && fields[0].title"
v-model="row.row[fields[0].title]"
:column="fields[0]"
:active="activeCell.col === 0 && activeCell.row === rowIndex"
:row="row"
:read-only="!hasEditPermission"
@navigate="onNavigate"
@save="updateOrSaveRow?.(row, '', state)"
/>
<LazySmartsheetCell
v-else-if="fields[0] && fields[0].title"
v-model="row.row[fields[0].title]"
:column="fields[0]"
:edit-enabled="
!!hasEditPermission && !!editEnabled && activeCell.col === 0 && activeCell.row === rowIndex
"
:row-index="rowIndex"
:active="activeCell.col === 0 && activeCell.row === rowIndex"
:read-only="!hasEditPermission"
@update:edit-enabled="editEnabled = $event"
@save="updateOrSaveRow?.(row, fields[0].title, state)"
@navigate="onNavigate"
@cancel="editEnabled = false"
/>
</div>
</SmartsheetTableDataCell>
<SmartsheetTableDataCell
v-for="{ field: columnObj, index: colIndex } of visibleFields"
:key="`cell-${colIndex}-${rowIndex}`"
class="cell relative nc-grid-cell cursor-pointer"
:class="{
'active': selectRangeMap[`${rowIndex}-${colIndex}`],
'active-cell': 'active-cell':
(activeCell.row === rowIndex && activeCell.col === colIndex) || (activeCell.row === rowIndex && activeCell.col === colIndex) ||
(selectedRange._start?.row === rowIndex && selectedRange._start?.col === colIndex), (selectedRange._start?.row === rowIndex && selectedRange._start?.col === colIndex),
'last-cell': 'nc-required-cell': cellMeta[rowIndex][colIndex].isColumnRequiredAndNull && !isPublicView,
rowIndex === (isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row) &&
colIndex === (isNaN(selectedRange.end.col) ? activeCell.col : selectedRange.end.col),
'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row) && !isPublicView,
'align-middle': !rowHeight || rowHeight === 1, 'align-middle': !rowHeight || rowHeight === 1,
'align-top': rowHeight && rowHeight !== 1, 'align-top': rowHeight && rowHeight !== 1,
'filling': isCellInFillRange(rowIndex, colIndex), 'filling': fillRangeMap[`${rowIndex}-${colIndex}`],
'readonly': 'readonly':
(isLookup(columnObj) || (colMeta[colIndex].isLookup ||
isRollup(columnObj) || colMeta[colIndex].isRollup ||
isFormula(columnObj) || colMeta[colIndex].isFormula ||
isCreatedOrLastModifiedTimeCol(columnObj) || colMeta[colIndex].isCreatedOrLastModifiedTimeCol ||
isCreatedOrLastModifiedByCol(columnObj)) && colMeta[colIndex].isCreatedOrLastModifiedByCol) &&
hasEditPermission && hasEditPermission &&
isCellSelected(rowIndex, colIndex), selectRangeMap[`${rowIndex}-${colIndex}`],
'!border-r-blue-400 !border-r-3': toBeDroppedColId === columnObj.id, '!border-r-blue-400 !border-r-3': toBeDroppedColId === columnObj.id,
}" }"
:style="{ :style="{
@ -1754,7 +2084,7 @@ onKeyStroke('ArrowDown', onDown)
> >
<div v-if="!switchingTab" class="w-full"> <div v-if="!switchingTab" class="w-full">
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell
v-if="isVirtualCol(columnObj) && columnObj.title" v-if="colMeta[colIndex].isVirtualCol && columnObj.title"
v-model="row.row[columnObj.title]" v-model="row.row[columnObj.title]"
:column="columnObj" :column="columnObj"
:active="activeCell.col === colIndex && activeCell.row === rowIndex" :active="activeCell.col === colIndex && activeCell.row === rowIndex"
@ -1797,6 +2127,9 @@ onKeyStroke('ArrowDown', onDown)
> >
<div <div
class="h-8 border-b-1 border-gray-100 bg-white group-hover:bg-gray-50 absolute left-0 bottom-0 px-2 sticky z-40 w-full flex items-center text-gray-500" class="h-8 border-b-1 border-gray-100 bg-white group-hover:bg-gray-50 absolute left-0 bottom-0 px-2 sticky z-40 w-full flex items-center text-gray-500"
:style="{
left: `-${leftOffset}px`,
}"
> >
<component <component
:is="iconMap.plus" :is="iconMap.plus"
@ -1826,6 +2159,7 @@ onKeyStroke('ArrowDown', onDown)
}" }"
/> />
</div> </div>
</div>
<template #overlay> <template #overlay>
<NcMenu class="!rounded !py-0" @click="contextMenu = false"> <NcMenu class="!rounded !py-0" @click="contextMenu = false">
@ -1906,7 +2240,7 @@ onKeyStroke('ArrowDown', onDown)
contextMenuTarget && contextMenuTarget &&
hasEditPermission && hasEditPermission &&
selectedRange.isSingleCell() && selectedRange.isSingleCell() &&
(isLinksOrLTAR(fields[contextMenuTarget.col]) || !isVirtualCol(fields[contextMenuTarget.col])) (isLinksOrLTAR(fields[contextMenuTarget.col]) || !cellMeta[0]?.[contextMenuTarget.col].isVirtualCol)
" "
class="nc-base-menu-item" class="nc-base-menu-item"
:disabled="isSystemColumn(fields[contextMenuTarget.col])" :disabled="isSystemColumn(fields[contextMenuTarget.col])"
@ -2365,11 +2699,6 @@ onKeyStroke('ArrowDown', onDown)
} }
.nc-grid-header { .nc-grid-header {
position: sticky;
top: -1px;
@apply z-5 bg-white;
&:hover { &:hover {
.nc-no-label { .nc-no-label {
@apply hidden; @apply hidden;

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

@ -24,6 +24,7 @@ import {
useMetas, useMetas,
useProvideCalendarViewStore, useProvideCalendarViewStore,
useProvideKanbanViewStore, useProvideKanbanViewStore,
useProvideSmartsheetLtarHelpers,
useProvideSmartsheetStore, useProvideSmartsheetStore,
useRoles, useRoles,
useSqlEditor, useSqlEditor,
@ -58,8 +59,6 @@ const { isGallery, isGrid, isForm, isKanban, isLocked, isMap, isCalendar, xWhere
useSqlEditor() useSqlEditor()
const { isPanelExpanded } = useExtensions()
const reloadViewDataEventHook = createEventHook() const reloadViewDataEventHook = createEventHook()
const reloadViewMetaEventHook = createEventHook<void | boolean>() const reloadViewMetaEventHook = createEventHook<void | boolean>()
@ -88,6 +87,8 @@ provide(
useProvideViewColumns(activeView, meta, () => reloadViewDataEventHook?.trigger()) useProvideViewColumns(activeView, meta, () => reloadViewDataEventHook?.trigger())
useProvideViewGroupBy(activeView, meta, xWhere) useProvideViewGroupBy(activeView, meta, xWhere)
useProvideSmartsheetLtarHelpers(meta)
const grid = ref() const grid = ref()
const onDrop = async (event: DragEvent) => { 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), : ({ row: {}, oldRow: {}, rowMeta: {} } as Row),
) )
const rowStore = useProvideSmartsheetRowStore(meta, row) const rowStore = useProvideSmartsheetRowStore(row)
const activeView = inject(ActiveViewInj, ref()) 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 type { TableType } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, storeToRefs, useBase, useNuxtApp, useState, watch } from '#imports' import { extractSdkResponseErrorMsg, storeToRefs, useBase, useNuxtApp, useState, watch } from '#imports'
export function useMetas() { export const useMetas = createSharedComposable(() => {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { tables: _tables } = storeToRefs(useBase()) const { tables: _tables } = storeToRefs(useBase())
@ -118,4 +118,4 @@ export function useMetas() {
} }
return { getMeta, clearAllMeta, metas, metasWithIdAsKey, removeMeta, setMeta } return { getMeta, clearAllMeta, metas, metasWithIdAsKey, removeMeta, setMeta }
} })

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

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

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

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

240
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 type { MaybeRef } from '@vueuse/core'
import { import { computed, ref, unref, useInjectionState } from '#imports'
NOCO,
computed,
deepCompare,
extractPkFromRow,
extractSdkResponseErrorMsg,
isBt,
isHm,
isMm,
isOo,
message,
ref,
storeToRefs,
unref,
useBase,
useI18n,
useInjectionState,
useMetas,
useNuxtApp,
} from '#imports'
import type { Row } from '#imports' import type { Row } from '#imports'
const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState( const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState((row: MaybeRef<Row>) => {
(meta: Ref<TableType | undefined>, row: MaybeRef<Row>) => {
const { $api } = useNuxtApp()
const { t } = useI18n()
const { base } = storeToRefs(useBase())
const { metas } = useMetas()
const currentRow = ref(row) const currentRow = ref(row)
// state // state
const state = ref<Record<string, Record<string, any> | Record<string, any>[] | null>>({}) const state = computed({
get: () => currentRow.value?.rowMeta?.ltarState ?? {},
// getters set: (value) => {
const isNew = computed(() => unref(row).rowMeta.new ?? false) if (currentRow.value) {
if (!currentRow.value.rowMeta) {
// actions currentRow.value.rowMeta = {}
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)
}
} else if (isBt(column) || isOo(column)) {
state.value[column.title!] = 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!]) { currentRow.value.rowMeta.ltarState = value
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 // getters
const isNew = computed(() => unref(row).rowMeta?.new ?? false)
return Array.isArray(result.unlink) ? result.unlink : [] const { addLTARRef, removeLTARRef, syncLTARRefs, loadRow, clearLTARCell, cleaMMCell } = useSmartsheetLtarHelpersOrThrow()
}
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
return { return {
row, row,
state, state,
isNew, isNew,
// todo: use better name // todo: use better name
addLTARRef, addLTARRef: (...args: any) => addLTARRef(currentRow.value, ...args),
removeLTARRef, removeLTARRef: (...args: any) => removeLTARRef(currentRow.value, ...args),
syncLTARRefs, syncLTARRefs: (...args: any) => syncLTARRefs(currentRow.value, ...args),
loadRow, loadRow: (...args: any) => loadRow(currentRow.value, ...args),
currentRow, currentRow,
clearLTARCell, clearLTARCell: (...args: any) => clearLTARCell(currentRow.value, ...args),
cleaMMCell, cleaMMCell: (...args: any) => cleaMMCell(currentRow.value, ...args),
} }
}, }, 'smartsheet-row-store')
'smartsheet-row-store',
)
export { useProvideSmartsheetRowStore } export { useProvideSmartsheetRowStore }

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

@ -64,6 +64,7 @@ interface Row {
commentCount?: number commentCount?: number
changed?: boolean changed?: boolean
saving?: boolean saving?: boolean
ltarState?: Record<string, Record<string, any> | Record<string, any>[] | null>
// use in datetime picker component // use in datetime picker component
isUpdatedFromCopyNPaste?: Record<string, boolean> isUpdatedFromCopyNPaste?: Record<string, boolean>
// Used in Calendar view // 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 // 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').scrollIntoViewIfNeeded();
await this.getColumnHeader(title).locator('.nc-ui-dt-dropdown').click(); 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' }); 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}"]`); 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() { async rowCount() {
return await this.get().locator('.nc-grid-row').count(); 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 }) { private async _fillRow({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) {
const cell = this.cell.get({ index, columnHeader }); 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({ await this.cell.dblclick({
index, index,
columnHeader, columnHeader,
@ -132,7 +170,9 @@ export class GridPage extends BasePage {
// fallback // fallback
await this.rootPage.waitForTimeout(400); 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 }); 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'); const locator = this.cell.get({ index, columnHeader }).locator('.ant-tag');
await locator.waitFor({ state: 'visible' }); await expect(locator).toBeVisible();
return expect(locator).toContainText(option); 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']>) { 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 }).scrollIntoViewIfNeeded();
await this.get({ index, columnHeader }).click(...clickOptions); await this.get({ index, columnHeader }).click(...clickOptions);
await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable'); 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() { async clickRefresh() {
await this.get().locator(`.nc-icon-reload`).waitFor({ state: 'visible' }); await this.get().locator(`.nc-icon-reload`).waitFor({ state: 'visible' });
await this.get().locator(`.nc-icon-reload`).click(); 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 grid.cell.click({ index: 3, columnHeader: 'User' });
await dashboard.rootPage.keyboard.press('Shift+ArrowDown'); 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 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 // refresh
await topbar.clickRefresh(); 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({ await dashboard.grid.cell.attachment.addFile({
index: 0, index: 0,
columnHeader: 'Attachment', columnHeader: 'Attachment',
@ -304,6 +306,7 @@ test.describe('Clipboard support', () => {
]; ];
for (const { type, value } of responseTable) { for (const { type, value } of responseTable) {
await dashboard.grid.renderColumn(type);
if (type === 'Rating') { if (type === 'Rating') {
await dashboard.grid.cell.rating.verify({ await dashboard.grid.cell.rating.verify({
index: rowIndex, index: rowIndex,

Loading…
Cancel
Save