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. 67
      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. 177
      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. 18
      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. 147
      packages/nc-gui/composables/useKanbanViewStore.ts
  30. 41
      packages/nc-gui/composables/useLTARStore.ts
  31. 9
      packages/nc-gui/composables/useSmartsheetRowStore.ts
  32. 180
      packages/nc-gui/composables/useUndoRedo.ts
  33. 19
      packages/nc-gui/composables/useViewColumns.ts
  34. 252
      packages/nc-gui/composables/useViewData.ts
  35. 159
      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. 10
      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. 46
      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}`"
class="w-full h-full break-word"
:text="`${props.value || ' '}`"
:max-lines="props.lines"
:max-lines="props.lines || 1"
/>
</div>
</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 = () => {
if (cellClickHook) return

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

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

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

@ -14,7 +14,10 @@ const { showNull } = useGlobal()
const editEnabled = inject(EditModeInj)
const rowHeight = inject(RowHeightInj)
const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
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 rowHeight = inject(RowHeightInj)
const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
const { showNull } = useGlobal()

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

@ -29,6 +29,7 @@ import {
useTabs,
useToggle,
useUIPermission,
useUndoRedo,
watchEffect,
} from '#imports'
@ -55,6 +56,8 @@ const [searchActive, toggleSearchActive] = useToggle()
const { appInfo } = useGlobal()
const { addUndo, defineProjectScope } = useUndoRedo()
const toggleDialog = inject(ToggleDialogInj, () => {})
const keys = $ref<Record<string, number>>({})
@ -90,13 +93,14 @@ const initSortable = (el: Element) => {
if (sortables[base_id]) sortables[base_id].destroy()
Sortable.create(el as HTMLLIElement, {
onEnd: async (evt) => {
const offset = tables.value.findIndex((table) => table.base_id === base_id)
const { newIndex = 0, oldIndex = 0 } = evt
const itemEl = evt.item as HTMLLIElement
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
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
}
// update the order of the moved item
tables.value?.splice(newIndex + offset, 0, ...tables.value?.splice(oldIndex + offset, 1))
// find the index of the moved item
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
if (keys[base_id]) {
@ -134,6 +149,38 @@ const initSortable = (el: Element) => {
await $api.dbTable.reorder(item.id as string, {
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,
})
@ -1085,6 +1132,7 @@ const setIcon = async (icon: string, table: TableType) => {
<style scoped lang="scss">
.nc-treeview-container {
@apply h-[calc(100vh_-_var(--header-height))];
border-right: 1px solid var(--navbar-border) !important;
}
.nc-treeview-footer-item {

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

@ -227,7 +227,7 @@ watch(
@click="vDataState = DataSourcesSubTab.New"
>
<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
</div>
</a-button>

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

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

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

@ -1,5 +1,5 @@
<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 {
ActiveViewInj,
@ -44,6 +44,7 @@ import {
useRoute,
useSmartsheetStoreOrThrow,
useUIPermission,
useUndoRedo,
useViewData,
watch,
} from '#imports'
@ -73,6 +74,8 @@ const hasEditPermission = $computed(() => isUIAllowed('xcDatatableEditable'))
const route = useRoute()
const router = useRouter()
const { addUndo, clone, defineViewScope } = useUndoRedo()
// todo: get from parent ( inject or use prop )
const isView = false
@ -455,6 +458,61 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
const columnObj = fields.value[ctx.col]
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)
return
}
@ -789,7 +847,7 @@ const closeAddColumnDropdown = () => {
:data-testid="`grid-row-${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
v-if="!readOnly || !isLocked"
class="nc-row-no text-xs text-gray-500"

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

@ -12,6 +12,7 @@ import {
IsPublicInj,
MetaInj,
OpenNewRecordFormHookInj,
extractPkFromRow,
iconMap,
inject,
isImage,
@ -21,6 +22,7 @@ import {
provide,
useAttachment,
useKanbanViewStoreOrThrow,
useUndoRedo,
} from '#imports'
import type { Row as RowType } from '~/lib'
@ -76,12 +78,15 @@ const {
deleteStack,
shouldScrollToRight,
deleteRow,
moveHistory,
} = useKanbanViewStoreOrThrow()
const { isUIAllowed } = useUIPermission()
const { appInfo } = $(useGlobal())
const { addUndo, defineViewScope } = useUndoRedo()
provide(IsFormInj, 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) {
const { oldIndex, newIndex } = event.moved
const { fk_grp_col_id, meta: stack_meta } = kanbanMetaData.value
@ -221,17 +226,52 @@ async function onMoveStack(event: any) {
await updateKanbanMeta({
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) {
if (event.added) {
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
countByStack.value.set(stackKey, countByStack.value.get(stackKey)! + 1)
await updateOrSaveRow(ele)
} else if (event.removed) {
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()
// preset the grouping field value
newRow.row = {
[groupingField.value]: selectedStackTitle.value,
[groupingField.value]: selectedStackTitle.value === '' ? null : selectedStackTitle.value,
}
// increase total count by 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 { 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
watch(isNew, async (nextVal, prevVal) => {
@ -49,6 +49,7 @@ provide(ReloadRowDataHookInj, reloadHook)
defineExpose({
syncLTARRefs,
clearLTARCell,
addLTARRef,
})
</script>

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

@ -18,7 +18,9 @@ import {
useMetas,
useNuxtApp,
useSmartsheetStoreOrThrow,
useUndoRedo,
} from '#imports'
import type { UndoRedoAction } from '~~/lib'
const { virtual = false } = defineProps<{ virtual?: boolean }>()
@ -42,6 +44,8 @@ const { t } = useI18n()
const { getMeta } = useMetas()
const { addUndo, defineModelScope, defineViewScope } = useUndoRedo()
const deleteColumn = () =>
Modal.confirm({
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 () => {
try {
const currentDisplayValue = meta?.value?.columns?.find((f) => f.pv)
await $api.dbTableColumn.primaryColumnSet(column?.value?.id as string)
await getMeta(meta?.value?.id as string, true)
@ -79,6 +85,36 @@ const setAsDisplayValue = async () => {
message.success(t('msg.success.primaryColumnUpdated'))
$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) {
message.error(t('msg.error.primaryColumnUpdateFailed'))
}
@ -87,11 +123,37 @@ const setAsDisplayValue = async () => {
const sortByColumn = async (direction: 'asc' | 'desc') => {
try {
$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,
direction,
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)
reloadDataHook?.trigger()
} catch (e: any) {
@ -209,6 +271,24 @@ const hideField = async () => {
await $api.dbViewColumn.update(view.value!.id!, currentColumn!.id!, { show: false })
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>

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

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

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

@ -18,6 +18,7 @@ import {
useI18n,
useNuxtApp,
useRouter,
useUndoRedo,
viewTypeAlias,
watch,
} from '#imports'
@ -46,6 +47,8 @@ const { api } = useApi()
const router = useRouter()
const { addUndo, defineModelScope } = useUndoRedo()
/** Selected view(s) for menu */
const selected = ref<string[]>([])
@ -84,16 +87,20 @@ function validate(view: ViewType) {
return true
}
let sortable: Sortable
function onSortStart(evt: SortableEvent) {
evt.stopImmediatePropagation()
evt.preventDefault()
dragging = true
}
async function onSortEnd(evt: SortableEvent) {
evt.stopImmediatePropagation()
evt.preventDefault()
dragging = false
async function onSortEnd(evt: SortableEvent, undo = false) {
if (!undo) {
evt.stopImmediatePropagation()
evt.preventDefault()
dragging = false
}
if (views.length < 2) return
@ -101,6 +108,32 @@ async function onSortEnd(evt: SortableEvent) {
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 previousEl = children[newIndex - 1]
@ -135,8 +168,6 @@ async function onSortEnd(evt: SortableEvent) {
$e('a:view:reorder')
}
let sortable: Sortable
const initSortable = (el: HTMLElement) => {
if (sortable) sortable.destroy()
@ -165,7 +196,7 @@ function changeView(view: ViewType) {
}
/** Rename a view */
async function onRename(view: ViewType) {
async function onRename(view: ViewType, originalTitle?: string, undo = false) {
try {
await api.dbView.update(view.id!, {
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
message.success(t('msg.success.viewRenamed'))
} 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: 'rename', view: ViewType): void
(event: 'rename', view: ViewType, originalTitle: string | undefined): void
(event: 'delete', view: ViewType): void
@ -142,7 +142,7 @@ async function onRename() {
return
}
emits('rename', vModel.value)
emits('rename', vModel.value, originalTitle)
onStopEdit()
}

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

@ -21,7 +21,7 @@ const onClick = () => {
>
<component
: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"
/>
</div>

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

@ -364,7 +364,7 @@ defineExpose({
</div>
<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">
<component :is="iconMap.plus" />
<!-- Add Filter -->
@ -372,7 +372,7 @@ defineExpose({
</div>
</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">
<!-- Add Filter Group -->
<component :is="iconMap.plus" />

177
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 Draggable from 'vuedraggable'
import type { SelectProps } from 'ant-design-vue'
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface'
import {
ActiveViewInj,
FieldsInj,
@ -18,6 +19,7 @@ import {
useMenuCloseOnEsc,
useNuxtApp,
useSmartsheetStoreOrThrow,
useUndoRedo,
useViewColumns,
watch,
} from '#imports'
@ -55,6 +57,8 @@ const {
const { eventBus } = useSmartsheetStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo()
eventBus.on((event) => {
if (event === SmartsheetStoreEvents.FIELD_RELOAD) {
loadViewColumns()
@ -79,10 +83,44 @@ const gridDisplayValueField = computed(() => {
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
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
fields.value.forEach((field, index) => {
@ -108,6 +146,27 @@ const coverOptions = computed<SelectProps['options']>(() => {
return [{ value: null, label: 'No Image' }, ...filterFields]
})
const updateCoverImage = async (val?: string | null) => {
if (
(activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) &&
activeView.value?.id &&
activeView.value?.view
) {
if (activeView.value?.type === ViewTypes.GALLERY) {
await $api.dbView.galleryUpdate(activeView.value?.id, {
fk_cover_image_col_id: val,
})
;(activeView.value.view as GalleryType).fk_cover_image_col_id = val
} else if (activeView.value?.type === ViewTypes.KANBAN) {
await $api.dbView.kanbanUpdate(activeView.value?.id, {
fk_cover_image_col_id: val,
})
;(activeView.value.view as KanbanType).fk_cover_image_col_id = val
}
reloadViewMetaHook?.trigger()
}
}
const coverImageColumnId = computed({
get: () => {
const fk_cover_image_col_id =
@ -121,23 +180,20 @@ const coverImageColumnId = computed({
return null
},
set: async (val) => {
if (
(activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) &&
activeView.value?.id &&
activeView.value?.view
) {
if (activeView.value?.type === ViewTypes.GALLERY) {
await $api.dbView.galleryUpdate(activeView.value?.id, {
fk_cover_image_col_id: val,
})
;(activeView.value.view as GalleryType).fk_cover_image_col_id = val
} else if (activeView.value?.type === ViewTypes.KANBAN) {
await $api.dbView.kanbanUpdate(activeView.value?.id, {
fk_cover_image_col_id: val,
})
;(activeView.value.view as KanbanType).fk_cover_image_col_id = val
}
reloadViewMetaHook?.trigger()
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 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)
</script>
@ -208,7 +341,7 @@ useMenuCloseOnEsc(open)
v-e="['a:fields:show-hide']"
class="shrink"
:disabled="field.isViewEssentialField"
@change="saveOrUpdate(field, index)"
@change="toggleFieldVisibility($event, field, index)"
>
<div class="flex items-center">
<component :is="getIcon(metaColumnById[field.fk_column_id])" />
@ -253,18 +386,18 @@ useMenuCloseOnEsc(open)
<a-divider class="!my-2" />
<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>
</a-checkbox>
</div>
<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 -->
{{ $t('general.showAll') }}
</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 -->
{{ $t('general.hideAll') }}
</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">
<component
: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' : ''"
@click="onClick"
/>

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

@ -1,6 +1,6 @@
<script setup lang="ts">
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())
@ -12,11 +12,28 @@ const isLocked = inject(IsLockedInj, ref(false))
const { $api } = useNuxtApp()
const { addUndo, defineViewScope } = useUndoRedo()
const open = ref(false)
const updateRowHeight = async (rh: number) => {
const updateRowHeight = async (rh: number, undo = false) => {
if (view.value?.id) {
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 {
if (!isPublic.value && !isSharedBase.value) {
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)"
wrap-class-name="nc-modal-share-view"
>
<div
data-testid="nc-modal-share-view__link"
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>
<div class="share-link-box !bg-primary !bg-opacity-5 ring-1 ring-accent ring-opacity-100">
<div data-testid="nc-modal-share-view__link" 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" />
</a>

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

@ -14,6 +14,7 @@ import {
ref,
useKanbanViewStoreOrThrow,
useMenuCloseOnEsc,
useUndoRedo,
useViewColumns,
watch,
} from '#imports'
@ -32,6 +33,8 @@ const { fields, loadViewColumns, metaColumnById } = useViewColumns(activeView, m
const { kanbanMetaData, loadKanbanMeta, loadKanbanData, updateKanbanMeta, groupingField } = useKanbanViewStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo()
const open = ref(false)
useMenuCloseOnEsc(open)
@ -46,16 +49,32 @@ watch(
{ 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({
get: () => kanbanMetaData.value.fk_grp_col_id,
set: async (val) => {
if (val) {
await updateKanbanMeta({
fk_grp_col_id: val,
addUndo({
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()
;(activeView.value?.view as KanbanType).fk_grp_col_id = val
await updateGroupingField(val)
}
},
})

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

@ -21,7 +21,11 @@ const qrCodeOptions: QRCode.QRCodeToDataURLOptions = {
quality: 1,
},
}
const rowHeight = inject(RowHeightInj)
const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
const qrCode = useQRCode(qrValue, {
...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 rowHeight = inject(RowHeightInj)
const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
</script>
<template>

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

@ -714,14 +714,16 @@ onMounted(async () => {
<a-row>
<a-col :span="24">
<div v-if="!(hook.version === 'v2' && hook.type === 'URL')" class="text-gray-600">
<em>Use context variable <strong>data</strong> to refer the record under consideration</em>
<a-tooltip bottom>
<template #title>
<span> <strong>data</strong> : Row data <br /> </span>
</template>
<component :is="iconMap.info" class="ml-2" />
</a-tooltip>
<div class="flex items-center"></div>
<em>Use context variable <strong>data</strong> to refer the record under consideration</em>
<a-tooltip bottom>
<template #title>
<span> <strong>data</strong> : Row data <br /> </span>
</template>
<component :is="iconMap.info" class="ml-2" />
</a-tooltip>
</div>
<div class="my-3">
<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 { UITypes } from 'nocodb-sdk'
import type { Ref } from 'vue'
@ -18,6 +18,8 @@ import {
watch,
} from '#imports'
const clone = rfdc()
const useForm = Form.useForm
const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]

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

@ -21,8 +21,9 @@ import {
useProject,
useProvideSmartsheetRowStore,
useSharedView,
useUndoRedo,
} from '#imports'
import type { Row } from '~/lib'
import type { Row, UndoRedoAction } from '~/lib'
const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((meta: Ref<TableType>, row: Ref<Row>) => {
const { $e, $state, $api } = useNuxtApp()
@ -51,6 +52,10 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const { sharedView } = useSharedView()
const { addUndo, clone, defineViewScope } = useUndoRedo()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
// getters
const displayValue = computed(() => {
if (row?.value?.row) {
@ -135,7 +140,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
$e('a:row-expand:comment')
}
const save = async (ltarState: Record<string, any> = {}) => {
const save = async (ltarState: Record<string, any> = {}, undo = false) => {
let data
try {
const isNewRow = row.value.rowMeta?.new ?? false
@ -160,6 +165,47 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
rowMeta: {},
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 {
const updateOrInsertObj = [...changedColumns.value].reduce((obj, 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)
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)) {
// audit
$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 { 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 { isUIAllowed } = useUIPermission()
@ -11,12 +22,14 @@ export function useGridViewColumnWidth(view: Ref<GridType | undefined>) {
const { metas } = useMetas()
const { addUndo, defineViewScope } = useUndoRedo()
const gridViewCols = ref<Record<string, GridColumnType>>({})
const resizingCol = ref('')
const resizingColWidth = ref('200px')
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(
[gridViewCols, resizingCol, resizingColWidth],
@ -54,7 +67,21 @@ export function useGridViewColumnWidth(view: Ref<GridType | undefined>) {
* or when view changes reload columns width */
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]) {
gridViewCols.value[id].width = width
}

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

@ -1,6 +1,6 @@
import type { ComputedRef, Ref } from 'vue'
import type { Api, ColumnType, KanbanType, SelectOptionType, SelectOptionsType, TableType, ViewType } from 'nocodb-sdk'
import type { Row } from '~/lib'
import type { Row, UndoRedoAction } from '~/lib'
import {
IsPublicInj,
SharedViewPasswordInj,
@ -14,6 +14,7 @@ import {
parseProp,
provide,
ref,
rowPkData,
storeToRefs,
useApi,
useFieldQuery,
@ -24,6 +25,7 @@ import {
useSharedView,
useSmartsheetStoreOrThrow,
useUIPermission,
useUndoRedo,
} from '#imports'
type GroupingFieldColOptionsType = SelectOptionType & { collapsed: boolean }
@ -58,6 +60,11 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
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(
(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)
}
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 {
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]
}
return o
@ -328,6 +346,31 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
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, {
row: insertedData,
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 {
const id = extractPkFromRow(toUpdate.row, meta?.value?.columns as ColumnType[])
@ -367,9 +410,49 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
prev_value: getHTMLEncodedText(toUpdate.oldRow[property]),
})
/** update row data(to sync formula and other related columns) */
Object.assign(toUpdate.row, updatedRowData)
Object.assign(toUpdate.oldRow, updatedRowData)
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) */
Object.assign(toUpdate.row, updatedRowData)
Object.assign(toUpdate.oldRow, updatedRowData)
}
return updatedRowData
} catch (e: any) {
message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`)
}
@ -463,7 +546,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
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 oldStackTitle = row.oldRow[groupingField.value]
@ -471,7 +554,11 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
// add a new record
if (stackTitle) {
// push the row to target stack
formattedData.value.get(stackTitle)!.push(row)
if (rowIndex !== undefined) {
formattedData.value.get(stackTitle)!.splice(rowIndex, 0, row)
} else {
formattedData.value.get(stackTitle)!.push(row)
}
// increase the current count in the target stack by 1
countByStack.value.set(stackTitle, countByStack.value.get(stackTitle)! + 1)
// 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
countByStack.value.set(stackTitle, countByStack.value.get(stackTitle)! + 1)
formattedData.value.set(stackTitle, [...formattedData.value.get(stackTitle)!, row])
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])
}
} else {
// update the row in formattedData
const updatedRow = formattedData.value.get(stackTitle)!
updatedRow[idxToUpdateOrDelete] = row
if (rowIndex !== undefined) {
updatedRow.splice(idxToUpdateOrDelete, 1)
updatedRow.splice(rowIndex, 0, row)
} else {
updatedRow[idxToUpdateOrDelete] = row
}
formattedData.value.set(oldStackTitle, updatedRow)
}
}
@ -530,8 +631,29 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
countByStack.value.set(null, countByStack.value.get(null)! - 1)
}
async function deleteRow(row: Row) {
async function deleteRow(row: Row, undo = false) {
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) {
const id = (meta?.value?.columns as ColumnType[])
?.filter((c) => c.pk)
@ -594,6 +716,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
removeRowFromUncategorizedStack,
shouldScrollToRight,
deleteRow,
moveHistory,
}
},
'kanban-view-store',

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

@ -43,6 +43,10 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const { $api } = useNuxtApp()
const activeView = inject(ActiveViewInj, ref())
const { addUndo, clone, defineViewScope } = useUndoRedo()
const sharedViewPassword = inject(SharedViewPasswordInj, ref(null))
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);
// todo: handle if new record
// if (this.isNew) {
@ -264,12 +268,27 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
await $api.dbTableRow.nestedRemove(
NOCO,
project.value.title as string,
meta.value.title,
metaValue.title,
rowId.value,
colOptions.type as 'mm' | 'hm',
encodeURIComponent(column?.value?.title),
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) {
message.error(`${t('msg.error.unlinkFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
}
@ -277,7 +296,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
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
// const pid = this._extractRowId(parent, this.parentMeta);
// const id = this._extractRowId(this.row, this.meta);
@ -295,13 +314,27 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
await $api.dbTableRow.nestedAdd(
NOCO,
project.value.title as string,
meta.value.title as string,
metaValue.title as string,
rowId.value,
colOptions.type as 'mm' | 'hm',
encodeURIComponent(column?.value?.title),
getRelatedTableRowId(row) as string,
)
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) {
message.error(`Linking failed: ${await extractSdkResponseErrorMsg(e)}`)
}

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

@ -51,7 +51,11 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
return message.info(t('msg.info.valueAlreadyInList'))
}
state.value[column.title!]!.push(value)
if (Array.isArray(value)) {
state.value[column.title!]!.push(...value)
} else {
state.value[column.title!]!.push(value)
}
} else if (isBt(column)) {
state.value[column.title!] = value
}
@ -119,6 +123,9 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
{ 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,
)
const localChanges = ref<Field[]>([])
const isColumnViewEssential = (column: ColumnType) => {
// 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)
@ -76,6 +78,16 @@ export function useViewColumns(
}
})
.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) => {
if (isPublic.value && fields.value) {
if (isLocalMode.value && fields.value) {
fields.value[index] = field
meta.value!.columns = meta.value!.columns?.map((column: ColumnType) => {
if (column.id === field.fk_column_id) {
return {
...column,
...field,
id: field.fk_column_id,
}
}
return column
})
await loadViewColumns()
reloadData?.()
localChanges.value.push(field)
}
if (isUIAllowed('fieldsSync')) {
@ -156,6 +168,7 @@ export function useViewColumns(
return insertedField
}
}
await loadViewColumns()
reloadData?.()
}

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

@ -12,6 +12,7 @@ import {
message,
populateInsertObject,
ref,
rowPkData,
storeToRefs,
until,
useApi,
@ -25,7 +26,7 @@ import {
useSmartsheetStoreOrThrow,
useUIPermission,
} from '#imports'
import type { Row } from '~/lib'
import type { Row, UndoRedoAction } from '~/lib'
const formatData = (list: Record<string, any>[]) =>
list.map((row) => ({
@ -55,6 +56,8 @@ export function useViewData(
const { getMeta } = useMetas()
const { addUndo, clone, defineViewScope } = useUndoRedo()
const appInfoDefaultLimit = appInfo.defaultLimit || 25
const _paginationData = ref<PaginatedType>({ page: 1, pageSize: appInfoDefaultLimit })
@ -221,10 +224,20 @@ export function useViewData(
: 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(
currentRow: Row,
ltarState: Record<string, any> = {},
{ metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {},
undo = false,
) {
const row = currentRow.row
if (currentRow.rowMeta) currentRow.rowMeta.saving = true
@ -234,6 +247,7 @@ export function useViewData(
ltarState,
getMeta,
row,
undo,
})
if (missingRequiredColumns.size) return
@ -246,11 +260,58 @@ export function useViewData(
insertObj,
)
Object.assign(currentRow, {
row: { ...insertedData, ...row },
rowMeta: { ...(currentRow.rowMeta || {}), new: undefined },
oldRow: { ...insertedData },
})
if (!undo) {
Object.assign(currentRow, {
row: { ...insertedData, ...row },
rowMeta: { ...(currentRow.rowMeta || {}), new: undefined },
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()
return insertedData
@ -267,6 +328,7 @@ export function useViewData(
toUpdate: Row,
property: string,
{ metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {},
undo = false,
) {
if (toUpdate.rowMeta) toUpdate.rowMeta.saving = true
@ -297,25 +359,74 @@ export function useViewData(
prev_value: getHTMLEncodedText(toUpdate.oldRow[property]),
})
/** 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
*/
Object.assign(
toUpdate.row,
metaValue!.columns!.reduce<Record<string, any>>((acc: Record<string, any>, col: ColumnType) => {
if (
col.uidt === UITypes.Formula ||
col.uidt === UITypes.QrCode ||
col.uidt === UITypes.Barcode ||
col.uidt === UITypes.Rollup ||
col.au ||
col.cdf?.includes(' on update ')
)
acc[col.title!] = updatedRowData[col.title!]
return acc
}, {} as Record<string, any>),
)
Object.assign(toUpdate.oldRow, updatedRowData)
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 only formula, rollup and auto updated datetime columns data to avoid overwriting any changes made by user
*/
Object.assign(
toUpdate.row,
metaValue!.columns!.reduce<Record<string, any>>((acc: Record<string, any>, col: ColumnType) => {
if (
col.uidt === UITypes.Formula ||
col.uidt === UITypes.QrCode ||
col.uidt === UITypes.Barcode ||
col.uidt === UITypes.Rollup ||
col.au ||
col.cdf?.includes(' on update ')
)
acc[col.title!] = updatedRowData[col.title!]
return acc
}, {} as Record<string, any>),
)
Object.assign(toUpdate.oldRow, updatedRowData)
}
return updatedRowData
} catch (e: any) {
message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`)
} finally {
@ -354,7 +465,10 @@ export function useViewData(
$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) {
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(
'noco',
project.value.id as string,
meta.value?.id as string,
viewMeta.value?.id as string,
metaValue?.id as string,
viewMetaValue?.id as string,
id,
)
@ -378,7 +492,7 @@ export function useViewData(
return true
}
async function deleteRow(rowIndex: number) {
async function deleteRow(rowIndex: number, undo?: boolean) {
try {
const row = formattedData.value[rowIndex]
if (!row.rowMeta.new) {
@ -387,6 +501,44 @@ export function useViewData(
.map((c) => row.row[c.title!])
.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)
if (!deleted) {
return
@ -403,6 +555,7 @@ export function useViewData(
async function deleteSelectedRows() {
let row = formattedData.value.length
const removedRowsData: { id?: string; row: Row; rowIndex: number }[] = []
while (row--) {
try {
const { row: rowObj, rowMeta } = formattedData.value[row] as Record<string, any>
@ -419,6 +572,7 @@ export function useViewData(
if (!successfulDeletion) {
continue
}
removedRowsData.push({ id, row: clone(formattedData.value[row]), rowIndex: row })
}
formattedData.value.splice(row, 1)
} 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 syncPagination()
}

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

@ -20,7 +20,7 @@ import {
watch,
} from '#imports'
import { TabMetaInj } from '~/context'
import type { Filter, TabItem } from '~/lib'
import type { Filter, TabItem, UndoRedoAction } from '~/lib'
export function useViewFilters(
view: Ref<ViewType | undefined>,
@ -46,6 +46,8 @@ export function useViewFilters(
const { metas } = useMetas()
const { addUndo, clone, defineViewScope } = useUndoRedo()
const _filters = ref<Filter[]>([])
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 = (
filter: FilterType,
compOp: {
@ -230,39 +245,40 @@ export function useViewFilters(
}
}
const deleteFilter = async (filter: Filter, i: number) => {
// 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))
}
const saveOrUpdate = async (filter: Filter, i: number, force = false, undo = false) => {
if (!view.value) return
if (!undo) {
const lastFilter = lastFilters.value[i]
if (lastFilter) {
const delta = clone(getFieldDelta(filter, lastFilter))
if (Object.keys(delta).length > 0) {
addUndo({
undo: {
fn: (prop: string, data: any) => {
const f = filters.value[i]
if (f) {
f[prop as keyof Filter] = data
saveOrUpdate(f, i, force, true)
}
},
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)
}
},
args: [Object.keys(delta)[0], filter[Object.keys(delta)[0] as keyof Filter]],
},
scope: defineViewScope({ view: activeView.value }),
})
}
// if not synced yet remove it from array
} else {
filters.value.splice(i, 1)
}
$e('a:filter:delete', { length: nonDeletedFilters.value.length })
}
}
const saveOrUpdate = async (filter: Filter, i: number, force = false) => {
if (!view.value) return
try {
if (nestedMode.value) {
@ -270,7 +286,7 @@ export function useViewFilters(
filters.value = [...filters.value]
} else if (!autoApply?.value && !force) {
filter.status = filter.id ? 'update' : 'create'
} else if (filter.id) {
} else if (filter.id && filter.status !== 'create') {
await $api.dbTableFilter.update(filter.id, {
...filter,
fk_parent_id: parentId,
@ -290,13 +306,88 @@ export function useViewFilters(
message.error(await extractSdkResponseErrorMsg(e))
}
lastFilters.value = clone(filters.value)
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 addFilter = () => {
const addFilter = async (undo = false) => {
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 })
}
@ -317,6 +408,8 @@ export function useViewFilters(
await saveOrUpdate(filters.value[index], index, true)
lastFilters.value = clone(filters.value)
$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 { addUndo, clone, defineViewScope } = useUndoRedo()
const reloadHook = inject(ReloadViewDataHookInj)
const isPublic = inject(IsPublicInj, ref(false))
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 () => {
if (isPublic.value) {
// 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) {
sorts.value[i] = sort
sorts.value = [...sorts.value]
@ -81,21 +128,11 @@ export function useViewSorts(view: Ref<ViewType | undefined>, reloadData?: () =>
console.error(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 {
if (isUIAllowed('sortSync') && sort.id && !isPublic.value && !isSharedBase.value) {
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 = [...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)
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 }
}

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 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",
"jsbarcode": "^3.11.5",
"jsep": "^1.3.6",
"just-clone": "^6.1.1",
"jwt-decode": "^3.1.2",
"leaflet": "^1.9.2",
"leaflet.markercluster": "^1.5.3",
@ -35,6 +34,7 @@
"papaparse": "^5.3.2",
"pinia": "^2.0.33",
"qrcode": "^1.5.1",
"rfdc": "^1.3.0",
"socket.io-client": "^4.5.1",
"sortablejs": "^1.15.0",
"tinycolor2": "^1.4.2",
@ -10911,11 +10911,6 @@
"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": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
@ -14623,6 +14618,11 @@
"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": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@ -26325,11 +26325,6 @@
"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": {
"version": "3.1.2",
"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",
"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": {
"version": "3.0.2",
"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",
"jsbarcode": "^3.11.5",
"jsep": "^1.3.6",
"just-clone": "^6.1.1",
"jwt-decode": "^3.1.2",
"leaflet": "^1.9.2",
"leaflet.markercluster": "^1.5.3",
@ -59,6 +58,7 @@
"papaparse": "^5.3.2",
"pinia": "^2.0.33",
"qrcode": "^1.5.1",
"rfdc": "^1.3.0",
"socket.io-client": "^4.5.1",
"sortablejs": "^1.15.0",
"tinycolor2": "^1.4.2",

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

@ -509,11 +509,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
</a-sub-menu>
</template>
<!-- Language -->
<a-sub-menu
key="language"
class="lang-menu !py-0"
popup-class-name="scrollbar-thin-dull min-w-50 max-h-90vh !overflow-auto"
>
<a-sub-menu key="language" class="lang-menu !py-0">
<template #title>
<div class="nc-project-menu-item group">
<component :is="iconMap.translate" class="group-hover:text-accent nc-language" />
@ -529,7 +525,9 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
<template #expandIcon></template>
<LazyGeneralLanguageMenu />
<div class="scrollbar-thin-dull min-w-50 max-h-90vh">
<LazyGeneralLanguageMenu />
</div>
</a-sub-menu>
<!-- 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
export async function populateInsertObject({
getMeta,
@ -18,12 +29,14 @@ export async function populateInsertObject({
meta,
ltarState,
throwError,
undo = false,
}: {
meta: TableType
ltarState: Record<string, any>
getMeta: (tableIdOrTitle: string, force?: boolean) => Promise<TableType | null>
row: Record<string, any>
throwError?: boolean
undo?: boolean
}) {
const missingRequiredColumns = new Set()
const insertObj = await meta.columns?.reduce(async (_o: Promise<any>, col) => {
@ -51,7 +64,7 @@ export async function populateInsertObject({
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]
}

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

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

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

@ -15831,7 +15831,7 @@
"$ref": "#/components/schemas/Bool"
},
"fk_cover_image_col_id": {
"type": "string",
"$ref": "#/components/schemas/StringOrNull",
"description": "Foreign Key to Cover Image Column"
},
"fk_model_id": {
@ -16916,7 +16916,7 @@
"description": "View ID"
},
"fk_cover_image_col_id": {
"$ref": "#/components/schemas/Id",
"$ref": "#/components/schemas/StringOrNull",
"description": "Cover Image Column ID"
},
"columns": {
@ -19923,6 +19923,10 @@
"$ref": "#/components/schemas/Bool",
"description": "Should show system fields in this view?"
},
"is_default": {
"$ref": "#/components/schemas/Bool",
"description": "Is this view default view for the model?"
},
"title": {
"description": "View Title",
"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) {
await this.rootPage.waitForTimeout(100);
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
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,
requestUrlPathToMatch: 'api/v1/db/data/noco',
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 {
await clickOnColumnHeaderToSave();
@ -122,7 +123,8 @@ export class GridPage extends BasePage {
// since edit row on an empty row will emit POST request
'POST',
],
responseJsonMatcher: resJson => resJson?.[columnHeader] === value,
// numerical types are returned in number format from the server
responseJsonMatcher: resJson => String(resJson?.[columnHeader]) === String(value),
});
} else {
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);
}
async deleteRow(index: number) {
await this.get().getByTestId(`cell-Title-${index}`).click({
async deleteRow(index: number, title = 'Title') {
await this.get().getByTestId(`cell-${title}-${index}`).click({
button: 'right',
});

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

@ -29,20 +29,21 @@ export class KanbanPage extends BasePage {
// todo: Implement
async addOption() {}
// todo: Implement
async dragDropCard(param: { from: string; to: string }) {
// const { from, to } = param;
// const srcStack = await this.get().locator(`.nc-kanban-stack`).nth(1);
// const dstStack = await this.get().locator(`.nc-kanban-stack`).nth(2);
// const fromCard = await srcStack.locator(`.nc-kanban-item`).nth(1);
// const toCard = await dstStack.locator(`.nc-kanban-item`).nth(1);
// const [fromCard, toCard] = await Promise.all([
// srcStack.locator(`.nc-kanban-item[data-draggable="true"]`).nth(0),
// dstStack.locator(`.nc-kanban-item[data-draggable="true"]`).nth(0),
// ]);
// const fromCard = await this.get().locator(`.nc-kanban-item`).nth(0);
// const toCard = await this.get().locator(`.nc-kanban-item`).nth(25);
// await fromCard.dragTo(toCard);
async dragDropCard(param: { from: { stack: number; card: number }; to: { stack: number; card: number } }) {
const { from, to } = param;
const srcStack = await this.get().locator(`.nc-kanban-stack`).nth(from.stack);
const dstStack = await this.get().locator(`.nc-kanban-stack`).nth(to.stack);
const fromCard = await srcStack.locator(`.nc-kanban-item`).nth(from.card);
const toCard = await dstStack.locator(`.nc-kanban-item`).nth(to.card);
console.log(await fromCard.allTextContents());
console.log(await toCard.allTextContents());
await fromCard.dragTo(toCard, {
force: true,
sourcePosition: { x: 10, y: 10 },
targetPosition: { x: 10, y: 10 },
});
}
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) {
const cell = this.get({ index, columnHeader });
await cell.click();
await cell.locator('.nc-icon.unlink-icon').click();
await cell.locator('.unlink-icon').first().click();
}
async verifyRoleAccess(param: { role: string }) {

46
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
async toggle({ title, isLocallySaved }: { title: string; isLocallySaved?: boolean }) {
async toggle({
title,
isLocallySaved,
validateResponse = true,
}: {
title: string;
isLocallySaved?: boolean;
validateResponse?: boolean;
}) {
await this.toolbar.clickFields();
// hack
await this.rootPage.waitForTimeout(100);
const toggleColumn = () =>
this.get().locator(`[data-testid="nc-fields-menu-${title}"]`).locator('input[type="checkbox"]').click();
await this.waitForResponse({
uiAction: toggleColumn,
requestUrlPathToMatch: isLocallySaved ? '/api/v1/db/public/' : '/api/v1/db/data/noco/',
httpMethodsToMatch: ['GET'],
});
if (validateResponse) {
await this.waitForResponse({
uiAction: toggleColumn,
requestUrlPathToMatch: isLocallySaved ? '/api/v1/db/public/' : '/api/v1/db/data/noco/',
httpMethodsToMatch: ['GET'],
});
} else {
await toggleColumn();
}
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
await this.toolbar.clickFields();
}
@ -78,4 +95,21 @@ export class ToolbarFieldsPage extends BasePage {
});
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({
columnTitle,
opType,
opSubType,
title,
operation,
subOperation,
value,
isLocallySaved,
locallySaved = false,
dataType,
openModal = false,
}: {
columnTitle: string;
opType: string;
opSubType?: string; // for date datatype
title: string;
operation: string;
subOperation?: string; // for date datatype
value?: string;
isLocallySaved: boolean;
locallySaved?: boolean;
dataType?: string;
openModal?: boolean;
}) {
if (!openModal) await this.get().locator(`button:has-text("Add Filter")`).first().click();
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('div.ant-select-dropdown.nc-dropdown-toolbar-field-list')
.locator(`div[label="${columnTitle}"]:visible`)
.locator(`div[label="${title}"]:visible`)
.click();
}
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();
// first() : filter list has >, >=
await this.rootPage
.locator('.nc-dropdown-filter-comp-op')
.locator(`.ant-select-item:has-text("${opType}")`)
.locator(`.ant-select-item:has-text("${operation}")`)
.first()
.click();
}
// 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();
if (selectedSubType !== opSubType) {
if (selectedSubType !== subOperation) {
await this.rootPage.locator('.nc-filter-sub_operation-select').click();
// first() : filter list has >, >=
await this.rootPage
.locator('.nc-dropdown-filter-comp-sub-op')
.locator(`.ant-select-item:has-text("${opSubType}")`)
.locator(`.ant-select-item:has-text("${subOperation}")`)
.first()
.click();
}
@ -115,7 +115,7 @@ export class ToolbarFilterPage extends BasePage {
await this.rootPage.locator(`.ant-btn-primary:has-text("Ok")`).click();
break;
case UITypes.Date:
if (opSubType === 'exact date') {
if (subOperation === 'exact date') {
await this.get().locator('.nc-filter-value-select').click();
await this.rootPage.locator(`.ant-picker-dropdown:visible`);
await this.rootPage.locator(`.ant-picker-cell-inner:has-text("${value}")`).click();
@ -124,7 +124,7 @@ export class ToolbarFilterPage extends BasePage {
await this.waitForResponse({
uiAction: fillFilter,
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.waitLoading();
@ -174,7 +174,7 @@ export class ToolbarFilterPage extends BasePage {
await this.waitForResponse({
uiAction: fillFilter,
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.waitLoading();

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

@ -25,27 +25,19 @@ export class ToolbarSortPage extends BasePage {
).toHaveText(direction);
}
async add({
columnTitle,
isAscending,
isLocallySaved,
}: {
columnTitle: string;
isAscending: boolean;
isLocallySaved: boolean;
}) {
async add({ title, ascending, locallySaved }: { title: string; ascending: boolean; locallySaved: boolean }) {
// open sort menu
await this.toolbar.clickSort();
await this.get().locator(`button:has-text("Add Sort Option")`).click();
// read content of the dropdown
const col = await this.rootPage.locator('.nc-sort-field-select').textContent();
if (col !== columnTitle) {
const col = await this.rootPage.locator('.nc-sort-field-select').last().textContent();
if (col !== title) {
await this.rootPage.locator('.nc-sort-field-select').last().click();
await this.rootPage
.locator('div.ant-select-dropdown.nc-dropdown-toolbar-field-list')
.locator(`div[label="${columnTitle}"]`)
.locator(`div[label="${title}"]`)
.last()
.click();
}
@ -68,14 +60,15 @@ export class ToolbarSortPage extends BasePage {
const selectSortDirection = () =>
this.rootPage
.locator('.nc-dropdown-sort-dir')
.last()
.locator('.ant-select-item')
.nth(isAscending ? 0 : 1)
.nth(ascending ? 0 : 1)
.click();
await this.waitForResponse({
uiAction: selectSortDirection,
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();
// close sort menu
@ -88,7 +81,7 @@ export class ToolbarSortPage extends BasePage {
// open sort menu
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
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[] }) {
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: 'checkbox',
opType: param.opType,
title: 'checkbox',
operation: param.opType,
value: param.value,
isLocallySaved: false,
locallySaved: false,
dataType: 'Checkbox',
});
await toolbar.clickFilter();
@ -94,18 +94,18 @@ test.describe('Checkbox - cell, filter, sort', () => {
// Sort column
await toolbar.sort.add({
columnTitle: 'checkbox',
isAscending: true,
isLocallySaved: false,
title: 'checkbox',
ascending: true,
locallySaved: false,
});
await validateRowArray(['1b', '1d', '1e', '1a', '1c', '1f']);
await toolbar.sort.reset();
// sort descending & validate
await toolbar.sort.add({
columnTitle: 'checkbox',
isAscending: false,
isLocallySaved: false,
title: 'checkbox',
ascending: false,
locallySaved: false,
});
await validateRowArray(['1a', '1c', '1f', '1b', '1d', '1e']);
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[] }) {
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: 'MultiSelect',
opType: param.opType,
title: 'MultiSelect',
operation: param.opType,
value: param.value,
isLocallySaved: false,
locallySaved: false,
dataType: 'MultiSelect',
});
await toolbar.clickFilter();
@ -267,18 +267,18 @@ test.describe('Multi select - filters', () => {
// Sort column
await toolbar.sort.add({
columnTitle: 'MultiSelect',
isAscending: true,
isLocallySaved: false,
title: 'MultiSelect',
ascending: true,
locallySaved: false,
});
await validateRowArray(['1', '3', '4', '2', '5', '6']);
await toolbar.sort.reset();
// sort descending & validate
await toolbar.sort.add({
columnTitle: 'MultiSelect',
isAscending: false,
isLocallySaved: false,
title: 'MultiSelect',
ascending: false,
locallySaved: false,
});
await validateRowArray(['6', '5', '2', '4', '3', '1']);
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[] }) {
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: 'rating',
opType: param.opType,
title: 'rating',
operation: param.opType,
value: param.value,
isLocallySaved: false,
locallySaved: false,
dataType: 'Rating',
});
await toolbar.clickFilter();
@ -88,18 +88,18 @@ test.describe('Rating - cell, filter, sort', () => {
// Sort column
await toolbar.sort.add({
columnTitle: 'rating',
isAscending: true,
isLocallySaved: false,
title: 'rating',
ascending: true,
locallySaved: false,
});
await validateRowArray(['1b', '1d', '1e', '1f', '1c', '1a']);
await toolbar.sort.reset();
// sort descending & validate
await toolbar.sort.add({
columnTitle: 'rating',
isAscending: false,
isLocallySaved: false,
title: 'rating',
ascending: false,
locallySaved: false,
});
await validateRowArray(['1a', '1c', '1f', '1b', '1d', '1e']);
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[] }) {
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: 'SingleSelect',
opType: param.opType,
title: 'SingleSelect',
operation: param.opType,
value: param.value,
isLocallySaved: false,
locallySaved: false,
dataType: 'SingleSelect',
});
await toolbar.clickFilter();
@ -171,18 +171,18 @@ test.describe('Single select - filter & sort', () => {
// Sort column
await toolbar.sort.add({
columnTitle: 'SingleSelect',
isAscending: true,
isLocallySaved: false,
title: 'SingleSelect',
ascending: true,
locallySaved: false,
});
await validateRowArray(['1', '3', '4', '2']);
await toolbar.sort.reset();
// sort descending & validate
await toolbar.sort.add({
columnTitle: 'SingleSelect',
isAscending: false,
isLocallySaved: false,
title: 'SingleSelect',
ascending: false,
locallySaved: false,
});
await validateRowArray(['2', '4', '3', '1']);
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
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: 'FirstName',
opType: 'is equal',
title: 'FirstName',
operation: 'is equal',
value: 'NICK',
isLocallySaved: false,
locallySaved: false,
});
await toolbar.clickFilter();

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

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

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

@ -48,7 +48,7 @@ test.describe('Toolbar operations (GRID)', () => {
await validateFirstRow('Afghanistan');
// 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');
// reset sort
@ -58,10 +58,10 @@ test.describe('Toolbar operations (GRID)', () => {
// Filter column
await toolbar.clickFilter();
await toolbar.filter.add({
columnTitle: 'Country',
title: 'Country',
value: 'India',
opType: 'is equal',
isLocallySaved: false,
operation: 'is equal',
locallySaved: false,
});
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' });
// sort
await dashboard.grid.toolbar.sort.add({
columnTitle: 'District',
isAscending: false,
isLocallySaved: false,
title: 'District',
ascending: false,
locallySaved: false,
});
// filter
await dashboard.grid.toolbar.clickFilter();
await dashboard.grid.toolbar.filter.add({
columnTitle: 'Address',
title: 'Address',
value: 'Ab',
opType: 'is like',
isLocallySaved: false,
operation: 'is like',
locallySaved: false,
});
await dashboard.grid.toolbar.clickFilter();
@ -103,18 +103,18 @@ test.describe('Shared view', () => {
// create new sort & filter criteria in shared view
await sharedPage.grid.toolbar.sort.reset();
await sharedPage.grid.toolbar.sort.add({
columnTitle: 'Address',
isAscending: true,
isLocallySaved: true,
title: 'Address',
ascending: true,
locallySaved: true,
});
if (isMysql(context)) {
await sharedPage.grid.toolbar.clickFilter();
await sharedPage.grid.toolbar.filter.add({
columnTitle: 'District',
title: 'District',
value: 'Ta',
opType: 'is like',
isLocallySaved: true,
operation: 'is like',
locallySaved: true,
});
await sharedPage.grid.toolbar.clickFilter();
}
@ -198,10 +198,10 @@ test.describe('Shared view', () => {
});
await sharedPage2.grid.toolbar.clickFilter();
await sharedPage2.grid.toolbar.filter.add({
columnTitle: 'Country',
title: 'Country',
value: 'New Country',
opType: 'is like',
isLocallySaved: true,
operation: 'is like',
locallySaved: true,
});
await sharedPage2.grid.toolbar.clickFilter();

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

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

Loading…
Cancel
Save