Browse Source

Merge branch 'develop' into refactor/webhooks

pull/5349/head
Wing-Kam Wong 2 years ago
parent
commit
33581794a2
  1. 2
      packages/nc-gui/components/cell/ClampedText.vue
  2. 2
      packages/nc-gui/components/cell/MultiSelect.vue
  3. 2
      packages/nc-gui/components/cell/SingleSelect.vue
  4. 5
      packages/nc-gui/components/cell/Text.vue
  5. 5
      packages/nc-gui/components/cell/TextArea.vue
  6. 56
      packages/nc-gui/components/dashboard/TreeView.vue
  7. 2
      packages/nc-gui/components/dashboard/settings/Modal.vue
  8. 29
      packages/nc-gui/components/dlg/TableRename.vue
  9. 62
      packages/nc-gui/components/smartsheet/Grid.vue
  10. 44
      packages/nc-gui/components/smartsheet/Kanban.vue
  11. 3
      packages/nc-gui/components/smartsheet/Row.vue
  12. 82
      packages/nc-gui/components/smartsheet/header/Menu.vue
  13. 8
      packages/nc-gui/components/smartsheet/header/VirtualCellIcon.ts
  14. 61
      packages/nc-gui/components/smartsheet/sidebar/MenuTop.vue
  15. 4
      packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue
  16. 2
      packages/nc-gui/components/smartsheet/toolbar/AddRow.vue
  17. 4
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue
  18. 169
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  19. 2
      packages/nc-gui/components/smartsheet/toolbar/Reload.vue
  20. 21
      packages/nc-gui/components/smartsheet/toolbar/RowHeight.vue
  21. 9
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  22. 29
      packages/nc-gui/components/smartsheet/toolbar/StackedBy.vue
  23. 6
      packages/nc-gui/components/virtual-cell/QrCode.vue
  24. 5
      packages/nc-gui/components/virtual-cell/barcode/Barcode.vue
  25. 2
      packages/nc-gui/components/webhook/Editor.vue
  26. 4
      packages/nc-gui/composables/useColumnCreateStore.ts
  27. 83
      packages/nc-gui/composables/useExpandedFormStore.ts
  28. 37
      packages/nc-gui/composables/useGridViewColumnWidth.ts
  29. 135
      packages/nc-gui/composables/useKanbanViewStore.ts
  30. 41
      packages/nc-gui/composables/useLTARStore.ts
  31. 7
      packages/nc-gui/composables/useSmartsheetRowStore.ts
  32. 180
      packages/nc-gui/composables/useUndoRedo.ts
  33. 19
      packages/nc-gui/composables/useViewColumns.ts
  34. 204
      packages/nc-gui/composables/useViewData.ts
  35. 155
      packages/nc-gui/composables/useViewFilters.ts
  36. 117
      packages/nc-gui/composables/useViewSorts.ts
  37. 1
      packages/nc-gui/just-clone-shims.d.ts
  38. 6
      packages/nc-gui/lib/types.ts
  39. 22
      packages/nc-gui/package-lock.json
  40. 2
      packages/nc-gui/package.json
  41. 8
      packages/nc-gui/pages/[projectType]/[projectId]/index.vue
  42. 15
      packages/nc-gui/utils/dataUtils.ts
  43. 6
      packages/nocodb-sdk/src/lib/Api.ts
  44. 8
      packages/nocodb/src/schema/swagger.json
  45. 1
      tests/playwright/pages/Dashboard/Grid/Column/LTAR/LinkRecord.ts
  46. 16
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  47. 10
      tests/playwright/pages/Dashboard/Grid/index.ts
  48. 29
      tests/playwright/pages/Dashboard/Kanban/index.ts
  49. 2
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  50. 36
      tests/playwright/pages/Dashboard/common/Toolbar/Fields.ts
  51. 36
      tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts
  52. 23
      tests/playwright/pages/Dashboard/common/Toolbar/Sort.ts
  53. 18
      tests/playwright/tests/columnCheckbox.spec.ts
  54. 18
      tests/playwright/tests/columnMultiSelect.spec.ts
  55. 18
      tests/playwright/tests/columnRating.spec.ts
  56. 18
      tests/playwright/tests/columnSingleSelect.spec.ts
  57. 6
      tests/playwright/tests/expandedFormUrl.spec.ts
  58. 22
      tests/playwright/tests/filters.spec.ts
  59. 12
      tests/playwright/tests/metaSync.spec.ts
  60. 8
      tests/playwright/tests/toolbarOperations.spec.ts
  61. 684
      tests/playwright/tests/undo-redo.spec.ts
  62. 30
      tests/playwright/tests/viewGridShare.spec.ts
  63. 24
      tests/playwright/tests/viewKanban.spec.ts

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

