Browse Source

Merge pull request #4861 from nocodb/fix/misc

fix: Miscellaneous bugs
pull/4891/head
Raju Udava 2 years ago committed by GitHub
parent
commit
dffce6fe6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/nc-gui/components.d.ts
  2. 2
      packages/nc-gui/components/cell/Checkbox.vue
  3. 16
      packages/nc-gui/components/dashboard/settings/Modal.vue
  4. 5
      packages/nc-gui/components/smartsheet/Gallery.vue
  5. 8
      packages/nc-gui/components/smartsheet/Grid.vue
  6. 2
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  7. 33
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  8. 57
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue
  9. 4
      packages/nc-gui/composables/useApi/interceptors.ts
  10. 2
      packages/nc-gui/composables/useGlobal/actions.ts
  11. 12
      packages/nc-gui/composables/useSharedView.ts
  12. 51
      packages/nc-gui/composables/useViewData.ts
  13. 7
      packages/nc-gui/lang/en.json
  14. 8
      packages/nc-gui/pages/[projectType]/form/[viewId]/index/index.vue
  15. 38
      packages/nc-gui/utils/filterUtils.ts
  16. 25
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/conditionV2.ts
  17. 6
      packages/nocodb/src/lib/meta/helpers/webhookHelpers.ts
  18. 2
      packages/nocodb/src/lib/models/Filter.ts
  19. 1
      packages/nocodb/src/lib/utils/projectAcl.ts
  20. 8
      packages/nocodb/src/lib/version-upgrader/ncFilterUpgrader.ts
  21. 30
      tests/playwright/pages/Account/AppStore.ts
  22. 3
      tests/playwright/pages/Account/index.ts
  23. 4
      tests/playwright/pages/Dashboard/Settings/index.ts
  24. 5
      tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts
  25. 129
      tests/playwright/tests/columnCheckbox.spec.ts
  26. 35
      tests/playwright/tests/viewForm.spec.ts

1
packages/nc-gui/components.d.ts vendored

@ -138,6 +138,7 @@ declare module '@vue/runtime-core' {
MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default'] MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default']
MdiChevronRight: typeof import('~icons/mdi/chevron-right')['default'] MdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
MdiChevronUp: typeof import('~icons/mdi/chevron-up')['default']
MdiClose: typeof import('~icons/mdi/close')['default'] MdiClose: typeof import('~icons/mdi/close')['default']
MdiCloseBox: typeof import('~icons/mdi/close-box')['default'] MdiCloseBox: typeof import('~icons/mdi/close-box')['default']
MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default'] MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']

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

