Browse Source

Merge remote-tracking branch 'origin/develop' into fix/i8n

pull/6512/head
Muhammed Mustafa 12 months ago
parent
commit
71f4d9d1f4
  1. 30
      .github/workflows/playwright-test-workflow.yml
  2. 28
      .github/workflows/pre-build-for-playwright.yml
  3. 2
      packages/nc-gui/assets/nc-icons/download.svg
  4. 6
      packages/nc-gui/components/dashboard/Sidebar.vue
  5. 2
      packages/nc-gui/components/dashboard/Sidebar/TopSection.vue
  6. 5
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  7. 16
      packages/nc-gui/components/dashboard/TreeView/ViewsList.vue
  8. 9
      packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue
  9. 9
      packages/nc-gui/components/dashboard/View.vue
  10. 2
      packages/nc-gui/components/nc/MenuItem.vue
  11. 2
      packages/nc-gui/components/nc/Pagination.vue
  12. 2
      packages/nc-gui/components/project/AllTables.vue
  13. 1
      packages/nc-gui/components/smartsheet/Toolbar.vue
  14. 14
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  15. 32
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  16. 32
      packages/nc-gui/components/smartsheet/grid/index.vue
  17. 6
      packages/nc-gui/components/smartsheet/header/Cell.vue
  18. 69
      packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue
  19. 53
      packages/nc-gui/components/smartsheet/toolbar/OpenViewSidebarBtn.vue
  20. 22
      packages/nc-gui/composables/useViewGroupBy.ts
  21. 22
      packages/nc-gui/store/sidebar.ts
  22. 427
      packages/nocodb-sdk/src/lib/Api.ts
  23. 268
      packages/nocodb/src/db/BaseModelSqlv2.ts
  24. 30
      packages/nocodb/src/db/CustomKnex.ts
  25. 163
      packages/nocodb/src/db/generateBTLookupSelectQuery.ts
  26. 892
      packages/nocodb/src/schema/swagger.json
  27. 18
      packages/nocodb/src/services/data-table.service.ts
  28. 19
      packages/nocodb/src/utils/common/NcConnectionMgrv2.ts
  29. 22
      packages/nocodb/tests/unit/factory/column.ts
  30. 132
      packages/nocodb/tests/unit/rest/tests/groupby.test.ts
  31. 206
      packages/nocodb/tests/unit/rest/tests/newDataApis.test.ts
  32. 22
      packages/nocodb/tests/unit/rest/tests/tableRow.test.ts
  33. 6
      packages/nocodb/tests/unit/rest/tests/viewRow.test.ts
  34. 35
      scripts/self-hosted-gh-runner/node-pnpm-check.sh
  35. 9
      tests/playwright/pages/Dashboard/Form/index.ts
  36. 4
      tests/playwright/pages/Dashboard/Kanban/index.ts
  37. 19
      tests/playwright/pages/Dashboard/common/LeftSidebar/index.ts
  38. 10
      tests/playwright/pages/Dashboard/common/WorkspaceMenu/index.ts
  39. 3
      tests/playwright/pages/Dashboard/index.ts
  40. 35
      tests/playwright/scripts/self-hosted-gh-runner/node-pnpm-check.sh
  41. 4
      tests/playwright/startPlayWrightServer.sh
  42. 8
      tests/playwright/tests/db/columns/columnAttachments.spec.ts
  43. 16
      tests/playwright/tests/db/features/baseShare.spec.ts
  44. 12
      tests/playwright/tests/db/features/verticalFillHandle.spec.ts
  45. 16
      tests/playwright/tests/db/features/webhook.spec.ts
  46. 19
      tests/playwright/tests/db/general/groupCRUD.spec.ts
  47. 4
      tests/playwright/tests/utils/general.ts

30
.github/workflows/playwright-test-workflow.yml

@ -17,32 +17,30 @@ jobs:
timeout-minutes: 100
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v3
- name: Check node,pnpm Installation and set Path
shell: bash
working-directory: scripts/self-hosted-gh-runner
timeout-minutes: 1
run: |
./node-pnpm-check.sh
echo "make sure below mentioned versions are expected versions"
echo "If you are expecting the node and pnpm versions to be updated. Please update the node-pnpm-check.sh script"
env
- name: Setup Node
if: ${{ env.SETUP_NODE != 'false' }}
uses: actions/setup-node@v3
with:
node-version: 18.14.0
node-version: ${{ env.NC_REQ_NODE_V }}
- name: Setup pnpm
if: ${{ env.SETUP_PNPM != 'false' }}
uses: pnpm/action-setup@v2
with:
version: 8
version: ${{ env.NC_REQ_PNPM_V }}
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=/root/setup-pnpm/node_modules/.bin/store/v3" >> $GITHUB_ENV
- name: check if npm cache is needed
shell: bash
run: |
IS_NPM_CACHE_DOWNLOAD_REQUIRED="/cache-marker-v1.txt"
# update the above file name to force the cache ex: /cache-marker-v2.txt.
if [[ ! -f ${PRE_REQ_CHECK_FILE_PATH} ]];
then
echo "IS_NPM_CACHE_DOWNLOAD_REQUIRED is true"
IS_NPM_CACHE_DOWNLOAD_REQUIRED="true"
else
IS_NPM_CACHE_DOWNLOAD_REQUIRED="false"
fi
echo "IS_NPM_CACHE_DOWNLOAD_REQUIRED=${IS_NPM_CACHE_DOWNLOAD_REQUIRED}" >> $GITHUB_ENV
- uses: actions/cache@v3
if: env.IS_NPM_CACHE_DOWNLOAD_REQUIRED == 'true'
name: Setup pnpm cache

28
.github/workflows/pre-build-for-playwright.yml

@ -16,27 +16,25 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Check node,pnpm Installation and set Path
shell: bash
working-directory: scripts/self-hosted-gh-runner
timeout-minutes: 1
run: |
./node-pnpm-check.sh
echo "make sure below mentioned versions are expected versions"
echo "If you are expecting the node and pnpm versions to be updated. Please update the node-pnpm-check.sh script"
env
- name: Setup Node
if: ${{ env.SETUP_NODE != 'false' }}
uses: actions/setup-node@v3
with:
node-version: 18.14.0
node-version: ${{ env.NC_REQ_NODE_V }}
- name: Setup pnpm
if: ${{ env.SETUP_PNPM != 'false' }}
uses: pnpm/action-setup@v2
with:
version: 8
- name: check if npm cache is needed
shell: bash
run: |
PRE_REQ_CHECK_FILE_PATH="/cache-marker-v1.txt"
# update the above file name to force the cache ex: /cache-marker-v2.txt.
if [[ ! -f ${PRE_REQ_CHECK_FILE_PATH} ]];
then
echo "IS_NPM_CACHE_DOWNLOAD_REQUIRED is true"
IS_NPM_CACHE_DOWNLOAD_REQUIRED="true"
else
IS_NPM_CACHE_DOWNLOAD_REQUIRED="false"
fi
echo "IS_NPM_CACHE_DOWNLOAD_REQUIRED=${IS_NPM_CACHE_DOWNLOAD_REQUIRED}" >> $GITHUB_ENV
version: ${{ env.NC_REQ_PNPM_V }}
- name: Get pnpm store directory
shell: bash
run: |

2
packages/nc-gui/assets/nc-icons/download.svg

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 10V12.6667C14 13.0203 13.8595 13.3594 13.6095 13.6095C13.3594 13.8595 13.0203 14 12.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V10" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.66675 6.66663L8.00008 9.99996L11.3334 6.66663" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 10V2" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 10V2" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 643 B

After

Width:  |  Height:  |  Size: 643 B

6
packages/nc-gui/components/dashboard/Sidebar.vue