@ -31,7 +31,7 @@ onMounted(() => {
:key="`clamp-${key}-${props.value?.toString().length || 0}`" :key="`clamp-${key}-${props.value?.toString().length || 0}`"
class="w-full h-full break-word" class="w-full h-full break-word"
:text="`${props.value || ' '}`" :text="`${props.value || ' '}`"
:max-lines="props.lines" :max-lines="props.lines || 1"
/> />
</div> </div>
</template> </template>

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

@ -283,7 +283,7 @@ const onTagClick = (e: Event, onClose: Function) => {
} }
} }
const cellClickHook = inject(CellClickHookInj) const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = () => { const toggleMenu = () => {
if (cellClickHook) return if (cellClickHook) return

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

@ -209,7 +209,7 @@ const onSelect = () => {
isOpen.value = false isOpen.value = false
} }
const cellClickHook = inject(CellClickHookInj) const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = (e: Event) => { const toggleMenu = (e: Event) => {
// todo: refactor // todo: refactor

5
packages/nc-gui/components/cell/Text.vue

@ -14,7 +14,10 @@ const { showNull } = useGlobal()
const editEnabled = inject(EditModeInj) const editEnabled = inject(EditModeInj)
const rowHeight = inject(RowHeightInj) const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
const readonly = inject(ReadonlyInj, ref(false)) const readonly = inject(ReadonlyInj, ref(false))

5
packages/nc-gui/components/cell/TextArea.vue

@ -10,7 +10,10 @@ const emits = defineEmits(['update:modelValue'])
const editEnabled = inject(EditModeInj) const editEnabled = inject(EditModeInj)
const rowHeight = inject(RowHeightInj) const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
const { showNull } = useGlobal() const { showNull } = useGlobal()

56
packages/nc-gui/components/dashboard/TreeView.vue

@ -29,6 +29,7 @@ import {
useTabs, useTabs,
useToggle, useToggle,
useUIPermission, useUIPermission,
useUndoRedo,
watchEffect, watchEffect,
} from '#imports' } from '#imports'
@ -55,6 +56,8 @@ const [searchActive, toggleSearchActive] = useToggle()
const { appInfo } = useGlobal() const { appInfo } = useGlobal()
const { addUndo, defineProjectScope } = useUndoRedo()
const toggleDialog = inject(ToggleDialogInj, () => {}) const toggleDialog = inject(ToggleDialogInj, () => {})
const keys = $ref<Record<string, number>>({}) const keys = $ref<Record<string, number>>({})
@ -90,13 +93,14 @@ const initSortable = (el: Element) => {
if (sortables[base_id]) sortables[base_id].destroy() if (sortables[base_id]) sortables[base_id].destroy()
Sortable.create(el as HTMLLIElement, { Sortable.create(el as HTMLLIElement, {
onEnd: async (evt) => { onEnd: async (evt) => {
const offset = tables.value.findIndex((table) => table.base_id === base_id)
const { newIndex = 0, oldIndex = 0 } = evt const { newIndex = 0, oldIndex = 0 } = evt
const itemEl = evt.item as HTMLLIElement const itemEl = evt.item as HTMLLIElement
const item = tablesById[itemEl.dataset.id as string] const item = tablesById[itemEl.dataset.id as string]
// store the old order for undo
const oldOrder = item.order
// get the html collection of all list items // get the html collection of all list items
const children: HTMLCollection = evt.to.children const children: HTMLCollection = evt.to.children
@ -120,8 +124,19 @@ const initSortable = (el: Element) => {
item.order = ((itemBefore.order as number) + (itemAfter.order as number)) / 2 item.order = ((itemBefore.order as number) + (itemAfter.order as number)) / 2
} }
// update the order of the moved item // find the index of the moved item
tables.value?.splice(newIndex + offset, 0, ...tables.value?.splice(oldIndex + offset, 1)) const itemIndex = tables.value?.findIndex((table) => table.id === item.id)
// move the item to the new position
if (itemBefore) {
// find the index of the item before the moved item
const itemBeforeIndex = tables.value?.findIndex((table) => table.id === itemBefore.id)
tables.value?.splice(itemBeforeIndex + (newIndex > oldIndex ? 0 : 1), 0, ...tables.value?.splice(itemIndex, 1))
} else {
// if the item before is undefined (moving item to first slot), then find the index of the item after the moved item
const itemAfterIndex = tables.value?.findIndex((table) => table.id === itemAfter.id)
tables.value?.splice(itemAfterIndex, 0, ...tables.value?.splice(itemIndex, 1))
}
// force re-render the list // force re-render the list
if (keys[base_id]) { if (keys[base_id]) {
@ -134,6 +149,38 @@ const initSortable = (el: Element) => {
await $api.dbTable.reorder(item.id as string, { await $api.dbTable.reorder(item.id as string, {
order: item.order, order: item.order,
}) })
const nextIndex = tables.value?.findIndex((table) => table.id === item.id)
addUndo({
undo: {
fn: async (id: string, order: number, index: number) => {
const itemIndex = tables.value.findIndex((table) => table.id === id)
if (itemIndex < 0) return
const item = tables.value[itemIndex]
item.order = order
tables.value?.splice(index, 0, ...tables.value?.splice(itemIndex, 1))
await $api.dbTable.reorder(item.id as string, {
order: item.order,
})
},
args: [item.id, oldOrder, itemIndex],
},
redo: {
fn: async (id: string, order: number, index: number) => {
const itemIndex = tables.value.findIndex((table) => table.id === id)
if (itemIndex < 0) return
const item = tables.value[itemIndex]
item.order = order
tables.value?.splice(index, 0, ...tables.value?.splice(itemIndex, 1))
await $api.dbTable.reorder(item.id as string, {
order: item.order,
})
},
args: [item.id, item.order, nextIndex],
},
scope: defineProjectScope({ project: project.value }),
})
}, },
animation: 150, animation: 150,
}) })
@ -1085,6 +1132,7 @@ const setIcon = async (icon: string, table: TableType) => {
<style scoped lang="scss"> <style scoped lang="scss">
.nc-treeview-container { .nc-treeview-container {
@apply h-[calc(100vh_-_var(--header-height))]; @apply h-[calc(100vh_-_var(--header-height))];
border-right: 1px solid var(--navbar-border) !important;
} }
.nc-treeview-footer-item { .nc-treeview-footer-item {

2
packages/nc-gui/components/dashboard/settings/Modal.vue

@ -227,7 +227,7 @@ watch(
@click="vDataState = DataSourcesSubTab.New" @click="vDataState = DataSourcesSubTab.New"
> >
<div v-if="vDataState === ''" class="flex items-center gap-2 text-primary font-light"> <div v-if="vDataState === ''" class="flex items-center gap-2 text-primary font-light">
<component :is="iconMap.plusCircle" class="text-lg group-hover:text-accent" /> <component :is="iconMap.plusCircle" class="group-hover:text-accent" />
New New
</div> </div>
</a-button> </a-button>

29
packages/nc-gui/components/dlg/TableRename.vue

@ -14,6 +14,7 @@ import {
useNuxtApp, useNuxtApp,
useProject, useProject,
useTabs, useTabs,
useUndoRedo,
useVModel, useVModel,
validateTableName, validateTableName,
watchEffect, watchEffect,
@ -43,6 +44,8 @@ const projectStore = useProject()
const { loadTables, isMysql, isMssql, isPg } = projectStore const { loadTables, isMysql, isMssql, isPg } = projectStore
const { tables, project } = storeToRefs(projectStore) const { tables, project } = storeToRefs(projectStore)
const { addUndo, defineProjectScope } = useUndoRedo()
const inputEl = $ref<ComponentPublicInstance>() const inputEl = $ref<ComponentPublicInstance>()
let loading = $ref(false) let loading = $ref(false)
@ -113,7 +116,7 @@ watchEffect(
{ flush: 'post' }, { flush: 'post' },
) )
const renameTable = async () => { const renameTable = async (undo = false) => {
if (!tableMeta) return if (!tableMeta) return
loading = true loading = true
@ -126,6 +129,26 @@ const renameTable = async () => {
dialogShow.value = false dialogShow.value = false
if (!undo) {
addUndo({
redo: {
fn: (t: string) => {
formState.title = t
renameTable(true)
},
args: [formState.title],
},
undo: {
fn: (t: string) => {
formState.title = t
renameTable(true)
},
args: [tableMeta.title],
},
scope: defineProjectScope({ model: tableMeta }),
})
}
await loadTables() await loadTables()
// update metas // update metas
@ -161,7 +184,7 @@ const renameTable = async () => {
<template #footer> <template #footer>
<a-button key="back" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button> <a-button key="back" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button>
<a-button key="submit" type="primary" :loading="loading" @click="renameTable">{{ $t('general.submit') }}</a-button> <a-button key="submit" type="primary" :loading="loading" @click="renameTable()">{{ $t('general.submit') }}</a-button>
</template> </template>
<div class="pl-10 pr-10 pt-5"> <div class="pl-10 pr-10 pt-5">
@ -175,7 +198,7 @@ const renameTable = async () => {
v-model:value="formState.title" v-model:value="formState.title"
hide-details hide-details
:placeholder="$t('msg.info.enterTableName')" :placeholder="$t('msg.info.enterTableName')"
@keydown.enter="renameTable" @keydown.enter="renameTable()"
/> />
</a-form-item> </a-form-item>
</a-form> </a-form>

62
packages/nc-gui/components/smartsheet/Grid.vue

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ColumnReqType, ColumnType, GridType, TableType, ViewType } from 'nocodb-sdk' import type { ColumnReqType, ColumnType, GridType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import { UITypes, isVirtualCol } from 'nocodb-sdk' import { UITypes, isVirtualCol } from 'nocodb-sdk'
import { import {
ActiveViewInj, ActiveViewInj,
@ -44,6 +44,7 @@ import {
useRoute, useRoute,
useSmartsheetStoreOrThrow, useSmartsheetStoreOrThrow,
useUIPermission, useUIPermission,
useUndoRedo,
useViewData, useViewData,
watch, watch,
} from '#imports' } from '#imports'
@ -73,6 +74,8 @@ const hasEditPermission = $computed(() => isUIAllowed('xcDatatableEditable'))
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { addUndo, clone, defineViewScope } = useUndoRedo()
// todo: get from parent ( inject or use prop ) // todo: get from parent ( inject or use prop )
const isView = false const isView = false
@ -455,6 +458,61 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
const columnObj = fields.value[ctx.col] const columnObj = fields.value[ctx.col]
if (isVirtualCol(columnObj)) { if (isVirtualCol(columnObj)) {
addUndo({
undo: {
fn: async (ctx: { row: number; col: number }, col: ColumnType, row: Row, pg: PaginatedType) => {
if (paginationData.value.pageSize === pg.pageSize) {
if (paginationData.value.page !== pg.page) {
await changePage(pg.page!)
}
const rowId = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const rowObj = data.value[ctx.row]
const columnObj = fields.value[ctx.col]
if (
columnObj.title &&
rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) &&
columnObj.id === col.id
) {
rowObj.row[columnObj.title] = row.row[columnObj.title]
await rowRefs[ctx.row]!.addLTARRef(rowObj.row[columnObj.title], columnObj)
await rowRefs[ctx.row]!.syncLTARRefs(rowObj.row)
activeCell.col = ctx.col
activeCell.row = ctx.row
scrollToCell?.()
} else {
throw new Error('Record could not be found')
}
} else {
throw new Error('Page size changed')
}
},
args: [clone(ctx), clone(columnObj), clone(rowObj), clone(paginationData.value)],
},
redo: {
fn: async (ctx: { row: number; col: number }, col: ColumnType, row: Row, pg: PaginatedType) => {
if (paginationData.value.pageSize === pg.pageSize) {
if (paginationData.value.page !== pg.page) {
await changePage(pg.page!)
}
const rowId = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const rowObj = data.value[ctx.row]
const columnObj = fields.value[ctx.col]
if (rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) && columnObj.id === col.id) {
await rowRefs[ctx.row]!.clearLTARCell(columnObj)
activeCell.col = ctx.col
activeCell.row = ctx.row
scrollToCell?.()
} else {
throw new Error('Record could not be found')
}
} else {
throw new Error('Page size changed')
}
},
args: [clone(ctx), clone(columnObj), clone(rowObj), clone(paginationData.value)],
},
scope: defineViewScope({ view: view.value }),
})
await rowRefs[ctx.row]!.clearLTARCell(columnObj) await rowRefs[ctx.row]!.clearLTARCell(columnObj)
return return
} }
@ -789,7 +847,7 @@ const closeAddColumnDropdown = () => {
:data-testid="`grid-row-${rowIndex}`" :data-testid="`grid-row-${rowIndex}`"
> >
<td key="row-index" class="caption nc-grid-cell pl-5 pr-1" :data-testid="`cell-Id-${rowIndex}`"> <td key="row-index" class="caption nc-grid-cell pl-5 pr-1" :data-testid="`cell-Id-${rowIndex}`">
<div class="items-center flex gap-1 min-w-[55px]"> <div class="items-center flex gap-1 min-w-[60px]">
<div <div
v-if="!readOnly || !isLocked" v-if="!readOnly || !isLocked"
class="nc-row-no text-xs text-gray-500" class="nc-row-no text-xs text-gray-500"

44
packages/nc-gui/components/smartsheet/Kanban.vue

@ -12,6 +12,7 @@ import {
IsPublicInj, IsPublicInj,
MetaInj, MetaInj,
OpenNewRecordFormHookInj, OpenNewRecordFormHookInj,
extractPkFromRow,
iconMap, iconMap,
inject, inject,
isImage, isImage,
@ -21,6 +22,7 @@ import {
provide, provide,
useAttachment, useAttachment,
useKanbanViewStoreOrThrow, useKanbanViewStoreOrThrow,
useUndoRedo,
} from '#imports' } from '#imports'
import type { Row as RowType } from '~/lib' import type { Row as RowType } from '~/lib'
@ -76,12 +78,15 @@ const {
deleteStack, deleteStack,
shouldScrollToRight, shouldScrollToRight,
deleteRow, deleteRow,
moveHistory,
} = useKanbanViewStoreOrThrow() } = useKanbanViewStoreOrThrow()
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { appInfo } = $(useGlobal()) const { appInfo } = $(useGlobal())
const { addUndo, defineViewScope } = useUndoRedo()
provide(IsFormInj, ref(false)) provide(IsFormInj, ref(false))
provide(IsGalleryInj, ref(false)) provide(IsGalleryInj, ref(false))
@ -210,7 +215,7 @@ function onMoveCallback(event: { draggedContext: { futureIndex: number } }) {
} }
} }
async function onMoveStack(event: any) { async function onMoveStack(event: any, undo = false) {
if (event.moved) { if (event.moved) {
const { oldIndex, newIndex } = event.moved const { oldIndex, newIndex } = event.moved
const { fk_grp_col_id, meta: stack_meta } = kanbanMetaData.value const { fk_grp_col_id, meta: stack_meta } = kanbanMetaData.value
@ -221,17 +226,52 @@ async function onMoveStack(event: any) {
await updateKanbanMeta({ await updateKanbanMeta({
meta: stackMetaObj, meta: stackMetaObj,
}) })
if (!undo) {
addUndo({
undo: {
fn: async (e: any) => {
const temp = groupingFieldColOptions.value.splice(e.moved.newIndex, 1)
groupingFieldColOptions.value.splice(e.moved.oldIndex, 0, temp[0])
await onMoveStack(e, true)
},
args: [{ moved: { oldIndex, newIndex } }],
},
redo: {
fn: async (e: any) => {
const temp = groupingFieldColOptions.value.splice(e.moved.oldIndex, 1)
groupingFieldColOptions.value.splice(e.moved.newIndex, 0, temp[0])
await onMoveStack(e, true)
},
args: [{ moved: { oldIndex, newIndex } }, true],
},
scope: defineViewScope({ view: view.value }),
})
}
} }
} }
async function onMove(event: any, stackKey: string) { async function onMove(event: any, stackKey: string) {
if (event.added) { if (event.added) {
const ele = event.added.element const ele = event.added.element
moveHistory.value.unshift({
op: 'added',
pk: extractPkFromRow(event.added.element.row, meta.value!.columns!),
index: event.added.newIndex,
stack: stackKey,
})
ele.row[groupingField.value] = stackKey ele.row[groupingField.value] = stackKey
countByStack.value.set(stackKey, countByStack.value.get(stackKey)! + 1) countByStack.value.set(stackKey, countByStack.value.get(stackKey)! + 1)
await updateOrSaveRow(ele) await updateOrSaveRow(ele)
} else if (event.removed) { } else if (event.removed) {
countByStack.value.set(stackKey, countByStack.value.get(stackKey)! - 1) countByStack.value.set(stackKey, countByStack.value.get(stackKey)! - 1)
moveHistory.value.unshift({
op: 'removed',
pk: extractPkFromRow(event.removed.element.row, meta.value!.columns!),
index: event.removed.oldIndex,
stack: stackKey,
})
} }
} }
@ -273,7 +313,7 @@ const openNewRecordFormHookHandler = async () => {
const newRow = await addEmptyRow() const newRow = await addEmptyRow()
// preset the grouping field value // preset the grouping field value
newRow.row = { newRow.row = {
[groupingField.value]: selectedStackTitle.value, [groupingField.value]: selectedStackTitle.value === '' ? null : selectedStackTitle.value,
} }
// increase total count by 1 // increase total count by 1
countByStack.value.set(null, countByStack.value.get(null)! + 1) countByStack.value.set(null, countByStack.value.get(null)! + 1)

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

@ -22,7 +22,7 @@ const currentRow = toRef(props, 'row')
const { meta } = useSmartsheetStoreOrThrow() const { meta } = useSmartsheetStoreOrThrow()
const { isNew, state, syncLTARRefs, clearLTARCell } = useProvideSmartsheetRowStore(meta as Ref<TableType>, currentRow) const { isNew, state, syncLTARRefs, clearLTARCell, addLTARRef } = useProvideSmartsheetRowStore(meta as Ref<TableType>, currentRow)
// on changing isNew(new record insert) status sync LTAR cell values // on changing isNew(new record insert) status sync LTAR cell values
watch(isNew, async (nextVal, prevVal) => { watch(isNew, async (nextVal, prevVal) => {
@ -49,6 +49,7 @@ provide(ReloadRowDataHookInj, reloadHook)
defineExpose({ defineExpose({
syncLTARRefs, syncLTARRefs,
clearLTARCell, clearLTARCell,
addLTARRef,
}) })
</script> </script>

82
packages/nc-gui/components/smartsheet/header/Menu.vue

@ -18,7 +18,9 @@ import {
useMetas, useMetas,
useNuxtApp, useNuxtApp,
useSmartsheetStoreOrThrow, useSmartsheetStoreOrThrow,
useUndoRedo,
} from '#imports' } from '#imports'
import type { UndoRedoAction } from '~~/lib'
const { virtual = false } = defineProps<{ virtual?: boolean }>() const { virtual = false } = defineProps<{ virtual?: boolean }>()
@ -42,6 +44,8 @@ const { t } = useI18n()
const { getMeta } = useMetas() const { getMeta } = useMetas()
const { addUndo, defineModelScope, defineViewScope } = useUndoRedo()
const deleteColumn = () => const deleteColumn = () =>
Modal.confirm({ Modal.confirm({
title: h('div', ['Do you want to delete ', h('span', { class: 'font-weight-bold' }, [column?.value?.title]), ' column ?']), title: h('div', ['Do you want to delete ', h('span', { class: 'font-weight-bold' }, [column?.value?.title]), ' column ?']),
@ -69,6 +73,8 @@ const deleteColumn = () =>
const setAsDisplayValue = async () => { const setAsDisplayValue = async () => {
try { try {
const currentDisplayValue = meta?.value?.columns?.find((f) => f.pv)
await $api.dbTableColumn.primaryColumnSet(column?.value?.id as string) await $api.dbTableColumn.primaryColumnSet(column?.value?.id as string)
await getMeta(meta?.value?.id as string, true) await getMeta(meta?.value?.id as string, true)
@ -79,6 +85,36 @@ const setAsDisplayValue = async () => {
message.success(t('msg.success.primaryColumnUpdated')) message.success(t('msg.success.primaryColumnUpdated'))
$e('a:column:set-primary') $e('a:column:set-primary')
addUndo({
redo: {
fn: async (id: string) => {
await $api.dbTableColumn.primaryColumnSet(id)
await getMeta(meta?.value?.id as string, true)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
// Successfully updated as primary column
message.success(t('msg.success.primaryColumnUpdated'))
},
args: [column?.value?.id as string],
},
undo: {
fn: async (id: string) => {
await $api.dbTableColumn.primaryColumnSet(id)
await getMeta(meta?.value?.id as string, true)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
// Successfully updated as primary column
message.success(t('msg.success.primaryColumnUpdated'))
},
args: [currentDisplayValue?.id],
},
scope: defineModelScope({ model: meta.value }),
})
} catch (e) { } catch (e) {
message.error(t('msg.error.primaryColumnUpdateFailed')) message.error(t('msg.error.primaryColumnUpdateFailed'))
} }
@ -87,11 +123,37 @@ const setAsDisplayValue = async () => {
const sortByColumn = async (direction: 'asc' | 'desc') => { const sortByColumn = async (direction: 'asc' | 'desc') => {
try { try {
$e('a:sort:add', { from: 'column-menu' }) $e('a:sort:add', { from: 'column-menu' })
await $api.dbTableSort.create(view.value?.id as string, { const data: any = await $api.dbTableSort.create(view.value?.id as string, {
fk_column_id: column!.value.id, fk_column_id: column!.value.id,
direction, direction,
push_to_top: true, push_to_top: true,
}) })
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction) {
const data: any = await $api.dbTableSort.create(view.value?.id as string, {
fk_column_id: column!.value.id,
direction,
push_to_top: true,
})
this.undo.args = [data.id]
eventBus.emit(SmartsheetStoreEvents.SORT_RELOAD)
reloadDataHook?.trigger()
},
args: [],
},
undo: {
fn: async function undo(id: string) {
await $api.dbTableSort.delete(id)
eventBus.emit(SmartsheetStoreEvents.SORT_RELOAD)
reloadDataHook?.trigger()
},
args: [data.id],
},
scope: defineViewScope({ view: view.value }),
})
eventBus.emit(SmartsheetStoreEvents.SORT_RELOAD) eventBus.emit(SmartsheetStoreEvents.SORT_RELOAD)
reloadDataHook?.trigger() reloadDataHook?.trigger()
} catch (e: any) { } catch (e: any) {
@ -209,6 +271,24 @@ const hideField = async () => {
await $api.dbViewColumn.update(view.value!.id!, currentColumn!.id!, { show: false }) await $api.dbViewColumn.update(view.value!.id!, currentColumn!.id!, { show: false })
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD) eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
addUndo({
redo: {
fn: async function redo(id: string) {
await $api.dbViewColumn.update(view.value!.id!, id, { show: false })
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
},
args: [currentColumn!.id],
},
undo: {
fn: async function undo(id: string) {
await $api.dbViewColumn.update(view.value!.id!, id, { show: true })
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
},
args: [currentColumn!.id],
},
scope: defineViewScope({ view: view.value }),
})
} }
</script> </script>

8
packages/nc-gui/components/smartsheet/header/VirtualCellIcon.ts

@ -52,13 +52,13 @@ const renderIcon = (column: ColumnType, relationColumn?: ColumnType) => {
case UITypes.Rollup: case UITypes.Rollup:
switch ((relationColumn?.colOptions as LinkToAnotherRecordType)?.type) { switch ((relationColumn?.colOptions as LinkToAnotherRecordType)?.type) {
case RelationTypes.MANY_TO_MANY: case RelationTypes.MANY_TO_MANY:
return { icon: iconMap, color: 'text-accent' } return { icon: iconMap.rollup, color: 'text-accent' }
case RelationTypes.HAS_MANY: case RelationTypes.HAS_MANY:
return { icon: iconMap, color: 'text-yellow-500' } return { icon: iconMap.rollup, color: 'text-yellow-500' }
case RelationTypes.BELONGS_TO: case RelationTypes.BELONGS_TO:
return { icon: iconMap, color: 'text-sky-500' } return { icon: iconMap.rollup, color: 'text-sky-500' }
} }
return { icon: iconMap, color: 'text-grey' } return { icon: iconMap.rollup, color: 'text-grey' }
case UITypes.Count: case UITypes.Count:
return { icon: CountIcon, color: 'text-grey' } return { icon: CountIcon, color: 'text-grey' }
} }

61
packages/nc-gui/components/smartsheet/sidebar/MenuTop.vue

@ -18,6 +18,7 @@ import {
useI18n, useI18n,
useNuxtApp, useNuxtApp,
useRouter, useRouter,
useUndoRedo,
viewTypeAlias, viewTypeAlias,
watch, watch,
} from '#imports' } from '#imports'
@ -46,6 +47,8 @@ const { api } = useApi()
const router = useRouter() const router = useRouter()
const { addUndo, defineModelScope } = useUndoRedo()
/** Selected view(s) for menu */ /** Selected view(s) for menu */
const selected = ref<string[]>([]) const selected = ref<string[]>([])
@ -84,16 +87,20 @@ function validate(view: ViewType) {
return true return true
} }
let sortable: Sortable
function onSortStart(evt: SortableEvent) { function onSortStart(evt: SortableEvent) {
evt.stopImmediatePropagation() evt.stopImmediatePropagation()
evt.preventDefault() evt.preventDefault()
dragging = true dragging = true
} }
async function onSortEnd(evt: SortableEvent) { async function onSortEnd(evt: SortableEvent, undo = false) {
if (!undo) {
evt.stopImmediatePropagation() evt.stopImmediatePropagation()
evt.preventDefault() evt.preventDefault()
dragging = false dragging = false
}
if (views.length < 2) return if (views.length < 2) return
@ -101,6 +108,32 @@ async function onSortEnd(evt: SortableEvent) {
if (newIndex === oldIndex) return if (newIndex === oldIndex) return
if (!undo) {
addUndo({
redo: {
fn: async () => {
const ord = sortable.toArray()
const temp = ord.splice(oldIndex, 1)
ord.splice(newIndex, 0, temp[0])
sortable.sort(ord)
await onSortEnd(evt, true)
},
args: [],
},
undo: {
fn: async () => {
const ord = sortable.toArray()
const temp = ord.splice(newIndex, 1)
ord.splice(oldIndex, 0, temp[0])
sortable.sort(ord)
await onSortEnd({ ...evt, oldIndex: newIndex, newIndex: oldIndex }, true)
},
args: [],
},
scope: defineModelScope({ view: activeView.value }),
})
}
const children = evt.to.children as unknown as HTMLLIElement[] const children = evt.to.children as unknown as HTMLLIElement[]
const previousEl = children[newIndex - 1] const previousEl = children[newIndex - 1]
@ -135,8 +168,6 @@ async function onSortEnd(evt: SortableEvent) {
$e('a:view:reorder') $e('a:view:reorder')
} }
let sortable: Sortable
const initSortable = (el: HTMLElement) => { const initSortable = (el: HTMLElement) => {
if (sortable) sortable.destroy() if (sortable) sortable.destroy()
@ -165,7 +196,7 @@ function changeView(view: ViewType) {
} }
/** Rename a view */ /** Rename a view */
async function onRename(view: ViewType) { async function onRename(view: ViewType, originalTitle?: string, undo = false) {
try { try {
await api.dbView.update(view.id!, { await api.dbView.update(view.id!, {
title: view.title, title: view.title,
@ -178,6 +209,28 @@ async function onRename(view: ViewType) {
}, },
}) })
if (!undo) {
addUndo({
redo: {
fn: (v: ViewType, title: string) => {
const tempTitle = v.title
v.title = title
onRename(v, tempTitle, true)
},
args: [view, view.title],
},
undo: {
fn: (v: ViewType, title: string) => {
const tempTitle = v.title
v.title = title
onRename(v, tempTitle, true)
},
args: [view, originalTitle],
},
scope: defineModelScope({ view: activeView.value }),
})
}
// View renamed successfully // View renamed successfully
message.success(t('msg.success.viewRenamed')) message.success(t('msg.success.viewRenamed'))
} catch (e: any) { } catch (e: any) {

4
packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue

@ -27,7 +27,7 @@ interface Emits {
(event: 'changeView', view: Record<string, any>): void (event: 'changeView', view: Record<string, any>): void
(event: 'rename', view: ViewType): void (event: 'rename', view: ViewType, originalTitle: string | undefined): void
(event: 'delete', view: ViewType): void (event: 'delete', view: ViewType): void
@ -142,7 +142,7 @@ async function onRename() {
return return
} }
emits('rename', vModel.value) emits('rename', vModel.value, originalTitle)
onStopEdit() onStopEdit()
} }

2
packages/nc-gui/components/smartsheet/toolbar/AddRow.vue

@ -21,7 +21,7 @@ const onClick = () => {
> >
<component <component
:is="iconMap.plus" :is="iconMap.plus"
:class="{ 'cursor-pointer text-gray-500 group-hover:(text-primary)': !isLocked, 'disabled': isLocked }" :class="{ 'cursor-pointer group-hover:(text-primary)': !isLocked, 'disabled': isLocked }"
@click="onClick" @click="onClick"
/> />
</div> </div>

4
packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue

@ -364,7 +364,7 @@ defineExpose({
</div> </div>
<div class="flex gap-2 mb-2 mt-4"> <div class="flex gap-2 mb-2 mt-4">
<a-button class="elevation-0 text-capitalize" type="primary" ghost @click.stop="addFilter"> <a-button class="elevation-0 text-capitalize" type="primary" ghost @click.stop="addFilter()">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<component :is="iconMap.plus" /> <component :is="iconMap.plus" />
<!-- Add Filter --> <!-- Add Filter -->
@ -372,7 +372,7 @@ defineExpose({
</div> </div>
</a-button> </a-button>
<a-button v-if="!webHook" class="text-capitalize !text-gray-500" @click.stop="addFilterGroup"> <a-button v-if="!webHook" class="text-capitalize !text-gray-500" @click.stop="addFilterGroup()">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- Add Filter Group --> <!-- Add Filter Group -->
<component :is="iconMap.plus" /> <component :is="iconMap.plus" />

169
packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue

@ -3,6 +3,7 @@ import type { ColumnType, GalleryType, KanbanType } from 'nocodb-sdk'
import { UITypes, ViewTypes, isVirtualCol } from 'nocodb-sdk' import { UITypes, ViewTypes, isVirtualCol } from 'nocodb-sdk'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import type { SelectProps } from 'ant-design-vue' import type { SelectProps } from 'ant-design-vue'
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface'
import { import {
ActiveViewInj, ActiveViewInj,
FieldsInj, FieldsInj,
@ -18,6 +19,7 @@ import {
useMenuCloseOnEsc, useMenuCloseOnEsc,
useNuxtApp, useNuxtApp,
useSmartsheetStoreOrThrow, useSmartsheetStoreOrThrow,
useUndoRedo,
useViewColumns, useViewColumns,
watch, watch,
} from '#imports' } from '#imports'
@ -55,6 +57,8 @@ const {
const { eventBus } = useSmartsheetStoreOrThrow() const { eventBus } = useSmartsheetStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo()
eventBus.on((event) => { eventBus.on((event) => {
if (event === SmartsheetStoreEvents.FIELD_RELOAD) { if (event === SmartsheetStoreEvents.FIELD_RELOAD) {
loadViewColumns() loadViewColumns()
@ -79,10 +83,44 @@ const gridDisplayValueField = computed(() => {
return filteredFieldList.value?.find((field) => field.fk_column_id === pvCol?.id) return filteredFieldList.value?.find((field) => field.fk_column_id === pvCol?.id)
}) })
const onMove = (_event: { moved: { newIndex: number } }) => { const onMove = (_event: { moved: { newIndex: number; oldIndex: number } }, undo = false) => {
// todo : sync with server // todo : sync with server
if (!fields.value) return if (!fields.value) return
if (!undo) {
addUndo({
undo: {
fn: () => {
if (!fields.value) return
const temp = fields.value[_event.moved.newIndex]
fields.value[_event.moved.newIndex] = fields.value[_event.moved.oldIndex]
fields.value[_event.moved.oldIndex] = temp
onMove(
{
moved: {
newIndex: _event.moved.oldIndex,
oldIndex: _event.moved.newIndex,
},
},
true,
)
},
args: [],
},
redo: {
fn: () => {
if (!fields.value) return
const temp = fields.value[_event.moved.oldIndex]
fields.value[_event.moved.oldIndex] = fields.value[_event.moved.newIndex]
fields.value[_event.moved.newIndex] = temp
onMove(_event, true)
},
args: [],
},
scope: defineViewScope({ view: activeView.value }),
})
}
if (fields.value.length < 2) return if (fields.value.length < 2) return
fields.value.forEach((field, index) => { fields.value.forEach((field, index) => {
@ -108,19 +146,7 @@ const coverOptions = computed<SelectProps['options']>(() => {
return [{ value: null, label: 'No Image' }, ...filterFields] return [{ value: null, label: 'No Image' }, ...filterFields]
}) })
const coverImageColumnId = computed({ const updateCoverImage = async (val?: string | null) => {
get: () => {
const fk_cover_image_col_id =
(activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) && activeView.value?.view
? (activeView.value?.view as GalleryType).fk_cover_image_col_id
: undefined
// check if `fk_cover_image_col_id` is in `coverOptions`
// e.g. in share view, users may not share the cover image column
if (coverOptions.value?.find((o) => o.value === fk_cover_image_col_id)) return fk_cover_image_col_id
// set to `No Image`
return null
},
set: async (val) => {
if ( if (
(activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) && (activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) &&
activeView.value?.id && activeView.value?.id &&
@ -139,6 +165,36 @@ const coverImageColumnId = computed({
} }
reloadViewMetaHook?.trigger() reloadViewMetaHook?.trigger()
} }
}
const coverImageColumnId = computed({
get: () => {
const fk_cover_image_col_id =
(activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) && activeView.value?.view
? (activeView.value?.view as GalleryType).fk_cover_image_col_id
: undefined
// check if `fk_cover_image_col_id` is in `coverOptions`
// e.g. in share view, users may not share the cover image column
if (coverOptions.value?.find((o) => o.value === fk_cover_image_col_id)) return fk_cover_image_col_id
// set to `No Image`
return null
},
set: async (val) => {
if (val !== coverImageColumnId.value) {
addUndo({
undo: {
fn: await updateCoverImage,
args: [coverImageColumnId.value],
},
redo: {
fn: await updateCoverImage,
args: [val],
},
scope: defineViewScope({ view: activeView.value }),
})
await updateCoverImage(val)
}
}, },
}) })
@ -149,6 +205,83 @@ const getIcon = (c: ColumnType) =>
const open = ref(false) const open = ref(false)
const toggleFieldVisibility = (e: CheckboxChangeEvent, field: any, index: number) => {
addUndo({
undo: {
fn: (v: boolean) => {
field.show = !v
saveOrUpdate(field, index)
},
args: [e.target.checked],
},
redo: {
fn: (v: boolean) => {
field.show = v
saveOrUpdate(field, index)
},
args: [e.target.checked],
},
scope: defineViewScope({ view: activeView.value }),
})
saveOrUpdate(field, index)
}
const toggleSystemFields = (e: CheckboxChangeEvent) => {
addUndo({
undo: {
fn: (v: boolean) => {
showSystemFields.value = !v
},
args: [e.target.checked],
},
redo: {
fn: (v: boolean) => {
showSystemFields.value = v
},
args: [e.target.checked],
},
scope: defineViewScope({ view: activeView.value }),
})
}
const onShowAll = () => {
addUndo({
undo: {
fn: async () => {
await hideAll()
},
args: [],
},
redo: {
fn: async () => {
await showAll()
},
args: [],
},
scope: defineViewScope({ view: activeView.value }),
})
showAll()
}
const onHideAll = () => {
addUndo({
undo: {
fn: async () => {
await showAll()
},
args: [],
},
redo: {
fn: async () => {
await hideAll()
},
args: [],
},
scope: defineViewScope({ view: activeView.value }),
})
hideAll()
}
useMenuCloseOnEsc(open) useMenuCloseOnEsc(open)
</script> </script>
@ -208,7 +341,7 @@ useMenuCloseOnEsc(open)
v-e="['a:fields:show-hide']" v-e="['a:fields:show-hide']"
class="shrink" class="shrink"
:disabled="field.isViewEssentialField" :disabled="field.isViewEssentialField"
@change="saveOrUpdate(field, index)" @change="toggleFieldVisibility($event, field, index)"
> >
<div class="flex items-center"> <div class="flex items-center">
<component :is="getIcon(metaColumnById[field.fk_column_id])" /> <component :is="getIcon(metaColumnById[field.fk_column_id])" />
@ -253,18 +386,18 @@ useMenuCloseOnEsc(open)
<a-divider class="!my-2" /> <a-divider class="!my-2" />
<div v-if="!isPublic" class="p-2 py-1 flex nc-fields-show-system-fields" @click.stop> <div v-if="!isPublic" class="p-2 py-1 flex nc-fields-show-system-fields" @click.stop>
<a-checkbox v-model:checked="showSystemFields" class="!items-center"> <a-checkbox v-model:checked="showSystemFields" class="!items-center" @change="toggleSystemFields">
<span class="text-xs"> {{ $t('activity.showSystemFields') }}</span> <span class="text-xs"> {{ $t('activity.showSystemFields') }}</span>
</a-checkbox> </a-checkbox>
</div> </div>
<div class="p-2 flex gap-2" @click.stop> <div class="p-2 flex gap-2" @click.stop>
<a-button size="small" class="!text-xs text-gray-500 text-capitalize" @click.stop="showAll()"> <a-button size="small" class="!text-xs text-gray-500 text-capitalize" @click.stop="onShowAll">
<!-- Show All --> <!-- Show All -->
{{ $t('general.showAll') }} {{ $t('general.showAll') }}
</a-button> </a-button>
<a-button size="small" class="!text-xs text-gray-500 text-capitalize" @click.stop="hideAll()"> <a-button size="small" class="!text-xs text-gray-500 text-capitalize" @click.stop="onHideAll">
<!-- Hide All --> <!-- Hide All -->
{{ $t('general.hideAll') }} {{ $t('general.hideAll') }}
</a-button> </a-button>

2
packages/nc-gui/components/smartsheet/toolbar/Reload.vue

@ -28,7 +28,7 @@ const onClick = () => {
<div class="nc-toolbar-btn flex min-w-32px w-32px h-32px items-center justify-center select-none"> <div class="nc-toolbar-btn flex min-w-32px w-32px h-32px items-center justify-center select-none">
<component <component
:is="iconMap.reload" :is="iconMap.reload"
class="cursor-pointer text-gray-500 group-hover:(text-primary) nc-toolbar-reload-btn" class="cursor-pointer group-hover:(text-primary) nc-toolbar-reload-btn"
:class="isReloading ? 'animate-spin' : ''" :class="isReloading ? 'animate-spin' : ''"
@click="onClick" @click="onClick"
/> />

21
packages/nc-gui/components/smartsheet/toolbar/RowHeight.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { GridType } from 'nocodb-sdk' import type { GridType } from 'nocodb-sdk'
import { ActiveViewInj, IsLockedInj, iconMap, inject, ref, storeToRefs, useMenuCloseOnEsc } from '#imports' import { ActiveViewInj, IsLockedInj, iconMap, inject, ref, storeToRefs, useMenuCloseOnEsc, useUndoRedo } from '#imports'
const { isSharedBase } = storeToRefs(useProject()) const { isSharedBase } = storeToRefs(useProject())
@ -12,11 +12,28 @@ const isLocked = inject(IsLockedInj, ref(false))
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { addUndo, defineViewScope } = useUndoRedo()
const open = ref(false) const open = ref(false)
const updateRowHeight = async (rh: number) => { const updateRowHeight = async (rh: number, undo = false) => {
if (view.value?.id) { if (view.value?.id) {
if (rh === (view.value.view as GridType).row_height) return if (rh === (view.value.view as GridType).row_height) return
if (!undo) {
addUndo({
redo: {
fn: (r: number) => updateRowHeight(r, true),
args: [rh],
},
undo: {
fn: (r: number) => updateRowHeight(r, true),
args: [(view.value.view as GridType).row_height || 0],
},
scope: defineViewScope({ view: view.value }),
})
}
try { try {
if (!isPublic.value && !isSharedBase.value) { if (!isPublic.value && !isSharedBase.value) {
await $api.dbView.gridUpdate(view.value.id, { await $api.dbView.gridUpdate(view.value.id, {

9
packages/nc-gui/components/smartsheet/toolbar/ShareView.vue

@ -266,13 +266,10 @@ const copyIframeCode = async () => {
width="min(100vw,720px)" width="min(100vw,720px)"
wrap-class-name="nc-modal-share-view" wrap-class-name="nc-modal-share-view"
> >
<div <div class="share-link-box !bg-primary !bg-opacity-5 ring-1 ring-accent ring-opacity-100">
data-testid="nc-modal-share-view__link" <div data-testid="nc-modal-share-view__link" class="flex-1 h-min text-xs text-gray-500">{{ sharedViewUrl }}</div>
class="share-link-box !bg-primary !bg-opacity-5 ring-1 ring-accent ring-opacity-100"
>
<div class="flex-1 h-min text-xs text-gray-500">{{ sharedViewUrl }}</div>
<a v-e="['c:view:share:open-url']" :href="sharedViewUrl" target="_blank"> <a v-e="['c:view:share:open-url']" :href="sharedViewUrl" target="_blank" class="flex items-center !no-underline">
<component :is="iconMap.share" class="text-sm text-gray-500" /> <component :is="iconMap.share" class="text-sm text-gray-500" />
</a> </a>

29
packages/nc-gui/components/smartsheet/toolbar/StackedBy.vue

@ -14,6 +14,7 @@ import {
ref, ref,
useKanbanViewStoreOrThrow, useKanbanViewStoreOrThrow,
useMenuCloseOnEsc, useMenuCloseOnEsc,
useUndoRedo,
useViewColumns, useViewColumns,
watch, watch,
} from '#imports' } from '#imports'
@ -32,6 +33,8 @@ const { fields, loadViewColumns, metaColumnById } = useViewColumns(activeView, m
const { kanbanMetaData, loadKanbanMeta, loadKanbanData, updateKanbanMeta, groupingField } = useKanbanViewStoreOrThrow() const { kanbanMetaData, loadKanbanMeta, loadKanbanData, updateKanbanMeta, groupingField } = useKanbanViewStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo()
const open = ref(false) const open = ref(false)
useMenuCloseOnEsc(open) useMenuCloseOnEsc(open)
@ -46,16 +49,32 @@ watch(
{ immediate: true }, { immediate: true },
) )
const updateGroupingField = async (v: string) => {
await updateKanbanMeta({
fk_grp_col_id: v,
})
await loadKanbanMeta()
await loadKanbanData()
;(activeView.value?.view as KanbanType).fk_grp_col_id = v
}
const groupingFieldColumnId = computed({ const groupingFieldColumnId = computed({
get: () => kanbanMetaData.value.fk_grp_col_id, get: () => kanbanMetaData.value.fk_grp_col_id,
set: async (val) => { set: async (val) => {
if (val) { if (val) {
await updateKanbanMeta({ addUndo({
fk_grp_col_id: val, undo: {
fn: await updateGroupingField,
args: [kanbanMetaData.value.fk_grp_col_id],
},
redo: {
fn: await updateGroupingField,
args: [val],
},
scope: defineViewScope({ view: activeView.value }),
}) })
await loadKanbanMeta()
await loadKanbanData() await updateGroupingField(val)
;(activeView.value?.view as KanbanType).fk_grp_col_id = val
} }
}, },
}) })

6
packages/nc-gui/components/virtual-cell/QrCode.vue

@ -21,7 +21,11 @@ const qrCodeOptions: QRCode.QRCodeToDataURLOptions = {
quality: 1, quality: 1,
}, },
} }
const rowHeight = inject(RowHeightInj)
const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
const qrCode = useQRCode(qrValue, { const qrCode = useQRCode(qrValue, {
...qrCodeOptions, ...qrCodeOptions,

5
packages/nc-gui/components/virtual-cell/barcode/Barcode.vue

@ -32,7 +32,10 @@ const showBarcode = computed(() => barcodeValue?.value.length > 0 && !tooManyCha
const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning } = useShowNotEditableWarning() const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning } = useShowNotEditableWarning()
const rowHeight = inject(RowHeightInj) const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
</script> </script>
<template> <template>

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

@ -714,6 +714,7 @@ onMounted(async () => {
<a-row> <a-row>
<a-col :span="24"> <a-col :span="24">
<div v-if="!(hook.version === 'v2' && hook.type === 'URL')" class="text-gray-600"> <div v-if="!(hook.version === 'v2' && hook.type === 'URL')" class="text-gray-600">
<div class="flex items-center"></div>
<em>Use context variable <strong>data</strong> to refer the record under consideration</em> <em>Use context variable <strong>data</strong> to refer the record under consideration</em>
<a-tooltip bottom> <a-tooltip bottom>
@ -722,6 +723,7 @@ onMounted(async () => {
</template> </template>
<component :is="iconMap.info" class="ml-2" /> <component :is="iconMap.info" class="ml-2" />
</a-tooltip> </a-tooltip>
</div>
<div class="my-3"> <div class="my-3">
<a href="https://docs.nocodb.com/developer-resources/webhooks/" target="_blank"> <a href="https://docs.nocodb.com/developer-resources/webhooks/" target="_blank">

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

@ -1,4 +1,4 @@
import clone from 'just-clone' import rfdc from 'rfdc'
import type { ColumnReqType, ColumnType, TableType } from 'nocodb-sdk' import type { ColumnReqType, ColumnType, TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
@ -18,6 +18,8 @@ import {
watch, watch,
} from '#imports' } from '#imports'
const clone = rfdc()
const useForm = Form.useForm const useForm = Form.useForm
const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber] const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]

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

@ -21,8 +21,9 @@ import {
useProject, useProject,
useProvideSmartsheetRowStore, useProvideSmartsheetRowStore,
useSharedView, useSharedView,
useUndoRedo,
} from '#imports' } from '#imports'
import type { Row } from '~/lib' import type { Row, UndoRedoAction } from '~/lib'
const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((meta: Ref<TableType>, row: Ref<Row>) => { const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((meta: Ref<TableType>, row: Ref<Row>) => {
const { $e, $state, $api } = useNuxtApp() const { $e, $state, $api } = useNuxtApp()
@ -51,6 +52,10 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const { sharedView } = useSharedView() const { sharedView } = useSharedView()
const { addUndo, clone, defineViewScope } = useUndoRedo()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
// getters // getters
const displayValue = computed(() => { const displayValue = computed(() => {
if (row?.value?.row) { if (row?.value?.row) {
@ -135,7 +140,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
$e('a:row-expand:comment') $e('a:row-expand:comment')
} }
const save = async (ltarState: Record<string, any> = {}) => { const save = async (ltarState: Record<string, any> = {}, undo = false) => {
let data let data
try { try {
const isNewRow = row.value.rowMeta?.new ?? false const isNewRow = row.value.rowMeta?.new ?? false
@ -160,6 +165,47 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
rowMeta: {}, rowMeta: {},
oldRow: { ...data }, oldRow: { ...data },
}) })
if (!undo) {
const id = extractPkFromRow(data, meta.value?.columns as ColumnType[])
const pkData = rowPkData(row.value.row, meta.value?.columns as ColumnType[])
// TODO remove linked record
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction, rowData: any) {
await $api.dbTableRow.create('noco', project.value.title as string, meta.value.title, { ...pkData, ...rowData })
if (activeView.value?.type === ViewTypes.KANBAN) {
const { loadKanbanData } = useKanbanViewStoreOrThrow()
await loadKanbanData()
}
reloadTrigger?.trigger()
},
args: [clone(insertObj)],
},
undo: {
fn: async function undo(this: UndoRedoAction, id: string) {
const res: any = await $api.dbViewRow.delete(
'noco',
project.value.id as string,
meta.value?.id as string,
activeView.value?.id as string,
id,
)
if (res.message) {
throw new Error(res.message)
}
if (activeView.value?.type === ViewTypes.KANBAN) {
const { loadKanbanData } = useKanbanViewStoreOrThrow()
await loadKanbanData()
}
reloadTrigger?.trigger()
},
args: [id],
},
scope: defineViewScope({ view: activeView.value }),
})
}
} else { } else {
const updateOrInsertObj = [...changedColumns.value].reduce((obj, col) => { const updateOrInsertObj = [...changedColumns.value].reduce((obj, col) => {
obj[col] = row.value.row[col] obj[col] = row.value.row[col]
@ -174,6 +220,39 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
await $api.dbTableRow.update(NOCO, project.value.title as string, meta.value.title, id, updateOrInsertObj) await $api.dbTableRow.update(NOCO, project.value.title as string, meta.value.title, id, updateOrInsertObj)
if (!undo) {
const undoObject = [...changedColumns.value].reduce((obj, col) => {
obj[col] = row.value.oldRow[col]
return obj
}, {} as Record<string, any>)
addUndo({
redo: {
fn: async (id: string, data: Record<string, any>) => {
await $api.dbTableRow.update(NOCO, project.value.title as string, meta.value.title, id, data)
if (activeView.value?.type === ViewTypes.KANBAN) {
const { loadKanbanData } = useKanbanViewStoreOrThrow()
await loadKanbanData()
}
reloadTrigger?.trigger()
},
args: [id, clone(updateOrInsertObj)],
},
undo: {
fn: async (id: string, data: Record<string, any>) => {
await $api.dbTableRow.update(NOCO, project.value.title as string, meta.value.title, id, data)
if (activeView.value?.type === ViewTypes.KANBAN) {
const { loadKanbanData } = useKanbanViewStoreOrThrow()
await loadKanbanData()
}
reloadTrigger?.trigger()
},
args: [id, clone(undoObject)],
},
scope: defineViewScope({ view: activeView.value }),
})
}
for (const key of Object.keys(updateOrInsertObj)) { for (const key of Object.keys(updateOrInsertObj)) {
// audit // audit
$api.utils $api.utils

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

@ -1,8 +1,19 @@
import type { ColumnType, GridColumnType, GridType, ViewType } from 'nocodb-sdk' import type { ColumnType, GridColumnType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { IsPublicInj, computed, inject, ref, useMetas, useNuxtApp, useStyleTag, useUIPermission, watch } from '#imports' import {
IsPublicInj,
computed,
inject,
ref,
useMetas,
useNuxtApp,
useStyleTag,
useUIPermission,
useUndoRedo,
watch,
} from '#imports'
export function useGridViewColumnWidth(view: Ref<GridType | undefined>) { export function useGridViewColumnWidth(view: Ref<ViewType | undefined>) {
const { css, load: loadCss, unload: unloadCss } = useStyleTag('') const { css, load: loadCss, unload: unloadCss } = useStyleTag('')
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
@ -11,12 +22,14 @@ export function useGridViewColumnWidth(view: Ref<GridType | undefined>) {
const { metas } = useMetas() const { metas } = useMetas()
const { addUndo, defineViewScope } = useUndoRedo()
const gridViewCols = ref<Record<string, GridColumnType>>({}) const gridViewCols = ref<Record<string, GridColumnType>>({})
const resizingCol = ref('') const resizingCol = ref('')
const resizingColWidth = ref('200px') const resizingColWidth = ref('200px')
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
const columns = computed<ColumnType[]>(() => metas.value?.[(view.value as ViewType)?.fk_model_id as string]?.columns || []) const columns = computed<ColumnType[]>(() => metas.value?.[view.value?.fk_model_id as string]?.columns || [])
watch( watch(
[gridViewCols, resizingCol, resizingColWidth], [gridViewCols, resizingCol, resizingColWidth],
@ -54,7 +67,21 @@ export function useGridViewColumnWidth(view: Ref<GridType | undefined>) {
* or when view changes reload columns width */ * or when view changes reload columns width */
watch([() => columns.value?.length, () => view?.value?.id], loadGridViewColumns) watch([() => columns.value?.length, () => view?.value?.id], loadGridViewColumns)
const updateWidth = async (id: string, width: string) => { const updateWidth = async (id: string, width: string, undo = false) => {
if (!undo) {
addUndo({
redo: {
fn: (w: string) => updateWidth(id, w, true),
args: [width],
},
undo: {
fn: (w: string) => updateWidth(id, w, true),
args: [gridViewCols.value[id].width],
},
scope: defineViewScope({ view: view.value }),
})
}
if (gridViewCols?.value?.[id]) { if (gridViewCols?.value?.[id]) {
gridViewCols.value[id].width = width gridViewCols.value[id].width = width
} }

135
packages/nc-gui/composables/useKanbanViewStore.ts

@ -1,6 +1,6 @@
import type { ComputedRef, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import type { Api, ColumnType, KanbanType, SelectOptionType, SelectOptionsType, TableType, ViewType } from 'nocodb-sdk' import type { Api, ColumnType, KanbanType, SelectOptionType, SelectOptionsType, TableType, ViewType } from 'nocodb-sdk'
import type { Row } from '~/lib' import type { Row, UndoRedoAction } from '~/lib'
import { import {
IsPublicInj, IsPublicInj,
SharedViewPasswordInj, SharedViewPasswordInj,
@ -14,6 +14,7 @@ import {
parseProp, parseProp,
provide, provide,
ref, ref,
rowPkData,
storeToRefs, storeToRefs,
useApi, useApi,
useFieldQuery, useFieldQuery,
@ -24,6 +25,7 @@ import {
useSharedView, useSharedView,
useSmartsheetStoreOrThrow, useSmartsheetStoreOrThrow,
useUIPermission, useUIPermission,
useUndoRedo,
} from '#imports' } from '#imports'
type GroupingFieldColOptionsType = SelectOptionType & { collapsed: boolean } type GroupingFieldColOptionsType = SelectOptionType & { collapsed: boolean }
@ -58,6 +60,11 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
const { search } = useFieldQuery() const { search } = useFieldQuery()
const { addUndo, clone, defineViewScope } = useUndoRedo()
// save history of stack changes for undo/redo
const moveHistory = ref<{ op: 'added' | 'removed'; pk: string; stack: string; index: number }[]>([])
const sqlUi = ref( const sqlUi = ref(
(meta.value as TableType)?.base_id ? sqlUis.value[(meta.value as TableType).base_id!] : Object.values(sqlUis.value)[0], (meta.value as TableType)?.base_id ? sqlUis.value[(meta.value as TableType).base_id!] : Object.values(sqlUis.value)[0],
) )
@ -311,10 +318,21 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
await $api.dbView.kanbanUpdate(viewMeta.value.id, updateObj) await $api.dbView.kanbanUpdate(viewMeta.value.id, updateObj)
} }
async function insertRow(row: Record<string, any>, rowIndex = formattedData.value.get(null)!.length) { function findRowInState(rowData: Record<string, any>) {
const pk: Record<string, string> = rowPkData(rowData, meta?.value?.columns as ColumnType[])
for (const rows of formattedData.value.values()) {
for (const row of rows) {
if (Object.keys(pk).every((k) => pk[k] === row.row[k])) {
return row
}
}
}
}
async function insertRow(row: Record<string, any>, rowIndex = formattedData.value.get(null)!.length, undo = false) {
try { try {
const insertObj = (meta?.value?.columns as ColumnType[]).reduce((o: Record<string, any>, col) => { const insertObj = (meta?.value?.columns as ColumnType[]).reduce((o: Record<string, any>, col) => {
if (!col.ai && row?.[col.title as string] !== null) { if ((!col.ai || undo) && row?.[col.title as string] !== null) {
o[col.title!] = row?.[col.title as string] o[col.title!] = row?.[col.title as string]
} }
return o return o
@ -328,6 +346,31 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
insertObj, insertObj,
) )
if (!undo) {
const id = extractPkFromRow(insertedData, meta.value?.columns as ColumnType[])
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction, row: Row, rowIndex: number) {
const pkData = rowPkData(row.row, meta.value?.columns as ColumnType[])
row.row = { ...pkData, ...row.row }
await insertRow(row, rowIndex, true)
addOrEditStackRow(row, true)
},
args: [clone(row), rowIndex],
},
undo: {
fn: async function undo(this: UndoRedoAction, id: string) {
await deleteRowById(id)
const row = findRowInState(insertedData)
if (row) removeRowFromTargetStack(row)
},
args: [id],
},
scope: defineViewScope({ view: viewMeta.value as ViewType }),
})
}
formattedData.value.get(null)?.splice(rowIndex ?? 0, 1, { formattedData.value.get(null)?.splice(rowIndex ?? 0, 1, {
row: insertedData, row: insertedData,
rowMeta: {}, rowMeta: {},
@ -340,7 +383,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
} }
} }
async function updateRowProperty(toUpdate: Row, property: string) { async function updateRowProperty(toUpdate: Row, property: string, undo = false) {
try { try {
const id = extractPkFromRow(toUpdate.row, meta?.value?.columns as ColumnType[]) const id = extractPkFromRow(toUpdate.row, meta?.value?.columns as ColumnType[])
@ -367,9 +410,49 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
prev_value: getHTMLEncodedText(toUpdate.oldRow[property]), prev_value: getHTMLEncodedText(toUpdate.oldRow[property]),
}) })
if (!undo) {
const oldRowIndex = moveHistory.value.find((ele) => ele.op === 'removed' && ele.pk === id)
const nextRowIndex = moveHistory.value.find((ele) => ele.op === 'added' && ele.pk === id)
addUndo({
redo: {
fn: async function redo(toUpdate: Row, property: string) {
const updatedData = await updateRowProperty(toUpdate, property, true)
const row = findRowInState(toUpdate.row)
if (row) {
Object.assign(row.row, updatedData)
if (row.row[groupingField.value] !== row.oldRow[groupingField.value])
addOrEditStackRow(row, false, nextRowIndex?.index)
Object.assign(row.oldRow, updatedData)
}
},
args: [clone(toUpdate), property],
},
undo: {
fn: async function undo(toUpdate: Row, property: string) {
const updatedData = await updateRowProperty(
{ row: toUpdate.oldRow, oldRow: toUpdate.row, rowMeta: toUpdate.rowMeta },
property,
true,
)
const row = findRowInState(toUpdate.row)
if (row) {
Object.assign(row.row, updatedData)
if (row.row[groupingField.value] !== row.oldRow[groupingField.value])
addOrEditStackRow(row, false, oldRowIndex?.index)
Object.assign(row.oldRow, updatedData)
}
},
args: [clone(toUpdate), property],
},
scope: defineViewScope({ view: viewMeta.value as ViewType }),
})
/** update row data(to sync formula and other related columns) */ /** update row data(to sync formula and other related columns) */
Object.assign(toUpdate.row, updatedRowData) Object.assign(toUpdate.row, updatedRowData)
Object.assign(toUpdate.oldRow, updatedRowData) Object.assign(toUpdate.oldRow, updatedRowData)
}
return updatedRowData
} catch (e: any) { } catch (e: any) {
message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`) message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`)
} }
@ -463,7 +546,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
return formattedData.value.get(null)![addAfter] return formattedData.value.get(null)![addAfter]
} }
function addOrEditStackRow(row: Row, isNewRow: boolean) { function addOrEditStackRow(row: Row, isNewRow: boolean, rowIndex?: number) {
const stackTitle = row.row[groupingField.value] const stackTitle = row.row[groupingField.value]
const oldStackTitle = row.oldRow[groupingField.value] const oldStackTitle = row.oldRow[groupingField.value]
@ -471,7 +554,11 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
// add a new record // add a new record
if (stackTitle) { if (stackTitle) {
// push the row to target stack // push the row to target stack
if (rowIndex !== undefined) {
formattedData.value.get(stackTitle)!.splice(rowIndex, 0, row)
} else {
formattedData.value.get(stackTitle)!.push(row) formattedData.value.get(stackTitle)!.push(row)
}
// increase the current count in the target stack by 1 // increase the current count in the target stack by 1
countByStack.value.set(stackTitle, countByStack.value.get(stackTitle)! + 1) countByStack.value.set(stackTitle, countByStack.value.get(stackTitle)! + 1)
// clear the one under uncategorized since we don't reload the view // clear the one under uncategorized since we don't reload the view
@ -496,11 +583,25 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
// add new row to countByStack & formattedData // add new row to countByStack & formattedData
countByStack.value.set(stackTitle, countByStack.value.get(stackTitle)! + 1) countByStack.value.set(stackTitle, countByStack.value.get(stackTitle)! + 1)
if (rowIndex !== undefined) {
const targetStack = formattedData.value.get(stackTitle)!
targetStack.splice(rowIndex, 0, row)
formattedData.value.set(stackTitle, targetStack)
} else {
formattedData.value.set(stackTitle, [...formattedData.value.get(stackTitle)!, row]) formattedData.value.set(stackTitle, [...formattedData.value.get(stackTitle)!, row])
}
} else { } else {
// update the row in formattedData // update the row in formattedData
const updatedRow = formattedData.value.get(stackTitle)! const updatedRow = formattedData.value.get(stackTitle)!
if (rowIndex !== undefined) {
updatedRow.splice(idxToUpdateOrDelete, 1)
updatedRow.splice(rowIndex, 0, row)
} else {
updatedRow[idxToUpdateOrDelete] = row updatedRow[idxToUpdateOrDelete] = row
}
formattedData.value.set(oldStackTitle, updatedRow) formattedData.value.set(oldStackTitle, updatedRow)
} }
} }
@ -530,8 +631,29 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
countByStack.value.set(null, countByStack.value.get(null)! - 1) countByStack.value.set(null, countByStack.value.get(null)! - 1)
} }
async function deleteRow(row: Row) { async function deleteRow(row: Row, undo = false) {
try { try {
if (!undo) {
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction, r: Row) {
await deleteRow(r, true)
},
args: [clone(row)],
},
undo: {
fn: async function undo(this: UndoRedoAction, row: Row) {
const pkData = rowPkData(row.row, meta.value?.columns as ColumnType[])
row.row = { ...pkData, ...row.row }
await insertRow(row.row, undefined, true)
addOrEditStackRow(row, true)
},
args: [clone(row)],
},
scope: defineViewScope({ view: viewMeta.value as ViewType }),
})
}
if (!row.rowMeta.new) { if (!row.rowMeta.new) {
const id = (meta?.value?.columns as ColumnType[]) const id = (meta?.value?.columns as ColumnType[])
?.filter((c) => c.pk) ?.filter((c) => c.pk)
@ -594,6 +716,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
removeRowFromUncategorizedStack, removeRowFromUncategorizedStack,
shouldScrollToRight, shouldScrollToRight,
deleteRow, deleteRow,
moveHistory,
} }
}, },
'kanban-view-store', 'kanban-view-store',

41
packages/nc-gui/composables/useLTARStore.ts

@ -43,6 +43,10 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const activeView = inject(ActiveViewInj, ref())
const { addUndo, clone, defineViewScope } = useUndoRedo()
const sharedViewPassword = inject(SharedViewPasswordInj, ref(null)) const sharedViewPassword = inject(SharedViewPasswordInj, ref(null))
const childrenExcludedList = ref<DataApiResponse | undefined>() const childrenExcludedList = ref<DataApiResponse | undefined>()
@ -245,7 +249,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
}) })
} }
const unlink = async (row: Record<string, any>) => { const unlink = async (row: Record<string, any>, { metaValue = meta.value }: { metaValue?: TableType } = {}, undo = false) => {
// const column = meta.columns.find(c => c.id === this.column.colOptions.fk_child_column_id); // const column = meta.columns.find(c => c.id === this.column.colOptions.fk_child_column_id);
// todo: handle if new record // todo: handle if new record
// if (this.isNew) { // if (this.isNew) {
@ -264,12 +268,27 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
await $api.dbTableRow.nestedRemove( await $api.dbTableRow.nestedRemove(
NOCO, NOCO,
project.value.title as string, project.value.title as string,
meta.value.title, metaValue.title,
rowId.value, rowId.value,
colOptions.type as 'mm' | 'hm', colOptions.type as 'mm' | 'hm',
encodeURIComponent(column?.value?.title), encodeURIComponent(column?.value?.title),
getRelatedTableRowId(row) as string, getRelatedTableRowId(row) as string,
) )
if (!undo) {
addUndo({
redo: {
fn: (row: Record<string, any>) => unlink(row, {}, true),
args: [clone(row)],
},
undo: {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
fn: (row: Record<string, any>) => link(row, {}, true),
args: [clone(row)],
},
scope: defineViewScope({ view: activeView.value }),
})
}
} catch (e: any) { } catch (e: any) {
message.error(`${t('msg.error.unlinkFailed')}: ${await extractSdkResponseErrorMsg(e)}`) message.error(`${t('msg.error.unlinkFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
} }
@ -277,7 +296,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
reloadData?.(false) reloadData?.(false)
} }
const link = async (row: Record<string, any>) => { const link = async (row: Record<string, any>, { metaValue = meta.value }: { metaValue?: TableType } = {}, undo = false) => {
// todo: handle new record // todo: handle new record
// const pid = this._extractRowId(parent, this.parentMeta); // const pid = this._extractRowId(parent, this.parentMeta);
// const id = this._extractRowId(this.row, this.meta); // const id = this._extractRowId(this.row, this.meta);
@ -295,13 +314,27 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
await $api.dbTableRow.nestedAdd( await $api.dbTableRow.nestedAdd(
NOCO, NOCO,
project.value.title as string, project.value.title as string,
meta.value.title as string, metaValue.title as string,
rowId.value, rowId.value,
colOptions.type as 'mm' | 'hm', colOptions.type as 'mm' | 'hm',
encodeURIComponent(column?.value?.title), encodeURIComponent(column?.value?.title),
getRelatedTableRowId(row) as string, getRelatedTableRowId(row) as string,
) )
await loadChildrenList() await loadChildrenList()
if (!undo) {
addUndo({
redo: {
fn: (row: Record<string, any>) => link(row, {}, true),
args: [clone(row)],
},
undo: {
fn: (row: Record<string, any>) => unlink(row, {}, true),
args: [clone(row)],
},
scope: defineViewScope({ view: activeView.value }),
})
}
} catch (e: any) { } catch (e: any) {
message.error(`Linking failed: ${await extractSdkResponseErrorMsg(e)}`) message.error(`Linking failed: ${await extractSdkResponseErrorMsg(e)}`)
} }

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

@ -51,7 +51,11 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
return message.info(t('msg.info.valueAlreadyInList')) 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.value[column.title!]!.push(value)
}
} else if (isBt(column)) { } else if (isBt(column)) {
state.value[column.title!] = value state.value[column.title!] = value
} }
@ -119,6 +123,9 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
{ metaValue }, { metaValue },
) )
} }
// clear LTAR refs after sync
state.value[column.title!] = null
} }
} }

180
packages/nc-gui/composables/useUndoRedo.ts

@ -0,0 +1,180 @@
import type { Ref } from 'vue'
import rfdc from 'rfdc'
import type { ProjectType, TableType, ViewType } from 'nocodb-sdk'
import { createSharedComposable, ref, useRouter } from '#imports'
import type { UndoRedoAction } from '~/lib'
export const useUndoRedo = createSharedComposable(() => {
const clone = rfdc()
const router = useRouter()
const route = $(router.currentRoute)
// keys: projectType | projectId | type | title | viewTitle
const scope = computed<{ key: string; param: string }[]>(() => {
const tempScope: { key: string; param: string }[] = [{ key: 'root', param: 'root' }]
for (const [key, param] of Object.entries(route.params)) {
if (Array.isArray(param)) {
tempScope.push({ key, param: param.join(',') })
} else {
tempScope.push({ key, param })
}
}
return tempScope
})
const isSameScope = (sc: { key: string; param: string }[]) => {
return sc.every((s) => {
return scope.value.some(
// viewTitle is optional for default view
(s2) =>
(s.key === 'viewTitle' && s2.key === 'viewTitle' && s2.param === '') || (s.key === s2.key && s.param === s2.param),
)
})
}
const undoQueue: Ref<UndoRedoAction[]> = ref([])
const redoQueue: Ref<UndoRedoAction[]> = ref([])
const addUndo = (action: UndoRedoAction, fromRedo = false) => {
// remove all redo actions that are in the same scope
if (!fromRedo) redoQueue.value = redoQueue.value.filter((a) => !isSameScope(a.scope || []))
undoQueue.value.push(action)
}
const addRedo = (action: UndoRedoAction) => {
redoQueue.value.push(action)
}
const undo = async () => {
let actionIndex = -1
for (let i = undoQueue.value.length - 1; i >= 0; i--) {
const elScope = undoQueue.value[i].scope || [{ key: 'root', param: 'root' }]
if (isSameScope(elScope)) {
actionIndex = i
break
}
}
if (actionIndex === -1) return
const action = undoQueue.value.splice(actionIndex, 1)[0]
if (action) {
try {
await action.undo.fn.apply(action, action.undo.args)
addRedo(action)
} catch (e) {
message.warn('Error while undoing action, it is skipped.')
}
}
}
const redo = async () => {
let actionIndex = -1
for (let i = redoQueue.value.length - 1; i >= 0; i--) {
const elScope = redoQueue.value[i].scope || [{ key: 'root', param: 'root' }]
if (isSameScope(elScope)) {
actionIndex = i
break
}
}
if (actionIndex === -1) return
const action = redoQueue.value.splice(actionIndex, 1)[0]
if (action) {
try {
await action.redo.fn.apply(action, action.redo.args)
addUndo(action, true)
} catch (e) {
message.warn('Error while redoing action, it is skipped.')
}
}
}
const defineRootScope = () => {
return [{ key: 'root', param: 'root' }]
}
const defineProjectScope = (param: { project?: ProjectType; model?: TableType; view?: ViewType; project_id?: string }) => {
if (param.project) {
return [{ key: 'projectId', param: param.project.id! }]
} else if (param.model) {
return [{ key: 'projectId', param: param.model.project_id! }]
} else if (param.view) {
return [{ key: 'projectId', param: param.view.project_id! }]
} else {
return [{ key: 'projectId', param: param.project_id! }]
}
}
const defineModelScope = (param: { model?: TableType; view?: ViewType; project_id?: string; model_id?: string }) => {
if (param.model) {
return [
{ key: 'projectId', param: param.model.project_id! },
{ key: 'title', param: param.model.id! },
]
} else if (param.view) {
return [
{ key: 'projectId', param: param.view.project_id! },
{ key: 'title', param: param.view.fk_model_id! },
]
} else {
return [
{ key: 'projectId', param: param.project_id! },
{ key: 'title', param: param.model_id! },
]
}
}
const defineViewScope = (param: { view?: ViewType; project_id?: string; model_id?: string; title?: string }) => {
if (param.view) {
return [
{ key: 'projectId', param: param.view.project_id! },
{ key: 'title', param: param.view.fk_model_id! },
{ key: 'viewTitle', param: param.view.title! },
]
} else {
return [
{ key: 'projectId', param: param.project_id! },
{ key: 'title', param: param.model_id! },
{ key: 'viewTitle', param: param.title! },
]
}
}
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (cmdOrCtrl && !e.altKey) {
switch (e.keyCode) {
case 90: {
e.preventDefault()
// CMD + z and CMD + shift + z
if (!e.shiftKey) {
if (undoQueue.value.length) {
undo()
}
} else {
if (redoQueue.value.length) {
redo()
}
}
break
}
}
}
})
return {
addUndo,
undo,
clone,
defineRootScope,
defineProjectScope,
defineModelScope,
defineViewScope,
}
})

19
packages/nc-gui/composables/useViewColumns.ts

@ -25,6 +25,8 @@ export function useViewColumns(
() => isPublic.value || !isUIAllowed('hideAllColumns') || !isUIAllowed('showAllColumns') || isSharedBase.value, () => isPublic.value || !isUIAllowed('hideAllColumns') || !isUIAllowed('showAllColumns') || isSharedBase.value,
) )
const localChanges = ref<Field[]>([])
const isColumnViewEssential = (column: ColumnType) => { const isColumnViewEssential = (column: ColumnType) => {
// TODO: consider at some point ti delegate this via a cleaner design pattern to view specific check logic // TODO: consider at some point ti delegate this via a cleaner design pattern to view specific check logic
// which could be inside of a view specific helper class (and generalized via an interface) // which could be inside of a view specific helper class (and generalized via an interface)
@ -76,6 +78,16 @@ export function useViewColumns(
} }
}) })
.sort((a: Field, b: Field) => a.order - b.order) .sort((a: Field, b: Field) => a.order - b.order)
if (isLocalMode.value && fields.value) {
for (const field of localChanges.value) {
const fieldIndex = fields.value.findIndex((f) => f.fk_column_id === field.fk_column_id)
if (fieldIndex !== undefined && fieldIndex > -1) {
fields.value[fieldIndex] = field
fields.value = fields.value.sort((a: Field, b: Field) => a.order - b.order)
}
}
}
} }
} }
@ -128,20 +140,20 @@ export function useViewColumns(
} }
const saveOrUpdate = async (field: any, index: number) => { const saveOrUpdate = async (field: any, index: number) => {
if (isPublic.value && fields.value) { if (isLocalMode.value && fields.value) {
fields.value[index] = field fields.value[index] = field
meta.value!.columns = meta.value!.columns?.map((column: ColumnType) => { meta.value!.columns = meta.value!.columns?.map((column: ColumnType) => {
if (column.id === field.fk_column_id) { if (column.id === field.fk_column_id) {
return { return {
...column, ...column,
...field, ...field,
id: field.fk_column_id,
} }
} }
return column return column
}) })
await loadViewColumns() localChanges.value.push(field)
reloadData?.()
} }
if (isUIAllowed('fieldsSync')) { if (isUIAllowed('fieldsSync')) {
@ -156,6 +168,7 @@ export function useViewColumns(
return insertedField return insertedField
} }
} }
await loadViewColumns() await loadViewColumns()
reloadData?.() reloadData?.()
} }

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

@ -12,6 +12,7 @@ import {
message, message,
populateInsertObject, populateInsertObject,
ref, ref,
rowPkData,
storeToRefs, storeToRefs,
until, until,
useApi, useApi,
@ -25,7 +26,7 @@ import {
useSmartsheetStoreOrThrow, useSmartsheetStoreOrThrow,
useUIPermission, useUIPermission,
} from '#imports' } from '#imports'
import type { Row } from '~/lib' import type { Row, UndoRedoAction } from '~/lib'
const formatData = (list: Record<string, any>[]) => const formatData = (list: Record<string, any>[]) =>
list.map((row) => ({ list.map((row) => ({
@ -55,6 +56,8 @@ export function useViewData(
const { getMeta } = useMetas() const { getMeta } = useMetas()
const { addUndo, clone, defineViewScope } = useUndoRedo()
const appInfoDefaultLimit = appInfo.defaultLimit || 25 const appInfoDefaultLimit = appInfo.defaultLimit || 25
const _paginationData = ref<PaginatedType>({ page: 1, pageSize: appInfoDefaultLimit }) const _paginationData = ref<PaginatedType>({ page: 1, pageSize: appInfoDefaultLimit })
@ -221,10 +224,20 @@ export function useViewData(
: await $api.dbView.galleryRead(viewMeta.value.id) : await $api.dbView.galleryRead(viewMeta.value.id)
} }
const findIndexByPk = (pk: Record<string, string>) => {
for (const [i, row] of Object.entries(formattedData.value)) {
if (Object.keys(pk).every((k) => pk[k] === row.row[k])) {
return parseInt(i)
}
}
return -1
}
async function insertRow( async function insertRow(
currentRow: Row, currentRow: Row,
ltarState: Record<string, any> = {}, ltarState: Record<string, any> = {},
{ metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {}, { metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {},
undo = false,
) { ) {
const row = currentRow.row const row = currentRow.row
if (currentRow.rowMeta) currentRow.rowMeta.saving = true if (currentRow.rowMeta) currentRow.rowMeta.saving = true
@ -234,6 +247,7 @@ export function useViewData(
ltarState, ltarState,
getMeta, getMeta,
row, row,
undo,
}) })
if (missingRequiredColumns.size) return if (missingRequiredColumns.size) return
@ -246,12 +260,59 @@ export function useViewData(
insertObj, insertObj,
) )
if (!undo) {
Object.assign(currentRow, { Object.assign(currentRow, {
row: { ...insertedData, ...row }, row: { ...insertedData, ...row },
rowMeta: { ...(currentRow.rowMeta || {}), new: undefined }, rowMeta: { ...(currentRow.rowMeta || {}), new: undefined },
oldRow: { ...insertedData }, oldRow: { ...insertedData },
}) })
const id = extractPkFromRow(insertedData, metaValue?.columns as ColumnType[])
const pkData = rowPkData(insertedData, metaValue?.columns as ColumnType[])
const rowIndex = findIndexByPk(pkData)
addUndo({
redo: {
fn: async function redo(
this: UndoRedoAction,
row: Row,
ltarState: Record<string, any>,
pg: { page: number; pageSize: number },
) {
row.row = { ...pkData, ...row.row }
const insertedData = await insertRow(row, ltarState, undefined, true)
if (rowIndex !== -1 && pg.pageSize === paginationData.value.pageSize) {
if (pg.page === paginationData.value.page) {
formattedData.value.splice(rowIndex, 0, {
row: { ...row, ...insertedData },
rowMeta: row.rowMeta,
oldRow: row.oldRow,
})
} else {
await changePage(pg.page)
}
} else {
await loadData()
}
},
args: [
clone(currentRow),
clone(ltarState),
{ page: paginationData.value.page, pageSize: paginationData.value.pageSize },
],
},
undo: {
fn: async function undo(this: UndoRedoAction, id: string) {
await deleteRowById(id)
if (rowIndex !== -1) formattedData.value.splice(rowIndex, 1)
paginationData.value.totalRows = paginationData.value.totalRows! - 1
},
args: [id],
},
scope: defineViewScope({ view: viewMeta.value }),
})
}
await syncCount() await syncCount()
return insertedData return insertedData
} catch (error: any) { } catch (error: any) {
@ -267,6 +328,7 @@ export function useViewData(
toUpdate: Row, toUpdate: Row,
property: string, property: string,
{ metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {}, { metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {},
undo = false,
) { ) {
if (toUpdate.rowMeta) toUpdate.rowMeta.saving = true if (toUpdate.rowMeta) toUpdate.rowMeta.saving = true
@ -297,6 +359,52 @@ export function useViewData(
prev_value: getHTMLEncodedText(toUpdate.oldRow[property]), prev_value: getHTMLEncodedText(toUpdate.oldRow[property]),
}) })
if (!undo) {
addUndo({
redo: {
fn: async function redo(toUpdate: Row, property: string, pg: { page: number; pageSize: number }) {
const updatedData = await updateRowProperty(toUpdate, property, undefined, true)
if (pg.page === paginationData.value.page && pg.pageSize === paginationData.value.pageSize) {
const rowIndex = findIndexByPk(rowPkData(toUpdate.row, meta?.value?.columns as ColumnType[]))
if (rowIndex !== -1) {
const row = formattedData.value[rowIndex]
Object.assign(row.row, updatedData)
Object.assign(row.oldRow, updatedData)
} else {
await loadData()
}
} else {
await changePage(pg.page)
}
},
args: [clone(toUpdate), property, { page: paginationData.value.page, pageSize: paginationData.value.pageSize }],
},
undo: {
fn: async function undo(toUpdate: Row, property: string, pg: { page: number; pageSize: number }) {
const updatedData = await updateRowProperty(
{ row: toUpdate.oldRow, oldRow: toUpdate.row, rowMeta: toUpdate.rowMeta },
property,
undefined,
true,
)
if (pg.page === paginationData.value.page && pg.pageSize === paginationData.value.pageSize) {
const rowIndex = findIndexByPk(rowPkData(toUpdate.row, meta?.value?.columns as ColumnType[]))
if (rowIndex !== -1) {
const row = formattedData.value[rowIndex]
Object.assign(row.row, updatedData)
Object.assign(row.oldRow, updatedData)
} else {
await loadData()
}
} else {
await changePage(pg.page)
}
},
args: [clone(toUpdate), property, { page: paginationData.value.page, pageSize: paginationData.value.pageSize }],
},
scope: defineViewScope({ view: viewMeta.value }),
})
/** update row data(to sync formula and other related columns) /** update row data(to sync formula and other related columns)
* update only formula, rollup and auto updated datetime columns data to avoid overwriting any changes made by user * update only formula, rollup and auto updated datetime columns data to avoid overwriting any changes made by user
*/ */
@ -316,6 +424,9 @@ export function useViewData(
}, {} as Record<string, any>), }, {} as Record<string, any>),
) )
Object.assign(toUpdate.oldRow, updatedRowData) Object.assign(toUpdate.oldRow, updatedRowData)
}
return updatedRowData
} catch (e: any) { } catch (e: any) {
message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`) message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`)
} finally { } finally {
@ -354,7 +465,10 @@ export function useViewData(
$e('a:grid:pagination') $e('a:grid:pagination')
} }
async function deleteRowById(id: string) { async function deleteRowById(
id: string,
{ metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {},
) {
if (!id) { if (!id) {
throw new Error("Delete not allowed for table which doesn't have primary Key") throw new Error("Delete not allowed for table which doesn't have primary Key")
} }
@ -362,8 +476,8 @@ export function useViewData(
const res: any = await $api.dbViewRow.delete( const res: any = await $api.dbViewRow.delete(
'noco', 'noco',
project.value.id as string, project.value.id as string,
meta.value?.id as string, metaValue?.id as string,
viewMeta.value?.id as string, viewMetaValue?.id as string,
id, id,
) )
@ -378,7 +492,7 @@ export function useViewData(
return true return true
} }
async function deleteRow(rowIndex: number) { async function deleteRow(rowIndex: number, undo?: boolean) {
try { try {
const row = formattedData.value[rowIndex] const row = formattedData.value[rowIndex]
if (!row.rowMeta.new) { if (!row.rowMeta.new) {
@ -387,6 +501,44 @@ export function useViewData(
.map((c) => row.row[c.title!]) .map((c) => row.row[c.title!])
.join('___') .join('___')
if (!undo) {
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction, id: string) {
await deleteRowById(id)
const pk: Record<string, string> = rowPkData(row.row, meta?.value?.columns as ColumnType[])
const rowIndex = findIndexByPk(pk)
if (rowIndex !== -1) formattedData.value.splice(rowIndex, 1)
paginationData.value.totalRows = paginationData.value.totalRows! - 1
},
args: [id],
},
undo: {
fn: async function undo(
this: UndoRedoAction,
row: Row,
ltarState: Record<string, any>,
pg: { page: number; pageSize: number },
) {
const pkData = rowPkData(row.row, meta.value?.columns as ColumnType[])
row.row = { ...pkData, ...row.row }
await insertRow(row, ltarState, {}, true)
if (rowIndex !== -1 && pg.pageSize === paginationData.value.pageSize) {
if (pg.page === paginationData.value.page) {
formattedData.value.splice(rowIndex, 0, row)
} else {
await changePage(pg.page)
}
} else {
await loadData()
}
},
args: [clone(row), {}, { page: paginationData.value.page, pageSize: paginationData.value.pageSize }],
},
scope: defineViewScope({ view: viewMeta.value }),
})
}
const deleted = await deleteRowById(id as string) const deleted = await deleteRowById(id as string)
if (!deleted) { if (!deleted) {
return return
@ -403,6 +555,7 @@ export function useViewData(
async function deleteSelectedRows() { async function deleteSelectedRows() {
let row = formattedData.value.length let row = formattedData.value.length
const removedRowsData: { id?: string; row: Row; rowIndex: number }[] = []
while (row--) { while (row--) {
try { try {
const { row: rowObj, rowMeta } = formattedData.value[row] as Record<string, any> const { row: rowObj, rowMeta } = formattedData.value[row] as Record<string, any>
@ -419,6 +572,7 @@ export function useViewData(
if (!successfulDeletion) { if (!successfulDeletion) {
continue continue
} }
removedRowsData.push({ id, row: clone(formattedData.value[row]), rowIndex: row })
} }
formattedData.value.splice(row, 1) formattedData.value.splice(row, 1)
} catch (e: any) { } catch (e: any) {
@ -426,6 +580,46 @@ export function useViewData(
} }
} }
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction, removedRowsData: { id?: string; row: Row; rowIndex: number }[]) {
for (const { id, row } of removedRowsData) {
await deleteRowById(id as string)
const pk: Record<string, string> = rowPkData(row.row, meta?.value?.columns as ColumnType[])
const rowIndex = findIndexByPk(pk)
if (rowIndex !== -1) formattedData.value.splice(rowIndex, 1)
paginationData.value.totalRows = paginationData.value.totalRows! - 1
}
await syncPagination()
},
args: [removedRowsData],
},
undo: {
fn: async function undo(
this: UndoRedoAction,
removedRowsData: { id?: string; row: Row; rowIndex: number }[],
pg: { page: number; pageSize: number },
) {
for (const { row, rowIndex } of removedRowsData.slice().reverse()) {
const pkData = rowPkData(row.row, meta.value?.columns as ColumnType[])
row.row = { ...pkData, ...row.row }
await insertRow(row, {}, {}, true)
if (rowIndex !== -1 && pg.pageSize === paginationData.value.pageSize) {
if (pg.page === paginationData.value.page) {
formattedData.value.splice(rowIndex, 0, row)
} else {
await changePage(pg.page)
}
} else {
await loadData()
}
}
},
args: [removedRowsData, { page: paginationData.value.page, pageSize: paginationData.value.pageSize }],
},
scope: defineViewScope({ view: viewMeta.value }),
})
await syncCount() await syncCount()
await syncPagination() await syncPagination()
} }

155
packages/nc-gui/composables/useViewFilters.ts

@ -20,7 +20,7 @@ import {
watch, watch,
} from '#imports' } from '#imports'
import { TabMetaInj } from '~/context' import { TabMetaInj } from '~/context'
import type { Filter, TabItem } from '~/lib' import type { Filter, TabItem, UndoRedoAction } from '~/lib'
export function useViewFilters( export function useViewFilters(
view: Ref<ViewType | undefined>, view: Ref<ViewType | undefined>,
@ -46,6 +46,8 @@ export function useViewFilters(
const { metas } = useMetas() const { metas } = useMetas()
const { addUndo, clone, defineViewScope } = useUndoRedo()
const _filters = ref<Filter[]>([]) const _filters = ref<Filter[]>([])
const nestedMode = computed(() => isPublic.value || !isUIAllowed('filterSync') || !isUIAllowed('filterChildrenRead')) const nestedMode = computed(() => isPublic.value || !isUIAllowed('filterSync') || !isUIAllowed('filterChildrenRead'))
@ -107,6 +109,19 @@ export function useViewFilters(
}, {}) }, {})
}) })
const lastFilters = ref<Filter[]>([])
watchOnce(filters, (filters: Filter[]) => {
lastFilters.value = clone(filters)
})
// get delta between two objects and return the changed fields (value is from b)
const getFieldDelta = (a: any, b: any) => {
return Object.entries(b)
.filter(([key, val]) => a[key] !== val && key in a)
.reduce((a, [key, v]) => ({ ...a, [key]: v }), {})
}
const isComparisonOpAllowed = ( const isComparisonOpAllowed = (
filter: FilterType, filter: FilterType,
compOp: { compOp: {
@ -230,47 +245,48 @@ export function useViewFilters(
} }
} }
const deleteFilter = async (filter: Filter, i: number) => { const saveOrUpdate = async (filter: Filter, i: number, force = false, undo = false) => {
// if shared or sync permission not allowed simply remove it from array if (!view.value) return
if (nestedMode.value) {
filters.value.splice(i, 1) if (!undo) {
filters.value = [...filters.value] const lastFilter = lastFilters.value[i]
reloadData?.() if (lastFilter) {
} else { const delta = clone(getFieldDelta(filter, lastFilter))
if (filter.id) { if (Object.keys(delta).length > 0) {
// if auto-apply disabled mark it as disabled addUndo({
if (!autoApply?.value) { undo: {
filter.status = 'delete' fn: (prop: string, data: any) => {
// if auto-apply enabled invoke delete api and remove from array const f = filters.value[i]
// no splice is required here if (f) {
} else { f[prop as keyof Filter] = data
try { saveOrUpdate(f, i, force, true)
await $api.dbTableFilter.delete(filter.id)
reloadData?.()
filters.value.splice(i, 1)
} catch (e: any) {
console.log(e)
message.error(await extractSdkResponseErrorMsg(e))
} }
},
args: [Object.keys(delta)[0], Object.values(delta)[0]],
},
redo: {
fn: (prop: string, data: any) => {
const f = filters.value[i]
if (f) {
f[prop as keyof Filter] = data
saveOrUpdate(f, i, force, true)
} }
// if not synced yet remove it from array },
} else { args: [Object.keys(delta)[0], filter[Object.keys(delta)[0] as keyof Filter]],
filters.value.splice(i, 1) },
scope: defineViewScope({ view: activeView.value }),
})
} }
$e('a:filter:delete', { length: nonDeletedFilters.value.length })
} }
} }
const saveOrUpdate = async (filter: Filter, i: number, force = false) => {
if (!view.value) return
try { try {
if (nestedMode.value) { if (nestedMode.value) {
filters.value[i] = { ...filter } filters.value[i] = { ...filter }
filters.value = [...filters.value] filters.value = [...filters.value]
} else if (!autoApply?.value && !force) { } else if (!autoApply?.value && !force) {
filter.status = filter.id ? 'update' : 'create' filter.status = filter.id ? 'update' : 'create'
} else if (filter.id) { } else if (filter.id && filter.status !== 'create') {
await $api.dbTableFilter.update(filter.id, { await $api.dbTableFilter.update(filter.id, {
...filter, ...filter,
fk_parent_id: parentId, fk_parent_id: parentId,
@ -290,13 +306,88 @@ export function useViewFilters(
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
lastFilters.value = clone(filters.value)
reloadData?.() reloadData?.()
} }
const deleteFilter = async (filter: Filter, i: number, undo = false) => {
if (!undo && !filter.is_group) {
addUndo({
undo: {
fn: async (fl: Filter) => {
fl.status = 'create'
filters.value.splice(i, 0, fl)
await saveOrUpdate(fl, i, false, true)
},
args: [clone(filter)],
},
redo: {
fn: async (index: number) => {
await deleteFilter(filters.value[index], index, true)
},
args: [i],
},
scope: defineViewScope({ view: activeView.value }),
})
}
// if shared or sync permission not allowed simply remove it from array
if (nestedMode.value) {
filters.value.splice(i, 1)
filters.value = [...filters.value]
reloadData?.()
} else {
if (filter.id) {
// if auto-apply disabled mark it as disabled
if (!autoApply?.value) {
filter.status = 'delete'
// if auto-apply enabled invoke delete api and remove from array
// no splice is required here
} else {
try {
await $api.dbTableFilter.delete(filter.id)
reloadData?.()
filters.value.splice(i, 1)
} catch (e: any) {
console.log(e)
message.error(await extractSdkResponseErrorMsg(e))
}
}
// if not synced yet remove it from array
} else {
filters.value.splice(i, 1)
}
$e('a:filter:delete', { length: nonDeletedFilters.value.length })
}
}
const saveOrUpdateDebounced = useDebounceFn(saveOrUpdate, 500) const saveOrUpdateDebounced = useDebounceFn(saveOrUpdate, 500)
const addFilter = () => { const addFilter = async (undo = false) => {
filters.value.push(placeholderFilter()) filters.value.push(placeholderFilter())
if (!undo) {
addUndo({
undo: {
fn: async function undo(this: UndoRedoAction, i: number) {
this.redo.args = [i, clone(filters.value[i])]
await deleteFilter(filters.value[i], i, true)
},
args: [filters.value.length - 1],
},
redo: {
fn: async (i: number, fl: Filter) => {
fl.status = 'create'
filters.value.splice(i, 0, fl)
await saveOrUpdate(fl, i, false, true)
},
args: [],
},
scope: defineViewScope({ view: activeView.value }),
})
}
lastFilters.value = clone(filters.value)
$e('a:filter:add', { length: filters.value.length }) $e('a:filter:add', { length: filters.value.length })
} }
@ -317,6 +408,8 @@ export function useViewFilters(
await saveOrUpdate(filters.value[index], index, true) await saveOrUpdate(filters.value[index], index, true)
lastFilters.value = clone(filters.value)
$e('a:filter:add', { length: filters.value.length, group: true }) $e('a:filter:add', { length: filters.value.length, group: true })
} }

117
packages/nc-gui/composables/useViewSorts.ts

@ -27,12 +27,20 @@ export function useViewSorts(view: Ref<ViewType | undefined>, reloadData?: () =>
const { isSharedBase } = storeToRefs(useProject()) const { isSharedBase } = storeToRefs(useProject())
const { addUndo, clone, defineViewScope } = useUndoRedo()
const reloadHook = inject(ReloadViewDataHookInj) const reloadHook = inject(ReloadViewDataHookInj)
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
const tabMeta = inject(TabMetaInj, ref({ sortsState: new Map() } as TabItem)) const tabMeta = inject(TabMetaInj, ref({ sortsState: new Map() } as TabItem))
const lastSorts = ref<SortType[]>([])
watchOnce(sorts, (sorts: SortType[]) => {
lastSorts.value = clone(sorts)
})
const loadSorts = async () => { const loadSorts = async () => {
if (isPublic.value) { if (isPublic.value) {
// todo: sorts missing on `ViewType` // todo: sorts missing on `ViewType`
@ -57,7 +65,46 @@ export function useViewSorts(view: Ref<ViewType | undefined>, reloadData?: () =>
} }
} }
const saveOrUpdate = async (sort: SortType, i: number) => { // get delta between two objects and return the changed fields (value is from b)
const getDelta = (a: any, b: any) => {
return Object.entries(b)
.filter(([key, val]) => a[key] !== val && key in a)
.reduce((a, [key, v]) => ({ ...a, [key]: v }), {})
}
const saveOrUpdate = async (sort: SortType, i: number, undo = false) => {
if (!undo) {
const lastSort = lastSorts.value[i]
if (lastSort) {
const delta = clone(getDelta(sort, lastSort))
if (Object.keys(delta).length > 0) {
addUndo({
undo: {
fn: (prop: string, data: any) => {
const f = sorts.value[i]
if (f) {
f[prop] = data
saveOrUpdate(f, i, true)
}
},
args: [Object.keys(delta)[0], Object.values(delta)[0]],
},
redo: {
fn: (prop: string, data: any) => {
const f = sorts.value[i]
if (f) {
f[prop] = data
saveOrUpdate(f, i, true)
}
},
args: [Object.keys(delta)[0], sort[Object.keys(delta)[0]]],
},
scope: defineViewScope({ view: view.value }),
})
}
}
}
if (isPublic.value || isSharedBase.value) { if (isPublic.value || isSharedBase.value) {
sorts.value[i] = sort sorts.value[i] = sort
sorts.value = [...sorts.value] sorts.value = [...sorts.value]
@ -81,21 +128,11 @@ export function useViewSorts(view: Ref<ViewType | undefined>, reloadData?: () =>
console.error(e) console.error(e)
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
}
const addSort = () => {
sorts.value = [
...sorts.value,
{
direction: 'asc',
},
]
$e('a:sort:add', { length: sorts?.value?.length })
tabMeta.value.sortsState!.set(view.value!.id!, sorts.value) lastSorts.value = clone(sorts.value)
} }
const deleteSort = async (sort: SortType, i: number) => { const deleteSort = async (sort: SortType, i: number, undo = false) => {
try { try {
if (isUIAllowed('sortSync') && sort.id && !isPublic.value && !isSharedBase.value) { if (isUIAllowed('sortSync') && sort.id && !isPublic.value && !isSharedBase.value) {
await $api.dbTableSort.delete(sort.id) await $api.dbTableSort.delete(sort.id)
@ -103,6 +140,27 @@ export function useViewSorts(view: Ref<ViewType | undefined>, reloadData?: () =>
sorts.value.splice(i, 1) sorts.value.splice(i, 1)
sorts.value = [...sorts.value] sorts.value = [...sorts.value]
if (!undo) {
addUndo({
redo: {
fn: async () => {
await deleteSort(sort, i, true)
},
args: [],
},
undo: {
fn: () => {
sorts.value.splice(i, 0, sort)
saveOrUpdate(sort, i, true)
},
args: [clone(sort), i],
},
scope: defineViewScope({ view: view.value }),
})
}
lastSorts.value = clone(sorts.value)
tabMeta.value.sortsState!.set(view.value!.id!, sorts.value) tabMeta.value.sortsState!.set(view.value!.id!, sorts.value)
reloadHook?.trigger() reloadHook?.trigger()
@ -113,5 +171,38 @@ export function useViewSorts(view: Ref<ViewType | undefined>, reloadData?: () =>
} }
} }
const addSort = (undo = false) => {
sorts.value = [
...sorts.value,
{
direction: 'asc',
},
]
$e('a:sort:add', { length: sorts?.value?.length })
if (!undo) {
addUndo({
undo: {
fn: async () => {
await deleteSort(sorts.value[sorts.value.length - 1], sorts.value.length - 1, true)
},
args: [],
},
redo: {
fn: () => {
addSort(true)
},
args: [],
},
scope: defineViewScope({ view: view.value }),
})
}
lastSorts.value = clone(sorts.value)
tabMeta.value.sortsState!.set(view.value!.id!, sorts.value)
}
return { sorts, loadSorts, addSort, deleteSort, saveOrUpdate } return { sorts, loadSorts, addSort, deleteSort, saveOrUpdate }
} }

1
packages/nc-gui/just-clone-shims.d.ts vendored

@ -1 +0,0 @@
declare module 'just-clone'

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

@ -104,3 +104,9 @@ export type importFileList = (UploadFile & { data: string | ArrayBuffer })[]
export type streamImportFileList = UploadFile[] export type streamImportFileList = UploadFile[]
export type Nullable<T> = { [K in keyof T]: T[K] | null } export type Nullable<T> = { [K in keyof T]: T[K] | null }
export interface UndoRedoAction {
undo: { fn: Function; args: any[] }
redo: { fn: Function; args: any[] }
scope?: { key: string; param: string }[]
}

22
packages/nc-gui/package-lock.json generated

@ -25,7 +25,6 @@
"httpsnippet": "^2.0.0", "httpsnippet": "^2.0.0",
"jsbarcode": "^3.11.5", "jsbarcode": "^3.11.5",
"jsep": "^1.3.6", "jsep": "^1.3.6",
"just-clone": "^6.1.1",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"leaflet": "^1.9.2", "leaflet": "^1.9.2",
"leaflet.markercluster": "^1.5.3", "leaflet.markercluster": "^1.5.3",
@ -35,6 +34,7 @@
"papaparse": "^5.3.2", "papaparse": "^5.3.2",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"qrcode": "^1.5.1", "qrcode": "^1.5.1",
"rfdc": "^1.3.0",
"socket.io-client": "^4.5.1", "socket.io-client": "^4.5.1",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",
"tinycolor2": "^1.4.2", "tinycolor2": "^1.4.2",
@ -10911,11 +10911,6 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/just-clone": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/just-clone/-/just-clone-6.1.1.tgz",
"integrity": "sha512-V24KLIid8uaG7ayOymGfheNHtxgrbpzj1UznQnF9vQZMHlKGTSLT3WWmFx62OXSQPwk1Tn+uo+H5/Xhb4bL9pA=="
},
"node_modules/jwt-decode": { "node_modules/jwt-decode": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
@ -14623,6 +14618,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/rfdc": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz",
"integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA=="
},
"node_modules/rimraf": { "node_modules/rimraf": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@ -26325,11 +26325,6 @@
"object.assign": "^4.1.2" "object.assign": "^4.1.2"
} }
}, },
"just-clone": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/just-clone/-/just-clone-6.1.1.tgz",
"integrity": "sha512-V24KLIid8uaG7ayOymGfheNHtxgrbpzj1UznQnF9vQZMHlKGTSLT3WWmFx62OXSQPwk1Tn+uo+H5/Xhb4bL9pA=="
},
"jwt-decode": { "jwt-decode": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
@ -29067,6 +29062,11 @@
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="
}, },
"rfdc": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz",
"integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA=="
},
"rimraf": { "rimraf": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",

2
packages/nc-gui/package.json

@ -49,7 +49,6 @@
"httpsnippet": "^2.0.0", "httpsnippet": "^2.0.0",
"jsbarcode": "^3.11.5", "jsbarcode": "^3.11.5",
"jsep": "^1.3.6", "jsep": "^1.3.6",
"just-clone": "^6.1.1",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"leaflet": "^1.9.2", "leaflet": "^1.9.2",
"leaflet.markercluster": "^1.5.3", "leaflet.markercluster": "^1.5.3",
@ -59,6 +58,7 @@
"papaparse": "^5.3.2", "papaparse": "^5.3.2",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"qrcode": "^1.5.1", "qrcode": "^1.5.1",
"rfdc": "^1.3.0",
"socket.io-client": "^4.5.1", "socket.io-client": "^4.5.1",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",
"tinycolor2": "^1.4.2", "tinycolor2": "^1.4.2",

8
packages/nc-gui/pages/[projectType]/[projectId]/index.vue

@ -509,11 +509,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
</a-sub-menu> </a-sub-menu>
</template> </template>
<!-- Language --> <!-- Language -->
<a-sub-menu <a-sub-menu key="language" class="lang-menu !py-0">
key="language"
class="lang-menu !py-0"
popup-class-name="scrollbar-thin-dull min-w-50 max-h-90vh !overflow-auto"
>
<template #title> <template #title>
<div class="nc-project-menu-item group"> <div class="nc-project-menu-item group">
<component :is="iconMap.translate" class="group-hover:text-accent nc-language" /> <component :is="iconMap.translate" class="group-hover:text-accent nc-language" />
@ -529,7 +525,9 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
<template #expandIcon></template> <template #expandIcon></template>
<div class="scrollbar-thin-dull min-w-50 max-h-90vh">
<LazyGeneralLanguageMenu /> <LazyGeneralLanguageMenu />
</div>
</a-sub-menu> </a-sub-menu>
<!-- Account --> <!-- Account -->

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

@ -11,6 +11,17 @@ export const extractPkFromRow = (row: Record<string, any>, columns: ColumnType[]
) )
} }
export const rowPkData = (row: Record<string, any>, columns: ColumnType[]) => {
const pkData: Record<string, string> = {}
const pks = columns?.filter((c) => c.pk)
if (row && pks && pks.length) {
for (const pk of pks) {
if (pk.title) pkData[pk.title] = row[pk.title]
}
}
return pkData
}
// a function to populate insert object and verify if all required fields are present // a function to populate insert object and verify if all required fields are present
export async function populateInsertObject({ export async function populateInsertObject({
getMeta, getMeta,
@ -18,12 +29,14 @@ export async function populateInsertObject({
meta, meta,
ltarState, ltarState,
throwError, throwError,
undo = false,
}: { }: {
meta: TableType meta: TableType
ltarState: Record<string, any> ltarState: Record<string, any>
getMeta: (tableIdOrTitle: string, force?: boolean) => Promise<TableType | null> getMeta: (tableIdOrTitle: string, force?: boolean) => Promise<TableType | null>
row: Record<string, any> row: Record<string, any>
throwError?: boolean throwError?: boolean
undo?: boolean
}) { }) {
const missingRequiredColumns = new Set() const missingRequiredColumns = new Set()
const insertObj = await meta.columns?.reduce(async (_o: Promise<any>, col) => { const insertObj = await meta.columns?.reduce(async (_o: Promise<any>, col) => {
@ -51,7 +64,7 @@ export async function populateInsertObject({
missingRequiredColumns.add(col.title) missingRequiredColumns.add(col.title)
} }
if (!col.ai && row?.[col.title as string] !== null) { if ((!col.ai || undo) && row?.[col.title as string] !== null) {
o[col.title as string] = row?.[col.title as string] o[col.title as string] = row?.[col.title as string]
} }

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

@ -912,7 +912,7 @@ export interface GalleryType {
/** Model for Bool */ /** Model for Bool */
deleted?: BoolType; deleted?: BoolType;
/** Foreign Key to Cover Image Column */ /** Foreign Key to Cover Image Column */
fk_cover_image_col_id?: string; fk_cover_image_col_id?: StringOrNullType;
/** Foreign Key to Model */ /** Foreign Key to Model */
fk_model_id?: string; fk_model_id?: string;
/** Foreign Key to View */ /** Foreign Key to View */
@ -1330,7 +1330,7 @@ export interface KanbanType {
/** View ID */ /** View ID */
fk_view_id?: IdType; fk_view_id?: IdType;
/** Cover Image Column ID */ /** Cover Image Column ID */
fk_cover_image_col_id?: IdType; fk_cover_image_col_id?: StringOrNullType;
/** Kanban Columns */ /** Kanban Columns */
columns?: KanbanColumnType[]; columns?: KanbanColumnType[];
/** Meta Info for Kanban */ /** Meta Info for Kanban */
@ -2304,6 +2304,8 @@ export interface ViewType {
show: BoolType; show: BoolType;
/** Should show system fields in this view? */ /** Should show system fields in this view? */
show_system_fields?: BoolType; show_system_fields?: BoolType;
/** Is this view default view for the model? */
is_default?: BoolType;
/** View Title */ /** View Title */
title: string; title: string;
/** View Type */ /** View Type */

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

@ -15831,7 +15831,7 @@
"$ref": "#/components/schemas/Bool" "$ref": "#/components/schemas/Bool"
}, },
"fk_cover_image_col_id": { "fk_cover_image_col_id": {
"type": "string", "$ref": "#/components/schemas/StringOrNull",
"description": "Foreign Key to Cover Image Column" "description": "Foreign Key to Cover Image Column"
}, },
"fk_model_id": { "fk_model_id": {
@ -16916,7 +16916,7 @@
"description": "View ID" "description": "View ID"
}, },
"fk_cover_image_col_id": { "fk_cover_image_col_id": {
"$ref": "#/components/schemas/Id", "$ref": "#/components/schemas/StringOrNull",
"description": "Cover Image Column ID" "description": "Cover Image Column ID"
}, },
"columns": { "columns": {
@ -19923,6 +19923,10 @@
"$ref": "#/components/schemas/Bool", "$ref": "#/components/schemas/Bool",
"description": "Should show system fields in this view?" "description": "Should show system fields in this view?"
}, },
"is_default": {
"$ref": "#/components/schemas/Bool",
"description": "Is this view default view for the model?"
},
"title": { "title": {
"description": "View Title", "description": "View Title",
"type": "string" "type": "string"

1
tests/playwright/pages/Dashboard/Grid/Column/LTAR/LinkRecord.ts

@ -35,6 +35,7 @@ export class LinkRecord extends BasePage {
} }
async select(cardTitle: string) { async select(cardTitle: string) {
await this.rootPage.waitForTimeout(100);
await this.get().locator(`.ant-card:has-text("${cardTitle}"):visible`).click(); await this.get().locator(`.ant-card:has-text("${cardTitle}"):visible`).click();
} }

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

@ -399,4 +399,20 @@ export class ColumnPageObject extends BasePage {
// close sort menu // close sort menu
await this.grid.toolbar.clickSort(); await this.grid.toolbar.clickSort();
} }
async resize(param: { src: string; dst: string }) {
const { src, dst } = param;
const [fromStack, toStack] = await Promise.all([
this.rootPage.locator(`[data-title="${src}"] >> .resizer`),
this.rootPage.locator(`[data-title="${dst}"] >> .resizer`),
]);
await fromStack.dragTo(toStack);
}
async getWidth(param: { title: string }) {
const { title } = param;
const cell = await this.rootPage.locator(`th[data-title="${title}"]`);
return await cell.evaluate(el => el.getBoundingClientRect().width);
}
} }

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

@ -87,7 +87,8 @@ export class GridPage extends BasePage {
uiAction: clickOnColumnHeaderToSave, uiAction: clickOnColumnHeaderToSave,
requestUrlPathToMatch: 'api/v1/db/data/noco', requestUrlPathToMatch: 'api/v1/db/data/noco',
httpMethodsToMatch: ['POST'], httpMethodsToMatch: ['POST'],
responseJsonMatcher: resJson => resJson?.[columnHeader] === rowValue, // numerical types are returned in number format from the server
responseJsonMatcher: resJson => String(resJson?.[columnHeader]) === String(rowValue),
}); });
} else { } else {
await clickOnColumnHeaderToSave(); await clickOnColumnHeaderToSave();
@ -122,7 +123,8 @@ export class GridPage extends BasePage {
// since edit row on an empty row will emit POST request // since edit row on an empty row will emit POST request
'POST', 'POST',
], ],
responseJsonMatcher: resJson => resJson?.[columnHeader] === value, // numerical types are returned in number format from the server
responseJsonMatcher: resJson => String(resJson?.[columnHeader]) === String(value),
}); });
} else { } else {
await clickOnColumnHeaderToSave(); await clickOnColumnHeaderToSave();
@ -142,8 +144,8 @@ export class GridPage extends BasePage {
return await expect(this.get().locator(`td[data-testid="cell-Title-${index}"]`)).toHaveCount(0); return await expect(this.get().locator(`td[data-testid="cell-Title-${index}"]`)).toHaveCount(0);
} }
async deleteRow(index: number) { async deleteRow(index: number, title = 'Title') {
await this.get().getByTestId(`cell-Title-${index}`).click({ await this.get().getByTestId(`cell-${title}-${index}`).click({
button: 'right', button: 'right',
}); });

29
tests/playwright/pages/Dashboard/Kanban/index.ts

@ -29,20 +29,21 @@ export class KanbanPage extends BasePage {
// todo: Implement // todo: Implement
async addOption() {} async addOption() {}
// todo: Implement async dragDropCard(param: { from: { stack: number; card: number }; to: { stack: number; card: number } }) {
async dragDropCard(param: { from: string; to: string }) { const { from, to } = param;
// const { from, to } = param; const srcStack = await this.get().locator(`.nc-kanban-stack`).nth(from.stack);
// const srcStack = await this.get().locator(`.nc-kanban-stack`).nth(1); const dstStack = await this.get().locator(`.nc-kanban-stack`).nth(to.stack);
// const dstStack = await this.get().locator(`.nc-kanban-stack`).nth(2); const fromCard = await srcStack.locator(`.nc-kanban-item`).nth(from.card);
// const fromCard = await srcStack.locator(`.nc-kanban-item`).nth(1); const toCard = await dstStack.locator(`.nc-kanban-item`).nth(to.card);
// const toCard = await dstStack.locator(`.nc-kanban-item`).nth(1);
// const [fromCard, toCard] = await Promise.all([ console.log(await fromCard.allTextContents());
// srcStack.locator(`.nc-kanban-item[data-draggable="true"]`).nth(0), console.log(await toCard.allTextContents());
// dstStack.locator(`.nc-kanban-item[data-draggable="true"]`).nth(0),
// ]); await fromCard.dragTo(toCard, {
// const fromCard = await this.get().locator(`.nc-kanban-item`).nth(0); force: true,
// const toCard = await this.get().locator(`.nc-kanban-item`).nth(25); sourcePosition: { x: 10, y: 10 },
// await fromCard.dragTo(toCard); targetPosition: { x: 10, y: 10 },
});
} }
async dragDropStack(param: { from: number; to: number }) { async dragDropStack(param: { from: number; to: number }) {

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

@ -280,7 +280,7 @@ export class CellPageObject extends BasePage {
async unlinkVirtualCell({ index, columnHeader }: CellProps) { async unlinkVirtualCell({ index, columnHeader }: CellProps) {
const cell = this.get({ index, columnHeader }); const cell = this.get({ index, columnHeader });
await cell.click(); await cell.click();
await cell.locator('.nc-icon.unlink-icon').click(); await cell.locator('.unlink-icon').first().click();
} }
async verifyRoleAccess(param: { role: string }) { async verifyRoleAccess(param: { role: string }) {

36
tests/playwright/pages/Dashboard/common/Toolbar/Fields.ts

@ -15,16 +15,33 @@ export class ToolbarFieldsPage extends BasePage {
} }
// todo: Click and toggle are similar method. Remove one of them // todo: Click and toggle are similar method. Remove one of them
async toggle({ title, isLocallySaved }: { title: string; isLocallySaved?: boolean }) { async toggle({
title,
isLocallySaved,
validateResponse = true,
}: {
title: string;
isLocallySaved?: boolean;
validateResponse?: boolean;
}) {
await this.toolbar.clickFields(); await this.toolbar.clickFields();
// hack
await this.rootPage.waitForTimeout(100);
const toggleColumn = () => const toggleColumn = () =>
this.get().locator(`[data-testid="nc-fields-menu-${title}"]`).locator('input[type="checkbox"]').click(); this.get().locator(`[data-testid="nc-fields-menu-${title}"]`).locator('input[type="checkbox"]').click();
if (validateResponse) {
await this.waitForResponse({ await this.waitForResponse({
uiAction: toggleColumn, uiAction: toggleColumn,
requestUrlPathToMatch: isLocallySaved ? '/api/v1/db/public/' : '/api/v1/db/data/noco/', requestUrlPathToMatch: isLocallySaved ? '/api/v1/db/public/' : '/api/v1/db/data/noco/',
httpMethodsToMatch: ['GET'], httpMethodsToMatch: ['GET'],
}); });
} else {
await toggleColumn();
}
await this.toolbar.parent.dashboard.waitForLoaderToDisappear(); await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
await this.toolbar.clickFields(); await this.toolbar.clickFields();
} }
@ -78,4 +95,21 @@ export class ToolbarFieldsPage extends BasePage {
}); });
await this.toolbar.clickFields(); await this.toolbar.clickFields();
} }
async getFieldsTitles() {
const fields: string[] = await this.rootPage.locator(`.nc-grid-header .name`).allTextContents();
return fields;
}
async dragDropFields(param: { from: number; to: number }) {
await this.toolbar.clickFields();
const { from, to } = param;
const [fromStack, toStack] = await Promise.all([
this.get().locator(`.cursor-move`).nth(from),
this.get().locator(`.cursor-move`).nth(to),
]);
await fromStack.dragTo(toStack);
await this.toolbar.clickFields();
}
} }

36
tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts

@ -38,53 +38,53 @@ export class ToolbarFilterPage extends BasePage {
} }
async add({ async add({
columnTitle, title,
opType, operation,
opSubType, subOperation,
value, value,
isLocallySaved, locallySaved = false,
dataType, dataType,
openModal = false, openModal = false,
}: { }: {
columnTitle: string; title: string;
opType: string; operation: string;
opSubType?: string; // for date datatype subOperation?: string; // for date datatype
value?: string; value?: string;
isLocallySaved: boolean; locallySaved?: boolean;
dataType?: string; dataType?: string;
openModal?: boolean; openModal?: boolean;
}) { }) {
if (!openModal) await this.get().locator(`button:has-text("Add Filter")`).first().click(); if (!openModal) await this.get().locator(`button:has-text("Add Filter")`).first().click();
const selectedField = await this.rootPage.locator('.nc-filter-field-select').textContent(); const selectedField = await this.rootPage.locator('.nc-filter-field-select').textContent();
if (selectedField !== columnTitle) { if (selectedField !== title) {
await this.rootPage.locator('.nc-filter-field-select').last().click(); await this.rootPage.locator('.nc-filter-field-select').last().click();
await this.rootPage await this.rootPage
.locator('div.ant-select-dropdown.nc-dropdown-toolbar-field-list') .locator('div.ant-select-dropdown.nc-dropdown-toolbar-field-list')
.locator(`div[label="${columnTitle}"]:visible`) .locator(`div[label="${title}"]:visible`)
.click(); .click();
} }
const selectedOpType = await this.rootPage.locator('.nc-filter-operation-select').textContent(); const selectedOpType = await this.rootPage.locator('.nc-filter-operation-select').textContent();
if (selectedOpType !== opType) { if (selectedOpType !== operation) {
await this.rootPage.locator('.nc-filter-operation-select').click(); await this.rootPage.locator('.nc-filter-operation-select').click();
// first() : filter list has >, >= // first() : filter list has >, >=
await this.rootPage await this.rootPage
.locator('.nc-dropdown-filter-comp-op') .locator('.nc-dropdown-filter-comp-op')
.locator(`.ant-select-item:has-text("${opType}")`) .locator(`.ant-select-item:has-text("${operation}")`)
.first() .first()
.click(); .click();
} }
// subtype for date // subtype for date
if (dataType === UITypes.Date && opSubType) { if (dataType === UITypes.Date && subOperation) {
const selectedSubType = await this.rootPage.locator('.nc-filter-sub_operation-select').textContent(); const selectedSubType = await this.rootPage.locator('.nc-filter-sub_operation-select').textContent();
if (selectedSubType !== opSubType) { if (selectedSubType !== subOperation) {
await this.rootPage.locator('.nc-filter-sub_operation-select').click(); await this.rootPage.locator('.nc-filter-sub_operation-select').click();
// first() : filter list has >, >= // first() : filter list has >, >=
await this.rootPage await this.rootPage
.locator('.nc-dropdown-filter-comp-sub-op') .locator('.nc-dropdown-filter-comp-sub-op')
.locator(`.ant-select-item:has-text("${opSubType}")`) .locator(`.ant-select-item:has-text("${subOperation}")`)
.first() .first()
.click(); .click();
} }
@ -115,7 +115,7 @@ export class ToolbarFilterPage extends BasePage {
await this.rootPage.locator(`.ant-btn-primary:has-text("Ok")`).click(); await this.rootPage.locator(`.ant-btn-primary:has-text("Ok")`).click();
break; break;
case UITypes.Date: case UITypes.Date:
if (opSubType === 'exact date') { if (subOperation === 'exact date') {
await this.get().locator('.nc-filter-value-select').click(); await this.get().locator('.nc-filter-value-select').click();
await this.rootPage.locator(`.ant-picker-dropdown:visible`); await this.rootPage.locator(`.ant-picker-dropdown:visible`);
await this.rootPage.locator(`.ant-picker-cell-inner:has-text("${value}")`).click(); await this.rootPage.locator(`.ant-picker-cell-inner:has-text("${value}")`).click();
@ -124,7 +124,7 @@ export class ToolbarFilterPage extends BasePage {
await this.waitForResponse({ await this.waitForResponse({
uiAction: fillFilter, uiAction: fillFilter,
httpMethodsToMatch: ['GET'], httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: isLocallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`, requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
}); });
await this.toolbar.parent.dashboard.waitForLoaderToDisappear(); await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
await this.toolbar.parent.waitLoading(); await this.toolbar.parent.waitLoading();
@ -174,7 +174,7 @@ export class ToolbarFilterPage extends BasePage {
await this.waitForResponse({ await this.waitForResponse({
uiAction: fillFilter, uiAction: fillFilter,
httpMethodsToMatch: ['GET'], httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: isLocallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`, requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
}); });
await this.toolbar.parent.dashboard.waitForLoaderToDisappear(); await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
await this.toolbar.parent.waitLoading(); await this.toolbar.parent.waitLoading();

23
tests/playwright/pages/Dashboard/common/Toolbar/Sort.ts

@ -25,27 +25,19 @@ export class ToolbarSortPage extends BasePage {
).toHaveText(direction); ).toHaveText(direction);
} }
async add({ async add({ title, ascending, locallySaved }: { title: string; ascending: boolean; locallySaved: boolean }) {
columnTitle,
isAscending,
isLocallySaved,
}: {
columnTitle: string;
isAscending: boolean;
isLocallySaved: boolean;
}) {
// open sort menu // open sort menu
await this.toolbar.clickSort(); await this.toolbar.clickSort();
await this.get().locator(`button:has-text("Add Sort Option")`).click(); await this.get().locator(`button:has-text("Add Sort Option")`).click();
// read content of the dropdown // read content of the dropdown
const col = await this.rootPage.locator('.nc-sort-field-select').textContent(); const col = await this.rootPage.locator('.nc-sort-field-select').last().textContent();
if (col !== columnTitle) { if (col !== title) {
await this.rootPage.locator('.nc-sort-field-select').last().click(); await this.rootPage.locator('.nc-sort-field-select').last().click();
await this.rootPage await this.rootPage
.locator('div.ant-select-dropdown.nc-dropdown-toolbar-field-list') .locator('div.ant-select-dropdown.nc-dropdown-toolbar-field-list')
.locator(`div[label="${columnTitle}"]`) .locator(`div[label="${title}"]`)
.last() .last()
.click(); .click();
} }
@ -68,14 +60,15 @@ export class ToolbarSortPage extends BasePage {
const selectSortDirection = () => const selectSortDirection = () =>
this.rootPage this.rootPage
.locator('.nc-dropdown-sort-dir') .locator('.nc-dropdown-sort-dir')
.last()
.locator('.ant-select-item') .locator('.ant-select-item')
.nth(isAscending ? 0 : 1) .nth(ascending ? 0 : 1)
.click(); .click();
await this.waitForResponse({ await this.waitForResponse({
uiAction: selectSortDirection, uiAction: selectSortDirection,
httpMethodsToMatch: ['GET'], httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: isLocallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`, requestUrlPathToMatch: locallySaved ? `/api/v1/db/public/` : `/api/v1/db/data/noco/`,
}); });
await this.toolbar.parent.dashboard.waitForLoaderToDisappear(); await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
// close sort menu // close sort menu
@ -88,7 +81,7 @@ export class ToolbarSortPage extends BasePage {
// open sort menu // open sort menu
await this.toolbar.clickSort(); await this.toolbar.clickSort();
await this.get().locator('.nc-sort-item-remove-btn').click(); await this.get().locator('.nc-sort-item-remove-btn').last().click();
// close sort menu // close sort menu
await this.toolbar.clickSort(); await this.toolbar.clickSort();

18
tests/playwright/tests/columnCheckbox.spec.ts

@ -22,10 +22,10 @@ test.describe('Checkbox - cell, filter, sort', () => {
async function verifyFilter(param: { opType: string; value?: string; result: string[] }) { async function verifyFilter(param: { opType: string; value?: string; result: string[] }) {
await toolbar.clickFilter(); await toolbar.clickFilter();
await toolbar.filter.add({ await toolbar.filter.add({
columnTitle: 'checkbox', title: 'checkbox',
opType: param.opType, operation: param.opType,
value: param.value, value: param.value,
isLocallySaved: false, locallySaved: false,
dataType: 'Checkbox', dataType: 'Checkbox',
}); });
await toolbar.clickFilter(); await toolbar.clickFilter();
@ -94,18 +94,18 @@ test.describe('Checkbox - cell, filter, sort', () => {
// Sort column // Sort column
await toolbar.sort.add({ await toolbar.sort.add({
columnTitle: 'checkbox', title: 'checkbox',
isAscending: true, ascending: true,
isLocallySaved: false, locallySaved: false,
}); });
await validateRowArray(['1b', '1d', '1e', '1a', '1c', '1f']); await validateRowArray(['1b', '1d', '1e', '1a', '1c', '1f']);
await toolbar.sort.reset(); await toolbar.sort.reset();
// sort descending & validate // sort descending & validate
await toolbar.sort.add({ await toolbar.sort.add({
columnTitle: 'checkbox', title: 'checkbox',
isAscending: false, ascending: false,
isLocallySaved: false, locallySaved: false,
}); });
await validateRowArray(['1a', '1c', '1f', '1b', '1d', '1e']); await validateRowArray(['1a', '1c', '1f', '1b', '1d', '1e']);
await toolbar.sort.reset(); await toolbar.sort.reset();

18
tests/playwright/tests/columnMultiSelect.spec.ts

@ -241,10 +241,10 @@ test.describe('Multi select - filters', () => {
async function verifyFilter(param: { opType: string; value?: string; result: string[] }) { async function verifyFilter(param: { opType: string; value?: string; result: string[] }) {
await toolbar.clickFilter(); await toolbar.clickFilter();
await toolbar.filter.add({ await toolbar.filter.add({
columnTitle: 'MultiSelect', title: 'MultiSelect',
opType: param.opType, operation: param.opType,
value: param.value, value: param.value,
isLocallySaved: false, locallySaved: false,
dataType: 'MultiSelect', dataType: 'MultiSelect',
}); });
await toolbar.clickFilter(); await toolbar.clickFilter();
@ -267,18 +267,18 @@ test.describe('Multi select - filters', () => {
// Sort column // Sort column
await toolbar.sort.add({ await toolbar.sort.add({
columnTitle: 'MultiSelect', title: 'MultiSelect',
isAscending: true, ascending: true,
isLocallySaved: false, locallySaved: false,
}); });
await validateRowArray(['1', '3', '4', '2', '5', '6']); await validateRowArray(['1', '3', '4', '2', '5', '6']);
await toolbar.sort.reset(); await toolbar.sort.reset();
// sort descending & validate // sort descending & validate
await toolbar.sort.add({ await toolbar.sort.add({
columnTitle: 'MultiSelect', title: 'MultiSelect',
isAscending: false, ascending: false,
isLocallySaved: false, locallySaved: false,
}); });
await validateRowArray(['6', '5', '2', '4', '3', '1']); await validateRowArray(['6', '5', '2', '4', '3', '1']);
await toolbar.sort.reset(); await toolbar.sort.reset();

18
tests/playwright/tests/columnRating.spec.ts

@ -22,10 +22,10 @@ test.describe('Rating - cell, filter, sort', () => {
async function verifyFilter(param: { opType: string; value?: string; result: string[] }) { async function verifyFilter(param: { opType: string; value?: string; result: string[] }) {
await toolbar.clickFilter(); await toolbar.clickFilter();
await toolbar.filter.add({ await toolbar.filter.add({
columnTitle: 'rating', title: 'rating',
opType: param.opType, operation: param.opType,
value: param.value, value: param.value,
isLocallySaved: false, locallySaved: false,
dataType: 'Rating', dataType: 'Rating',
}); });
await toolbar.clickFilter(); await toolbar.clickFilter();
@ -88,18 +88,18 @@ test.describe('Rating - cell, filter, sort', () => {
// Sort column // Sort column
await toolbar.sort.add({ await toolbar.sort.add({
columnTitle: 'rating', title: 'rating',
isAscending: true, ascending: true,
isLocallySaved: false, locallySaved: false,
}); });
await validateRowArray(['1b', '1d', '1e', '1f', '1c', '1a']); await validateRowArray(['1b', '1d', '1e', '1f', '1c', '1a']);
await toolbar.sort.reset(); await toolbar.sort.reset();
// sort descending & validate // sort descending & validate
await toolbar.sort.add({ await toolbar.sort.add({
columnTitle: 'rating', title: 'rating',
isAscending: false, ascending: false,
isLocallySaved: false, locallySaved: false,
}); });
await validateRowArray(['1a', '1c', '1f', '1b', '1d', '1e']); await validateRowArray(['1a', '1c', '1f', '1b', '1d', '1e']);
await toolbar.sort.reset(); await toolbar.sort.reset();

18
tests/playwright/tests/columnSingleSelect.spec.ts

@ -150,10 +150,10 @@ test.describe('Single select - filter & sort', () => {
async function verifyFilter(param: { opType: string; value?: string; result: string[] }) { async function verifyFilter(param: { opType: string; value?: string; result: string[] }) {
await toolbar.clickFilter(); await toolbar.clickFilter();
await toolbar.filter.add({ await toolbar.filter.add({
columnTitle: 'SingleSelect', title: 'SingleSelect',
opType: param.opType, operation: param.opType,
value: param.value, value: param.value,
isLocallySaved: false, locallySaved: false,
dataType: 'SingleSelect', dataType: 'SingleSelect',
}); });
await toolbar.clickFilter(); await toolbar.clickFilter();
@ -171,18 +171,18 @@ test.describe('Single select - filter & sort', () => {
// Sort column // Sort column
await toolbar.sort.add({ await toolbar.sort.add({
columnTitle: 'SingleSelect', title: 'SingleSelect',
isAscending: true, ascending: true,
isLocallySaved: false, locallySaved: false,
}); });
await validateRowArray(['1', '3', '4', '2']); await validateRowArray(['1', '3', '4', '2']);
await toolbar.sort.reset(); await toolbar.sort.reset();
// sort descending & validate // sort descending & validate
await toolbar.sort.add({ await toolbar.sort.add({
columnTitle: 'SingleSelect', title: 'SingleSelect',
isAscending: false, ascending: false,
isLocallySaved: false, locallySaved: false,
}); });
await validateRowArray(['2', '4', '3', '1']); await validateRowArray(['2', '4', '3', '1']);
await toolbar.sort.reset(); await toolbar.sort.reset();

6
tests/playwright/tests/expandedFormUrl.spec.ts

@ -151,10 +151,10 @@ test.describe('Expanded record duplicate & delete options', () => {
// create filter to narrow down the number of records // create filter to narrow down the number of records
await toolbar.clickFilter(); await toolbar.clickFilter();
await toolbar.filter.add({ await toolbar.filter.add({
columnTitle: 'FirstName', title: 'FirstName',
opType: 'is equal', operation: 'is equal',
value: 'NICK', value: 'NICK',
isLocallySaved: false, locallySaved: false,
}); });
await toolbar.clickFilter(); await toolbar.clickFilter();

22
tests/playwright/tests/filters.spec.ts

@ -60,11 +60,11 @@ async function verifyFilter_withFixedModal(param: {
} }
await toolbar.filter.add({ await toolbar.filter.add({
columnTitle: param.column, title: param.column,
opType: param.opType, operation: param.opType,
opSubType: param.opSubType, subOperation: param.opSubType,
value: param.value, value: param.value,
isLocallySaved: false, locallySaved: false,
dataType: param?.dataType, dataType: param?.dataType,
openModal: true, openModal: true,
}); });
@ -90,11 +90,11 @@ async function verifyFilter(param: {
await toolbar.clickFilter(); await toolbar.clickFilter();
await toolbar.filter.add({ await toolbar.filter.add({
columnTitle: param.column, title: param.column,
opType: param.opType, operation: param.opType,
opSubType: param.opSubType, subOperation: param.opSubType,
value: param.value, value: param.value,
isLocallySaved: false, locallySaved: false,
dataType: param?.dataType, dataType: param?.dataType,
}); });
await toolbar.clickFilter(); await toolbar.clickFilter();
@ -1271,10 +1271,10 @@ test.describe('Filter Tests: Toggle button', () => {
await toolbar.clickFilter({ networkValidation: false }); await toolbar.clickFilter({ networkValidation: false });
await toolbar.filter.add({ await toolbar.filter.add({
columnTitle: 'Country', title: 'Country',
opType: 'is null', operation: 'is null',
value: null, value: null,
isLocallySaved: false, locallySaved: false,
dataType: 'SingleLineText', dataType: 'SingleLineText',
}); });
await toolbar.clickFilter({ networkValidation: false }); await toolbar.clickFilter({ networkValidation: false });

12
tests/playwright/tests/metaSync.spec.ts

@ -258,17 +258,17 @@ test.describe('Meta sync', () => {
await dashboard.grid.toolbar.clickFields(); await dashboard.grid.toolbar.clickFields();
await dashboard.grid.toolbar.sort.add({ await dashboard.grid.toolbar.sort.add({
columnTitle: 'Col2', title: 'Col2',
isAscending: false, ascending: false,
isLocallySaved: false, locallySaved: false,
}); });
await dashboard.grid.toolbar.clickFilter(); await dashboard.grid.toolbar.clickFilter();
await dashboard.grid.toolbar.filter.add({ await dashboard.grid.toolbar.filter.add({
columnTitle: 'Col2', title: 'Col2',
opType: '>=', operation: '>=',
value: '5', value: '5',
isLocallySaved: false, locallySaved: false,
}); });
await dashboard.grid.toolbar.clickFilter(); await dashboard.grid.toolbar.clickFilter();

8
tests/playwright/tests/toolbarOperations.spec.ts

@ -48,7 +48,7 @@ test.describe('Toolbar operations (GRID)', () => {
await validateFirstRow('Afghanistan'); await validateFirstRow('Afghanistan');
// Sort column // Sort column
await toolbar.sort.add({ columnTitle: 'Country', isAscending: false, isLocallySaved: false }); await toolbar.sort.add({ title: 'Country', ascending: false, locallySaved: false });
await validateFirstRow('Zambia'); await validateFirstRow('Zambia');
// reset sort // reset sort
@ -58,10 +58,10 @@ test.describe('Toolbar operations (GRID)', () => {
// Filter column // Filter column
await toolbar.clickFilter(); await toolbar.clickFilter();
await toolbar.filter.add({ await toolbar.filter.add({
columnTitle: 'Country', title: 'Country',
value: 'India', value: 'India',
opType: 'is equal', operation: 'is equal',
isLocallySaved: false, locallySaved: false,
}); });
await toolbar.clickFilter(); await toolbar.clickFilter();

684
tests/playwright/tests/undo-redo.spec.ts

@ -0,0 +1,684 @@
import { expect, Page, test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard';
import setup from '../setup';
import { Api, UITypes } from 'nocodb-sdk';
import { rowMixedValue } from '../setup/xcdb-records';
import { GridPage } from '../pages/Dashboard/Grid';
import { ToolbarPage } from '../pages/Dashboard/common/Toolbar';
let dashboard: DashboardPage,
grid: GridPage,
toolbar: ToolbarPage,
context: any,
api: Api<any>,
records: Record<string, any>,
table: any,
cityTable: any,
countryTable: any;
const validateResponse = false;
/**
This change provides undo/redo on multiple actions over UI.
Scope Actions
------------------------------
Row Create, Update, Delete
LTAR Link, Unlink
Fields Show/hide, Reorder
Sort Add, Update, Delete
Filters Add, Update, Delete (Excluding Filter Groups)
Row Height Update
Column width Update
View Rename
Table Rename
**/
async function undo({ page }: { page: Page }) {
const isMac = await grid.isMacOs();
if (validateResponse) {
await dashboard.grid.waitForResponse({
uiAction: () => page.keyboard.press(isMac ? 'Meta+z' : 'Control+z'),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: `/api/v1/db/data/noco/`,
responseJsonMatcher: json => json.pageInfo,
});
} else {
await page.keyboard.press(isMac ? 'Meta+z' : 'Control+z');
await page.waitForTimeout(100);
}
}
test.describe('Undo Redo', () => {
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
grid = dashboard.grid;
toolbar = dashboard.grid.toolbar;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Number',
title: 'Number',
uidt: UITypes.Number,
pv: true,
},
{
column_name: 'Decimal',
title: 'Decimal',
uidt: UITypes.Decimal,
},
{
column_name: 'Currency',
title: 'Currency',
uidt: UITypes.Currency,
},
];
try {
const project = await api.project.read(context.project.id);
table = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'numberBased',
title: 'numberBased',
columns: columns,
});
const rowAttributes = [];
for (let i = 0; i < 10; i++) {
const row = {
Number: rowMixedValue(columns[1], i),
Decimal: rowMixedValue(columns[2], i),
Currency: rowMixedValue(columns[3], i),
};
rowAttributes.push(row);
}
await api.dbTableRow.bulkCreate('noco', context.project.id, table.id, rowAttributes);
records = await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 100 });
} catch (e) {
console.log(e);
}
// reload page after api calls
await page.reload();
});
async function verifyRecords(values: any[] = []) {
// inserted values
const expectedValues = [33, NaN, 456, 333, 267, 34, 8754, 3234, 44, 33, ...values];
const currentRecords: Record<string, any> = await api.dbTableRow.list('noco', context.project.id, table.id, {
fields: ['Number'],
limit: 100,
});
// verify if expectedValues are same as currentRecords
expect(currentRecords.list.map(r => parseInt(r.Number))).toEqual(expectedValues);
// verify row count
await dashboard.grid.verifyTotalRowCount({ count: expectedValues.length });
}
test('Row: Create, Update, Delete', async ({ page }) => {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'numberBased' });
// Row.Create
await grid.addNewRow({ index: 10, value: '333', columnHeader: 'Number', networkValidation: true });
await grid.addNewRow({ index: 11, value: '444', columnHeader: 'Number', networkValidation: true });
await verifyRecords([333, 444]);
// Row.Update
await grid.editRow({ index: 10, value: '555', columnHeader: 'Number', networkValidation: true });
await grid.editRow({ index: 11, value: '666', columnHeader: 'Number', networkValidation: true });
await verifyRecords([555, 666]);
// Row.Delete
await grid.deleteRow(10, 'Number');
await grid.deleteRow(10, 'Number');
await verifyRecords([]);
// Undo : Row.Delete
await undo({ page });
await verifyRecords([666]);
await undo({ page });
await verifyRecords([555, 666]);
// Undo : Row.Update
await undo({ page });
await verifyRecords([555, 444]);
await undo({ page });
await verifyRecords([333, 444]);
// Undo : Row.Create
await undo({ page });
await verifyRecords([333]);
await undo({ page });
await verifyRecords([]);
});
test('Fields: Hide, Show, Reorder', async ({ page }) => {
async function verifyFieldsOrder(fields: string[]) {
const fieldTitles = await toolbar.fields.getFieldsTitles();
expect(fieldTitles).toEqual(fields);
}
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'numberBased' });
await verifyFieldsOrder(['Number', 'Decimal', 'Currency']);
// Hide Decimal
await toolbar.fields.toggle({ title: 'Decimal', isLocallySaved: false });
await verifyFieldsOrder(['Number', 'Currency']);
// Hide Currency
await toolbar.fields.toggle({ title: 'Currency', isLocallySaved: false });
await verifyFieldsOrder(['Number']);
// Un hide Decimal
await toolbar.fields.toggle({ title: 'Decimal', isLocallySaved: false });
await verifyFieldsOrder(['Number', 'Decimal']);
// Un hide Currency
await toolbar.fields.toggle({ title: 'Currency', isLocallySaved: false });
await verifyFieldsOrder(['Number', 'Decimal', 'Currency']);
// Undo : un hide Currency
await undo({ page });
await verifyFieldsOrder(['Number', 'Decimal']);
// Undo : un hide Decimal
await undo({ page });
await verifyFieldsOrder(['Number']);
// Undo : hide Currency
await undo({ page });
await verifyFieldsOrder(['Number', 'Currency']);
// Undo : hide Decimal
await undo({ page });
await verifyFieldsOrder(['Number', 'Decimal', 'Currency']);
// reorder test
await toolbar.fields.dragDropFields({ from: 1, to: 0 });
await verifyFieldsOrder(['Number', 'Currency', 'Decimal']);
// Undo : reorder
await undo({ page });
await verifyFieldsOrder(['Number', 'Decimal', 'Currency']);
});
test('Fields: Sort', async ({ page }) => {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'numberBased' });
async function verifyRecords({ sorted }: { sorted: boolean }) {
// inserted values
const expectedSorted = [NaN, 33, 33, 34, 44, 267, 333, 456, 3234, 8754];
const expectedUnsorted = [33, NaN, 456, 333, 267, 34, 8754, 3234, 44, 33];
const currentRecords: Record<string, any> = await api.dbTableRow.list('noco', context.project.id, table.id, {
fields: ['Number'],
limit: 100,
sort: sorted ? ['Number'] : [],
});
// verify if expectedValues are same as currentRecords
expect(currentRecords.list.map(r => parseInt(r.Number))).toEqual(sorted ? expectedSorted : expectedUnsorted);
}
await toolbar.sort.add({ title: 'Number', ascending: true, locallySaved: false });
await verifyRecords({ sorted: true });
await toolbar.sort.reset();
await verifyRecords({ sorted: false });
await undo({ page });
await verifyRecords({ sorted: true });
await undo({ page });
await verifyRecords({ sorted: false });
});
test('Fields: Filter', async ({ page }) => {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'numberBased' });
async function verifyRecords({ filtered }: { filtered: boolean }) {
// inserted values
const expectedFiltered = [33, 33];
const expectedUnfiltered = [33, NaN, 456, 333, 267, 34, 8754, 3234, 44, 33];
const currentRecords: Record<string, any> = await api.dbTableRow.list('noco', context.project.id, table.id, {
fields: ['Number'],
limit: 100,
where: filtered ? '(Number,eq,33)' : '',
});
// verify if expectedValues are same as currentRecords
expect(currentRecords.list.map(r => parseInt(r.Number))).toEqual(
filtered ? expectedFiltered : expectedUnfiltered
);
}
await toolbar.clickFilter();
await toolbar.filter.add({ title: 'Number', operation: '=', value: '33' });
await toolbar.clickFilter();
await verifyRecords({ filtered: true });
await toolbar.filter.reset();
await verifyRecords({ filtered: false });
await undo({ page });
await verifyRecords({ filtered: true });
await undo({ page });
await verifyRecords({ filtered: false });
});
test('Row height', async ({ page }) => {
async function verifyRowHeight({ height }: { height: string }) {
await dashboard.grid.rowPage.getRecordHeight(0).then(readValue => {
expect(readValue).toBe(height);
});
}
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'numberBased' });
const timeOut = 200;
await verifyRowHeight({ height: '1.5rem' });
// set row height & verify
await toolbar.clickRowHeight();
await toolbar.rowHeight.click({ title: 'Tall' });
await new Promise(resolve => setTimeout(resolve, timeOut));
await verifyRowHeight({ height: '6rem' });
await toolbar.clickRowHeight();
await toolbar.rowHeight.click({ title: 'Medium' });
await new Promise(resolve => setTimeout(resolve, timeOut));
await verifyRowHeight({ height: '3rem' });
await undo({ page });
await new Promise(resolve => setTimeout(resolve, timeOut));
await verifyRowHeight({ height: '6rem' });
await undo({ page });
await new Promise(resolve => setTimeout(resolve, timeOut));
await verifyRowHeight({ height: '1.5rem' });
});
test('Column width', async ({ page }) => {
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'numberBased' });
const originalWidth = await dashboard.grid.column.getWidth({ title: 'Number' });
await dashboard.grid.column.resize({ src: 'Number', dst: 'Decimal' });
await dashboard.rootPage.waitForTimeout(100);
const modifiedWidth = await dashboard.grid.column.getWidth({ title: 'Number' });
expect(modifiedWidth).toBeGreaterThan(originalWidth);
await undo({ page });
expect(await dashboard.grid.column.getWidth({ title: 'Number' })).toBe(originalWidth);
});
});
test.describe('Undo Redo - Table & view rename operations', () => {
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
grid = dashboard.grid;
toolbar = dashboard.grid.toolbar;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Number',
title: 'Number',
uidt: UITypes.Number,
pv: true,
},
{
column_name: 'SingleSelect',
title: 'SingleSelect',
uidt: UITypes.SingleSelect,
dtxp: "'jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'",
},
];
try {
const project = await api.project.read(context.project.id);
table = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'selectBased',
title: 'selectBased',
columns: columns,
});
const rowAttributes = [];
for (let i = 0; i < 10; i++) {
const row = {
Number: rowMixedValue(columns[1], i),
SingleSelect: rowMixedValue(columns[2], i),
};
rowAttributes.push(row);
}
await api.dbTableRow.bulkCreate('noco', context.project.id, table.id, rowAttributes);
records = await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 100 });
} catch (e) {
console.log(e);
}
// reload page after api calls
await page.reload();
});
test('Table & View rename', async ({ page }) => {
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'selectBased' });
// table rename
await dashboard.treeView.renameTable({ title: 'selectBased', newTitle: 'newNameForTest' });
await dashboard.treeView.verifyTable({ title: 'newNameForTest' });
await dashboard.rootPage.waitForTimeout(100);
await undo({ page });
await dashboard.rootPage.waitForTimeout(100);
await dashboard.treeView.verifyTable({ title: 'selectBased' });
// View rename
const viewTypes = ['Grid', 'Gallery', 'Form', 'Kanban'];
for (let i = 0; i < viewTypes.length; i++) {
switch (viewTypes[i]) {
case 'Grid':
await dashboard.viewSidebar.createGridView({
title: 'Grid',
});
break;
case 'Gallery':
await dashboard.viewSidebar.createGalleryView({
title: 'Gallery',
});
break;
case 'Form':
await dashboard.viewSidebar.createFormView({
title: 'Form',
});
break;
case 'Kanban':
await dashboard.viewSidebar.createKanbanView({
title: 'Kanban',
});
break;
default:
break;
}
await dashboard.viewSidebar.renameView({ title: viewTypes[i], newTitle: 'newNameForTest' });
await dashboard.viewSidebar.verifyView({ title: 'newNameForTest', index: 1 });
await new Promise(resolve => setTimeout(resolve, 100));
await undo({ page });
await dashboard.viewSidebar.verifyView({ title: viewTypes[i], index: 1 });
await dashboard.viewSidebar.deleteView({ title: viewTypes[i] });
}
});
});
test.describe('Undo Redo - LTAR', () => {
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
grid = dashboard.grid;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
const cityColumns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'City',
title: 'City',
uidt: UITypes.SingleLineText,
pv: true,
},
];
const countryColumns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Country',
title: 'Country',
uidt: UITypes.SingleLineText,
pv: true,
},
];
try {
const project = await api.project.read(context.project.id);
cityTable = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'City',
title: 'City',
columns: cityColumns,
});
countryTable = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'Country',
title: 'Country',
columns: countryColumns,
});
const cityRowAttributes = [{ City: 'Mumbai' }, { City: 'Pune' }, { City: 'Delhi' }, { City: 'Bangalore' }];
await api.dbTableRow.bulkCreate('noco', context.project.id, cityTable.id, cityRowAttributes);
const countryRowAttributes = [
{ Country: 'India' },
{ Country: 'USA' },
{ Country: 'UK' },
{ Country: 'Australia' },
];
await api.dbTableRow.bulkCreate('noco', context.project.id, countryTable.id, countryRowAttributes);
// create LTAR Country has-many City
await api.dbTableColumn.create(countryTable.id, {
column_name: 'CityList',
title: 'CityList',
uidt: UITypes.LinkToAnotherRecord,
parentId: countryTable.id,
childId: cityTable.id,
type: 'hm',
});
// await api.dbTableRow.nestedAdd('noco', context.project.id, countryTable.id, '1', 'hm', 'CityList', '1');
} catch (e) {
console.log(e);
}
// reload page after api calls
await page.reload();
});
async function verifyRecords(values: any[] = []) {
// inserted values
const expectedValues = [...values];
const currentRecords: Record<string, any> = await api.dbTableRow.list('noco', context.project.id, countryTable.id, {
fields: ['CityList'],
limit: 100,
});
// verify if expectedValues array includes all the values in currentRecords
// currentRecords [ { Id: 1, City: 'Mumbai' }, { Id: 3, City: 'Delhi' } ]
// expectedValues [ 'Mumbai', 'Delhi' ]
currentRecords.list[0].CityList.forEach((record: any) => {
expect(expectedValues).toContain(record.City);
});
expect(currentRecords.list[0].CityList.length).toBe(expectedValues.length);
}
async function undo({ page, values }: { page: Page; values: string[] }) {
const isMac = await grid.isMacOs();
await dashboard.grid.waitForResponse({
uiAction: () => page.keyboard.press(isMac ? 'Meta+z' : 'Control+z'),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: `/api/v1/db/data/noco/`,
responseJsonMatcher: json => json.pageInfo,
});
await verifyRecords(values);
}
test('Row: Link, Unlink', async ({ page }) => {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'Country' });
await grid.cell.inCellAdd({ index: 0, columnHeader: 'CityList' });
await dashboard.linkRecord.select('Mumbai');
await grid.cell.inCellAdd({ index: 0, columnHeader: 'CityList' });
await dashboard.linkRecord.select('Delhi');
await grid.cell.unlinkVirtualCell({ index: 0, columnHeader: 'CityList' });
await grid.cell.unlinkVirtualCell({ index: 0, columnHeader: 'CityList' });
await verifyRecords([]);
await undo({ page, values: ['Delhi'] });
await undo({ page, values: ['Mumbai', 'Delhi'] });
await undo({ page, values: ['Mumbai'] });
await undo({ page, values: [] });
});
});
test.describe('Undo Redo - Select based', () => {
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
grid = dashboard.grid;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Title',
title: 'Title',
uidt: UITypes.SingleLineText,
pv: true,
},
{
column_name: 'select',
title: 'select',
uidt: UITypes.SingleSelect,
dtxp: "'jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'",
},
];
try {
const project = await api.project.read(context.project.id);
table = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'selectSample',
title: 'selectSample',
columns,
});
const RowAttributes = [
{ Title: 'Mumbai', select: 'jan' },
{ Title: 'Pune', select: 'feb' },
{ Title: 'Delhi', select: 'mar' },
{ Title: 'Bangalore', select: 'jan' },
];
await api.dbTableRow.bulkCreate('noco', context.project.id, table.id, RowAttributes);
} catch (e) {
console.log(e);
}
// reload page after api calls
await page.reload();
});
test.skip('Kanban', async ({ page }) => {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'selectSample' });
await dashboard.viewSidebar.createKanbanView({
title: 'Kanban',
});
const kanban = dashboard.kanban;
// Drag drop stack
await kanban.verifyStackOrder({
order: ['Uncategorized', 'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'],
});
// verify drag drop stack
await kanban.dragDropStack({
from: 1, // jan
to: 2, // feb
});
await kanban.verifyStackOrder({
order: ['Uncategorized', 'feb', 'jan', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'],
});
// undo drag drop stack
await undo({ page });
await kanban.verifyStackOrder({
order: ['Uncategorized', 'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'],
});
// drag drop card
await kanban.verifyCardCount({
count: [0, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
});
await kanban.dragDropCard({ from: { stack: 1, card: 0 }, to: { stack: 2, card: 0 } });
await kanban.verifyCardCount({
count: [0, 1, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
});
// undo drag drop card
await undo({ page });
await kanban.verifyCardCount({
count: [0, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
});
});
});

30
tests/playwright/tests/viewGridShare.spec.ts

@ -34,17 +34,17 @@ test.describe('Shared view', () => {
await dashboard.grid.toolbar.fields.toggle({ title: 'Address2' }); await dashboard.grid.toolbar.fields.toggle({ title: 'Address2' });
// sort // sort
await dashboard.grid.toolbar.sort.add({ await dashboard.grid.toolbar.sort.add({
columnTitle: 'District', title: 'District',
isAscending: false, ascending: false,
isLocallySaved: false, locallySaved: false,
}); });
// filter // filter
await dashboard.grid.toolbar.clickFilter(); await dashboard.grid.toolbar.clickFilter();
await dashboard.grid.toolbar.filter.add({ await dashboard.grid.toolbar.filter.add({
columnTitle: 'Address', title: 'Address',
value: 'Ab', value: 'Ab',
opType: 'is like', operation: 'is like',
isLocallySaved: false, locallySaved: false,
}); });
await dashboard.grid.toolbar.clickFilter(); await dashboard.grid.toolbar.clickFilter();
@ -103,18 +103,18 @@ test.describe('Shared view', () => {
// create new sort & filter criteria in shared view // create new sort & filter criteria in shared view
await sharedPage.grid.toolbar.sort.reset(); await sharedPage.grid.toolbar.sort.reset();
await sharedPage.grid.toolbar.sort.add({ await sharedPage.grid.toolbar.sort.add({
columnTitle: 'Address', title: 'Address',
isAscending: true, ascending: true,
isLocallySaved: true, locallySaved: true,
}); });
if (isMysql(context)) { if (isMysql(context)) {
await sharedPage.grid.toolbar.clickFilter(); await sharedPage.grid.toolbar.clickFilter();
await sharedPage.grid.toolbar.filter.add({ await sharedPage.grid.toolbar.filter.add({
columnTitle: 'District', title: 'District',
value: 'Ta', value: 'Ta',
opType: 'is like', operation: 'is like',
isLocallySaved: true, locallySaved: true,
}); });
await sharedPage.grid.toolbar.clickFilter(); await sharedPage.grid.toolbar.clickFilter();
} }
@ -198,10 +198,10 @@ test.describe('Shared view', () => {
}); });
await sharedPage2.grid.toolbar.clickFilter(); await sharedPage2.grid.toolbar.clickFilter();
await sharedPage2.grid.toolbar.filter.add({ await sharedPage2.grid.toolbar.filter.add({
columnTitle: 'Country', title: 'Country',
value: 'New Country', value: 'New Country',
opType: 'is like', operation: 'is like',
isLocallySaved: true, locallySaved: true,
}); });
await sharedPage2.grid.toolbar.clickFilter(); await sharedPage2.grid.toolbar.clickFilter();

24
tests/playwright/tests/viewKanban.spec.ts

@ -119,9 +119,9 @@ test.describe('View', () => {
// verify sort // verify sort
await toolbar.sort.add({ await toolbar.sort.add({
columnTitle: 'Title', title: 'Title',
isAscending: false, ascending: false,
isLocallySaved: false, locallySaved: false,
}); });
// verify card order // verify card order
const order2 = [ const order2 = [
@ -150,10 +150,10 @@ test.describe('View', () => {
networkValidation: true, networkValidation: true,
}); });
await toolbar.filter.add({ await toolbar.filter.add({
columnTitle: 'Title', title: 'Title',
opType: 'is like', operation: 'is like',
value: 'BA', value: 'BA',
isLocallySaved: false, locallySaved: false,
}); });
await toolbar.clickFilter(); await toolbar.clickFilter();
@ -193,17 +193,17 @@ test.describe('View', () => {
}); });
await toolbar.sort.add({ await toolbar.sort.add({
columnTitle: 'Title', title: 'Title',
isAscending: false, ascending: false,
isLocallySaved: false, locallySaved: false,
}); });
await toolbar.clickFilter(); await toolbar.clickFilter();
await toolbar.filter.add({ await toolbar.filter.add({
columnTitle: 'Title', title: 'Title',
opType: 'is like', operation: 'is like',
value: 'BA', value: 'BA',
isLocallySaved: false, locallySaved: false,
}); });
await toolbar.clickFilter(); await toolbar.clickFilter();

Loading…
Cancel
Save