@ -65,7 +65,7 @@ useSelectedCellKeyupListener(active, (e) => {
<div <div
class="flex cursor-pointer" class="flex cursor-pointer"
:class="{ :class="{
'justify-center': !isForm && !readOnly, 'justify-center': !isForm,
'w-full': isForm, 'w-full': isForm,
'nc-cell-hover-show': !vModel && !readOnly, 'nc-cell-hover-show': !vModel && !readOnly,
'opacity-0': readOnly && !vModel, 'opacity-0': readOnly && !vModel,

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

@ -1,10 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FunctionalComponent, SVGAttributes } from 'vue' import type { FunctionalComponent, SVGAttributes } from 'vue'
import AppStore from './AppStore.vue'
import DataSources from './DataSources.vue' import DataSources from './DataSources.vue'
import Misc from './Misc.vue' import Misc from './Misc.vue'
import { DataSourcesSubTab, useI18n, useNuxtApp, useUIPermission, useVModel, watch } from '#imports' import { DataSourcesSubTab, useI18n, useNuxtApp, useUIPermission, useVModel, watch } from '#imports'
import StoreFrontOutline from '~icons/mdi/storefront-outline'
import TeamFillIcon from '~icons/ri/team-fill' import TeamFillIcon from '~icons/ri/team-fill'
import MultipleTableIcon from '~icons/mdi/table-multiple' import MultipleTableIcon from '~icons/mdi/table-multiple'
import NotebookOutline from '~icons/mdi/notebook-outline' import NotebookOutline from '~icons/mdi/notebook-outline'
@ -81,20 +79,6 @@ const tabsInfo: TabGroup = {
$e('c:settings:team-auth') $e('c:settings:team-auth')
}, },
}, },
appStore: {
// App Store
title: t('title.appStore'),
icon: StoreFrontOutline,
subTabs: {
new: {
title: 'Apps',
body: AppStore,
},
},
onClick: () => {
$e('c:settings:appstore')
},
},
dataSources: { dataSources: {
// Data Sources // Data Sources
title: 'Data Sources', title: 'Data Sources',

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

@ -8,6 +8,7 @@ import {
IsGalleryInj, IsGalleryInj,
IsGridInj, IsGridInj,
MetaInj, MetaInj,
NavigateDir,
OpenNewRecordFormHookInj, OpenNewRecordFormHookInj,
PaginationDataInj, PaginationDataInj,
ReloadRowDataHookInj, ReloadRowDataHookInj,
@ -48,6 +49,7 @@ const {
galleryData, galleryData,
changePage, changePage,
addEmptyRow, addEmptyRow,
navigateToSiblingRow,
} = useViewData(meta, view) } = useViewData(meta, view)
provide(IsFormInj, ref(false)) provide(IsFormInj, ref(false))
@ -270,6 +272,9 @@ watch(view, async (nextView) => {
:meta="meta" :meta="meta"
:row-id="route.query.rowId" :row-id="route.query.rowId"
:view="view" :view="view"
show-next-prev-icons
@next="navigateToSiblingRow(NavigateDir.NEXT)"
@prev="navigateToSiblingRow(NavigateDir.PREV)"
/> />
</Suspense> </Suspense>
</div> </div>

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

@ -117,6 +117,7 @@ const {
deleteSelectedRows, deleteSelectedRows,
selectedAllRecords, selectedAllRecords,
removeRowIfNew, removeRowIfNew,
navigateToSiblingRow,
} = useViewData(meta, view, xWhere) } = useViewData(meta, view, xWhere)
const { getMeta } = useMetas() const { getMeta } = useMetas()
@ -201,7 +202,7 @@ const { isCellSelected, activeCell, handleMouseDown, handleMouseOver, handleCell
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
const altOrOptionKey = e.altKey const altOrOptionKey = e.altKey
if (e.key === ' ') { if (e.key === ' ') {
if (activeCell.row != null && !editEnabled) { if (activeCell.row != null && !editEnabled && hasEditPermission) {
e.preventDefault() e.preventDefault()
clearSelectedRange() clearSelectedRange()
const row = data.value[activeCell.row] const row = data.value[activeCell.row]
@ -805,6 +806,7 @@ const closeAddColumnDropdown = () => {
:column="columnObj" :column="columnObj"
:active="activeCell.col === colIndex && activeCell.row === rowIndex" :active="activeCell.col === colIndex && activeCell.row === rowIndex"
:row="row" :row="row"
:read-only="readOnly"
@navigate="onNavigate" @navigate="onNavigate"
/> />
@ -817,6 +819,7 @@ const closeAddColumnDropdown = () => {
" "
:row-index="rowIndex" :row-index="rowIndex"
:active="activeCell.col === colIndex && activeCell.row === rowIndex" :active="activeCell.col === colIndex && activeCell.row === rowIndex"
:read-only="readOnly"
@update:edit-enabled="editEnabled = $event" @update:edit-enabled="editEnabled = $event"
@save="updateOrSaveRow(row, columnObj.title, state)" @save="updateOrSaveRow(row, columnObj.title, state)"
@navigate="onNavigate" @navigate="onNavigate"
@ -916,6 +919,9 @@ const closeAddColumnDropdown = () => {
:meta="meta" :meta="meta"
:row-id="routeQuery.rowId" :row-id="routeQuery.rowId"
:view="view" :view="view"
show-next-prev-icons
@next="navigateToSiblingRow(NavigateDir.NEXT)"
@prev="navigateToSiblingRow(NavigateDir.PREV)"
/> />
</Suspense> </Suspense>
</div> </div>

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

@ -155,5 +155,3 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
</a-dropdown-button> </a-dropdown-button>
</div> </div>
</template> </template>
<style scoped></style>

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

@ -32,11 +32,12 @@ interface Props {
useMetaFields?: boolean useMetaFields?: boolean
rowId?: string rowId?: string
view?: ViewType view?: ViewType
showNextPrevIcons?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue', 'cancel']) const emits = defineEmits(['update:modelValue', 'cancel', 'next', 'prev'])
const row = ref(props.row) const row = ref(props.row)
@ -141,7 +142,7 @@ export default {
<a-drawer <a-drawer
v-model:visible="isExpanded" v-model:visible="isExpanded"
:footer="null" :footer="null"
width="min(90vw,1000px)" width="min(90vw,800px)"
:body-style="{ 'padding': 0, 'display': 'flex', 'flex-direction': 'column' }" :body-style="{ 'padding': 0, 'display': 'flex', 'flex-direction': 'column' }"
:closable="false" :closable="false"
class="nc-drawer-expanded-form" class="nc-drawer-expanded-form"
@ -149,7 +150,22 @@ export default {
> >
<SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" /> <SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" />
<div class="!bg-gray-100 rounded flex-1"> <div class="!bg-gray-100 rounded flex-1 relative">
<template v-if="props.showNextPrevIcons">
<a-tooltip placement="bottom">
<template #title>
{{ $t('labels.nextRow') }}
</template>
<MdiChevronRight class="cursor-pointer nc-next-arrow" @click="$emit('next')" />
</a-tooltip>
<a-tooltip placement="bottom">
<template #title>
{{ $t('labels.prevRow') }}
</template>
<MdiChevronLeft class="cursor-pointer nc-prev-arrow" @click="$emit('prev')" />
</a-tooltip>
</template>
<div class="flex h-full nc-form-wrapper items-stretch min-h-[max(70vh,100%)]"> <div class="flex h-full nc-form-wrapper items-stretch min-h-[max(70vh,100%)]">
<div class="flex-1 overflow-auto scrollbar-thin-dull nc-form-fields-container"> <div class="flex-1 overflow-auto scrollbar-thin-dull nc-form-fields-container">
<div class="w-[500px] mx-auto"> <div class="w-[500px] mx-auto">
@ -216,4 +232,15 @@ export default {
max-height: max(calc(100vh - 65px), 600px); max-height: max(calc(100vh - 65px), 600px);
height: max-content !important; height: max-content !important;
} }
.nc-prev-arrow,
.nc-next-arrow {
@apply absolute opacity-70 rounded-full transition-transform transition-background transition-opacity transform bg-white hover:(bg-gray-200) active:(scale-125 opacity-100) text-xl;
}
.nc-prev-arrow {
@apply left-4 top-4;
}
.nc-next-arrow {
@apply right-4 top-4;
}
</style> </style>

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

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType, FilterType } from 'nocodb-sdk' import type { FilterType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import { import {
ActiveViewInj, ActiveViewInj,
MetaInj, MetaInj,
@ -70,15 +69,7 @@ const types = computed(() => {
} }
return meta.value?.columns?.reduce((obj: any, col: any) => { return meta.value?.columns?.reduce((obj: any, col: any) => {
switch (col.uidt) { obj[col.id] = col.uidt
case UITypes.Number:
case UITypes.Decimal:
obj[col.title] = obj[col.column_name] = 'number'
break
case UITypes.Checkbox:
obj[col.title] = obj[col.column_name] = 'boolean'
break
}
return obj return obj
}, {}) }, {})
}) })
@ -100,11 +91,6 @@ watch(
}, },
) )
const getApplicableFilters = (id?: string) => {
const colType = (meta.value?.columnsById as Record<string, ColumnType>)?.[id ?? '']?.uidt
return comparisonOpList.filter((op) => !op.types || op.types.includes(colType))
}
const applyChanges = async (hookId?: string, _nested = false) => { const applyChanges = async (hookId?: string, _nested = false) => {
await sync(hookId, _nested) await sync(hookId, _nested)
@ -117,6 +103,21 @@ const applyChanges = async (hookId?: string, _nested = false) => {
} }
} }
const isComparisonOpAllowed = (filter: FilterType, compOp: typeof comparisonOpList[number]) => {
// show current selected value in list even if not allowed
if (filter.comparison_op === compOp.value) return true
// include allowed values only if selected column type matches
if (compOp.includedTypes) {
return filter.fk_column_id && compOp.includedTypes.includes(types.value[filter.fk_column_id])
}
// include not allowed values only if selected column type not matches
else if (compOp.excludedTypes) {
return filter.fk_column_id && !compOp.excludedTypes.includes(types.value[filter.fk_column_id])
}
return true
}
defineExpose({ defineExpose({
applyChanges, applyChanges,
parentId, parentId,
@ -191,7 +192,7 @@ defineExpose({
@click.stop @click.stop
@change="filterUpdateCondition(filter, i)" @change="filterUpdateCondition(filter, i)"
> >
<a-select-option v-for="op in logicalOps" :key="op.value" :value="op.value"> <a-select-option v-for="op of logicalOps" :key="op.value" :value="op.value">
{{ op.text }} {{ op.text }}
</a-select-option> </a-select-option>
</a-select> </a-select>
@ -218,29 +219,21 @@ defineExpose({
dropdown-class-name="nc-dropdown-filter-comp-op" dropdown-class-name="nc-dropdown-filter-comp-op"
@change="filterUpdateCondition(filter, i)" @change="filterUpdateCondition(filter, i)"
> >
<a-select-option <template v-for="compOp of comparisonOpList" :key="compOp.value">
v-for="compOp in getApplicableFilters(filter.fk_column_id)" <a-select-option v-if="isComparisonOpAllowed(filter, compOp)" :value="compOp.value">
:key="compOp.value"
:value="compOp.value"
class=""
>
{{ compOp.text }} {{ compOp.text }}
</a-select-option> </a-select-option>
</template>
</a-select> </a-select>
<span <span
v-if="filter.comparison_op && ['null', 'notnull', 'empty', 'notempty'].includes(filter.comparison_op)" v-if="
filter.comparison_op &&
['null', 'notnull', 'checked', 'notchecked', 'empty', 'notempty'].includes(filter.comparison_op)
"
:key="`span${i}`" :key="`span${i}`"
/> />
<a-checkbox
v-else-if="filter.field && types[filter.field] === 'boolean'"
v-model:checked="filter.value"
dense
:disabled="filter.readOnly"
@change="saveOrUpdate(filter, i)"
/>
<a-input <a-input
v-else v-else
:key="`${i}_7`" :key="`${i}_7`"

4
packages/nc-gui/composables/useApi/interceptors.ts

@ -38,7 +38,7 @@ export function addAxiosInterceptors(api: Api<any>) {
} }
// Logout user if token refresh didn't work or user is disabled // Logout user if token refresh didn't work or user is disabled
if (error.config.url === '/auth/refresh-token') { if (error.config.url === '/auth/token/refresh') {
state.signOut() state.signOut()
return Promise.reject(error) return Promise.reject(error)
@ -46,7 +46,7 @@ export function addAxiosInterceptors(api: Api<any>) {
// Try request again with new token // Try request again with new token
return api.instance return api.instance
.post('/auth/refresh-token', null, { .post('/auth/token/refresh', null, {
withCredentials: true, withCredentials: true,
}) })
.then((token) => { .then((token) => {

2
packages/nc-gui/composables/useGlobal/actions.ts

@ -29,7 +29,7 @@ export function useGlobalActions(state: State): Actions {
const t = nuxtApp.vueApp.i18n.global.t const t = nuxtApp.vueApp.i18n.global.t
nuxtApp.$api.instance nuxtApp.$api.instance
.post('/auth/refresh-token', null, { .post('/auth/token/refresh', null, {
withCredentials: true, withCredentials: true,
}) })
.then((response) => { .then((response) => {

12
packages/nc-gui/composables/useSharedView.ts

@ -1,14 +1,4 @@
import type { import type { ExportTypes, FilterType, KanbanType, PaginatedType, RequestParams, SortType, TableType, ViewType } from 'nocodb-sdk'
Api,
ExportTypes,
FilterType,
KanbanType,
PaginatedType,
RequestParams,
SortType,
TableType,
ViewType,
} from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import { computed, useGlobal, useMetas, useNuxtApp, useState } from '#imports' import { computed, useGlobal, useMetas, useNuxtApp, useState } from '#imports'

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

@ -4,6 +4,7 @@ import type { ComputedRef, Ref } from 'vue'
import { import {
IsPublicInj, IsPublicInj,
NOCO, NOCO,
NavigateDir,
computed, computed,
extractPkFromRow, extractPkFromRow,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
@ -18,6 +19,8 @@ import {
useMetas, useMetas,
useNuxtApp, useNuxtApp,
useProject, useProject,
useRoute,
useRouter,
useSharedView, useSharedView,
useSmartsheetStoreOrThrow, useSmartsheetStoreOrThrow,
useUIPermission, useUIPermission,
@ -44,6 +47,10 @@ export function useViewData(
const { api, isLoading, error } = useApi() const { api, isLoading, error } = useApi()
const router = useRouter()
const route = useRoute()
const { appInfo } = $(useGlobal()) const { appInfo } = $(useGlobal())
const { getMeta } = useMetas() const { getMeta } = useMetas()
@ -74,6 +81,8 @@ export function useViewData(
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const routeQuery = $computed(() => route.query as Record<string, string>)
const paginationData = computed({ const paginationData = computed({
get: () => (isPublic.value ? sharedPaginationData.value : _paginationData.value), get: () => (isPublic.value ? sharedPaginationData.value : _paginationData.value),
set: (value) => { set: (value) => {
@ -469,6 +478,47 @@ export function useViewData(
} }
} }
const navigateToSiblingRow = async (dir: NavigateDir) => {
// get current expanded row index
const expandedRowIndex = formattedData.value.findIndex(
(row: Row) => routeQuery.rowId === extractPkFromRow(row.row, meta.value?.columns as ColumnType[]),
)
// calculate next row index based on direction
let siblingRowIndex = expandedRowIndex + (dir === NavigateDir.NEXT ? 1 : -1)
const currentPage = paginationData?.value?.page || 1
// if next row index is less than 0, go to previous page and point to last element
if (siblingRowIndex < 0) {
// if first page, do nothing
if (currentPage === 1) return message.info(t('msg.info.noMoreRecords'))
await changePage(currentPage - 1)
siblingRowIndex = formattedData.value.length - 1
// if next row index is greater than total rows in current view
// then load next page of formattedData and set next row index to 0
} else if (siblingRowIndex >= formattedData.value.length) {
if (paginationData?.value?.isLastPage) return message.info(t('msg.info.noMoreRecords'))
await changePage(currentPage + 1)
siblingRowIndex = 0
}
// extract the row id of the sibling row
const rowId = extractPkFromRow(formattedData.value[siblingRowIndex].row, meta.value?.columns as ColumnType[])
if (rowId) {
router.push({
query: {
...routeQuery,
rowId,
},
})
}
}
return { return {
error, error,
isLoading, isLoading,
@ -496,5 +546,6 @@ export function useViewData(
loadAggCommentsCount, loadAggCommentsCount,
removeLastEmptyRow, removeLastEmptyRow,
removeRowIfNew, removeRowIfNew,
navigateToSiblingRow,
} }
} }

7
packages/nc-gui/lang/en.json

@ -312,7 +312,9 @@
"signInWithGoogle": "Sign in with Google", "signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service", "agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!", "welcomeToNc": "Welcome to NocoDB!",
"inviteOnlySignup": "Allow signup only using invite url" "inviteOnlySignup": "Allow signup only using invite url",
"nextRow": "Next Row",
"prevRow": "Previous Row"
}, },
"activity": { "activity": {
"createProject": "Create Project", "createProject": "Create Project",
@ -629,7 +631,8 @@
"showM2mTables": "Show M2M Tables", "showM2mTables": "Show M2M Tables",
"deleteKanbanStackConfirmation": "Deleting this stack will also remove the select option `{stackToBeDeleted}` from the `{groupingField}`. The records will move to the uncategorized stack.", "deleteKanbanStackConfirmation": "Deleting this stack will also remove the select option `{stackToBeDeleted}` from the `{groupingField}`. The records will move to the uncategorized stack.",
"computedFieldEditWarning": "Computed field: contents are read-only. Use column edit menu to reconfigure", "computedFieldEditWarning": "Computed field: contents are read-only. Use column edit menu to reconfigure",
"computedFieldDeleteWarning": "Computed field: contents are read-only. Unable to clear content." "computedFieldDeleteWarning": "Computed field: contents are read-only. Unable to clear content.",
"noMoreRecords": "No more records"
}, },
"error": { "error": {
"searchProject": "Your search for {search} found no results", "searchProject": "Your search for {search} found no results",

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

@ -85,7 +85,7 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell
v-if="isVirtualCol(field)" v-if="isVirtualCol(field)"
:model-value="null" :model-value="null"
class="mt-0 nc-input" class="mt-0 nc-input nc-cell"
:data-testid="`nc-form-input-cell-${field.label || field.title}`" :data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title?.replaceAll(' ', '')}`" :class="`nc-form-input-${field.title?.replaceAll(' ', '')}`"
:column="field" :column="field"
@ -133,3 +133,9 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
:deep(.nc-cell .nc-action-icon) {
@apply !text-white-500 !bg-white/50 !rounded-full !p-1 !text-xs !w-7 !h-7 !flex !items-center !justify-center !cursor-pointer !hover:!bg-white-600 !hover:!text-white-600 !transition;
}
</style>

38
packages/nc-gui/utils/filterUtils.ts

@ -1,4 +1,24 @@
export const comparisonOpList = [ import { UITypes } from 'nocodb-sdk'
export const comparisonOpList: {
text: string
value: string
ignoreVal?: boolean
includedTypes?: UITypes[]
excludedTypes?: UITypes[]
}[] = [
{
text: 'is checked',
value: 'checked',
ignoreVal: true,
includedTypes: [UITypes.Checkbox],
},
{
text: 'is not checked',
value: 'notchecked',
ignoreVal: true,
includedTypes: [UITypes.Checkbox],
},
{ {
text: 'is equal', text: 'is equal',
value: 'eq', value: 'eq',
@ -10,20 +30,24 @@ export const comparisonOpList = [
{ {
text: 'is like', text: 'is like',
value: 'like', value: 'like',
excludedTypes: [UITypes.Checkbox],
}, },
{ {
text: 'is not like', text: 'is not like',
value: 'nlike', value: 'nlike',
excludedTypes: [UITypes.Checkbox],
}, },
{ {
text: 'is empty', text: 'is empty',
value: 'empty', value: 'empty',
ignoreVal: true, ignoreVal: true,
excludedTypes: [UITypes.Checkbox],
}, },
{ {
text: 'is not empty', text: 'is not empty',
value: 'notempty', value: 'notempty',
ignoreVal: true, ignoreVal: true,
excludedTypes: [UITypes.Checkbox],
}, },
{ {
text: 'is null', text: 'is null',
@ -38,37 +62,41 @@ export const comparisonOpList = [
{ {
text: 'contains all of', text: 'contains all of',
value: 'allof', value: 'allof',
types: ['MultiSelect'], includedTypes: [UITypes.MultiSelect],
}, },
{ {
text: 'contains any of', text: 'contains any of',
value: 'anyof', value: 'anyof',
types: ['MultiSelect'], includedTypes: [UITypes.MultiSelect],
}, },
{ {
text: 'does not contain all of', text: 'does not contain all of',
value: 'nallof', value: 'nallof',
types: ['MultiSelect'], includedTypes: [UITypes.MultiSelect],
}, },
{ {
text: 'does not contain any of', text: 'does not contain any of',
value: 'nanyof', value: 'nanyof',
types: ['MultiSelect'], includedTypes: [UITypes.MultiSelect],
}, },
{ {
text: '>', text: '>',
value: 'gt', value: 'gt',
excludedTypes: [UITypes.Checkbox],
}, },
{ {
text: '<', text: '<',
value: 'lt', value: 'lt',
excludedTypes: [UITypes.Checkbox],
}, },
{ {
text: '>=', text: '>=',
value: 'gte', value: 'gte',
excludedTypes: [UITypes.Checkbox],
}, },
{ {
text: '<=', text: '<=',
value: 'lte', value: 'lte',
excludedTypes: [UITypes.Checkbox],
}, },
] ]

25
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/conditionV2.ts

@ -9,7 +9,6 @@ import RollupColumn from '../../../../models/RollupColumn';
import formulaQueryBuilderv2 from './formulav2/formulaQueryBuilderv2'; import formulaQueryBuilderv2 from './formulav2/formulaQueryBuilderv2';
import FormulaColumn from '../../../../models/FormulaColumn'; import FormulaColumn from '../../../../models/FormulaColumn';
import { RelationTypes, UITypes } from 'nocodb-sdk'; import { RelationTypes, UITypes } from 'nocodb-sdk';
// import LookupColumn from '../../../models/LookupColumn';
import { sanitize } from './helpers/sanitize'; import { sanitize } from './helpers/sanitize';
export default async function conditionV2( export default async function conditionV2(
@ -278,7 +277,11 @@ const parseConditionV2 = async (
break; break;
case 'neq': case 'neq':
case 'not': case 'not':
qb = qb.whereNot(field, val); qb = qb.where((nestedQb) => {
nestedQb
.whereNot(field, val)
.orWhereNull(customWhereClause ? _val : _field);
});
break; break;
case 'like': case 'like':
if (column.uidt === UITypes.Formula) { if (column.uidt === UITypes.Formula) {
@ -368,6 +371,10 @@ const parseConditionV2 = async (
qb = qb.where(customWhereClause || field, ''); qb = qb.where(customWhereClause || field, '');
else if (filter.value === 'notempty') else if (filter.value === 'notempty')
qb = qb.whereNot(customWhereClause || field, ''); qb = qb.whereNot(customWhereClause || field, '');
else if (filter.value === 'true')
qb = qb.where(customWhereClause || field, true);
else if (filter.value === 'false')
qb = qb.where(customWhereClause || field, false);
break; break;
case 'isnot': case 'isnot':
if (filter.value === 'null') if (filter.value === 'null')
@ -378,6 +385,10 @@ const parseConditionV2 = async (
qb = qb.whereNot(customWhereClause || field, ''); qb = qb.whereNot(customWhereClause || field, '');
else if (filter.value === 'notempty') else if (filter.value === 'notempty')
qb = qb.where(customWhereClause || field, ''); qb = qb.where(customWhereClause || field, '');
else if (filter.value === 'true')
qb = qb.whereNot(customWhereClause || field, true);
else if (filter.value === 'false')
qb = qb.whereNot(customWhereClause || field, false);
break; break;
case 'lt': case 'lt':
qb = qb.where(field, customWhereClause ? '>' : '<', val); qb = qb.where(field, customWhereClause ? '>' : '<', val);
@ -405,6 +416,16 @@ const parseConditionV2 = async (
case 'notnull': case 'notnull':
qb = qb.whereNotNull(customWhereClause || field); qb = qb.whereNotNull(customWhereClause || field);
break; break;
case 'checked':
qb = qb.where(customWhereClause || field, true);
break;
case 'notchecked':
qb = qb.where((grpdQb) => {
grpdQb
.whereNull(customWhereClause || field)
.orWhere(customWhereClause || field, false);
});
break;
case 'btw': case 'btw':
qb = qb.whereBetween(field, val.split(',')); qb = qb.whereBetween(field, val.split(','));
break; break;

6
packages/nocodb/src/lib/meta/helpers/webhookHelpers.ts

@ -66,6 +66,12 @@ export async function validateCondition(filters: Filter[], data: any) {
data[field] === undefined data[field] === undefined
); );
break; break;
case 'checked':
res = !!data[field];
break;
case 'notchecked':
res = !data[field];
break;
case 'null': case 'null':
res = res = data[field] === null; res = res = data[field] === null;
break; break;

2
packages/nocodb/src/lib/models/Filter.ts

@ -32,6 +32,8 @@ export default class Filter {
| 'notempty' | 'notempty'
| 'null' | 'null'
| 'notnull' | 'notnull'
| 'checked'
| 'notchecked'
| 'allof' | 'allof'
| 'anyof' | 'anyof'
| 'nallof' | 'nallof'

1
packages/nocodb/src/lib/utils/projectAcl.ts

@ -17,6 +17,7 @@ export default {
pluginRead: true, pluginRead: true,
pluginUpdate: true, pluginUpdate: true,
isPluginActive: true, isPluginActive: true,
projectDelete: true,
}, },
}, },
guest: {}, guest: {},

8
packages/nocodb/src/lib/version-upgrader/ncFilterUpgrader.ts

@ -21,7 +21,13 @@ export default async function ({ ncMeta }: NcUpgraderCtx) {
} else { } else {
continue; continue;
} }
if (filter.project_id != model.project_id) {
// skip if related model is not found
if (!model) {
continue;
}
if (filter.project_id !== model.project_id) {
await ncMeta.metaUpdate( await ncMeta.metaUpdate(
null, null,
null, null,

30
tests/playwright/pages/Dashboard/Settings/AppStore.ts → tests/playwright/pages/Account/AppStore.ts

@ -1,21 +1,31 @@
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
import { SettingsPage } from '.'; import BasePage from '../Base';
import BasePage from '../../Base'; import { AccountPage } from './index';
export class AppStoreSettingsPage extends BasePage { export class AccountAppStorePage extends BasePage {
private readonly settings: SettingsPage; private accountPage: AccountPage;
constructor(settings: SettingsPage) { constructor(accountPage: AccountPage) {
super(settings.rootPage); super(accountPage.rootPage);
this.settings = settings; this.accountPage = accountPage;
}
async goto() {
await this.rootPage.goto('/#/account/apps', { waitUntil: 'networkidle' });
}
async waitUntilContentLoads() {
return this.rootPage.waitForResponse(
resp => resp.url().includes('api/v1/db/meta/plugins') && resp.status() === 200
);
} }
get() { get() {
return this.settings.get().locator(`[data-testid="nc-settings-subtab-appStore"]`); return this.accountPage.get().locator(`[data-testid="nc-settings-subtab-appStore"]`);
} }
async install({ name }: { name: string }) { async install({ name }: { name: string }) {
const card = await this.settings.get().locator(`.nc-app-store-card-${name}`); const card = await this.accountPage.get().locator(`.nc-app-store-card-${name}`);
await card.click(); await card.click();
// todo: Hack to solve the issue when if the test installing a plugin fails, the next test will fail because the plugin is already installed // todo: Hack to solve the issue when if the test installing a plugin fails, the next test will fail because the plugin is already installed
@ -48,7 +58,7 @@ export class AppStoreSettingsPage extends BasePage {
} }
async uninstall(param: { name: string }) { async uninstall(param: { name: string }) {
const card = this.settings.get().locator(`.nc-app-store-card-${param.name}`); const card = this.accountPage.get().locator(`.nc-app-store-card-${param.name}`);
// await card.scrollIntoViewIfNeeded(); // await card.scrollIntoViewIfNeeded();
await card.click(); await card.click();

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

@ -3,17 +3,20 @@ import BasePage from '../Base';
import { AccountSettingsPage } from './Settings'; import { AccountSettingsPage } from './Settings';
import { AccountTokenPage } from './Token'; import { AccountTokenPage } from './Token';
import { AccountUsersPage } from './Users'; import { AccountUsersPage } from './Users';
import { AccountAppStorePage } from './AppStore';
export class AccountPage extends BasePage { export class AccountPage extends BasePage {
readonly settings: AccountSettingsPage; readonly settings: AccountSettingsPage;
readonly token: AccountTokenPage; readonly token: AccountTokenPage;
readonly users: AccountUsersPage; readonly users: AccountUsersPage;
readonly appStore: AccountAppStorePage;
constructor(page: Page) { constructor(page: Page) {
super(page); super(page);
this.settings = new AccountSettingsPage(this); this.settings = new AccountSettingsPage(this);
this.token = new AccountTokenPage(this); this.token = new AccountTokenPage(this);
this.users = new AccountUsersPage(this); this.users = new AccountUsersPage(this);
this.appStore = new AccountAppStorePage(this);
} }
get() { get() {

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

@ -1,14 +1,12 @@
import { DashboardPage } from '..'; import { DashboardPage } from '..';
import BasePage from '../../Base'; import BasePage from '../../Base';
import { AuditSettingsPage } from './Audit'; import { AuditSettingsPage } from './Audit';
import { AppStoreSettingsPage } from './AppStore';
import { MiscSettingsPage } from './Miscellaneous'; import { MiscSettingsPage } from './Miscellaneous';
import { TeamsPage } from './Teams'; import { TeamsPage } from './Teams';
import { DataSourcesPage } from './DataSources'; import { DataSourcesPage } from './DataSources';
export enum SettingTab { export enum SettingTab {
TeamAuth = 'teamAndAuth', TeamAuth = 'teamAndAuth',
AppStore = 'appStore',
DataSources = 'dataSources', DataSources = 'dataSources',
Audit = 'audit', Audit = 'audit',
ProjectSettings = 'projectSettings', ProjectSettings = 'projectSettings',
@ -22,7 +20,6 @@ export enum SettingsSubTab {
export class SettingsPage extends BasePage { export class SettingsPage extends BasePage {
readonly audit: AuditSettingsPage; readonly audit: AuditSettingsPage;
readonly appStore: AppStoreSettingsPage;
readonly miscellaneous: MiscSettingsPage; readonly miscellaneous: MiscSettingsPage;
readonly dataSources: DataSourcesPage; readonly dataSources: DataSourcesPage;
readonly teams: TeamsPage; readonly teams: TeamsPage;
@ -30,7 +27,6 @@ export class SettingsPage extends BasePage {
constructor(dashboard: DashboardPage) { constructor(dashboard: DashboardPage) {
super(dashboard.rootPage); super(dashboard.rootPage);
this.audit = new AuditSettingsPage(this); this.audit = new AuditSettingsPage(this);
this.appStore = new AppStoreSettingsPage(this);
this.miscellaneous = new MiscSettingsPage(this); this.miscellaneous = new MiscSettingsPage(this);
this.dataSources = new DataSourcesPage(this); this.dataSources = new DataSourcesPage(this);
this.teams = new TeamsPage(this); this.teams = new TeamsPage(this);

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

@ -37,7 +37,7 @@ export class ToolbarFilterPage extends BasePage {
}: { }: {
columnTitle: string; columnTitle: string;
opType: string; opType: string;
value: string; value?: string;
isLocallySaved: boolean; isLocallySaved: boolean;
}) { }) {
await this.get().locator(`button:has-text("Add Filter")`).first().click(); await this.get().locator(`button:has-text("Add Filter")`).first().click();
@ -70,6 +70,8 @@ export class ToolbarFilterPage extends BasePage {
await this.toolbar.parent.dashboard.waitForLoaderToDisappear(); await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
} }
// if value field was provided, fill it
if (value) {
const fillFilter = this.rootPage.locator('.nc-filter-value-select').last().fill(value); const fillFilter = this.rootPage.locator('.nc-filter-value-select').last().fill(value);
await this.waitForResponse({ await this.waitForResponse({
uiAction: fillFilter, uiAction: fillFilter,
@ -79,6 +81,7 @@ export class ToolbarFilterPage extends BasePage {
await this.toolbar.parent.dashboard.waitForLoaderToDisappear(); await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
await this.toolbar.parent.waitLoading(); await this.toolbar.parent.waitLoading();
} }
}
async resetFilter() { async resetFilter() {
await this.toolbar.clickFilter(); await this.toolbar.clickFilter();

129
tests/playwright/tests/columnCheckbox.spec.ts

@ -0,0 +1,129 @@
import { test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard';
import setup from '../setup';
import { ToolbarPage } from '../pages/Dashboard/common/Toolbar';
import { isPg } from '../setup/db';
test.describe('Checkbox - cell, filter, sort', () => {
let dashboard: DashboardPage, toolbar: ToolbarPage;
let context: any;
// define validateRowArray function
async function validateRowArray(value: string[]) {
const length = value.length;
for (let i = 0; i < length; i++) {
await dashboard.grid.cell.verify({
index: i,
columnHeader: 'Title',
value: value[i],
});
}
}
async function verifyFilter(param: { opType: string; value?: string; result: string[] }) {
await toolbar.clickFilter();
await toolbar.filter.addNew({
columnTitle: 'checkbox',
opType: param.opType,
value: param.value,
isLocallySaved: false,
});
await toolbar.clickFilter();
// verify filtered rows
await validateRowArray(param.result);
// Reset filter
await toolbar.filter.resetFilter();
}
test.beforeEach(async ({ page }) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
toolbar = dashboard.grid.toolbar;
});
test('Checkbox', async () => {
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.createTable({ title: 'Sheet1' });
await dashboard.grid.addNewRow({ index: 0, value: '1a' });
await dashboard.grid.addNewRow({ index: 1, value: '1b' });
await dashboard.grid.addNewRow({ index: 2, value: '1c' });
await dashboard.grid.addNewRow({ index: 3, value: '1d' });
await dashboard.grid.addNewRow({ index: 4, value: '1e' });
await dashboard.grid.addNewRow({ index: 5, value: '1f' });
// Create Checkbox column
await dashboard.grid.column.create({
title: 'checkbox',
type: 'Checkbox',
});
// In cell insert
await dashboard.grid.cell.checkbox.click({ index: 0, columnHeader: 'checkbox' });
await dashboard.grid.cell.checkbox.click({ index: 1, columnHeader: 'checkbox' });
await dashboard.grid.cell.checkbox.click({ index: 2, columnHeader: 'checkbox' });
await dashboard.grid.cell.checkbox.click({ index: 5, columnHeader: 'checkbox' });
await dashboard.grid.cell.checkbox.click({ index: 1, columnHeader: 'checkbox' });
// verify checkbox state
await dashboard.grid.cell.checkbox.verifyChecked({ index: 0, columnHeader: 'checkbox' });
await dashboard.grid.cell.checkbox.verifyChecked({ index: 2, columnHeader: 'checkbox' });
await dashboard.grid.cell.checkbox.verifyChecked({ index: 5, columnHeader: 'checkbox' });
await dashboard.grid.cell.checkbox.verifyUnchecked({ index: 1, columnHeader: 'checkbox' });
await dashboard.grid.cell.checkbox.verifyUnchecked({ index: 3, columnHeader: 'checkbox' });
await dashboard.grid.cell.checkbox.verifyUnchecked({ index: 4, columnHeader: 'checkbox' });
// column values
// 1a : true
// 1b : false
// 1c : true
// 1d : null
// 1e : null
// 1f : true
// Filter column
await verifyFilter({ opType: 'is checked', result: ['1a', '1c', '1f'] });
await verifyFilter({ opType: 'is not checked', result: ['1b', '1d', '1e'] });
await verifyFilter({ opType: 'is equal', value: '0', result: ['1b'] });
await verifyFilter({ opType: 'is not equal', value: '1', result: ['1b', '1d', '1e'] });
await verifyFilter({ opType: 'is null', result: ['1d', '1e'] });
await verifyFilter({ opType: 'is not null', result: ['1a', '1b', '1c', '1f'] });
// Sort column
await toolbar.sort.addSort({
columnTitle: 'checkbox',
isAscending: true,
isLocallySaved: false,
});
if (isPg(context)) {
await validateRowArray(['1b', '1a', '1c', '1f', '1d', '1e']);
} else {
await validateRowArray(['1d', '1e', '1b', '1a', '1c', '1f']);
}
await toolbar.sort.resetSort();
// sort descending & validate
await toolbar.sort.addSort({
columnTitle: 'checkbox',
isAscending: false,
isLocallySaved: false,
});
if (isPg(context)) {
await validateRowArray(['1d', '1e', '1a', '1c', '1f', '1b']);
} else {
await validateRowArray(['1a', '1c', '1f', '1b', '1d', '1e']);
}
await toolbar.sort.resetSort();
// wait for 10 seconds
await dashboard.rootPage.waitForTimeout(10000);
// TBD: Add more tests
// Expanded form insert
// Expanded record insert
// Expanded form insert
});
});

35
tests/playwright/tests/viewForm.spec.ts

@ -1,20 +1,25 @@
import { test } from '@playwright/test'; import { test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard'; import { DashboardPage } from '../pages/Dashboard';
import { SettingTab } from '../pages/Dashboard/Settings';
import setup from '../setup'; import setup from '../setup';
import { FormPage } from '../pages/Dashboard/Form'; import { FormPage } from '../pages/Dashboard/Form';
import { SharedFormPage } from '../pages/SharedForm'; import { SharedFormPage } from '../pages/SharedForm';
import { AccountPage } from '../pages/Account';
import { AccountAppStorePage } from '../pages/Account/AppStore';
// todo: Move most of the ui actions to page object and await on the api response // todo: Move most of the ui actions to page object and await on the api response
test.describe('Form view', () => { test.describe('Form view', () => {
let dashboard: DashboardPage; let dashboard: DashboardPage;
let form: FormPage; let form: FormPage;
let accountAppStorePage: AccountAppStorePage;
let accountPage: AccountPage;
let context: any; let context: any;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
context = await setup({ page }); context = await setup({ page });
dashboard = new DashboardPage(page, context.project); dashboard = new DashboardPage(page, context.project);
form = dashboard.form; form = dashboard.form;
accountPage = new AccountPage(page);
accountAppStorePage = accountPage.appStore;
}); });
test('Field re-order operations', async () => { test('Field re-order operations', async () => {
@ -78,7 +83,7 @@ test.describe('Form view', () => {
}); });
}); });
test('Form elements validation', async () => { test('Form elements validation', async ({ page }) => {
// close 'Team & Auth' tab // close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' }); await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'Country' }); await dashboard.treeView.openTable({ title: 'Country' });
@ -168,12 +173,16 @@ test.describe('Form view', () => {
await dashboard.verifyToast({ await dashboard.verifyToast({
message: 'Please activate SMTP plugin in App store for enabling email notification', message: 'Please activate SMTP plugin in App store for enabling email notification',
}); });
const url = dashboard.rootPage.url();
// activate SMTP plugin // activate SMTP plugin
await dashboard.gotoSettings(); await accountAppStorePage.goto();
await dashboard.settings.selectTab({ tab: SettingTab.AppStore }); await accountAppStorePage.rootPage.reload({ waitUntil: 'networkidle' });
await dashboard.settings.appStore.install({ name: 'SMTP' }); await accountAppStorePage.waitUntilContentLoads();
await dashboard.settings.appStore.configureSMTP({
// install SMTP
await accountAppStorePage.install({ name: 'SMTP' });
await accountAppStorePage.configureSMTP({
email: 'a@b.com', email: 'a@b.com',
host: 'smtp.gmail.com', host: 'smtp.gmail.com',
port: '587', port: '587',
@ -181,7 +190,9 @@ test.describe('Form view', () => {
await dashboard.verifyToast({ await dashboard.verifyToast({
message: 'Successfully installed and email notification will use SMTP configuration', message: 'Successfully installed and email notification will use SMTP configuration',
}); });
await dashboard.settings.close();
// revisit form view
await page.goto(url);
// enable 'email-me' option // enable 'email-me' option
await dashboard.viewSidebar.openView({ title: 'CountryForm' }); await dashboard.viewSidebar.openView({ title: 'CountryForm' });
@ -192,15 +203,15 @@ test.describe('Form view', () => {
showBlankForm: false, showBlankForm: false,
}); });
// reset SMTP // Uninstall SMTP
await dashboard.gotoSettings(); await accountAppStorePage.goto();
await dashboard.settings.selectTab({ tab: SettingTab.AppStore }); await accountAppStorePage.rootPage.reload({ waitUntil: 'networkidle' });
await dashboard.settings.appStore.uninstall({ name: 'SMTP' }); await accountAppStorePage.waitUntilContentLoads();
await accountAppStorePage.uninstall({ name: 'SMTP' });
await dashboard.verifyToast({ await dashboard.verifyToast({
message: 'Plugin uninstalled successfully', message: 'Plugin uninstalled successfully',
}); });
await dashboard.settings.close();
}); });
test('Form share, verify attachment file', async () => { test('Form share, verify attachment file', async () => {

Loading…
Cancel
Save