@ -5,11 +5,15 @@ const { isWorkspaceLoading } = storeToRefs(workspaceStore)
const { isSharedBase } = storeToRefs(useProject())
const { isMobileMode } = useGlobal()
const treeViewDom = ref<HTMLElement>()
const isTreeViewOnScrollTop = ref(false)
const checkScrollTopMoreThanZero = () => {
if (isMobileMode.value) return
if (treeViewDom.value) {
if (treeViewDom.value.scrollTop > 0) {
isTreeViewOnScrollTop.value = true
@ -43,7 +47,7 @@ onUnmounted(() => {
</div>
<div
ref="treeViewDom"
class="flex flex-col nc-scrollbar-dark-md flex-grow"
class="flex flex-col nc-scrollbar-dark-md flex-grow xs:(border-transparent pt-2)"
:class="{
'border-t-1': !isSharedBase,
'border-transparent': !isTreeViewOnScrollTop,

2
packages/nc-gui/components/dashboard/Sidebar/TopSection.vue

@ -43,7 +43,7 @@ const navigateToSettings = () => {
</div>
</template>
<template v-else-if="!isSharedBase">
<div class="flex flex-col p-1 gap-y-0.5 mt-0.25 mb-0.5 truncate">
<div class="xs:hidden flex flex-col p-1 gap-y-0.5 mt-0.25 mb-0.5 truncate">
<DashboardSidebarTopSectionHeader />
<NcButton

5
packages/nc-gui/components/dashboard/TreeView/TableNode.vue

@ -46,6 +46,7 @@ const { setMenuContext, openRenameTableDialog, duplicateTable } = inject(TreeVie
const { loadViews: _loadViews } = useViewsStore()
const { activeView } = storeToRefs(useViewsStore())
const { isLeftSidebarOpen } = storeToRefs(useSidebarStore())
// todo: temp
const { projectTables } = storeToRefs(useTablesStore())
@ -108,6 +109,10 @@ const onOpenTable = async () => {
isLoading.value = true
try {
await _openTable(table.value)
if (isMobileMode.value) {
isLeftSidebarOpen.value = false
}
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {

16
packages/nc-gui/components/dashboard/TreeView/ViewsList.vue

@ -31,6 +31,8 @@ const emits = defineEmits<Emits>()
const project = inject(ProjectInj)!
const table = inject(SidebarTableInj)!
const { isLeftSidebarOpen } = storeToRefs(useSidebarStore())
const { isMobileMode } = useGlobal()
const { $e } = useNuxtApp()
@ -191,13 +193,17 @@ const initSortable = (el: HTMLElement) => {
onMounted(() => menuRef.value && initSortable(menuRef.value.$el))
/** Navigate to view by changing url param */
function changeView(view: ViewType) {
navigateToView({
async function changeView(view: ViewType) {
await navigateToView({
view,
tableId: table.value.id!,
projectId: project.value.id!,
hardReload: view.type === ViewTypes.FORM && selected.value[0] === view.id,
})
if (isMobileMode.value) {
isLeftSidebarOpen.value = false
}
}
/** Rename a view */
@ -352,10 +358,10 @@ function onOpenModal({
<template>
<div
v-if="!views.length"
class="text-gray-500 my-1.75"
class="text-gray-500 my-1.75 xs:(my-2.5 text-base)"
:class="{
'ml-19.25': isDefaultBase,
'ml-24.75': !isDefaultBase,
'ml-19.25 xs:ml-22.25': isDefaultBase,
'ml-24.75 xs:ml-30': !isDefaultBase,
}"
>
{{ $t('labels.noViews') }}

9
packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue

@ -53,8 +53,6 @@ const activeView = inject(ActiveViewInj, ref())
const isLocked = inject(IsLockedInj, ref(false))
const { rightSidebarState } = storeToRefs(useSidebarStore())
const isDefaultBase = computed(() => {
const base = project.value?.bases?.find((b) => b.id === vModel.value.base_id)
if (!base) return false
@ -82,6 +80,7 @@ const onClick = useDebounceFn(() => {
/** Enable editing view name on dbl click */
function onDblClick() {
if (isMobileMode.value) return
if (!isUIAllowed('viewCreateOrEdit')) return
if (!isEditing.value) {
@ -193,12 +192,6 @@ function onStopEdit() {
}, 250)
}
watch(rightSidebarState, () => {
if (rightSidebarState.value === 'peekCloseEnd') {
isDropdownOpen.value = false
}
})
function onRef(el: HTMLElement) {
if (activeViewTitleOrId.value === vModel.value.id) {
nextTick(() => {

9
packages/nc-gui/components/dashboard/View.vue

@ -12,6 +12,7 @@ const {
leftSidebarWidthPercent,
leftSideBarSize: sideBarSize,
leftSidebarState: sidebarState,
mobileNormalizedSidebarSize,
} = storeToRefs(useSidebarStore())
const wrapperRef = ref<HTMLDivElement>()
@ -31,14 +32,6 @@ const { handleSidebarOpenOnMobileForNonViews } = useConfigStore()
const contentSize = computed(() => 100 - sideBarSize.value.current)
const mobileNormalizedSidebarSize = computed(() => {
if (isMobileMode.value) {
return isLeftSidebarOpen.value ? 100 : 0
}
return currentSidebarSize.value
})
const mobileNormalizedContentSize = computed(() => {
if (isMobileMode.value) {
return isLeftSidebarOpen.value ? 0 : 100

2
packages/nc-gui/components/nc/MenuItem.vue

@ -18,7 +18,7 @@
.nc-menu-item > .ant-dropdown-menu-title-content {
// Not Icon
:not(.nc-icon):not(.material-symbols) {
line-height: 0.95;
line-height: 1.5;
}
@apply flex flex-row items-center;

2
packages/nc-gui/components/nc/Pagination.vue

@ -13,7 +13,7 @@ const current = useVModel(props, 'current', emits)
const pageSize = useVModel(props, 'pageSize', emits)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
const totalPages = computed(() => Math.max(Math.ceil(total.value / pageSize.value), 1))
const { isMobileMode } = useGlobal()

2
packages/nc-gui/components/project/AllTables.vue

@ -119,7 +119,7 @@ function openTableCreateDialog(baseIndex?: number | undefined) {
<style lang="scss" scoped>
.nc-project-view-all-table-btn {
@apply flex flex-col gap-y-6 p-4 bg-gray-100 rounded-xl w-56 cursor-pointer text-gray-600 hover:(bg-gray-100 !text-black);
@apply flex flex-col gap-y-6 p-4 bg-gray-100 rounded-xl w-56 cursor-pointer text-gray-600 hover:(bg-gray-200 !text-black);
.nc-icon {
@apply h-10 w-10;

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

@ -58,7 +58,6 @@ const { allowCSVDownload } = useSharedView()
:show-system-fields="false"
/>
</template>
<LazySmartsheetToolbarOpenViewSidebarBtn v-if="isViewSidebarAvailable" />
</template>
</div>
</template>

14
packages/nc-gui/components/smartsheet/expanded-form/Comments.vue

@ -153,7 +153,7 @@ const processedAudit = (log: string) => {
</div>
<div class="font-medium text-center my-6 text-gray-500">{{ $t('activity.startCommenting') }}</div>
</div>
<div v-else class="flex flex-col h-full p-2 space-y-2 nc-scrollbar-md">
<div v-else class="flex flex-col h-full py-2 pl-2 pr-1 space-y-2 nc-scrollbar-md">
<div v-for="log of comments" :key="log.id">
<div class="bg-white rounded-xl group border-1 gap-2 border-gray-200">
<div class="flex flex-col p-4 gap-3">
@ -198,14 +198,12 @@ const processedAudit = (log: string) => {
</div>
</div>
</div>
<div v-if="hasEditPermission" class="h-16.5 p-2 rounded-b-xl bg-gray-50 gap-2 flex">
<div class="flex flex-row items-end">
<GeneralUserIcon size="base" />
</div>
<div class="flex flex-row bg-white py-2.75 px-1.5 items-center rounded-lg border-1 border-gray-200">
<div v-if="hasEditPermission" class="p-2 bg-gray-50 gap-2 flex">
<div class="h-14 flex flex-row w-full bg-white py-2.75 px-1.5 items-center rounded-xl border-1 border-gray-200">
<GeneralUserIcon size="base" class="!w-10" />
<a-input
v-model:value="comment"
class="!rounded-lg border-1 bg-white !px-2 !py-2 !border-gray-200 nc-comment-box !outline-none"
class="!rounded-lg border-1 bg-white !px-2.5 !py-2 !border-gray-200 nc-comment-box !outline-none"
placeholder="Start typing..."
:bordered="false"
@keyup.enter.prevent="saveComment"
@ -217,7 +215,7 @@ const processedAudit = (log: string) => {
</div>
</div>
</div>
<div v-else ref="commentsWrapperEl" class="flex flex-col h-full pl-1.5 pr-1 pt-1 nc-scrollbar-md space-y-2">
<div v-else ref="commentsWrapperEl" class="flex flex-col h-full pl-2 pr-1 pt-2 nc-scrollbar-md space-y-2">
<template v-if="audits.length === 0">
<div class="flex flex-col text-center justify-center h-full">
<div class="text-center text-3xl text-gray-600">

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

@ -367,20 +367,32 @@ export default {
:width="commentsDrawer && isUIAllowed('commentList') ? 'min(80vw,1280px)' : 'min(80vw,1280px)'"
:body-style="{ padding: 0 }"
:closable="false"
size="large"
size="small"
class="nc-drawer-expanded-form"
:class="{ active: isExpanded }"
>
<div class="h-[75vh] xs:(h-[95vh] max-h-full) max-h-215 flex flex-col p-6">
<div class="h-[85vh] xs:(max-h-full) max-h-215 flex flex-col p-6">
<div class="flex h-8 flex-shrink-0 w-full items-center nc-expanded-form-header relative mb-4 justify-between">
<template v-if="!isMobileMode">
<div class="flex gap-3">
<div class="flex gap-2">
<NcButton v-if="props.showNextPrevIcons" type="secondary" class="nc-prev-arrow !w-10" @click="$emit('prev')">
<MdiChevronUp class="text-md text-gray-700" />
<NcButton
v-if="props.showNextPrevIcons"
:disabled="props.firstRow"
type="secondary"
class="nc-prev-arrow !w-10"
@click="$emit('prev')"
>
<MdiChevronUp class="text-md" />
</NcButton>
<NcButton v-if="!props.lastRow" type="secondary" class="nc-next-arrow !w-10" @click="onNext">
<MdiChevronDown class="text-md text-gray-700" />
<NcButton
v-if="props.showNextPrevIcons"
:disabled="props.lastRow"
type="secondary"
class="nc-next-arrow !w-10"
@click="onNext"
>
<MdiChevronDown class="text-md" />
</NcButton>
</div>
<div v-if="displayValue" class="flex items-center truncate max-w-32 font-bold text-gray-800 text-xl">
@ -455,8 +467,8 @@ export default {
</div>
</template>
</div>
<div ref="wrapper" class="flex flex-grow flex-row h-[calc(100%-3rem)] w-full gap-4">
<div class="flex w-full flex-col border-1 rounded-xl overflow-hidden border-gray-200 xs:(border-0 rounded-none)">
<div ref="wrapper" class="flex flex-grow flex-row h-[calc(100%-4rem)] w-full gap-4">
<div class="flex w-2/3 xs:w-full flex-col border-1 rounded-xl overflow-hidden border-gray-200 xs:(border-0 rounded-none)">
<div
class="flex flex-col flex-grow mt-2 h-full max-h-full nc-scrollbar-md !pb-2 items-center w-full bg-white p-4 xs:p-0"
>
@ -543,7 +555,7 @@ export default {
</div>
<div
v-if="isUIAllowed('dataEdit')"
class="w-full h-14 border-t-1 border-gray-200 bg-white flex items-center justify-end p-2 xs:(p-0 mt-4 border-t-0 gap-x-4 justify-between)"
class="w-full h-16 border-t-1 border-gray-200 bg-white flex items-center justify-end p-3 xs:(p-0 mt-4 border-t-0 gap-x-4 justify-between)"
>
<NcDropdown v-if="!isNew && isMobileMode">
<NcButton type="secondary" class="nc-expand-form-more-actions w-10">
@ -598,7 +610,7 @@ export default {
</div>
<div
v-if="!isNew && commentsDrawer && isUIAllowed('commentList')"
class="nc-comments-drawer border-1 relative border-gray-200 w-[380px] bg-gray-50 rounded-xl min-w-0 overflow-hidden h-full xs:hidden"
class="nc-comments-drawer border-1 relative border-gray-200 w-1/3 max-w-125 bg-gray-50 rounded-xl min-w-0 overflow-hidden h-full xs:hidden"
:class="{ active: commentsDrawer && isUIAllowed('commentList') }"
>
<LazySmartsheetExpandedFormComments />

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

@ -237,23 +237,21 @@ onMounted(() => {
/>
</Suspense>
<Suspense>
<LazySmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg"
:key="routeQuery.rowId"
v-model="expandedFormOnRowIdDlg"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:meta="meta"
:state="expandedFormRowState"
:row-id="routeQuery.rowId"
:view="view"
show-next-prev-icons
:first-row="getExpandedRowIndex() === 0"
:last-row="getExpandedRowIndex() === data.length - 1"
@next="navigateToSiblingRow(NavigateDir.NEXT)"
@prev="navigateToSiblingRow(NavigateDir.PREV)"
/>
</Suspense>
<SmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg"
:key="routeQuery.rowId"
v-model="expandedFormOnRowIdDlg"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:meta="meta"
:state="expandedFormRowState"
:row-id="routeQuery.rowId"
:view="view"
show-next-prev-icons
:first-row="getExpandedRowIndex() === 0"
:last-row="getExpandedRowIndex() === data.length - 1"
@next="navigateToSiblingRow(NavigateDir.NEXT)"
@prev="navigateToSiblingRow(NavigateDir.PREV)"
/>
<Suspense>
<LazyDlgBulkUpdate

6
packages/nc-gui/components/smartsheet/header/Cell.vue

@ -11,6 +11,8 @@ interface Props {
const props = defineProps<Props>()
const { isMobileMode } = useGlobal()
const hideMenu = toRef(props, 'hideMenu')
const isForm = inject(IsFormInj, ref(false))
@ -42,13 +44,13 @@ const closeAddColumnDropdown = () => {
}
const openHeaderMenu = () => {
if (!isForm.value && !isExpandedForm.value && isUIAllowed('fieldEdit')) {
if (!isForm.value && !isExpandedForm.value && isUIAllowed('fieldEdit') && !isMobileMode.value) {
editColumnDropdown.value = true
}
}
const openDropDown = () => {
if (isForm.value || isExpandedForm.value || !isUIAllowed('fieldEdit')) return
if (isForm.value || isExpandedForm.value || (!isUIAllowed('fieldEdit') && !isMobileMode.value)) return
isDropDownOpen.value = !isDropDownOpen.value
}
</script>

69
packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue

@ -1,6 +1,6 @@
<script setup lang="ts">
import { type ColumnType, UITypes } from 'nocodb-sdk'
import GroupIcon from '~icons/nc-icons/group'
import type { ColumnType, LinkToAnotherRecordType, LookupType } from 'nocodb-sdk'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import {
ActiveViewInj,
IsLockedInj,
@ -8,11 +8,14 @@ import {
computed,
getSortDirectionOptions,
inject,
onMounted,
ref,
useMenuCloseOnEsc,
useMetas,
useNuxtApp,
useSmartsheetStoreOrThrow,
} from '#imports'
import GroupIcon from '~icons/nc-icons/group'
const groupingUidt = [
UITypes.SingleSelect,
@ -21,6 +24,10 @@ const groupingUidt = [
UITypes.Date,
UITypes.SingleLineText,
UITypes.Number,
UITypes.Rollup,
UITypes.Lookup,
UITypes.Links,
UITypes.Formula,
]
const meta = inject(MetaInj, ref())
@ -33,6 +40,8 @@ const { $e } = useNuxtApp()
const _groupBy = ref<{ fk_column_id?: string; sort: string; order: number }[]>([])
const { getMeta } = useMetas()
const groupBy = computed<{ fk_column_id?: string; sort: string; order: number }[]>(() => {
const tempGroupBy: { fk_column_id?: string; sort: string; order: number }[] = []
Object.values(gridViewCols.value).forEach((col) => {
@ -53,12 +62,19 @@ const groupedByColumnIds = computed(() => groupBy.value.map((g) => g.fk_column_i
const { eventBus } = useSmartsheetStoreOrThrow()
const { isMobileMode } = useGlobal()
const btLookups = ref([])
const fieldsToGroupBy = computed(() => {
const fields = meta.value?.columns || []
return fields.filter((field) => {
return groupingUidt.includes(field.uidt as UITypes)
if (!groupingUidt.includes(field.uidt as UITypes)) return false
if (field.uidt === UITypes.Lookup) {
return btLookups.value.includes(field.id)
}
return true
})
})
@ -144,6 +160,53 @@ watch(open, () => {
}
}
})
const loadBtLookups = async () => {
const filteredLookupCols = []
try {
for (const col of meta.value?.columns || []) {
if (col.uidt !== UITypes.Lookup) continue
let nextCol = col
let btLookup = true
// check all the relation of nested lookup columns is bt or not
// include the column only if all only if all relations are bt
while (btLookup && nextCol && nextCol.uidt === UITypes.Lookup) {
const lookupRelation = (await getMeta(nextCol.fk_model_id))?.columns?.find(
(c) => c.id === (nextCol.colOptions as LookupType).fk_relation_column_id,
)
if ((lookupRelation.colOptions as LinkToAnotherRecordType).type !== RelationTypes.BELONGS_TO) {
btLookup = false
continue
}
const relatedTableMeta = await getMeta((lookupRelation.colOptions as LinkToAnotherRecordType).fk_related_model_id)
nextCol = relatedTableMeta?.columns?.find(
(c) => c.id === (nextCol.colOptions as LinkToAnotherRecordType).fk_lookup_column_id,
)
// if next column is same as root lookup column then break the loop
// since it's going to be a circular loop, and ignore the column
if (nextCol.id === col.id) {
btLookup = false
break
}
}
if (btLookup) filteredLookupCols.push(col.id)
}
btLookups.value = filteredLookupCols
} catch (e) {
console.error(e)
}
}
onMounted(async () => {
await loadBtLookups()
})
</script>
<template>

53
packages/nc-gui/components/smartsheet/toolbar/OpenViewSidebarBtn.vue

@ -1,53 +0,0 @@
<script lang="ts" setup>
const { isRightSidebarOpen: _isRightSidebarOpen } = storeToRefs(useSidebarStore())
const isRightSidebarOpen = ref(_isRightSidebarOpen.value)
watch(_isRightSidebarOpen, (val) => {
if (val) {
isRightSidebarOpen.value = true
} else {
setTimeout(() => {
isRightSidebarOpen.value = false
}, 300)
}
})
const onClick = () => {
if (_isRightSidebarOpen.value) return
_isRightSidebarOpen.value = !_isRightSidebarOpen.value
}
</script>
<template>
<NcTooltip
placement="bottomLeft"
hide-on-click
class="transition-all duration-100"
:class="{
'!w-0 !opacity-0': isRightSidebarOpen,
'!w-8 !opacity-100 mr-2': !isRightSidebarOpen,
}"
>
<template #title>
{{
isRightSidebarOpen
? `${$t('general.hide')} ${$t('objects.sidebar').toLowerCase()}`
: `${$t('general.show')} ${$t('objects.sidebar').toLowerCase()}`
}}
</template>
<NcButton
type="text"
size="small"
class="nc-sidebar-right-toggle-icon !text-gray-600 !hover:text-gray-800"
:class="{
'invisible !w-0': isRightSidebarOpen,
}"
@click="onClick"
>
<div class="flex items-center text-inherit">
<GeneralIcon icon="doubleLeftArrow" class="duration-150 transition-all !text-lg -mt-0.25" />
</div>
</NcButton>
</NcTooltip>
</template>

22
packages/nc-gui/composables/useViewGroupBy.ts

@ -161,12 +161,10 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
if (group.nestedIn.length > groupBy.value.length) return
if (group.nestedIn.length === 0) nextGroupColor.value = colors.value[0]
const groupby = groupBy.value[group.nestedIn.length]
const nestedWhere = calculateNestedWhere(group.nestedIn, where?.value)
if (!groupby || !groupby.column.column_name) return
if (!groupby || !(groupby.column.title)) return
if (isPublic.value && !sharedView.value?.uuid) {
return
@ -181,7 +179,7 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
where: `${nestedWhere}`,
sort: `${groupby.sort === 'desc' ? '-' : ''}${groupby.column.title}`,
column_name: groupby.column.column_name,
column_name: groupby.column.title,
} as any)
: await api.public.dataGroupBy(sharedView.value!.uuid!, {
offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByLimit),
@ -189,30 +187,32 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
...params,
where: nestedWhere,
sort: `${groupby.sort === 'desc' ? '-' : ''}${groupby.column.title}`,
column_name: groupby.column.column_name,
column_name: groupby.column.title,
sortsArr: sorts.value,
filtersArr: nestedFilters.value,
})
const tempList: Group[] = response.list.reduce((acc: Group[], curr: Record<string, any>) => {
const keyExists = acc.find((a) => a.key === valueToTitle(curr[groupby.column.column_name!], groupby.column))
const keyExists = acc.find(
(a) => a.key === valueToTitle(curr[groupby.column.column_name!] ?? curr[groupby.column.title!], groupby.column),
)
if (keyExists) {
keyExists.count += +curr.count
keyExists.paginationData = { page: 1, pageSize: groupByLimit, totalRows: keyExists.count }
return acc
}
if (groupby.column.title && groupby.column.column_name && groupby.column.uidt) {
if (groupby.column.title && groupby.column.uidt) {
acc.push({
key: valueToTitle(curr[groupby.column.column_name!], groupby.column),
key: valueToTitle(curr[(groupby.column.title)!], groupby.column),
column: groupby.column,
count: +curr.count,
color: findKeyColor(curr[groupby.column.column_name!], groupby.column),
color: findKeyColor(curr[(groupby.column.title)!], groupby.column),
nestedIn: [
...group!.nestedIn,
{
title: groupby.column.title,
column_name: groupby.column.column_name!,
key: valueToTitle(curr[groupby.column.column_name!], groupby.column),
column_name: (groupby.column.title)!,
key: valueToTitle(curr[(groupby.column.title)!], groupby.column),
column_uidt: groupby.column.uidt,
},
],

22
packages/nc-gui/store/sidebar.ts

@ -4,6 +4,7 @@ import { MAX_WIDTH_FOR_MOBILE_MODE } from '~/lib'
export const useSidebarStore = defineStore('sidebarStore', () => {
const { width } = useWindowSize()
const isViewPortMobile = () => width.value < MAX_WIDTH_FOR_MOBILE_MODE
const { isMobileMode } = useGlobal()
const isLeftSidebarOpen = ref(!isViewPortMobile())
const isRightSidebarOpen = ref(true)
@ -15,27 +16,28 @@ export const useSidebarStore = defineStore('sidebarStore', () => {
current: leftSidebarWidthPercent.value,
})
const rightSidebarSize = ref({
old: 17.5,
current: 17.5,
})
const leftSidebarState = ref<
'openStart' | 'openEnd' | 'hiddenStart' | 'hiddenEnd' | 'peekOpenStart' | 'peekOpenEnd' | 'peekCloseOpen' | 'peekCloseEnd'
>(isLeftSidebarOpen.value ? 'openEnd' : 'hiddenEnd')
const rightSidebarState = ref<
'openStart' | 'openEnd' | 'hiddenStart' | 'hiddenEnd' | 'peekOpenStart' | 'peekOpenEnd' | 'peekCloseOpen' | 'peekCloseEnd'
>(isRightSidebarOpen.value ? 'openEnd' : 'hiddenEnd')
const mobileNormalizedSidebarSize = computed(() => {
if (isMobileMode.value) {
return isLeftSidebarOpen.value ? 100 : 0
}
return leftSideBarSize.value.current
})
const leftSidebarWidth = computed(() => (width.value * mobileNormalizedSidebarSize.value) / 100)
return {
isLeftSidebarOpen,
isRightSidebarOpen,
rightSidebarSize,
leftSidebarWidthPercent,
leftSideBarSize,
leftSidebarState,
rightSidebarState,
leftSidebarWidth,
mobileNormalizedSidebarSize,
}
})

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

@ -10432,4 +10432,431 @@ export class Api<
...params,
}),
};
dbDataTableRow = {
/**
* @description List all table rows in a given table
*
* @tags DB Data Table Row
* @name List
* @summary List Table Rows
* @request GET:/api/v1/tables/{tableId}/rows
* @response `200` `{
\** List of data objects *\
list: (object)[],
\** Paginated Info *\
pageInfo: PaginatedType,
}` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
list: (
tableId: string,
query: {
/** View ID */
viewId: string;
/** Which fields to be shown */
fields?: any[];
/** The result will be sorted based on `sort` query */
sort?: string[] | string;
/** Extra filtering */
where?: string;
/**
* Offset in rows
* @min 0
*/
offset?: number;
/**
* Limit in rows
* @min 1
*/
limit?: number;
/** Used for multiple sort queries */
sortArrJson?: string;
/** Used for multiple filter queries */
filterArrJson?: string;
},
params: RequestParams = {}
) =>
this.request<
{
/** List of data objects */
list: object[];
/** Paginated Info */
pageInfo: PaginatedType;
},
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/tables/${tableId}/rows`,
method: 'GET',
query: query,
format: 'json',
...params,
}),
/**
* @description Create a new row in a given table and project.
*
* @tags DB Data Table Row
* @name Create
* @summary Create Table Rows
* @request POST:/api/v1/tables/{tableId}/rows
* @response `200` `any` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
create: (
tableId: string,
query: {
/** View ID */
viewId: string;
},
data: object | object[],
params: RequestParams = {}
) =>
this.request<
any,
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/tables/${tableId}/rows`,
method: 'POST',
query: query,
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* @description Create a new row in a given table and project.
*
* @tags DB Data Table Row
* @name Update
* @summary Update Table Rows
* @request PUT:/api/v1/tables/{tableId}/rows
* @response `200` `any` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
update: (
tableId: string,
query: {
/** View ID */
viewId: string;
},
data: object | object[],
params: RequestParams = {}
) =>
this.request<
any,
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/tables/${tableId}/rows`,
method: 'PUT',
query: query,
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* @description Create a new row in a given table and project.
*
* @tags DB Data Table Row
* @name Delete
* @summary Delete Table Rows
* @request DELETE:/api/v1/tables/{tableId}/rows
* @response `200` `any` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
delete: (
tableId: string,
query: {
/** View ID */
viewId: string;
},
data: object | object[],
params: RequestParams = {}
) =>
this.request<
any,
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/tables/${tableId}/rows`,
method: 'DELETE',
query: query,
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* @description Get table row in a given table
*
* @tags DB Data Table Row
* @name Read
* @summary Read Table Row
* @request GET:/api/v1/tables/{tableId}/rows/{rowId}
* @response `200` `object` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
read: (
tableId: string,
rowId: string,
query: {
/** View ID */
viewId: string;
/** Which fields to be shown */
fields?: any[];
/**
* Offset in rows
* @min 0
*/
offset?: number;
},
params: RequestParams = {}
) =>
this.request<
object,
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/tables/${tableId}/rows/${rowId}`,
method: 'GET',
query: query,
format: 'json',
...params,
}),
/**
* @description Count of rows in a given table
*
* @tags DB Data Table Row
* @name Count
* @summary Table Rows Count
* @request GET:/api/v1/tables/{tableId}/rows/count
* @response `200` `{
count?: number,
}` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
count: (
tableId: string,
query: {
/** View ID */
viewId: string;
/** Which fields to be shown */
fields?: any[];
/** Extra filtering */
where?: string;
/** Used for multiple filter queries */
filterArrJson?: string;
},
params: RequestParams = {}
) =>
this.request<
{
count?: number;
},
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/tables/${tableId}/rows/count`,
method: 'GET',
query: query,
format: 'json',
...params,
}),
/**
* @description Linked rows in a given Links/LinkToAnotherRecord column
*
* @tags DB Data Table Row
* @name NestedList
* @summary Get Nested Relations Rows
* @request GET:/api/v1/tables/{tableId}/links/{columnId}/rows/{rowId}
* @response `200` `{
\** List of data objects *\
list: (object)[],
\** Paginated Info *\
pageInfo: PaginatedType,
}` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
nestedList: (
tableId: string,
columnId: string,
rowId: string,
query: {
/** View ID */
viewId: string;
/** Which fields to be shown */
fields?: any[];
/** The result will be sorted based on `sort` query */
sort?: string[] | string;
/** Extra filtering */
where?: string;
/**
* Offset in rows
* @min 0
*/
offset?: number;
/**
* Limit in rows
* @min 1
*/
limit?: number;
/** Used for multiple sort queries */
sortArrJson?: string;
/** Used for multiple filter queries */
filterArrJson?: string;
},
params: RequestParams = {}
) =>
this.request<
{
/** List of data objects */
list: object[];
/** Paginated Info */
pageInfo: PaginatedType;
},
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/tables/${tableId}/links/${columnId}/rows/${rowId}`,
method: 'GET',
query: query,
format: 'json',
...params,
}),
/**
* @description Create a link with the row.
*
* @tags DB Data Table Row
* @name NestedLink
* @summary Create Nested Relations Rows
* @request POST:/api/v1/tables/{tableId}/links/{columnId}/rows/{rowId}
* @response `200` `any` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
nestedLink: (
tableId: string,
columnId: string,
rowId: string,
query: {
/** View ID */
viewId: string;
},
data: object | object[],
params: RequestParams = {}
) =>
this.request<
any,
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/tables/${tableId}/links/${columnId}/rows/${rowId}`,
method: 'POST',
query: query,
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* @description Create a new row in a given table and project.
*
* @tags DB Data Table Row
* @name NestedUnlink
* @summary Delete Nested Relations Rows
* @request DELETE:/api/v1/tables/{tableId}/links/{columnId}/rows/{rowId}
* @response `200` `any` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
nestedUnlink: (
tableId: string,
columnId: string,
rowId: string,
query: {
/** View ID */
viewId: string;
},
data: object | object[],
params: RequestParams = {}
) =>
this.request<
any,
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/tables/${tableId}/links/${columnId}/rows/${rowId}`,
method: 'DELETE',
query: query,
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
};
}

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

@ -18,6 +18,7 @@ import Validator from 'validator';
import { customAlphabet } from 'nanoid';
import DOMPurify from 'isomorphic-dompurify';
import { v4 as uuidv4 } from 'uuid';
import type LookupColumn from '~/models/LookupColumn';
import type { Knex } from 'knex';
import type { XKnex } from '~/db/CustomKnex';
import type {
@ -34,6 +35,7 @@ import type {
SelectOption,
} from '~/models';
import type { SortType } from 'nocodb-sdk';
import generateBTLookupSelectQuery from '~/db/generateBTLookupSelectQuery';
import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2';
import genRollupSelectv2 from '~/db/genRollupSelectv2';
import conditionV2 from '~/db/conditionV2';
@ -53,6 +55,7 @@ import {
} from '~/utils/globals';
dayjs.extend(utc);
dayjs.extend(timezone);
const GROUP_COL = '__nc_group_id';
@ -337,7 +340,6 @@ class BaseModelSqlv2 {
const proto = await this.getProto();
let data;
try {
data = await this.execAndParse(qb);
} catch (e) {
@ -497,21 +499,95 @@ class BaseModelSqlv2 {
args.column_name = args.column_name || '';
const groupByColumns = await this.model.getColumns().then((cols) =>
args.column_name.split(',').map((col) => {
const cols = await this.model.getColumns();
const groupByColumns: Record<string, Column> = {};
const selectors = [];
const groupBySelectors = [];
await Promise.all(
args.column_name.split(',').map(async (col) => {
const column = cols.find(
(c) => c.column_name === col || c.title === col,
);
groupByColumns[column.id] = column;
if (!column) {
throw NcError.notFound('Column not found');
}
return column.column_name;
switch (column.uidt) {
case UITypes.Links:
case UITypes.Rollup:
selectors.push(
(
await genRollupSelectv2({
baseModelSqlv2: this,
knex: this.dbDriver,
columnOptions: (await column.getColOptions()) as RollupColumn,
})
).builder.as(sanitize(column.title)),
);
groupBySelectors.push(sanitize(column.title));
break;
case UITypes.Formula:
{
let selectQb;
try {
const _selectQb = await this.getSelectQueryBuilderForFormula(
column,
);
selectQb = this.dbDriver.raw(`?? as ??`, [
_selectQb.builder,
sanitize(column.title),
]);
} catch (e) {
console.log(e);
// return dummy select
selectQb = this.dbDriver.raw(`'ERR' as ??`, [
sanitize(column.title),
]);
}
selectors.push(selectQb);
groupBySelectors.push(column.title);
}
break;
case UITypes.Lookup:
{
const _selectQb = await generateBTLookupSelectQuery({
baseModelSqlv2: this,
column,
alias: null,
model: this.model,
});
const selectQb = this.dbDriver.raw(`?? as ??`, [
this.dbDriver.raw(_selectQb.builder).wrap('(', ')'),
sanitize(column.title),
]);
selectors.push(selectQb);
groupBySelectors.push(sanitize(column.title));
}
break;
default:
selectors.push(
this.dbDriver.raw('?? as ??', [column.column_name, column.title]),
);
groupBySelectors.push(sanitize(column.title));
break;
}
}),
);
const qb = this.dbDriver(this.tnPath);
// get aggregated count of each group
qb.count(`${this.model.primaryKey?.column_name || '*'} as count`);
qb.select(...groupByColumns);
// get each group
qb.select(...selectors);
if (+rest?.shuffle) {
await this.shuffle({ qb });
@ -548,14 +624,28 @@ class BaseModelSqlv2 {
qb,
);
qb.groupBy(...groupByColumns);
if (!sorts)
sorts = args.sortArr?.length
? args.sortArr
: await Sort.list({ viewId: this.viewId });
if (sorts) await sortV2(this, sorts, qb);
// if sort is provided filter out the group by columns sort and apply
// since we are grouping by the column and applying sort on any other column is not required
for (const sort of sorts) {
if (!groupByColumns[sort.fk_column_id]) {
continue;
}
qb.orderBy(
groupByColumns[sort.fk_column_id].title,
sort.direction,
sort.direction === 'desc' ? 'LAST' : 'FIRST',
);
}
// group by using the column aliases
qb.groupBy(...groupBySelectors);
applyPaginate(qb, rest);
return await qb;
}
@ -566,29 +656,99 @@ class BaseModelSqlv2 {
limit?;
offset?;
filterArr?: Filter[];
// skip sort for count
// sort?: string | string[];
// sortArr?: Sort[];
}) {
const { where, ..._rest } = this._getListArgs(args as any);
const { where } = this._getListArgs(args as any);
args.column_name = args.column_name || '';
const groupByColumns = await this.model.getColumns().then((cols) =>
args.column_name.split(',').map((col) => {
const column = cols.find(
(c) => c.column_name === col || c.title === col,
);
if (!column) {
throw NcError.notFound('Column not found');
}
return column.column_name;
}),
const selectors = [];
const groupBySelectors = [];
await this.model.getColumns().then((cols) =>
Promise.all(
args.column_name.split(',').map(async (col) => {
const column = cols.find(
(c) => c.column_name === col || c.title === col,
);
if (!column) {
throw NcError.notFound('Column not found');
}
switch (column.uidt) {
case UITypes.Rollup:
case UITypes.Links:
selectors.push(
(
await genRollupSelectv2({
baseModelSqlv2: this,
// tn: this.title,
knex: this.dbDriver,
// column,
// alias,
columnOptions:
(await column.getColOptions()) as RollupColumn,
})
).builder.as(sanitize(column.title)),
);
groupBySelectors.push(sanitize(column.title));
break;
case UITypes.Formula:
let selectQb;
try {
const _selectQb = await this.getSelectQueryBuilderForFormula(
column,
);
selectQb = this.dbDriver.raw(`?? as ??`, [
_selectQb.builder,
sanitize(column.title),
]);
} catch (e) {
console.log(e);
// return dummy select
selectQb = this.dbDriver.raw(`'ERR' as ??`, [
sanitize(column.title),
]);
}
selectors.push(selectQb);
groupBySelectors.push(column.title);
break;
case UITypes.Lookup:
{
const _selectQb = await generateBTLookupSelectQuery({
baseModelSqlv2: this,
column,
alias: null,
model: this.model,
});
const selectQb = this.dbDriver.raw(`?? as ??`, [
this.dbDriver.raw(_selectQb.builder).wrap('(', ')'),
sanitize(column.title),
]);
selectors.push(selectQb);
groupBySelectors.push(sanitize(column.title));
}
break;
default:
selectors.push(
this.dbDriver.raw('?? as ??', [
column.column_name,
column.title,
]),
);
groupBySelectors.push(sanitize(column.title));
break;
}
}),
),
);
const qb = this.dbDriver(this.tnPath);
qb.count(`${this.model.primaryKey?.column_name || '*'} as count`);
qb.select(...groupByColumns);
qb.select(...selectors);
const aliasColObjMap = await this.model.getAliasColObjMap();
@ -619,11 +779,12 @@ class BaseModelSqlv2 {
qb,
);
qb.groupBy(...groupByColumns);
qb.groupBy(...groupBySelectors);
const qbP = this.dbDriver
.count('*', { as: 'count' })
.from(qb.as('groupby'));
return (await qbP.first())?.count;
}
@ -2478,6 +2639,7 @@ class BaseModelSqlv2 {
skip_hooks = false,
raw = false,
insertOneByOneAsFallback = false,
isSingleRecordInsertion = false,
}: {
chunkSize?: number;
cookie?: any;
@ -2485,6 +2647,7 @@ class BaseModelSqlv2 {
skip_hooks?: boolean;
raw?: boolean;
insertOneByOneAsFallback?: boolean;
isSingleRecordInsertion?: boolean;
} = {},
) {
let trx;
@ -2671,8 +2834,14 @@ class BaseModelSqlv2 {
await trx.commit();
if (!raw && !skip_hooks)
await this.afterBulkInsert(insertDatas, this.dbDriver, cookie);
if (!raw && !skip_hooks) {
if (isSingleRecordInsertion) {
const insertData = await this.readByPk(response[0]);
await this.afterInsert(insertData, this.dbDriver, cookie);
} else {
await this.afterBulkInsert(insertDatas, this.dbDriver, cookie);
}
}
return response;
} catch (e) {
@ -2688,7 +2857,13 @@ class BaseModelSqlv2 {
cookie,
raw = false,
throwExceptionIfNotExist = false,
}: { cookie?: any; raw?: boolean; throwExceptionIfNotExist?: boolean } = {},
isSingleRecordUpdation = false,
}: {
cookie?: any;
raw?: boolean;
throwExceptionIfNotExist?: boolean;
isSingleRecordUpdation?: boolean;
} = {},
) {
let transaction;
try {
@ -2740,8 +2915,19 @@ class BaseModelSqlv2 {
}
}
if (!raw)
await this.afterBulkUpdate(prevData, newData, this.dbDriver, cookie);
if (!raw) {
if (isSingleRecordUpdation) {
await this.afterUpdate(
prevData[0],
newData[0],
null,
cookie,
datas[0],
);
} else {
await this.afterBulkUpdate(prevData, newData, this.dbDriver, cookie);
}
}
return res;
} catch (e) {
@ -2816,7 +3002,12 @@ class BaseModelSqlv2 {
{
cookie,
throwExceptionIfNotExist = false,
}: { cookie?: any; throwExceptionIfNotExist?: boolean } = {},
isSingleRecordDeletion = false,
}: {
cookie?: any;
throwExceptionIfNotExist?: boolean;
isSingleRecordDeletion?: boolean;
} = {},
) {
let transaction;
try {
@ -2918,7 +3109,11 @@ class BaseModelSqlv2 {
await transaction.commit();
await this.afterBulkDelete(deleted, this.dbDriver, cookie);
if (isSingleRecordDeletion) {
await this.afterDelete(deleted[0], null, cookie);
} else {
await this.afterBulkDelete(deleted, this.dbDriver, cookie);
}
return res;
} catch (e) {
@ -4462,14 +4657,16 @@ export function extractSortsObject(
let sorts = _sorts;
if (!Array.isArray(sorts)) sorts = sorts.split(',');
if (!Array.isArray(sorts)) sorts = sorts.split(/\s*,\s*/);
return sorts.map((s) => {
const sort: SortType = { direction: 'asc' };
if (s.startsWith('-')) {
sort.direction = 'desc';
sort.fk_column_id = aliasColObjMap[s.slice(1)]?.id;
} else sort.fk_column_id = aliasColObjMap[s]?.id;
}
// replace + at the beginning if present
else sort.fk_column_id = aliasColObjMap[s.replace(/^\+/, '')]?.id;
return new Sort(sort);
});
@ -4618,6 +4815,11 @@ function applyPaginate(
}
export function _wherePk(primaryKeys: Column[], id: unknown | unknown[]) {
// if id object is provided use as it is
if (id && typeof id === 'object') {
return id;
}
const ids = Array.isArray(id) ? id : (id + '').split('___');
const where = {};
for (let i = 0; i < primaryKeys.length; ++i) {

30
packages/nocodb/src/db/CustomKnex.ts

@ -1,25 +1,43 @@
import { Knex, knex } from 'knex';
import { SnowflakeClient } from 'nc-help';
import { types } from 'pg';
import { defaults, types } from 'pg';
import dayjs from 'dayjs';
import type { FilterType } from 'nocodb-sdk';
import type { BaseModelSql } from '~/db/BaseModelSql';
import Filter from '~/models/Filter';
// For the code, check out
// https://raw.githubusercontent.com/brianc/node-pg-types/master/lib/builtins.js
// refer : https://github.com/brianc/node-pg-types/blob/master/lib/builtins.js
const pgTypes = {
FLOAT4: 700,
FLOAT8: 701,
DATE: 1082,
TIMESTAMP: 1114,
TIMESTAMPTZ: 1184,
NUMERIC: 1700,
};
// override parsing date column to Date()
types.setTypeParser(1082, (val) => val);
types.setTypeParser(pgTypes.DATE, (val) => val);
// override timestamp
types.setTypeParser(1114, (val) => {
types.setTypeParser(pgTypes.TIMESTAMP, (val) => {
return dayjs.utc(val).format('YYYY-MM-DD HH:mm:ssZ');
});
// override timestampz
types.setTypeParser(1184, (val) => {
types.setTypeParser(pgTypes.TIMESTAMPTZ, (val) => {
return dayjs(val).utc().format('YYYY-MM-DD HH:mm:ssZ');
});
const parseFloatVal = (val: string) => {
return parseFloat(val);
};
// parse integer values
defaults.parseInt8 = true;
// parse float values
types.setTypeParser(pgTypes.FLOAT8, parseFloatVal);
types.setTypeParser(pgTypes.NUMERIC, parseFloatVal);
const opMappingGen = {
eq: '=',
lt: '<',

163
packages/nocodb/src/db/generateBTLookupSelectQuery.ts

@ -0,0 +1,163 @@
import { RelationTypes, UITypes } from 'nocodb-sdk';
import type LookupColumn from '../models/LookupColumn';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type {
Column,
FormulaColumn,
LinkToAnotherRecordColumn,
Model,
RollupColumn,
} from '~/models';
import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2';
import genRollupSelectv2 from '~/db/genRollupSelectv2';
import { NcError } from '~/helpers/catchError';
export default async function generateBTLookupSelectQuery({
column,
baseModelSqlv2,
alias,
model,
}: {
column: Column;
baseModelSqlv2: BaseModelSqlv2;
alias: string;
model: Model;
}): Promise<any> {
const knex = baseModelSqlv2.dbDriver;
const rootAlias = alias;
{
let aliasCount = 0,
selectQb;
const alias = `__nc_lk_${aliasCount++}`;
const lookup = await column.getColOptions<LookupColumn>();
{
const relationCol = await lookup.getRelationColumn();
const relation =
await relationCol.getColOptions<LinkToAnotherRecordColumn>();
// if not belongs to then throw error as we don't support
if (relation.type !== RelationTypes.BELONGS_TO)
NcError.badRequest('HasMany/ManyToMany lookup is not supported');
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
const childModel = await childColumn.getModel();
await childModel.getColumns();
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
selectQb = knex(
`${baseModelSqlv2.getTnPath(parentModel.table_name)} as ${alias}`,
).where(
`${alias}.${parentColumn.column_name}`,
knex.raw(`??`, [
`${rootAlias || baseModelSqlv2.getTnPath(childModel.table_name)}.${
childColumn.column_name
}`,
]),
);
}
let lookupColumn = await lookup.getLookupColumn();
let prevAlias = alias;
while (lookupColumn.uidt === UITypes.Lookup) {
const nestedAlias = `__nc_lk_nested_${aliasCount++}`;
const nestedLookup = await lookupColumn.getColOptions<LookupColumn>();
const relationCol = await nestedLookup.getRelationColumn();
const relation =
await relationCol.getColOptions<LinkToAnotherRecordColumn>();
// if any of the relation in nested lookup is
// not belongs to then throw error as we don't support
if (relation.type !== RelationTypes.BELONGS_TO)
NcError.badRequest('HasMany/ManyToMany lookup is not supported');
const childColumn = await relation.getChildColumn();
const parentColumn = await relation.getParentColumn();
const childModel = await childColumn.getModel();
await childModel.getColumns();
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
selectQb.join(
`${baseModelSqlv2.getTnPath(parentModel.table_name)} as ${nestedAlias}`,
`${nestedAlias}.${parentColumn.column_name}`,
`${prevAlias}.${childColumn.column_name}`,
);
lookupColumn = await nestedLookup.getLookupColumn();
prevAlias = nestedAlias;
}
switch (lookupColumn.uidt) {
case UITypes.Links:
case UITypes.Rollup:
{
const builder = (
await genRollupSelectv2({
baseModelSqlv2,
knex,
columnOptions:
(await lookupColumn.getColOptions()) as RollupColumn,
alias: prevAlias,
})
).builder;
selectQb.select(builder);
}
break;
case UITypes.LinkToAnotherRecord:
{
const nestedAlias = `__nc_sort${aliasCount++}`;
const relation =
await lookupColumn.getColOptions<LinkToAnotherRecordColumn>();
if (relation.type !== 'bt') return;
const colOptions =
(await column.getColOptions()) as LinkToAnotherRecordColumn;
const childColumn = await colOptions.getChildColumn();
const parentColumn = await colOptions.getParentColumn();
const childModel = await childColumn.getModel();
await childModel.getColumns();
const parentModel = await parentColumn.getModel();
await parentModel.getColumns();
selectQb
.join(
`${baseModelSqlv2.getTnPath(
parentModel.table_name,
)} as ${nestedAlias}`,
`${nestedAlias}.${parentColumn.column_name}`,
`${prevAlias}.${childColumn.column_name}`,
)
.select(parentModel?.displayValue?.column_name);
}
break;
case UITypes.Formula:
{
const builder = (
await formulaQueryBuilderv2(
baseModelSqlv2,
(
await column.getColOptions<FormulaColumn>()
).formula,
null,
model,
column,
)
).builder;
selectQb.select(builder);
}
break;
default:
{
selectQb.select(`${prevAlias}.${lookupColumn.column_name}`);
}
break;
}
return { builder: selectQb };
}
}

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

@ -15337,6 +15337,896 @@
}
]
}
},
"/api/v1/tables/{tableId}/rows": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true,
"description": "Table ID"
},
{
"schema": {
"type": "string"
},
"name": "viewId",
"in": "query",
"required": true,
"description": "View ID"
}
],
"get": {
"summary": "List Table Rows",
"operationId": "db-data-table-row-list",
"description": "List all table rows in a given table",
"tags": [
"DB Data Table Row"
],
"parameters": [
{
"schema": {
"type": "array"
},
"in": "query",
"name": "fields",
"description": "Which fields to be shown"
},
{
"schema": {
"oneOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "string"
}
]
},
"in": "query",
"name": "sort",
"description": "The result will be sorted based on `sort` query"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where",
"description": "Extra filtering"
},
{
"schema": {
"type": "integer",
"minimum": 0
},
"in": "query",
"name": "offset",
"description": "Offset in rows"
},
{
"schema": {
"type": "integer",
"minimum": 1
},
"in": "query",
"name": "limit",
"description": "Limit in rows"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "sortArrJson",
"description": "Used for multiple sort queries"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "filterArrJson",
"description": "Used for multiple filter queries"
},
{
"$ref": "#/components/parameters/xc-auth"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"list": {
"type": "array",
"description": "List of data objects",
"items": {
"type": "object"
}
},
"pageInfo": {
"$ref": "#/components/schemas/Paginated",
"description": "Paginated Info"
}
},
"required": [
"list",
"pageInfo"
]
},
"examples": {
"Example 1": {
"value": {
"list": [
{
"Id": 1,
"Title": "baz",
"SingleSelect": null,
"Sheet-1 List": [
{
"Id": 1,
"Title": "baz"
}
],
"LTAR": [
{
"Id": 1,
"Title": "baz"
}
]
},
{
"Id": 2,
"Title": "foo",
"SingleSelect": "a",
"Sheet-1 List": [
{
"Id": 2,
"Title": "foo"
}
],
"LTAR": [
{
"Id": 2,
"Title": "foo"
}
]
},
{
"Id": 3,
"Title": "bar",
"SingleSelect": "b",
"Sheet-1 List": [],
"LTAR": []
}
],
"pageInfo": {
"totalRows": 3,
"page": 1,
"pageSize": 25,
"isFirstPage": true,
"isLastPage": true
}
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
}
},
"post": {
"summary": "Create Table Rows",
"operationId": "db-data-table-row-create",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"tags": [
"DB Data Table Row"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object"
},
{
"type": "array",
"items": {
"type": "object"
}
}
]
},
"examples": {
"Example 1": {
"value": {
"Id": 1,
"Title": "foo",
"CreatedAt": "2023-03-11T09:10:53.567Z",
"UpdatedAt": "2023-03-11T09:10:53.567Z"
}
}
}
}
}
},
"description": "Create a new row in a given table and project.",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
},
"put": {
"summary": "Update Table Rows",
"operationId": "db-data-table-row-update",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"tags": [
"DB Data Table Row"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object"
},
{
"type": "array",
"items": {
"type": "object"
}
}
]
},
"examples": {
"Example 1": {
"value": {
"Id": 1,
"Title": "foo",
"CreatedAt": "2023-03-11T09:10:53.567Z",
"UpdatedAt": "2023-03-11T09:10:53.567Z"
}
}
}
}
}
},
"description": "Create a new row in a given table and project.",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
},
"delete": {
"summary": "Delete Table Rows",
"operationId": "db-data-table-row-delete",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"tags": [
"DB Data Table Row"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object"
},
{
"type": "array",
"items": {
"type": "object"
}
}
]
},
"examples": {
"Example 1": {
"value": [
{
"Id": 1
}
]
}
}
}
}
},
"description": "Create a new row in a given table and project.",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
}
},
"/api/v1/tables/{tableId}/rows/{rowId}": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true,
"description": "Table ID"
},
{
"schema": {
"type": "string"
},
"name": "rowId",
"in": "path",
"required": true,
"description": "Row ID"
},
{
"schema": {
"type": "string"
},
"name": "viewId",
"in": "query",
"required": true,
"description": "View ID"
}
],
"get": {
"summary": "Read Table Row",
"operationId": "db-data-table-row-read",
"description": "Get table row in a given table",
"tags": [
"DB Data Table Row"
],
"parameters": [
{
"schema": {
"type": "array"
},
"in": "query",
"name": "fields",
"description": "Which fields to be shown"
},
{
"schema": {
"type": "integer",
"minimum": 0
},
"in": "query",
"name": "offset",
"description": "Offset in rows"
},
{
"$ref": "#/components/parameters/xc-auth"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
},
"examples": {
"Example 1": {
"value": {
"Id": 1,
"Title": "baz",
"SingleSelect": null,
"Sheet-1 List": [
{
"Id": 1,
"Title": "baz"
}
],
"LTAR": [
{
"Id": 1,
"Title": "baz"
}
]
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
}
}
},
"/api/v1/tables/{tableId}/rows/count": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true,
"description": "Table ID"
},
{
"schema": {
"type": "string"
},
"name": "viewId",
"in": "query",
"required": true,
"description": "View ID"
}
],
"get": {
"summary": "Table Rows Count",
"operationId": "db-data-table-row-count",
"description": "Count of rows in a given table",
"tags": [
"DB Data Table Row"
],
"parameters": [
{
"schema": {
"type": "array"
},
"in": "query",
"name": "fields",
"description": "Which fields to be shown"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where",
"description": "Extra filtering"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "filterArrJson",
"description": "Used for multiple filter queries"
},
{
"$ref": "#/components/parameters/xc-auth"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"count": {
"type": "number"
}
},
"required": [
"list",
"pageInfo"
]
},
"examples": {
"Example 1": {
"value": {
"list": [
{
"Id": 1,
"Title": "baz",
"SingleSelect": null,
"Sheet-1 List": [
{
"Id": 1,
"Title": "baz"
}
],
"LTAR": [
{
"Id": 1,
"Title": "baz"
}
]
},
{
"Id": 2,
"Title": "foo",
"SingleSelect": "a",
"Sheet-1 List": [
{
"Id": 2,
"Title": "foo"
}
],
"LTAR": [
{
"Id": 2,
"Title": "foo"
}
]
},
{
"Id": 3,
"Title": "bar",
"SingleSelect": "b",
"Sheet-1 List": [],
"LTAR": []
}
],
"pageInfo": {
"totalRows": 3,
"page": 1,
"pageSize": 25,
"isFirstPage": true,
"isLastPage": true
}
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
}
}
},
"/api/v1/tables/{tableId}/links/{columnId}/rows/{rowId}": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true,
"description": "Table ID"
},
{
"schema": {
"type": "string"
},
"name": "viewId",
"in": "query",
"required": true,
"description": "View ID"
}
],
"get": {
"summary": "Get Nested Relations Rows",
"operationId": "db-data-table-row-nested-list",
"description": "Linked rows in a given Links/LinkToAnotherRecord column",
"tags": [
"DB Data Table Row"
],
"parameters": [
{
"schema": {
"type": "array"
},
"in": "query",
"name": "fields",
"description": "Which fields to be shown"
},
{
"schema": {
"oneOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "string"
}
]
},
"in": "query",
"name": "sort",
"description": "The result will be sorted based on `sort` query"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where",
"description": "Extra filtering"
},
{
"schema": {
"type": "integer",
"minimum": 0
},
"in": "query",
"name": "offset",
"description": "Offset in rows"
},
{
"schema": {
"type": "integer",
"minimum": 1
},
"in": "query",
"name": "limit",
"description": "Limit in rows"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "sortArrJson",
"description": "Used for multiple sort queries"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "filterArrJson",
"description": "Used for multiple filter queries"
},
{
"$ref": "#/components/parameters/xc-auth"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"list": {
"type": "array",
"description": "List of data objects",
"items": {
"type": "object"
}
},
"pageInfo": {
"$ref": "#/components/schemas/Paginated",
"description": "Paginated Info"
}
},
"required": [
"list",
"pageInfo"
]
},
"examples": {
"Example 1": {
"value": {
"list": [
{
"Id": 1,
"Title": "baz",
"SingleSelect": null,
"Sheet-1 List": [
{
"Id": 1,
"Title": "baz"
}
],
"LTAR": [
{
"Id": 1,
"Title": "baz"
}
]
},
{
"Id": 2,
"Title": "foo",
"SingleSelect": "a",
"Sheet-1 List": [
{
"Id": 2,
"Title": "foo"
}
],
"LTAR": [
{
"Id": 2,
"Title": "foo"
}
]
},
{
"Id": 3,
"Title": "bar",
"SingleSelect": "b",
"Sheet-1 List": [],
"LTAR": []
}
],
"pageInfo": {
"totalRows": 3,
"page": 1,
"pageSize": 25,
"isFirstPage": true,
"isLastPage": true
}
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
}
},
"post": {
"summary": "Create Nested Relations Rows",
"operationId": "db-data-table-row-nested-link",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"tags": [
"DB Data Table Row"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object"
},
{
"type": "array",
"items": {
"type": "object"
}
}
]
},
"examples": {
"Example 1": {
"value": [
{
"Id": 1
}
]
}
}
}
}
},
"description": "Create a link with the row.",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
},
"delete": {
"summary": "Delete Nested Relations Rows",
"operationId": "db-data-table-row-nested-unlink",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"tags": [
"DB Data Table Row"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object"
},
{
"type": "array",
"items": {
"type": "object"
}
}
]
},
"examples": {
"Example 1": {
"value": [
{
"Id": 1
}
]
}
}
}
}
},
"description": "Create a new row in a given table and project.",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
}
}
},
"components": {
@ -20901,7 +21791,7 @@
},
{
"type": "boolean"
},
},
{
"type": "number"
}

18
packages/nocodb/src/services/data-table.service.ts

@ -73,7 +73,11 @@ export class DataTableService {
// if array then do bulk insert
const result = await baseModel.bulkInsert(
Array.isArray(param.body) ? param.body : [param.body],
{ cookie: param.cookie, insertOneByOneAsFallback: true },
{
cookie: param.cookie,
insertOneByOneAsFallback: true,
isSingleRecordInsertion: !Array.isArray(param.body),
},
);
return Array.isArray(param.body) ? result : result[0];
@ -101,7 +105,11 @@ export class DataTableService {
await baseModel.bulkUpdate(
Array.isArray(param.body) ? param.body : [param.body],
{ cookie: param.cookie, throwExceptionIfNotExist: true },
{
cookie: param.cookie,
throwExceptionIfNotExist: true,
isSingleRecordUpdation: !Array.isArray(param.body),
},
);
return this.extractIdObj({ body: param.body, model });
@ -128,7 +136,11 @@ export class DataTableService {
await baseModel.bulkDelete(
Array.isArray(param.body) ? param.body : [param.body],
{ cookie: param.cookie, throwExceptionIfNotExist: true },
{
cookie: param.cookie,
throwExceptionIfNotExist: true,
isSingleRecordDeletion: !Array.isArray(param.body),
},
);
return this.extractIdObj({ body: param.body, model });

19
packages/nocodb/src/utils/common/NcConnectionMgrv2.ts

@ -66,13 +66,22 @@ export default class NcConnectionMgrv2 {
connection: {
...defaultConnectionConfig,
...connectionConfig.connection,
typeCast(_field, next) {
typeCast(field, next) {
const res = next();
if (res instanceof Buffer) {
return [...res]
.map((v) => ('00' + v.toString(16)).slice(-2))
.join('');
// mysql `bit` datatype returns value as Buffer, convert it to integer number
if (field.type == 'BIT' && res && res instanceof Buffer) {
return parseInt(
[...res].map((v) => ('00' + v.toString(16)).slice(-2)).join(''),
16,
);
}
// mysql `decimal` datatype returns value as string, convert it to float number
if (field.type == 'NEWDECIMAL') {
return res && parseFloat(res);
}
return res;
},
},

22
packages/nocodb/tests/unit/factory/column.ts

@ -233,12 +233,14 @@ const createLookupColumn = async (
table,
relatedTableName,
relatedTableColumnTitle,
relationColumnId,
}: {
project: Project;
title: string;
table: Model;
relatedTableName: string;
relatedTableColumnTitle: string;
relationColumnId?: string;
},
) => {
const childBases = await project.getBases();
@ -258,12 +260,20 @@ const createLookupColumn = async (
);
}
const ltarColumn = (await table.getColumns()).find(
(column) =>
(column.uidt === UITypes.Links ||
column.uidt === UITypes.LinkToAnotherRecord) &&
column.colOptions?.fk_related_model_id === childTable.id,
);
let ltarColumn;
if (relationColumnId)
ltarColumn = (await table.getColumns()).find(
(column) => column.id === relationColumnId,
);
else {
ltarColumn = (await table.getColumns()).find(
(column) =>
(column.uidt === UITypes.Links ||
column.uidt === UITypes.LinkToAnotherRecord) &&
column.colOptions?.fk_related_model_id === childTable.id,
);
}
const lookupColumn = await createColumn(context, table, {
title: title,
uidt: UITypes.Lookup,

132
packages/nocodb/tests/unit/rest/tests/groupby.test.ts

@ -1,9 +1,14 @@
import { UITypes } from 'nocodb-sdk';
import request from 'supertest';
import { assert, expect } from 'chai';
import { createColumn, createLookupColumn } from '../../factory/column';
import { createProject, createSakilaProject } from '../../factory/project';
import { listRow } from '../../factory/row';
import { getTable } from '../../factory/table';
import { getView, updateView } from '../../factory/view';
import init from '../../init';
import type { Column, Model, Project, View } from '../../../../src/models';
import 'mocha';
function groupByTests() {
let context;
@ -234,6 +239,133 @@ function groupByTests() {
)
throw new Error('Invalid GroupBy With Filters');
});
it('Check One GroupBy Column with Links/Rollup', async function () {
const actorsColumn = filmColumns.find((c) => c.title === 'Actors');
const response = await request(context.app)
.get(`/api/v1/db/data/noco/${sakilaProject.id}/${filmTable.id}/groupby`)
.set('xc-auth', context.token)
.query({
column_name: actorsColumn.title,
sort: `-${actorsColumn.title}`,
})
.expect(200);
expect(response.body.list[0]['Actors']).not.equal('0');
expect(response.body.list[0]['count']).not.equal('10');
expect(+response.body.list[0]['Actors']).to.be.gte(
+response.body.list[1]['Actors'],
);
});
it('Check One GroupBy Column with BT Lookup', async function () {
// get the row list and extract the correct language column name which have the values
// this is to avoid issue since there is 2 language column
const rows = await listRow({
table: filmTable,
project: sakilaProject,
options: {
limit: 1,
offset: 0,
},
});
const language = await rows[0]['Language']();
const ltarColumn = filmColumns.find(
(c) => c.title === (language ? 'Language' : 'Language1'),
);
await createLookupColumn(context, {
project: sakilaProject,
title: 'LanguageName',
table: filmTable,
relatedTableName: 'language',
relatedTableColumnTitle: 'Name',
relationColumnId: ltarColumn.id,
});
const response = await request(context.app)
.get(`/api/v1/db/data/noco/${sakilaProject.id}/${filmTable.id}/groupby`)
.set('xc-auth', context.token)
.query({
column_name: 'LanguageName',
sort: `-LanguageName`,
})
.expect(200);
assert.match(response.body.list[0]['LanguageName'], /^English/);
expect(+response.body.list[0]['count']).to.gt(0);
expect(response.body.list.length).to.equal(1);
});
it('Check One GroupBy Column with MM Lookup which is not supported', async function () {
await createLookupColumn(context, {
project: sakilaProject,
title: 'ActorNames',
table: filmTable,
relatedTableName: 'actor',
relatedTableColumnTitle: 'FirstName',
});
const res = await request(context.app)
.get(`/api/v1/db/data/noco/${sakilaProject.id}/${filmTable.id}/groupby`)
.set('xc-auth', context.token)
.query({
column_name: 'ActorNames',
})
.expect(400);
assert.match(res.body.msg, /not supported/);
});
it('Check One GroupBy Column with Formula and Formula referring another formula', async function () {
const formulaColumnTitle = 'Formula';
await createColumn(context, filmTable, {
uidt: UITypes.Formula,
title: formulaColumnTitle,
formula: `ADD({RentalDuration}, 10)`,
});
const res = await request(context.app)
.get(`/api/v1/db/data/noco/${sakilaProject.id}/${filmTable.id}/groupby`)
.set('xc-auth', context.token)
.query({
column_name: formulaColumnTitle,
sort: `-${formulaColumnTitle}`,
})
.expect(200);
expect(res.body.list[0][formulaColumnTitle]).to.be.gte(
res.body.list[0][formulaColumnTitle],
);
expect(+res.body.list[0].count).to.gte(1);
// generate a formula column which refers to another formula column
const nestedFormulaColumnTitle = 'FormulaNested';
await createColumn(context, filmTable, {
uidt: UITypes.Formula,
title: nestedFormulaColumnTitle,
formula: `ADD(1000,{${formulaColumnTitle}})`,
});
const res1 = await request(context.app)
.get(`/api/v1/db/data/noco/${sakilaProject.id}/${filmTable.id}/groupby`)
.set('xc-auth', context.token)
.query({
column_name: nestedFormulaColumnTitle,
sort: `-${nestedFormulaColumnTitle}`,
})
.expect(200);
expect(res1.body.list[0][nestedFormulaColumnTitle]).to.be.gte(
res1.body.list[0][nestedFormulaColumnTitle],
);
expect(res1.body.list[0][nestedFormulaColumnTitle]).to.be.gte(1000);
expect(+res1.body.list[0][nestedFormulaColumnTitle]).to.equal(
1000 + +res.body.list[0][formulaColumnTitle],
);
expect(+res1.body.list[res1.body.list.length - 1].count).to.gte(0);
});
}
export default function () {

206
packages/nocodb/tests/unit/rest/tests/newDataApis.test.ts

@ -151,6 +151,7 @@ async function ncAxiosGet({
expect(response.status).to.equal(status);
return response;
}
async function ncAxiosPost({
url = `/api/v1/tables/${table.id}/rows`,
body = {},
@ -163,6 +164,7 @@ async function ncAxiosPost({
expect(response.status).to.equal(status);
return response;
}
async function ncAxiosPatch({
url = `/api/v1/tables/${table.id}/rows`,
body = {},
@ -175,6 +177,7 @@ async function ncAxiosPatch({
expect(response.status).to.equal(status);
return response;
}
async function ncAxiosDelete({
url = `/api/v1/tables/${table.id}/rows`,
body = {},
@ -215,6 +218,7 @@ async function ncAxiosLinkGet({
return response;
}
async function ncAxiosLinkAdd({
urlParams: { tableId, linkId, rowId },
body = {},
@ -241,6 +245,7 @@ async function ncAxiosLinkAdd({
return response;
}
async function ncAxiosLinkRemove({
urlParams: { tableId, linkId, rowId },
body = {},
@ -300,9 +305,8 @@ function generalDb() {
cityColumns = await cityTable.getColumns();
});
it('Nested List - Link to another record', async function () {
it.only('Nested List - Link to another record', async function () {
const expectedRecords = [1, 3, 1, 2];
const expectedRecordsPg = ['1', '3', '1', '2'];
// read first 4 records
const records = await ncAxiosGet({
@ -315,11 +319,10 @@ function generalDb() {
// extract LTAR column "City List"
const cityList = records.body.list.map((r) => r['Cities']);
if (isPg(context)) expect(cityList).to.deep.equal(expectedRecordsPg);
else expect(cityList).to.deep.equal(expectedRecords);
expect(cityList).to.deep.equal(expectedRecords);
});
it('Nested List - Lookup', async function () {
it.only('Nested List - Lookup', async function () {
const lookupColumn = await createLookupColumn(context, {
project: sakilaProject,
title: 'Lookup',
@ -349,7 +352,7 @@ function generalDb() {
expect(lookupData).to.deep.equal(expectedRecords);
});
it('Nested List - Rollup', async function () {
it.only('Nested List - Rollup', async function () {
const rollupColumn = await createRollupColumn(context, {
project: sakilaProject,
title: 'Rollup',
@ -360,7 +363,6 @@ function generalDb() {
});
const expectedRecords = [1, 3, 1, 2];
const expectedRecordsPg = ['1', '3', '1', '2'];
// read first 4 records
const records = await ncAxiosGet({
@ -373,14 +375,11 @@ function generalDb() {
// extract Lookup column
const rollupData = records.body.list.map((record) => record.Rollup);
if (isPg(context)) {
expect(rollupData).to.deep.equal(expectedRecordsPg);
} else {
expect(rollupData).to.deep.equal(expectedRecords);
}
expect(rollupData).to.deep.equal(expectedRecords);
});
it('Nested Read - Link to another record', async function () {
it.only('Nested Read - Link to another record', async function () {
const records = await ncAxiosGet({
url: `/api/v1/tables/${countryTable.id}/rows/1`,
});
@ -389,7 +388,7 @@ function generalDb() {
expect(+records.body['Cities']).to.equal(1);
});
it('Nested Read - Lookup', async function () {
it.only('Nested Read - Lookup', async function () {
const lookupColumn = await createLookupColumn(context, {
project: sakilaProject,
title: 'Lookup',
@ -404,7 +403,7 @@ function generalDb() {
expect(records.body.Lookup).to.deep.equal(['Kabul']);
});
it('Nested Read - Rollup', async function () {
it.only('Nested Read - Rollup', async function () {
const rollupColumn = await createRollupColumn(context, {
project: sakilaProject,
title: 'Rollup',
@ -417,11 +416,8 @@ function generalDb() {
const records = await ncAxiosGet({
url: `/api/v1/tables/${countryTable.id}/rows/1`,
});
if (isPg(context)) {
expect(records.body.Rollup).to.equal('1');
} else {
expect(records.body.Rollup).to.equal(1);
}
expect(records.body.Rollup).to.equal(1);
});
}
@ -474,7 +470,7 @@ function textBased() {
/////////////////////////////////////////////////////////////////////////////
it('List: default', async function () {
it.only('List: default', async function () {
const rsp = await ncAxiosGet();
const expectedPageInfo = {
@ -496,7 +492,7 @@ function textBased() {
);
});
it('List: offset, limit', async function () {
it.only('List: offset, limit', async function () {
const rsp = await ncAxiosGet({ query: { offset: 200, limit: 100 } });
const expectedPageInfo = {
@ -509,7 +505,7 @@ function textBased() {
expect(rsp.body.pageInfo).to.deep.equal(expectedPageInfo);
});
it('List: fields, single', async function () {
it.only('List: fields, single', async function () {
const rsp = await ncAxiosGet({
query: { fields: 'SingleLineText' },
});
@ -519,7 +515,7 @@ function textBased() {
).to.equal(true);
});
it('List: fields, multiple', async function () {
it.only('List: fields, multiple', async function () {
const rsp = await ncAxiosGet({
query: { fields: ['SingleLineText', 'MultiLineText'] },
});
@ -532,7 +528,7 @@ function textBased() {
).to.equal(true);
});
it('List: sort, ascending', async function () {
it.only('List: sort, ascending', async function () {
const sortColumn = columns.find((c) => c.title === 'SingleLineText');
const rsp = await ncAxiosGet({
query: { sort: 'SingleLineText', limit: 400 },
@ -543,7 +539,7 @@ function textBased() {
expect(sortedArray).to.deep.equal(sortedArray.sort());
});
it('List: sort, descending', async function () {
it.only('List: sort, descending', async function () {
const sortColumn = columns.find((c) => c.title === 'SingleLineText');
const rsp = await ncAxiosGet({
query: { sort: '-SingleLineText', limit: 400 },
@ -554,7 +550,7 @@ function textBased() {
expect(descSortedArray).to.deep.equal(descSortedArray.sort().reverse());
});
it('List: sort, multiple', async function () {
it.only('List: sort, multiple', async function () {
const rsp = await ncAxiosGet({
query: {
sort: ['-SingleLineText', '-MultiLineText'],
@ -570,7 +566,7 @@ function textBased() {
expect(sortedArray).to.deep.equal(sortedArray.sort().reverse());
});
it('List: filter, single', async function () {
it.only('List: filter, single', async function () {
const rsp = await ncAxiosGet({
query: {
where: '(SingleLineText,eq,Afghanistan)',
@ -583,7 +579,7 @@ function textBased() {
expect(filteredArray).to.deep.equal(filteredArray.fill('Afghanistan'));
});
it('List: filter, multiple', async function () {
it.only('List: filter, multiple', async function () {
const rsp = await ncAxiosGet({
query: {
where:
@ -601,7 +597,7 @@ function textBased() {
);
});
it('List: view ID', async function () {
it.only('List: view ID', async function () {
const gridView = await createView(context, {
title: 'grid0',
table,
@ -735,7 +731,7 @@ function textBased() {
return gridView;
}
it('List: view ID + sort', async function () {
it.only('List: view ID + sort', async function () {
const gridView = await prepareViewForTests();
const rsp = await ncAxiosGet({
@ -754,7 +750,7 @@ function textBased() {
expect(sortedArray).to.deep.equal(sortedArray.sort());
});
it('List: view ID + filter', async function () {
it.only('List: view ID + filter', async function () {
const gridView = await prepareViewForTests();
const rsp = await ncAxiosGet({
@ -773,7 +769,7 @@ function textBased() {
expect(filteredArray).to.deep.equal(filteredArray.fill('1-541-754-3010'));
});
it('List: view ID + fields', async function () {
it.only('List: view ID + fields', async function () {
const gridView = await prepareViewForTests();
const rsp = await ncAxiosGet({
@ -793,7 +789,7 @@ function textBased() {
});
// Error handling
it('List: invalid ID', async function () {
it.only('List: invalid ID', async function () {
// Invalid table ID
await ncAxiosGet({
url: `/api/v1/tables/123456789/rows`,
@ -809,7 +805,7 @@ function textBased() {
});
});
it('List: invalid limit & offset', async function () {
it.only('List: invalid limit & offset', async function () {
const expectedPageInfo = {
totalRows: 400,
page: 1,
@ -862,7 +858,7 @@ function textBased() {
expect(rsp.body.list.length).to.equal(0);
});
it('List: invalid sort, filter, fields', async function () {
it.only('List: invalid sort, filter, fields', async function () {
// expect to ignore invalid sort, filter, fields
await ncAxiosGet({
@ -896,13 +892,13 @@ function textBased() {
Phone: '1-234-567-8910',
};
it('Create: all fields', async function () {
it.only('Create: all fields', async function () {
const rsp = await ncAxiosPost({ body: newRecord });
expect(rsp.body).to.deep.equal({ Id: 401 });
});
it('Create: few fields left out', async function () {
it.only('Create: few fields left out', async function () {
const newRecord = {
SingleLineText: 'abc',
MultiLineText: 'abc abc \n abc \r abc \t abc 1234!@#$%^&*()_+',
@ -913,14 +909,14 @@ function textBased() {
expect(rsp.body).to.deep.equal({ Id: 401 });
});
it('Create: bulk', async function () {
it.only('Create: bulk', async function () {
const rsp = await ncAxiosPost({ body: [newRecord, newRecord, newRecord] });
expect(rsp.body).to.deep.equal([{ Id: 401 }, { Id: 402 }, { Id: 403 }]);
});
// Error handling
it('Create: invalid ID', async function () {
it.only('Create: invalid ID', async function () {
// Invalid table ID
await ncAxiosPost({
url: `/api/v1/tables/123456789/rows`,
@ -948,13 +944,13 @@ function textBased() {
/////////////////////////////////////////////////////////////////////////////
it('Read: all fields', async function () {
it.only('Read: all fields', async function () {
const rsp = await ncAxiosGet({
url: `/api/v1/tables/${table.id}/rows/100`,
});
});
it('Read: invalid ID', async function () {
it.only('Read: invalid ID', async function () {
// Invalid table ID
await ncAxiosGet({
url: `/api/v1/tables/123456789/rows/100`,
@ -974,7 +970,7 @@ function textBased() {
/////////////////////////////////////////////////////////////////////////////
it('Update: all fields', async function () {
it.only('Update: all fields', async function () {
const rsp = await ncAxiosPatch({
body: [
{
@ -986,7 +982,7 @@ function textBased() {
expect(rsp.body).to.deep.equal([{ Id: 1 }]);
});
it('Update: partial', async function () {
it.only('Update: partial', async function () {
const recordBeforeUpdate = await ncAxiosGet({
url: `/api/v1/tables/${table.id}/rows/1`,
});
@ -1012,7 +1008,7 @@ function textBased() {
});
});
it('Update: bulk', async function () {
it.only('Update: bulk', async function () {
const rsp = await ncAxiosPatch({
body: [
{
@ -1032,7 +1028,7 @@ function textBased() {
// Error handling
it('Update: invalid ID', async function () {
it.only('Update: invalid ID', async function () {
// Invalid table ID
await ncAxiosPatch({
url: `/api/v1/tables/123456789/rows`,
@ -1053,7 +1049,7 @@ function textBased() {
/////////////////////////////////////////////////////////////////////////////
it('Delete: single', async function () {
it.only('Delete: single', async function () {
const rsp = await ncAxiosDelete({ body: [{ Id: 1 }] });
expect(rsp.body).to.deep.equal([{ Id: 1 }]);
@ -1064,7 +1060,7 @@ function textBased() {
});
});
it('Delete: bulk', async function () {
it.only('Delete: bulk', async function () {
const rsp = await ncAxiosDelete({ body: [{ Id: 1 }, { Id: 2 }] });
expect(rsp.body).to.deep.equal([{ Id: 1 }, { Id: 2 }]);
@ -1081,7 +1077,7 @@ function textBased() {
// Error handling
it('Delete: invalid ID', async function () {
it.only('Delete: invalid ID', async function () {
// Invalid table ID
await ncAxiosDelete({
url: `/api/v1/tables/123456789/rows`,
@ -1231,97 +1227,97 @@ function numberBased() {
const recordsPg = [
{
Id: 1,
Number: '33',
Decimal: '33.3',
Currency: '33.3',
Number: 33,
Decimal: 33.3,
Currency: 33.3,
Percent: 33,
Duration: '10',
Duration: 10,
Rating: 0,
},
{
Id: 2,
Number: null,
Decimal: '456.34',
Currency: '456.34',
Decimal: 456.34,
Currency: 456.34,
Percent: null,
Duration: '20',
Duration: 20,
Rating: 1,
},
{
Id: 3,
Number: '456',
Decimal: '333.3',
Currency: '333.3',
Number: 456,
Decimal: 333.3,
Currency: 333.3,
Percent: 456,
Duration: '30',
Duration: 30,
Rating: 2,
},
{
Id: 4,
Number: '333',
Number: 333,
Decimal: null,
Currency: null,
Percent: 333,
Duration: '40',
Duration: 40,
Rating: 3,
},
{
Id: 5,
Number: '267',
Decimal: '267.5674',
Currency: '267.5674',
Number: 267,
Decimal: 267.5674,
Currency: 267.5674,
Percent: 267,
Duration: '50',
Duration: 50,
Rating: null,
},
{
Id: 6,
Number: '34',
Decimal: '34',
Currency: '34',
Number: 34,
Decimal: 34,
Currency: 34,
Percent: 34,
Duration: '60',
Duration: 60,
Rating: 0,
},
{
Id: 7,
Number: '8754',
Decimal: '8754',
Currency: '8754',
Number: 8754,
Decimal: 8754,
Currency: 8754,
Percent: 8754,
Duration: null,
Rating: 4,
},
{
Id: 8,
Number: '3234',
Decimal: '3234.547',
Currency: '3234.547',
Number: 3234,
Decimal: 3234.547,
Currency: 3234.547,
Percent: 3234,
Duration: '70',
Duration: 70,
Rating: 5,
},
{
Id: 9,
Number: '44',
Decimal: '44.2647',
Currency: '44.2647',
Number: 44,
Decimal: 44.2647,
Currency: 44.2647,
Percent: 44,
Duration: '80',
Duration: 80,
Rating: 0,
},
{
Id: 10,
Number: '33',
Decimal: '33.98',
Currency: '33.98',
Number: 33,
Decimal: 33.98,
Currency: 33.98,
Percent: 33,
Duration: '90',
Duration: 90,
Rating: 1,
},
];
it('Number based- List & CRUD', async function () {
it.only('Number based- List & CRUD', async function () {
// list 10 records
let rsp = await ncAxiosGet({
query: {
@ -1382,11 +1378,11 @@ function numberBased() {
Rating: 5,
};
const updatedRecordPg = {
Number: '55',
Decimal: '55.5',
Currency: '55.5',
Number: 55,
Decimal: 55.5,
Currency: 55.5,
Percent: 55,
Duration: '55',
Duration: 55,
Rating: 5,
};
@ -1550,7 +1546,7 @@ function selectBased() {
},
];
it('Select based- List & CRUD', async function () {
it.only('Select based- List & CRUD', async function () {
// list 10 records
let rsp = await ncAxiosGet({
query: {
@ -1683,7 +1679,7 @@ function dateBased() {
expect(insertedRecords.length).to.equal(800);
});
it('Date based- List & CRUD', async function () {
it.only('Date based- List & CRUD', async function () {
// list 10 records
let rsp = await ncAxiosGet({
query: {
@ -1913,7 +1909,7 @@ function linkBased() {
return columns.find((c) => c.title === title).id;
}
it('Has-Many ', async function () {
it.only('Has-Many ', async function () {
// Create hm link between Country and City
await ncAxiosLinkAdd({
urlParams: {
@ -2074,7 +2070,7 @@ function linkBased() {
return Array.from({ length: count }, (_, index) => i + index);
}
it('Create Many-Many ', async function () {
it.only('Create Many-Many ', async function () {
await ncAxiosLinkAdd({
urlParams: {
tableId: tblActor.id,
@ -2248,7 +2244,7 @@ function linkBased() {
// Other scenarios
// Has-many : change an existing link to a new one
it('HM: Change an existing link to a new one', async function () {
it.only('HM: Change an existing link to a new one', async function () {
// add a link
await ncAxiosLinkAdd({
urlParams: {
@ -2295,7 +2291,7 @@ function linkBased() {
});
// limit & offset verification
it('Limit & offset verification', async function () {
it.only('Limit & offset verification', async function () {
// add a link
await ncAxiosLinkAdd({
urlParams: {
@ -2603,7 +2599,7 @@ function linkBased() {
}
// Error handling (has-many)
it('Error handling : HM: Nested ADD', async function () {
it.only('Error handling : HM: Nested ADD', async function () {
const validParams = {
urlParams: {
tableId: tblCountry.id,
@ -2617,7 +2613,7 @@ function linkBased() {
await nestedAddTests(validParams);
});
it('Error handling : HM: Nested REMOVE', async function () {
it.only('Error handling : HM: Nested REMOVE', async function () {
// Prepare data
await ncAxiosLinkAdd({
urlParams: {
@ -2642,7 +2638,7 @@ function linkBased() {
await nestedRemoveTests(validParams);
});
it('Error handling : HM: Nested List', async function () {
it.only('Error handling : HM: Nested List', async function () {
// Prepare data
await ncAxiosLinkAdd({
urlParams: {
@ -2671,7 +2667,7 @@ function linkBased() {
});
// Error handling (belongs to)
it('Error handling : BT: Nested ADD', async function () {
it.only('Error handling : BT: Nested ADD', async function () {
const validParams = {
urlParams: {
tableId: tblCity.id,
@ -2685,7 +2681,7 @@ function linkBased() {
await nestedAddTests(validParams, 'bt');
});
it('Error handling : BT: Nested REMOVE', async function () {
it.only('Error handling : BT: Nested REMOVE', async function () {
// Prepare data
await ncAxiosLinkAdd({
urlParams: {
@ -2710,7 +2706,7 @@ function linkBased() {
await nestedRemoveTests(validParams, 'bt');
});
it('Error handling : BT: Nested List', async function () {
it.only('Error handling : BT: Nested List', async function () {
// Prepare data
await ncAxiosLinkAdd({
urlParams: {
@ -2739,7 +2735,7 @@ function linkBased() {
});
// Error handling (many-many)
it('Error handling : MM: Nested ADD', async function () {
it.only('Error handling : MM: Nested ADD', async function () {
const validParams = {
urlParams: {
tableId: tblActor.id,
@ -2753,7 +2749,7 @@ function linkBased() {
await nestedAddTests(validParams);
});
it('Error handling : MM: Nested REMOVE', async function () {
it.only('Error handling : MM: Nested REMOVE', async function () {
// Prepare data
await ncAxiosLinkAdd({
urlParams: {
@ -2778,7 +2774,7 @@ function linkBased() {
await nestedRemoveTests(validParams);
});
it('Error handling : MM: Nested List', async function () {
it.only('Error handling : MM: Nested List', async function () {
// Prepare data
await ncAxiosLinkAdd({
urlParams: {

22
packages/nocodb/tests/unit/rest/tests/tableRow.test.ts

@ -301,11 +301,7 @@ function tableStaticTest() {
.expect(200);
const record = response.body;
if (isPg(context)) {
expect(record['Films']).to.equal('19');
} else {
expect(record['Films']).to.equal(19);
}
expect(record['Films']).to.equal(19);
});
it('Exist should be true table row when it exists', async function () {
const row = await getOneRow(context, {
@ -1489,7 +1485,7 @@ function tableTest() {
});
const visibleColumns = [firstNameColumn];
const sortInfo = `-FirstName, +${rollupColumn.title}`;
const sortInfo = `-FirstName`;
const response = await request(context.app)
.get(
@ -1613,7 +1609,7 @@ function tableTest() {
});
const visibleColumns = [firstNameColumn];
const sortInfo = `-FirstName, +${rollupColumn.title}`;
const sortInfo = `-FirstName`;
const response = await request(context.app)
.get(
@ -1623,18 +1619,18 @@ function tableTest() {
.query({
fields: visibleColumns.map((c) => c.title),
sort: sortInfo,
column_name: firstNameColumn.column_name,
column_name: firstNameColumn.title,
})
.expect(200);
if (
response.body.list[4]['first_name'] !== 'WILLIE' ||
response.body.list[4][firstNameColumn.title] !== 'WILLIE' ||
parseInt(response.body.list[4]['count']) !== 2
)
throw new Error('Wrong groupby');
});
it('Groupby desc sorted and with rollup table data list with required columns', async function () {
it('Groupby desc sorted and with rollup tabl e data list with required columns', async function () {
const firstNameColumn = customerColumns.find(
(col) => col.title === 'FirstName',
);
@ -1649,7 +1645,7 @@ function tableTest() {
});
const visibleColumns = [firstNameColumn];
const sortInfo = `-FirstName, +${rollupColumn.title}`;
const sortInfo = `-FirstName`;
const response = await request(context.app)
.get(
@ -1659,13 +1655,13 @@ function tableTest() {
.query({
fields: visibleColumns.map((c) => c.title),
sort: sortInfo,
column_name: firstNameColumn.column_name,
column_name: firstNameColumn.title,
offset: 4,
})
.expect(200);
if (
response.body.list[0]['first_name'] !== 'WILLIE' ||
response.body.list[0][firstNameColumn.title] !== 'WILLIE' ||
parseInt(response.body.list[0]['count']) !== 2
)
throw new Error('Wrong groupby');

6
packages/nocodb/tests/unit/rest/tests/viewRow.test.ts

@ -940,7 +940,7 @@ function viewRowTests() {
.expect(200);
if (
response.body.list[4]['first_name'] !== 'WILLIE' ||
response.body.list[4]['FirstName'] !== 'WILLIE' ||
parseInt(response.body.list[4]['count']) !== 2
)
throw new Error('Wrong groupby');
@ -986,13 +986,13 @@ function viewRowTests() {
.query({
fields: visibleColumns.map((c) => c.title),
sort: sortInfo,
column_name: firstNameColumn.column_name,
column_name: firstNameColumn.title,
offset: 4,
})
.expect(200);
if (
response.body.list[0]['first_name'] !== 'WILLIE' ||
response.body.list[0]['FirstName'] !== 'WILLIE' ||
parseInt(response.body.list[0]['count']) !== 2
)
throw new Error('Wrong groupby');

35
scripts/self-hosted-gh-runner/node-pnpm-check.sh

@ -0,0 +1,35 @@
#!/bin/bash
# this script is intended to run in ci/cd job
# it checks if node and pnpm is installed
# and sets github env variable to skip installation
# this is suitable for self-hosted runners with
# docker image created from /Users/rajanishgj/Documents/GitHub/nocohub/tests/docker/Dockerfile
#
NC_REQ_NODE_V="18.17.1"
NC_REQ_PNPM_V="8.8.0"
NODE_PATH="/home/docker/actions-runner/_work/_tool/node/${NC_REQ_NODE_V}/x64/bin/node"
PNPM_PATH="/root/setup-pnpm/node_modules/.bin/pnpm"
NC_NODE_V=$($NODE_PATH -v || echo "error")
NC_PNPM_V=$($PNPM_PATH -v || echo "error")
if [[ $NC_NODE_V == *$NC_REQ_NODE_V* ]]; then
PATH=$PATH:$(dirname $NODE_PATH)
SETUP_NODE=false
fi
if [[ $NC_PNPM_V == $NC_REQ_PNPM_V ]]; then
PATH=$PATH:$(dirname $PNPM_PATH)
SETUP_PNPM=false
fi
echo "SETUP_NODE=${SETUP_NODE:-true}" >> $GITHUB_ENV
echo "SETUP_PNPM=${SETUP_PNPM:-true}" >> $GITHUB_ENV
echo "NC_REQ_NODE_V=${NC_REQ_NODE_V}" >> $GITHUB_ENV
echo "NC_REQ_PNPM_V=${NC_REQ_PNPM_V}" >> $GITHUB_ENV
echo "NC_NODE_V=${NC_NODE_V}" >> $GITHUB_ENV
echo "NC_PNPM_V=${NC_PNPM_V}" >> $GITHUB_ENV
echo "PATH=${PATH}" >> $GITHUB_ENV
echo "completed check node and pnpm installation"

9
tests/playwright/pages/Dashboard/Form/index.ts

@ -244,15 +244,6 @@ export class FormPage extends BasePage {
async verifyStatePostSubmit(param: { message?: string; submitAnotherForm?: boolean; showBlankForm?: boolean }) {
if (undefined !== param.message) {
let retryCounter = 0;
while (retryCounter <= 5) {
const msg = await this.getFormAfterSubmit().innerText();
if (msg.includes('Form submitted successfully')) {
break;
}
await this.rootPage.waitForTimeout(100 * retryCounter);
retryCounter++;
}
await expect(this.getFormAfterSubmit()).toContainText(param.message);
}
if (true === param.submitAnotherForm) {

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

@ -100,9 +100,13 @@ export class KanbanPage extends BasePage {
async verifyCardOrder(param: { order: string[]; stackIndex: number }) {
const { order, stackIndex } = param;
const stack = this.get().locator(`.nc-kanban-stack`).nth(stackIndex);
for (let i = 0; i < order.length; i++) {
const card = stack.locator(`.nc-kanban-item`).nth(i);
await (await card.elementHandle())?.waitForElementState('stable');
await card.scrollIntoViewIfNeeded();
const cardTitle = card.locator(`.nc-cell`);
await expect(cardTitle).toHaveText(order[i]);

19
tests/playwright/pages/Dashboard/common/LeftSidebar/index.ts

@ -1,4 +1,4 @@
import { Locator } from '@playwright/test';
import { expect, Locator } from '@playwright/test';
import { DashboardPage } from '../../index';
import BasePage from '../../../Base';
import { getTextExcludeIconText } from '../../../../tests/utils/general';
@ -20,7 +20,7 @@ export class LeftSidebarPage extends BasePage {
super(dashboard.rootPage);
this.dashboard = dashboard;
this.btn_workspace = this.get().locator('.nc-sidebar-header');
this.btn_workspace = this.get().locator('.nc-workspace-menu');
this.btn_newProject = this.get().locator('[data-testid="nc-sidebar-create-project-btn"]');
this.btn_cmdK = this.get().locator('[data-testid="nc-sidebar-search-btn"]');
this.btn_teamAndSettings = this.get().locator('[data-testid="nc-sidebar-team-settings-btn"]');
@ -57,6 +57,10 @@ export class LeftSidebarPage extends BasePage {
return await this.btn_workspace.getAttribute('data-workspace-title');
}
async verifyWorkspaceName({ title }: { title: string }) {
await expect(this.btn_workspace.locator('.nc-workspace-title')).toHaveText(title);
}
async createWorkspace({ title }: { title: string }) {
await this.clickWorkspace();
await this.modal_workspace.locator('.ant-dropdown-menu-item:has-text("Create New Workspace")').waitFor();
@ -69,6 +73,13 @@ export class LeftSidebarPage extends BasePage {
await inputModal.locator('button.ant-btn-primary').click();
}
async verifyWorkspaceCount({ count }: { count: number }) {
await this.clickWorkspace();
// TODO: THere is one extra html attribute
await expect(this.rootPage.getByTestId('nc-workspace-list')).toHaveCount(count + 1);
}
async getWorkspaceList() {
const ws = [];
await this.clickWorkspace();
@ -88,6 +99,8 @@ export class LeftSidebarPage extends BasePage {
await this.clickWorkspace();
const nodes = this.modal_workspace.locator('[data-testid="nc-workspace-list"]');
await this.rootPage.waitForTimeout(2000);
for (let i = 0; i < (await nodes.count()); i++) {
const text = await getTextExcludeIconText(nodes.nth(i));
if (text.toLowerCase() === param.title.toLowerCase()) {
@ -96,5 +109,7 @@ export class LeftSidebarPage extends BasePage {
}
}
await this.rootPage.keyboard.press('Escape');
await this.rootPage.waitForTimeout(3500);
}
}

10
tests/playwright/pages/Dashboard/common/WorkspaceMenu/index.ts

@ -17,6 +17,16 @@ export class WorkspaceMenuObject extends BasePage {
await this.rootPage.locator('[data-testid="nc-workspace-menu"]').click();
}
async switchWorkspace({ workspaceTitle }: { workspaceTitle: string }) {
await this.toggle();
await this.rootPage.waitForTimeout(2500);
await this.rootPage.getByTestId('nc-workspace-list').getByText(workspaceTitle).click({
force: true,
});
await this.rootPage.keyboard.press('Escape');
await this.rootPage.waitForTimeout(4000);
}
async click({ menu, subMenu }: { menu: string; subMenu: string }) {
const pMenu = this.rootPage.locator(`.nc-dropdown-workspace-menu:visible`);
await pMenu.locator(`div.nc-workspace-menu-item:has-text("${menu}"):visible`).click();

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

@ -189,8 +189,11 @@ export class DashboardPage extends BasePage {
async signOut() {
await this.sidebar.userMenu.click();
await this.rootPage.waitForTimeout(1000);
await this.rootPage.getByTestId('nc-sidebar-user-logout').waitFor({ state: 'visible' });
await this.sidebar.userMenu.clickLogout();
await this.rootPage.waitForTimeout(1000);
await this.rootPage.locator('[data-testid="nc-form-signin"]:visible').waitFor();
await new Promise(resolve => setTimeout(resolve, 150));

35
tests/playwright/scripts/self-hosted-gh-runner/node-pnpm-check.sh

@ -0,0 +1,35 @@
#!/bin/bash
# this script is intended to run in ci/cd job
# it checks if node and pnpm is installed
# and sets github env variable to skip installation
# this is suitable for self-hosted runners with
# docker image created from /Users/rajanishgj/Documents/GitHub/nocohub/tests/docker/Dockerfile
#
NC_REQ_NODE_V="18.17.1"
NC_REQ_PNPM_V="8.8.0"
NODE_PATH="/home/docker/actions-runner/_work/_tool/node/${NC_REQ_NODE_V}/x64/bin/node"
PNPM_PATH="/root/setup-pnpm/node_modules/.bin/pnpm"
NC_NODE_V=$($NODE_PATH -v || echo "error")
NC_PNPM_V=$($PNPM_PATH -v || echo "error")
if [[ $NC_NODE_V == *$NC_REQ_NODE_V* ]]; then
PATH=$PATH:$(dirname $NODE_PATH)
SETUP_NODE=false
fi
if [[ $NC_PNPM_V == $NC_REQ_PNPM_V ]]; then
PATH=$PATH:$(dirname $PNPM_PATH)
SETUP_PNPM=false
fi
echo "SETUP_NODE=${SETUP_NODE:-true}" >> $GITHUB_ENV
echo "SETUP_PNPM=${SETUP_PNPM:-true}" >> $GITHUB_ENV
echo "NC_REQ_NODE_V=${NC_REQ_NODE_V}" >> $GITHUB_ENV
echo "NC_REQ_PNPM_V=${NC_REQ_PNPM_V}" >> $GITHUB_ENV
echo "NC_NODE_V=${NC_NODE_V}" >> $GITHUB_ENV
echo "NC_PNPM_V=${NC_PNPM_V}" >> $GITHUB_ENV
echo "PATH=${PATH}" >> $GITHUB_ENV
echo "completed check node and pnpm installation"

4
tests/playwright/startPlayWrightServer.sh

@ -2,10 +2,10 @@
if ! curl --output /dev/null --silent --head --fail http://localhost:31000
then
echo "Starting PlayWright Server"
PWDEBUG=console pnpm dlx playwright run-server --port 31000 &
PWDEBUG=console pnpm exec playwright run-server --port 31000 &
# Wait for server to start
while ! curl --output /dev/null --silent --head --fail http://localhost:31000; do
sleep 0.2
done
fi
fi

8
tests/playwright/tests/db/columns/columnAttachments.spec.ts

@ -31,6 +31,9 @@ test.describe('Attachment column', () => {
columnHeader: 'testAttach',
filePath: filepath,
});
await dashboard.rootPage.waitForTimeout(500);
await dashboard.grid.cell.attachment.verifyFile({
index: i,
columnHeader: 'testAttach',
@ -41,6 +44,9 @@ test.describe('Attachment column', () => {
columnHeader: 'testAttach',
filePath: [`${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`],
});
await dashboard.rootPage.waitForTimeout(1000);
await dashboard.grid.cell.attachment.verifyFile({
index: 4,
columnHeader: 'testAttach',
@ -74,7 +80,7 @@ test.describe('Attachment column', () => {
filePath: [`${process.cwd()}/fixtures/sampleFiles/1.json`],
});
await sharedForm.rootPage.waitForTimeout(500);
await sharedForm.rootPage.waitForTimeout(1000);
await sharedForm.submit();
await sharedForm.verifySuccessMessage();
await newPage.close();

16
tests/playwright/tests/db/features/baseShare.spec.ts

@ -1,15 +1,16 @@
import { test } from '@playwright/test';
import { DashboardPage } from '../../../pages/Dashboard';
import setup, { unsetup } from '../../../setup';
import setup, { NcContext, unsetup } from '../../../setup';
import { ToolbarPage } from '../../../pages/Dashboard/common/Toolbar';
import { LoginPage } from '../../../pages/LoginPage';
import { getDefaultPwd } from '../../../tests/utils/general';
import { isEE } from '../../../setup/db';
// To be enabled after shared base is implemented
test.describe('Shared base', () => {
let dashboard: DashboardPage;
let toolbar: ToolbarPage;
let context: any;
let context: NcContext;
let loginPage: LoginPage;
async function roleTest(role: string) {
@ -83,8 +84,17 @@ test.describe('Shared base', () => {
withoutPrefix: true,
});
// await dashboard.treeView.openProject({ title: context.project.title });
await dashboard.rootPage.waitForTimeout(1000);
if (isEE()) {
await dashboard.grid.workspaceMenu.switchWorkspace({
workspaceTitle: context.workspace.title,
});
}
await dashboard.treeView.openProject({ title: context.project.title, context });
await dashboard.treeView.openTable({ title: 'Country' });
url = await dashboard.grid.topbar.getSharedBaseUrl({ role: 'viewer', enableSharedBase: false });
await dashboard.rootPage.waitForTimeout(2000);

12
tests/playwright/tests/db/features/verticalFillHandle.spec.ts

@ -99,12 +99,12 @@ test.describe('Fill Handle', () => {
test('Number based', async () => {
const fields = [
{ title: 'Number', value: '33', type: 'text' },
{ title: 'Decimal', value: '33.3', type: 'text' },
{ title: 'Currency', value: '33.30', type: 'text' },
{ title: 'Percent', value: '33', type: 'text' },
{ title: 'Number', value: 33, type: 'text' },
{ title: 'Decimal', value: 33.3, type: 'text' },
{ title: 'Currency', value: 33.3, type: 'text' },
{ title: 'Percent', value: 33, type: 'text' },
{ title: 'Duration', value: '00:01', type: 'text' },
{ title: 'Rating', value: '3', type: 'rating' },
{ title: 'Rating', value: 3, type: 'rating' },
{ title: 'Year', value: '2023', type: 'year' },
{ title: 'Time', value: '02:02', type: 'time' },
];
@ -212,7 +212,7 @@ test.describe('Fill Handle', () => {
await unsetup(p.context);
});
test('Miscellaneous (Checkbox, attachment) @flaky', async () => {
test('Miscellaneous (Checkbox, attachment)', async () => {
const fields = [
{ title: 'Checkbox', value: 'true', type: 'checkbox' },
{ title: 'Attachment', value: `${process.cwd()}/fixtures/sampleFiles/1.json`, type: 'attachment' },

16
tests/playwright/tests/db/features/webhook.spec.ts

@ -694,22 +694,22 @@ test.describe.serial('Webhook', () => {
{
Id: 1,
Country: 'India',
CountryCode: '1',
CityList: '2',
CityCodeRollup: '2',
CountryCode: 1,
CityList: 2,
CityCodeRollup: 2,
CityCodeFormula: 100,
CityCodeLookup: ['23', '33'],
CityCodeLookup: [23, 33],
},
],
rows: [
{
Id: 1,
Country: 'INDIA',
CountryCode: '1',
CityList: '2',
CityCodeRollup: '2',
CountryCode: 1,
CityList: 2,
CityCodeRollup: 2,
CityCodeFormula: 100,
CityCodeLookup: ['23', '33'],
CityCodeLookup: [23, 33],
},
],
},

19
tests/playwright/tests/db/general/groupCRUD.spec.ts

@ -30,7 +30,7 @@ test.describe('GroupBy CRUD Operations', () => {
let context: any;
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
context = await setup({ page, isEmptyProject: false });
dashboard = new DashboardPage(page, context.project);
toolbar = dashboard.grid.toolbar;
topbar = dashboard.grid.topbar;
@ -248,4 +248,21 @@ test.describe('GroupBy CRUD Operations', () => {
value: 'Zzzzzzzzzzzzzzzzzzz',
});
});
test('Single GroupBy CRUD Operations - Links', async ({ page }) => {
await dashboard.treeView.openTable({ title: 'Film' });
await toolbar.clickGroupBy();
await toolbar.groupBy.add({ title: 'Actors', ascending: false, locallySaved: false });
await dashboard.grid.groupPage.openGroup({ indexMap: [2] });
await dashboard.grid.groupPage.validateFirstRow({
indexMap: [2],
rowIndex: 0,
columnHeader: 'Title',
value: 'ARABIA DOGMA',
});
});
});

4
tests/playwright/tests/utils/general.ts

@ -37,10 +37,8 @@ function isSubset(obj, potentialSubset) {
if (!isSubset(objValue, potentialValue)) {
return false;
}
// skip strict type check since different database returns number values in string/number type
// todo: revert back to strict type check once we are consistent with type
// eslint-disable-next-line no-prototype-builtins
} else if (!obj.hasOwnProperty(prop) || objValue != potentialValue) {
} else if (!obj.hasOwnProperty(prop) || objValue !== potentialValue) {
return false;
}
}

Loading…
Cancel
Save