Browse Source

Merge pull request #4969 from nocodb/enhancement/filters

enhancement: filters
pull/5119/head
Raju Udava 2 years ago committed by GitHub
parent
commit
47c01dc784
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      packages/nc-gui/components/cell/ClampedText.vue
  2. 5
      packages/nc-gui/components/cell/Duration.vue
  3. 12
      packages/nc-gui/components/cell/MultiSelect.vue
  4. 13
      packages/nc-gui/components/cell/SingleSelect.vue
  5. 46
      packages/nc-gui/components/dashboard/settings/Misc.vue
  6. 108
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue
  7. 22
      packages/nc-gui/components/smartsheet/toolbar/FieldListAutoCompleteDropdown.vue
  8. 194
      packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue
  9. 14
      packages/nc-gui/composables/useProject.ts
  10. 108
      packages/nc-gui/composables/useViewFilters.ts
  11. 5
      packages/nc-gui/lang/en.json
  12. 19
      packages/nc-gui/package-lock.json
  13. 1
      packages/nc-gui/package.json
  14. 116
      packages/nc-gui/utils/filterUtils.ts
  15. 14
      packages/noco-docs/content/en/setup-and-usages/column-operations.md
  16. 7
      packages/nocodb-sdk/src/index.ts
  17. 16
      packages/nocodb-sdk/src/lib/Api.ts
  18. 22
      packages/nocodb-sdk/src/lib/UITypes.ts
  19. 2
      packages/nocodb/src/lib/Noco.ts
  20. 215
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/conditionV2.ts
  21. 10
      packages/nocodb/src/lib/meta/api/projectApis.ts
  22. 17
      packages/nocodb/src/lib/meta/api/sync/helpers/job.ts
  23. 2
      packages/nocodb/src/lib/meta/helpers/webhookHelpers.ts
  24. 60
      packages/nocodb/src/lib/models/Filter.ts
  25. 2
      packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts
  26. 358
      packages/nocodb/src/lib/version-upgrader/ncFilterUpgrader_0104004.ts
  27. 18
      packages/nocodb/tests/unit/rest/tests/filter.test.ts
  28. 3
      packages/nocodb/tests/unit/rest/tests/tableRow.test.ts
  29. 30
      scripts/sdk/swagger.json
  30. 54
      tests/playwright/package-lock.json
  31. 1
      tests/playwright/package.json
  32. 19
      tests/playwright/pages/Dashboard/Grid/index.ts
  33. 4
      tests/playwright/pages/Dashboard/Settings/Miscellaneous.ts
  34. 6
      tests/playwright/pages/Dashboard/Settings/index.ts
  35. 15
      tests/playwright/pages/Dashboard/TreeView.ts
  36. 2
      tests/playwright/pages/Dashboard/WebhookForm/index.ts
  37. 4
      tests/playwright/pages/Dashboard/common/Cell/SelectOptionCell.ts
  38. 4
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  39. 138
      tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts
  40. 26
      tests/playwright/pages/Dashboard/common/Toolbar/Sort.ts
  41. 135
      tests/playwright/setup/xcdb-records.ts
  42. 9
      tests/playwright/tests/columnCheckbox.spec.ts
  43. 9
      tests/playwright/tests/columnMultiSelect.spec.ts
  44. 17
      tests/playwright/tests/columnRating.spec.ts
  45. 6
      tests/playwright/tests/columnSingleSelect.spec.ts
  46. 770
      tests/playwright/tests/filters.spec.ts
  47. 10
      tests/playwright/tests/viewGridShare.spec.ts

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

@ -23,10 +23,14 @@ onMounted(() => {
<template>
<div ref="wrapper">
<!--
using '' for :text in text-clamp would keep the previous cell value after changing a filter
use ' ' instead of '' to trigger update
-->
<text-clamp
:key="`clamp-${key}-${props.value?.toString().length || 0}`"
class="w-full h-full break-all"
:text="`${props.value || ''}`"
:text="`${props.value || ' '}`"
:max-lines="props.lines"
/>
</div>

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

@ -13,9 +13,10 @@ import {
interface Props {
modelValue: number | string | null | undefined
showValidationError: boolean
}
const { modelValue } = defineProps<Props>()
const { modelValue, showValidationError = true } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
@ -99,7 +100,7 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
<span v-else> {{ localState }}</span>
<div v-if="showWarningMessage" class="duration-warning">
<div v-if="showWarningMessage && showValidationError" class="duration-warning">
<!-- TODO: i18n -->
Please enter a number
</div>

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

@ -31,9 +31,10 @@ import MdiCloseCircle from '~icons/mdi/close-circle'
interface Props {
modelValue?: string | string[]
rowIndex?: number
disableOptionCreation?: boolean
}
const { modelValue } = defineProps<Props>()
const { modelValue, disableOptionCreation } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
@ -336,6 +337,7 @@ useEventListener(document, 'click', handleClose, true)
:key="op.id || op.title"
:value="op.title"
:data-testid="`select-option-${column.title}-${rowIndex}`"
:class="`nc-select-option-${column.title}-${op.title}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color">
@ -354,7 +356,13 @@ useEventListener(document, 'click', handleClose, true)
</a-select-option>
<a-select-option
v-if="searchVal && isOptionMissing && !isPublic && (hasRole('owner', true) || hasRole('creator', true))"
v-if="
searchVal &&
isOptionMissing &&
!isPublic &&
!disableOptionCreation &&
(hasRole('owner', true) || hasRole('creator', true))
"
:key="searchVal"
:value="searchVal"
>

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

@ -18,6 +18,7 @@ import {
inject,
isDrawerOrModalExist,
ref,
useEventListener,
useRoles,
useSelectedCellKeyupListener,
watch,
@ -26,9 +27,10 @@ import {
interface Props {
modelValue?: string | undefined
rowIndex?: number
disableOptionCreation?: boolean
}
const { modelValue } = defineProps<Props>()
const { modelValue, disableOptionCreation } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
@ -263,6 +265,7 @@ useEventListener(document, 'click', handleClose, true)
:key="op.title"
:value="op.title"
:data-testid="`select-option-${column.title}-${rowIndex}`"
:class="`nc-select-option-${column.title}-${op.title}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color">
@ -280,7 +283,13 @@ useEventListener(document, 'click', handleClose, true)
</a-tag>
</a-select-option>
<a-select-option
v-if="searchVal && isOptionMissing && !isPublic && (hasRole('owner', true) || hasRole('creator', true))"
v-if="
searchVal &&
isOptionMissing &&
!isPublic &&
!disableOptionCreation &&
(hasRole('owner', true) || hasRole('creator', true))
"
:key="searchVal"
:value="searchVal"
>

46
packages/nc-gui/components/dashboard/settings/Misc.vue

@ -1,10 +1,34 @@
<script setup lang="ts">
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface'
import { useGlobal, useProject, watch } from '#imports'
const { includeM2M, showNull } = useGlobal()
const { loadTables } = useProject()
const { project, updateProject, projectMeta, loadTables, hasEmptyOrNullFilters } = useProject()
watch(includeM2M, async () => await loadTables())
const showNullAndEmptyInFilter = ref(projectMeta.value.showNullAndEmptyInFilter)
async function showNullAndEmptyInFilterOnChange(evt: CheckboxChangeEvent) {
// users cannot hide null & empty option if there is existing null / empty filters
if (!evt.target.checked) {
if (await hasEmptyOrNullFilters()) {
showNullAndEmptyInFilter.value = true
message.warning('Null / Empty filters exist. Please remove them first.')
}
}
const newProjectMeta = {
...projectMeta.value,
showNullAndEmptyInFilter: showNullAndEmptyInFilter.value,
}
// update local state
project.value.meta = newProjectMeta
// update db
await updateProject({
meta: newProjectMeta,
})
}
</script>
<template>
@ -13,12 +37,28 @@ watch(includeM2M, async () => await loadTables())
<div class="flex flex-row items-center w-full mb-4 gap-2">
<!-- Show M2M Tables -->
<a-checkbox v-model:checked="includeM2M" v-e="['c:themes:show-m2m-tables']" class="nc-settings-meta-misc">
{{ $t('msg.info.showM2mTables') }}
{{ $t('msg.info.showM2mTables') }} <br />
<span class="text-gray-500">{{ $t('msg.info.showM2mTablesDesc') }}</span>
</a-checkbox>
</div>
<div class="flex flex-row items-center w-full mb-4 gap-2">
<!-- Show NULL -->
<a-checkbox v-model:checked="showNull" v-e="['c:settings:show-null']" class="nc-settings-show-null">Show NULL</a-checkbox>
<a-checkbox v-model:checked="showNull" v-e="['c:settings:show-null']" class="nc-settings-show-null">
{{ $t('msg.info.showNullInCells') }} <br />
<span class="text-gray-500">{{ $t('msg.info.showNullInCellsDesc') }}</span>
</a-checkbox>
</div>
<div class="flex flex-row items-center w-full mb-4 gap-2">
<!-- Show NULL and EMPTY in Filters -->
<a-checkbox
v-model:checked="showNullAndEmptyInFilter"
v-e="['c:settings:show-null-and-empty-in-filter']"
class="nc-settings-show-null-and-empty-in-filter"
@change="showNullAndEmptyInFilterOnChange"
>
{{ $t('msg.info.showNullAndEmptyInFilter') }} <br />
<span class="text-gray-500">{{ $t('msg.info.showNullAndEmptyInFilterDesc') }}</span>
</a-checkbox>
</div>
</div>
</div>

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

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { FilterType } from 'nocodb-sdk'
import type { ColumnType, FilterType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import {
ActiveViewInj,
MetaInj,
@ -42,7 +43,18 @@ const reloadDataHook = inject(ReloadViewDataHookInj)!
const { $e } = useNuxtApp()
const { nestedFilters } = useSmartsheetStoreOrThrow()
const { filters, nonDeletedFilters, deleteFilter, saveOrUpdate, loadFilters, addFilter, addFilterGroup, sync } = useViewFilters(
const {
filters,
nonDeletedFilters,
deleteFilter,
saveOrUpdate,
loadFilters,
addFilter,
addFilterGroup,
sync,
saveOrUpdateDebounced,
isComparisonOpAllowed,
} = useViewFilters(
activeView,
parentId,
computed(() => autoSave),
@ -53,16 +65,37 @@ const { filters, nonDeletedFilters, deleteFilter, saveOrUpdate, loadFilters, add
const localNestedFilters = ref()
const columns = computed(() => meta.value?.columns)
const getColumn = (filter: Filter) => {
return columns.value?.find((col: ColumnType) => col.id === filter.fk_column_id)
}
const filterPrevComparisonOp = ref<Record<string, string>>({})
const filterUpdateCondition = (filter: FilterType, i: number) => {
const col = getColumn(filter)
if (
col.uidt === UITypes.SingleSelect &&
['anyof', 'nanyof'].includes(filterPrevComparisonOp.value[filter.id]) &&
['eq', 'neq'].includes(filter.comparison_op!)
) {
// anyof and nanyof can allow multiple selections,
// while `eq` and `neq` only allow one selection
filter.value = ''
} else if (['blank', 'notblank', 'empty', 'notempty', 'null', 'notnull'].includes(filter.comparison_op!)) {
// since `blank`, `empty`, `null` doesn't require value,
// hence remove the previous value
filter.value = ''
}
saveOrUpdate(filter, i)
filterPrevComparisonOp.value[filter.id] = filter.comparison_op
$e('a:filter:update', {
logical: filter.logical_op,
comparison: filter.comparison_op,
})
}
const columns = computed(() => meta.value?.columns)
const types = computed(() => {
if (!meta.value?.columns?.length) {
return {}
@ -76,7 +109,7 @@ const types = computed(() => {
watch(
() => activeView.value?.id,
(n, o) => {
(n: string, o: string) => {
// if nested no need to reload since it will get reloaded from parent
if (!nested && n !== o && (hookId || !webHook)) loadFilters(hookId as string)
},
@ -86,7 +119,7 @@ loadFilters(hookId as string)
watch(
() => nonDeletedFilters.value.length,
(length) => {
(length: number) => {
emit('update:filtersLength', length ?? 0)
},
)
@ -103,19 +136,23 @@ 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
const selectFilterField = (filter: Filter, index: number) => {
// when we change the field,
// the corresponding default filter operator needs to be changed as well
// since the existing one may not be supported for the new field
// e.g. `eq` operator is not supported in checkbox field
// hence, get the first option of the supported operators of the new field
filter.comparison_op = comparisonOpList(getColumn(filter)!.uidt as UITypes).filter((compOp) =>
isComparisonOpAllowed(filter, compOp),
)?.[0].value
// reset filter value as well
filter.value = ''
saveOrUpdate(filter, index)
}
// 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
const updateFilterValue = (value: string, filter: Filter, index: number) => {
filter.value = value
saveOrUpdateDebounced(filter, index)
}
defineExpose({
@ -127,7 +164,7 @@ defineExpose({
<template>
<div
class="p-4 menu-filter-dropdown bg-gray-50 !border mt-4"
:class="{ 'shadow min-w-[430px] max-w-[630px] max-h-[max(80vh,500px)] overflow-auto': !nested, 'border-1 w-full': nested }"
:class="{ 'shadow min-w-[430px] max-h-[max(80vh,500px)] overflow-auto': !nested, 'border-1 w-full': nested }"
>
<div v-if="filters && filters.length" class="nc-filter-grid mb-2" @click.stop>
<template v-for="(filter, i) in filters" :key="i">
@ -189,14 +226,13 @@ defineExpose({
hide-details
:disabled="filter.readOnly"
dropdown-class-name="nc-dropdown-filter-logical-op"
@click.stop
@change="filterUpdateCondition(filter, i)"
@click.stop
>
<a-select-option v-for="op of logicalOps" :key="op.value" :value="op.value">
{{ op.text }}
</a-select-option>
</a-select>
<LazySmartsheetToolbarFieldListAutoCompleteDropdown
:key="`${i}_6`"
v-model="filter.fk_column_id"
@ -204,9 +240,8 @@ defineExpose({
:columns="columns"
:disabled="filter.readOnly"
@click.stop
@change="saveOrUpdate(filter, i)"
@change="selectFilterField(filter, i)"
/>
<a-select
v-model:value="filter.comparison_op"
:dropdown-match-select-width="false"
@ -219,7 +254,7 @@ defineExpose({
dropdown-class-name="nc-dropdown-filter-comp-op"
@change="filterUpdateCondition(filter, i)"
>
<template v-for="compOp of comparisonOpList" :key="compOp.value">
<template v-for="compOp of comparisonOpList(getColumn(filter)?.uidt)" :key="compOp.value">
<a-select-option v-if="isComparisonOpAllowed(filter, compOp)" :value="compOp.value">
{{ compOp.text }}
</a-select-option>
@ -229,19 +264,28 @@ defineExpose({
<span
v-if="
filter.comparison_op &&
['null', 'notnull', 'checked', 'notchecked', 'empty', 'notempty'].includes(filter.comparison_op)
['null', 'notnull', 'checked', 'notchecked', 'empty', 'notempty', 'blank', 'notblank'].includes(
filter.comparison_op,
)
"
:key="`span${i}`"
/>
<a-input
<a-checkbox
v-else-if="filter.field && types[filter.field] === 'boolean'"
v-model:checked="filter.value"
dense
:disabled="filter.readOnly"
@change="saveOrUpdate(filter, i)"
/>
<LazySmartsheetToolbarFilterInput
v-else
:key="`${i}_7`"
v-model:value="filter.value"
class="nc-filter-value-select"
:disabled="filter.readOnly || !filter.fk_column_id"
class="nc-filter-value-select min-w-[120px]"
:column="getColumn(filter)"
:filter="filter"
@update-filter-value="(value) => updateFilterValue(value, filter, i)"
@click.stop
@input="saveOrUpdate(filter, i)"
/>
</template>
</template>
@ -271,7 +315,7 @@ defineExpose({
<style scoped>
.nc-filter-grid {
grid-template-columns: 18px 83px 160px auto auto;
grid-template-columns: auto auto auto auto auto;
@apply grid gap-[12px] items-center;
}

22
packages/nc-gui/components/smartsheet/toolbar/FieldListAutoCompleteDropdown.vue

@ -1,8 +1,8 @@
<script setup lang="ts">
import type { SelectProps } from 'ant-design-vue'
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { MetaInj, computed, inject, ref, resolveComponent } from '#imports'
import { RelationTypes, UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { ActiveViewInj, MetaInj, computed, inject, ref, resolveComponent, useViewColumns } from '#imports'
const { modelValue, isSort } = defineProps<{
modelValue?: string
@ -18,10 +18,21 @@ const localValue = computed({
set: (val) => emit('update:modelValue', val),
})
const activeView = inject(ActiveViewInj, ref())
const { showSystemFields, metaColumnById } = useViewColumns(activeView, meta)
const options = computed<SelectProps['options']>(() =>
meta.value?.columns
?.filter((c: ColumnType) => {
if (c.uidt === UITypes.QrCode || c.uidt === UITypes.Barcode) {
if (isSystemColumn(metaColumnById?.value?.[c.id!])) {
return (
/** if the field is used in filter, then show it anyway */
localValue.value === c.id ||
/** hide system columns if not enabled */
showSystemFields.value
)
} else if (c.uidt === UITypes.QrCode || c.uidt === UITypes.Barcode || c.uidt === UITypes.ID) {
return false
} else if (isSort) {
/** ignore hasmany and manytomany relations if it's using within sort menu */
@ -48,6 +59,11 @@ const options = computed<SelectProps['options']>(() =>
)
const filterOption = (input: string, option: any) => option.label.toLowerCase()?.includes(input.toLowerCase())
// when a new filter is created, select a field by default
if (!localValue.value) {
localValue.value = (options.value?.[0].value as string) || ''
}
</script>
<template>

194
packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue

@ -0,0 +1,194 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import {
ColumnInj,
EditModeInj,
ReadonlyInj,
computed,
isBoolean,
isCurrency,
isDate,
isDateTime,
isDecimal,
isDuration,
isFloat,
isInt,
isMultiSelect,
isPercent,
isRating,
isSingleSelect,
isTextArea,
isTime,
isYear,
provide,
ref,
toRef,
useProject,
} from '#imports'
import type { Filter } from '~/lib'
import SingleSelect from '~/components/cell/SingleSelect.vue'
import MultiSelect from '~/components/cell/MultiSelect.vue'
import DatePicker from '~/components/cell/DatePicker.vue'
import YearPicker from '~/components/cell/YearPicker.vue'
import DateTimePicker from '~/components/cell/DateTimePicker.vue'
import TimePicker from '~/components/cell/TimePicker.vue'
import Rating from '~/components/cell/Rating.vue'
import Duration from '~/components/cell/Duration.vue'
import Percent from '~/components/cell/Percent.vue'
import Currency from '~/components/cell/Currency.vue'
import Decimal from '~/components/cell/Decimal.vue'
import Integer from '~/components/cell/Integer.vue'
import Float from '~/components/cell/Float.vue'
import Text from '~/components/cell/Text.vue'
interface Props {
column: ColumnType
filter: Filter
}
interface Emits {
(event: 'updateFilterValue', model: any): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const column = toRef(props, 'column')
const editEnabled = ref(true)
provide(ColumnInj, column)
provide(EditModeInj, readonly(editEnabled))
provide(ReadonlyInj, ref(false))
const checkTypeFunctions = {
isSingleSelect,
isMultiSelect,
isDate,
isYear,
isDateTime,
isTime,
isRating,
isDuration,
isPercent,
isCurrency,
isDecimal,
isInt,
isFloat,
isTextArea,
}
type FilterType = keyof typeof checkTypeFunctions
const { sqlUi } = $(useProject())
const abstractType = $computed(() => (column.value?.dt && sqlUi ? sqlUi.getAbstractType(column.value) : null))
const checkType = (filterType: FilterType) => {
const checkTypeFunction = checkTypeFunctions[filterType]
if (!column.value || !checkTypeFunction) {
return false
}
return checkTypeFunction(column.value, abstractType)
}
const filterInput = computed({
get: () => {
return props.filter.value
},
set: (value) => {
emit('updateFilterValue', value)
},
})
const booleanOptions = [
{ value: true, label: 'true' },
{ value: false, label: 'false' },
{ value: null, label: 'unset' },
]
const componentMap: Partial<Record<FilterType, any>> = $computed(() => {
return {
// use MultiSelect for SingleSelect columns for anyof / nanyof filters
isSingleSelect: ['anyof', 'nanyof'].includes(props.filter.comparison_op!) ? MultiSelect : SingleSelect,
isMultiSelect: MultiSelect,
isDate: DatePicker,
isYear: YearPicker,
isDateTime: DateTimePicker,
isTime: TimePicker,
isRating: Rating,
isDuration: Duration,
isPercent: Percent,
isCurrency: Currency,
isDecimal: Decimal,
isInt: Integer,
isFloat: Float,
}
})
const filterType = $computed(() => {
return Object.keys(componentMap).find((key) => checkType(key as FilterType))
})
const componentProps = $computed(() => {
switch (filterType) {
case 'isSingleSelect':
case 'isMultiSelect': {
return { disableOptionCreation: true }
}
case 'isPercent':
case 'isDecimal':
case 'isFloat':
case 'isInt': {
return { class: 'h-32px' }
}
case 'isDuration': {
return { showValidationError: false }
}
default: {
return {}
}
}
})
const hasExtraPadding = $computed(() => {
return (
column.value &&
(isInt(column.value, abstractType) ||
isDate(column.value, abstractType) ||
isDateTime(column.value, abstractType) ||
isTime(column.value, abstractType) ||
isYear(column.value, abstractType))
)
})
</script>
<template>
<a-select
v-if="column && isBoolean(column, abstractType)"
v-model:value="filterInput"
:disabled="filter.readOnly"
:options="booleanOptions"
/>
<div
v-else
class="bg-white border-1 flex min-w-120px max-w-170px min-h-32px h-full"
:class="{ 'px-2': hasExtraPadding }"
@mouseup.stop
>
<component
:is="filterType ? componentMap[filterType] : Text"
v-model="filterInput"
:disabled="filter.readOnly"
placeholder="Enter a value"
:column="column"
class="flex"
v-bind="componentProps"
/>
</div>
</template>

14
packages/nc-gui/composables/useProject.ts

@ -34,7 +34,9 @@ export const useProject = createSharedComposable(() => {
const projectLoadedHook = createEventHook<ProjectType>()
const project = ref<ProjectType>({})
const bases = computed<BaseType[]>(() => project.value?.bases || [])
const tables = ref<TableType[]>([])
const projectMetaInfo = ref<ProjectMetaInfo | undefined>()
@ -49,10 +51,13 @@ export const useProject = createSharedComposable(() => {
const projectType = $computed(() => route.params.projectType as string)
const projectMeta = computed<Record<string, any>>(() => {
const defaultMeta = {
showNullAndEmptyInFilter: false,
}
try {
return isString(project.value.meta) ? JSON.parse(project.value.meta) : project.value.meta
return (isString(project.value.meta) ? JSON.parse(project.value.meta) : project.value.meta) ?? defaultMeta
} catch (e) {
return {}
return defaultMeta
}
})
@ -169,6 +174,10 @@ export const useProject = createSharedComposable(() => {
$e('c:themes:change')
}
async function hasEmptyOrNullFilters() {
return await api.project.hasEmptyOrNullFilters(projectId.value)
}
const reset = () => {
project.value = {}
tables.value = []
@ -207,5 +216,6 @@ export const useProject = createSharedComposable(() => {
isLoading,
lastOpenedViewMap,
isXcdbBase,
hasEmptyOrNullFilters,
}
})

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

@ -1,15 +1,20 @@
import type { FilterType, ViewType } from 'nocodb-sdk'
import type { ColumnType, FilterType, ViewType } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue'
import type { SelectProps } from 'ant-design-vue'
import { UITypes, isSystemColumn } from 'nocodb-sdk'
import {
ActiveViewInj,
IsPublicInj,
ReloadViewDataHookInj,
MetaInj,
computed,
extractSdkResponseErrorMsg,
inject,
message,
ref,
useDebounceFn,
useMetas,
useNuxtApp,
useProject,
useUIPermission,
watch,
} from '#imports'
@ -30,6 +35,8 @@ export function useViewFilters(
const { nestedFilters } = useSmartsheetStoreOrThrow()
const { projectMeta } = useProject()
const isPublic = inject(IsPublicInj, ref(false))
const { $api, $e } = useNuxtApp()
@ -68,12 +75,81 @@ export function useViewFilters(
// nonDeletedFilters are those filters that are not deleted physically & virtually
const nonDeletedFilters = computed(() => filters.value.filter((f) => f.status !== 'delete'))
const placeholderFilter: Filter = {
comparison_op: 'eq',
const meta = inject(MetaInj, ref())
const activeView = inject(ActiveViewInj, ref())
const { showSystemFields, metaColumnById } = useViewColumns(activeView, meta)
const options = computed<SelectProps['options']>(() =>
meta.value?.columns?.filter((c: ColumnType) => {
if (isSystemColumn(metaColumnById?.value?.[c.id!])) {
/** hide system columns if not enabled */
return showSystemFields.value
} else if (c.uidt === UITypes.QrCode || c.uidt === UITypes.Barcode || c.uidt === UITypes.ID || c.system) {
return false
} else {
const isVirtualSystemField = c.colOptions && c.system
return !isVirtualSystemField
}
}),
)
const types = computed(() => {
if (!meta.value?.columns?.length) {
return {}
}
return meta.value?.columns?.reduce((obj: any, col: any) => {
obj[col.id] = col.uidt
return obj
}, {})
})
const isComparisonOpAllowed = (
filter: FilterType,
compOp: {
text: string
value: string
ignoreVal?: boolean
includedTypes?: UITypes[]
excludedTypes?: UITypes[]
},
) => {
const isNullOrEmptyOp = ['empty', 'notempty', 'null', 'notnull'].includes(compOp.value)
if (compOp.includedTypes) {
// include allowed values only if selected column type matches
if (filter.fk_column_id && compOp.includedTypes.includes(types.value[filter.fk_column_id])) {
// for 'empty', 'notempty', 'null', 'notnull',
// show them based on `showNullAndEmptyInFilter` in Project Settings
return isNullOrEmptyOp ? projectMeta.value.showNullAndEmptyInFilter : true
} else {
return false
}
} else if (compOp.excludedTypes) {
// include not allowed values only if selected column type not matches
if (filter.fk_column_id && !compOp.excludedTypes.includes(types.value[filter.fk_column_id])) {
// for 'empty', 'notempty', 'null', 'notnull',
// show them based on `showNullAndEmptyInFilter` in Project Settings
return isNullOrEmptyOp ? projectMeta.value.showNullAndEmptyInFilter : true
} else {
return false
}
}
// explicitly include for non-null / non-empty ops
return isNullOrEmptyOp ? projectMeta.value.showNullAndEmptyInFilter : true
}
const placeholderFilter = (): Filter => {
return {
comparison_op: comparisonOpList(options.value?.[0].uidt as UITypes).filter((compOp) =>
isComparisonOpAllowed({ fk_column_id: options.value?.[0].id }, compOp),
)?.[0].value,
value: '',
status: 'create',
logical_op: 'and',
}
}
const loadFilters = async (hookId?: string) => {
if (nestedMode.value) {
@ -191,8 +267,6 @@ export function useViewFilters(
fk_parent_id: parentId,
})
}
reloadHook?.trigger()
} catch (e: any) {
console.log(e)
message.error(await extractSdkResponseErrorMsg(e))
@ -201,13 +275,16 @@ export function useViewFilters(
reloadData?.()
}
const saveOrUpdateDebounced = useDebounceFn(saveOrUpdate, 500)
const addFilter = () => {
filters.value.push({ ...placeholderFilter })
filters.value.push(placeholderFilter())
$e('a:filter:add', { length: filters.value.length })
}
const addFilterGroup = async () => {
const child = { ...placeholderFilter }
const child = placeholderFilter()
const placeHolderGroupFilter: Filter = {
is_group: true,
status: 'create',
@ -234,10 +311,21 @@ export function useViewFilters(
return metas?.value?.[view?.value?.fk_model_id as string]?.columns?.length || 0
},
async (nextColsLength, oldColsLength) => {
async (nextColsLength: number, oldColsLength: number) => {
if (nextColsLength && nextColsLength < oldColsLength) await loadFilters()
},
)
return { filters, nonDeletedFilters, loadFilters, sync, deleteFilter, saveOrUpdate, addFilter, addFilterGroup }
return {
filters,
nonDeletedFilters,
loadFilters,
sync,
deleteFilter,
saveOrUpdate,
addFilter,
addFilterGroup,
saveOrUpdateDebounced,
isComparisonOpAllowed,
}
}

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

@ -631,6 +631,11 @@
"deleteViewConfirmation": "Are you sure you want to delete this view?",
"deleteTableConfirmation": "Do you want to delete the table",
"showM2mTables": "Show M2M Tables",
"showM2mTablesDesc": "Many-to-many relation is supported via a junction table & is hidden by default. Enable this option to list all such tables along with existing tables.",
"showNullInCells": "Show NULL in Cells",
"showNullInCellsDesc": "Display 'NULL' tag in cells holding NULL value. This helps differentiate against cells holding EMPTY string.",
"showNullAndEmptyInFilter": "Show NULL and EMPTY in Filter",
"showNullAndEmptyInFilterDesc": "Enable 'additional' filters to differentiate fields containing NULL & Empty Strings. Default support for Blank treats both NULL & Empty strings alike.",
"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",
"computedFieldDeleteWarning": "Computed field: contents are read-only. Unable to clear content.",

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

@ -54,6 +54,7 @@
"@iconify-json/clarity": "^1.1.4",
"@iconify-json/eva": "^1.1.2",
"@iconify-json/ic": "^1.1.7",
"@iconify-json/la": "^1.1.2",
"@iconify-json/logos": "^1.1.14",
"@iconify-json/lucide": "^1.1.36",
"@iconify-json/material-symbols": "^1.1.8",
@ -1119,6 +1120,15 @@
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/la": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@iconify-json/la/-/la-1.1.2.tgz",
"integrity": "sha512-Cv93a5X5n9gYeUeQ7h9z5tmoZzChvwvbCorBQwMQgwCnMQynH6dCKdtbtYsZyT5wH4QYwywv7xgvpBIkqvZgqg==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/logos": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/@iconify-json/logos/-/logos-1.1.18.tgz",
@ -18589,6 +18599,15 @@
"@iconify/types": "*"
}
},
"@iconify-json/la": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@iconify-json/la/-/la-1.1.2.tgz",
"integrity": "sha512-Cv93a5X5n9gYeUeQ7h9z5tmoZzChvwvbCorBQwMQgwCnMQynH6dCKdtbtYsZyT5wH4QYwywv7xgvpBIkqvZgqg==",
"dev": true,
"requires": {
"@iconify/types": "*"
}
},
"@iconify-json/logos": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/@iconify-json/logos/-/logos-1.1.18.tgz",

1
packages/nc-gui/package.json

@ -77,6 +77,7 @@
"@iconify-json/clarity": "^1.1.4",
"@iconify-json/eva": "^1.1.2",
"@iconify-json/ic": "^1.1.7",
"@iconify-json/la": "^1.1.2",
"@iconify-json/logos": "^1.1.14",
"@iconify-json/lucide": "^1.1.36",
"@iconify-json/material-symbols": "^1.1.8",

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

@ -1,12 +1,46 @@
import { UITypes } from 'nocodb-sdk'
import { UITypes, isNumericCol, numericUITypes } from 'nocodb-sdk'
export const comparisonOpList: {
const getEqText = (fieldUiType: UITypes) => {
if (isNumericCol(fieldUiType)) {
return '='
} else if ([UITypes.SingleSelect, UITypes.Collaborator, UITypes.LinkToAnotherRecord].includes(fieldUiType)) {
return 'is'
}
return 'is equal'
}
const getNeqText = (fieldUiType: UITypes) => {
if (isNumericCol(fieldUiType)) {
return '!='
} else if ([UITypes.SingleSelect, UITypes.Collaborator, UITypes.LinkToAnotherRecord].includes(fieldUiType)) {
return 'is not'
}
return 'is not equal'
}
const getLikeText = (fieldUiType: UITypes) => {
if (fieldUiType === UITypes.Attachment) {
return 'filenames contain'
}
return 'is like'
}
const getNotLikeText = (fieldUiType: UITypes) => {
if (fieldUiType === UITypes.Attachment) {
return "filenames doesn't contain"
}
return 'is not like'
}
export const comparisonOpList = (
fieldUiType: UITypes,
): {
text: string
value: string
ignoreVal?: boolean
includedTypes?: UITypes[]
excludedTypes?: UITypes[]
}[] = [
}[] => [
{
text: 'is checked',
value: 'checked',
@ -20,44 +54,84 @@ export const comparisonOpList: {
includedTypes: [UITypes.Checkbox],
},
{
text: 'is equal',
text: getEqText(fieldUiType),
value: 'eq',
excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.Attachment],
},
{
text: 'is not equal',
text: getNeqText(fieldUiType),
value: 'neq',
excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.Attachment],
},
{
text: 'is like',
text: getLikeText(fieldUiType),
value: 'like',
excludedTypes: [UITypes.Checkbox],
excludedTypes: [UITypes.Checkbox, UITypes.SingleSelect, UITypes.MultiSelect, UITypes.Collaborator, ...numericUITypes],
},
{
text: 'is not like',
text: getNotLikeText(fieldUiType),
value: 'nlike',
excludedTypes: [UITypes.Checkbox],
excludedTypes: [UITypes.Checkbox, UITypes.SingleSelect, UITypes.MultiSelect, UITypes.Collaborator, ...numericUITypes],
},
{
text: 'is empty',
value: 'empty',
ignoreVal: true,
excludedTypes: [UITypes.Checkbox, UITypes.Rating, UITypes.Number, UITypes.Decimal, UITypes.Percent, UITypes.Currency],
excludedTypes: [
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.Collaborator,
UITypes.Attachment,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
...numericUITypes,
],
},
{
text: 'is not empty',
value: 'notempty',
ignoreVal: true,
excludedTypes: [UITypes.Checkbox, UITypes.Rating, UITypes.Number, UITypes.Decimal, UITypes.Percent, UITypes.Currency],
excludedTypes: [
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.Collaborator,
UITypes.Attachment,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
...numericUITypes,
],
},
{
text: 'is null',
value: 'null',
ignoreVal: true,
excludedTypes: [
...numericUITypes,
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.Collaborator,
UITypes.Attachment,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
],
},
{
text: 'is not null',
value: 'notnull',
ignoreVal: true,
excludedTypes: [
...numericUITypes,
UITypes.Checkbox,
UITypes.SingleSelect,
UITypes.MultiSelect,
UITypes.Collaborator,
UITypes.Attachment,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
],
},
{
text: 'contains all of',
@ -82,21 +156,33 @@ export const comparisonOpList: {
{
text: '>',
value: 'gt',
excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.SingleSelect],
includedTypes: [...numericUITypes],
},
{
text: '<',
value: 'lt',
excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.SingleSelect],
includedTypes: [...numericUITypes],
},
{
text: '>=',
value: 'gte',
excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.SingleSelect],
includedTypes: [...numericUITypes],
},
{
text: '<=',
value: 'lte',
excludedTypes: [UITypes.Checkbox, UITypes.MultiSelect, UITypes.SingleSelect],
includedTypes: [...numericUITypes],
},
{
text: 'is blank',
value: 'blank',
ignoreVal: true,
excludedTypes: [UITypes.Checkbox],
},
{
text: 'is not blank',
value: 'notblank',
ignoreVal: true,
excludedTypes: [UITypes.Checkbox],
},
]

14
packages/noco-docs/content/en/setup-and-usages/column-operations.md

@ -71,6 +71,16 @@ You can also group several filters together using Filter Group.
<img width="1025" alt="image" src="https://user-images.githubusercontent.com/35857179/189102932-aa0e31ef-554f-4e8b-ae0e-2024b7f4d35b.png">
### Supported filters
### Enable NULL and EMPTY Filters
Currently we support filter types - `is equal`, `is not equal`, `is like`, `is not like`, `is null`, `is not null` for string fields. We also support filter types - `>`, `<`, `>=`, and `<=` for numeric fields. Also we provide `is empty` and `is not empty` for checking if the column is empty or not.
NULL filters (`is null` & `is not null`) and EMPTY filters (`is empty` & `is not empty`) are hidden by default. If you wish to filter out either one only, you may enable `Show NULL and EMPTY Filter` in Project Settings.
![image](https://user-images.githubusercontent.com/35857179/219009085-0308b2a9-10af-4afe-84b6-df52e42fb1a8.png)
Otherwise, we can use Blank filters to filter out cells with NULL values and EMPTY values.
### Supported Filters
Currently we support different types of filters for corresponding columns. Please refer the below matrix for details.
<iframe width="100%" height="700vh" src="https://docs.google.com/spreadsheets/d/e/2PACX-1vTpCNKtA-szaXUKJEO5uuSIRnzUOK793MKnyBz9m2rQcwn7HqK19jPHeER-IIRWH9X56J78wfxXZuuv/pubhtml?gid=427284630&amp;single=true&amp;widget=true&amp;headers=false"></iframe>

7
packages/nocodb-sdk/src/index.ts

@ -7,7 +7,12 @@ export * from './lib/globals';
export * from './lib/helperFunctions';
export * from './lib/enums';
export * from './lib/formulaHelpers';
export { default as UITypes, isVirtualCol } from './lib/UITypes';
export {
default as UITypes,
numericUITypes,
isNumericCol,
isVirtualCol,
} from './lib/UITypes';
export { default as CustomAPI } from './lib/CustomAPI';
export { default as TemplateGenerator } from './lib/TemplateGenerator';
export * from './lib/passwordHelpers';

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

@ -1869,6 +1869,22 @@ export class Api<
* No description
*
* @tags Project
* @name HasEmptyOrNullFilters
* @request GET:/api/v1/db/meta/projects/{projectId}/has-empty-or-null-filters
* @response `200` `any` OK
*/
hasEmptyOrNullFilters: (projectId: string, params: RequestParams = {}) =>
this.request<any, any>({
path: `/api/v1/db/meta/projects/${projectId}/has-empty-or-null-filters`,
method: 'GET',
format: 'json',
...params,
}),
/**
* No description
*
* @tags Project
* @name AuditList
* @request GET:/api/v1/db/meta/projects/{projectId}/audits
* @response `200` `{

22
packages/nocodb-sdk/src/lib/UITypes.ts

@ -39,6 +39,28 @@ enum UITypes {
Button = 'Button',
}
export const numericUITypes = [
UITypes.Duration,
UITypes.Currency,
UITypes.Percent,
UITypes.Number,
UITypes.Decimal,
UITypes.Rating,
UITypes.Rollup,
];
export function isNumericCol(
col:
| UITypes
| { readonly uidt: UITypes | string }
| ColumnReqType
| ColumnType
) {
return numericUITypes.includes(
<UITypes>(typeof col === 'object' ? col?.uidt : col)
);
}
export function isVirtualCol(
col:
| UITypes

2
packages/nocodb/src/lib/Noco.ts

@ -105,7 +105,7 @@ export default class Noco {
constructor() {
process.env.PORT = process.env.PORT || '8080';
// todo: move
process.env.NC_VERSION = '0104003';
process.env.NC_VERSION = '0104004';
// if env variable NC_MINIMAL_DBS is set, then disable project creation with external sources
if (process.env.NC_MINIMAL_DBS) {

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

@ -8,7 +8,7 @@ import genRollupSelectv2 from './genRollupSelectv2';
import RollupColumn from '../../../../models/RollupColumn';
import formulaQueryBuilderv2 from './formulav2/formulaQueryBuilderv2';
import FormulaColumn from '../../../../models/FormulaColumn';
import { RelationTypes, UITypes } from 'nocodb-sdk';
import { RelationTypes, UITypes, isNumericCol } from 'nocodb-sdk';
import { sanitize } from './helpers/sanitize';
export default async function conditionV2(
@ -271,37 +271,135 @@ const parseConditionV2 = async (
return (qb: Knex.QueryBuilder) => {
let [field, val] = [_field, _val];
if (
[UITypes.Date, UITypes.DateTime].includes(column.uidt) &&
!val &&
['is', 'isnot'].includes(filter.comparison_op)
) {
// for date & datetime,
// val cannot be empty for non-is & non-isnot filters
return;
}
if (isNumericCol(column.uidt) && typeof val === 'string') {
// convert to number
val = +val;
}
switch (filter.comparison_op) {
case 'eq':
if (qb?.client?.config?.client === 'mysql2') {
if (
[
UITypes.Duration,
UITypes.Currency,
UITypes.Percent,
UITypes.Number,
UITypes.Decimal,
UITypes.Rating,
UITypes.Rollup,
].includes(column.uidt)
) {
qb = qb.where(field, val);
} else {
// mysql is case-insensitive for strings, turn to case-sensitive
qb = qb.whereRaw('BINARY ?? = ?', [field, val]);
}
} else {
qb = qb.where(field, val);
}
if (column.uidt === UITypes.Rating && val === 0) {
// unset rating is considered as NULL
qb = qb.orWhereNull(field);
}
break;
case 'neq':
case 'not':
if (qb?.client?.config?.client === 'mysql2') {
if (
[
UITypes.Duration,
UITypes.Currency,
UITypes.Percent,
UITypes.Number,
UITypes.Decimal,
UITypes.Rollup,
].includes(column.uidt)
) {
qb = qb.where((nestedQb) => {
nestedQb
.whereNot(field, val)
.orWhereNull(customWhereClause ? _val : _field);
});
} else if (column.uidt === UITypes.Rating) {
// unset rating is considered as NULL
if (val === 0) {
qb = qb.whereNot(field, val).whereNotNull(field);
} else {
qb = qb.whereNot(field, val).orWhereNull(field);
}
} else {
// mysql is case-insensitive for strings, turn to case-sensitive
qb = qb.where((nestedQb) => {
nestedQb.whereRaw('BINARY ?? != ?', [field, val]);
if (column.uidt !== UITypes.Rating) {
nestedQb.orWhereNull(customWhereClause ? _val : _field);
}
});
}
} else {
qb = qb.where((nestedQb) => {
nestedQb
.whereNot(field, val)
.orWhereNull(customWhereClause ? _val : _field);
});
}
break;
case 'like':
if (!val) {
if (column.uidt === UITypes.Attachment) {
qb = qb
.orWhereNull(field)
.orWhere(field, '[]')
.orWhere(field, 'null');
} else {
// val is empty -> all values including empty strings but NULL
qb.where(field, '');
qb.orWhereNotNull(field);
}
} else {
if (column.uidt === UITypes.Formula) {
[field, val] = [val, field];
val = `%${val}%`.replace(/^%'([\s\S]*)'%$/, '%$1%');
} else {
val = val.startsWith('%') || val.endsWith('%') ? val : `%${val}%`;
val =
val.startsWith('%') || val.endsWith('%') ? val : `%${val}%`;
}
if (qb?.client?.config?.client === 'pg') {
qb = qb.whereRaw('??::text ilike ?', [field, val]);
} else {
qb = qb.where(field, 'like', val);
}
}
break;
case 'nlike':
if (!val) {
if (column.uidt === UITypes.Attachment) {
qb.whereNot(field, '')
.whereNot(field, 'null')
.whereNot(field, '[]');
} else {
// val is empty -> all values including NULL but empty strings
qb.whereNot(field, '');
qb.orWhereNull(field);
}
} else {
if (column.uidt === UITypes.Formula) {
[field, val] = [val, field];
val = `%${val}%`.replace(/^%'([\s\S]*)'%$/, '%$1%');
} else {
val = val.startsWith('%') || val.endsWith('%') ? val : `%${val}%`;
val =
val.startsWith('%') || val.endsWith('%') ? val : `%${val}%`;
}
qb.where((nestedQb) => {
if (qb?.client?.config?.client === 'pg') {
@ -309,8 +407,16 @@ const parseConditionV2 = async (
} else {
nestedQb.whereNot(field, 'like', val);
}
if (val !== '%%') {
// if value is not empty, empty or null should be included
nestedQb.orWhere(field, '');
nestedQb.orWhereNull(field);
} else {
// if value is empty, then only null is included
nestedQb.orWhereNull(field);
}
});
}
break;
case 'allof':
case 'anyof':
@ -319,8 +425,8 @@ const parseConditionV2 = async (
{
// Condition for filter, without negation
const condition = (builder: Knex.QueryBuilder) => {
const items = val.split(',').map((item) => item.trim());
for (let i = 0; i < items.length; i++) {
const items = val?.split(',').map((item) => item.trim());
for (let i = 0; i < items?.length; i++) {
let sql;
const bindings = [field, `%,${items[i]},%`];
if (qb?.client?.config?.client === 'pg') {
@ -355,11 +461,25 @@ const parseConditionV2 = async (
}
break;
case 'gt':
qb = qb.where(field, customWhereClause ? '<' : '>', val);
const gt_op = customWhereClause ? '<' : '>';
qb = qb.where(field, gt_op, val);
if (column.uidt === UITypes.Rating) {
// unset rating is considered as NULL
if (gt_op === '<' && val > 0) {
qb = qb.orWhereNull(field);
}
}
break;
case 'ge':
case 'gte':
qb = qb.where(field, customWhereClause ? '<=' : '>=', val);
const ge_op = customWhereClause ? '<=' : '>=';
qb = qb.where(field, ge_op, val);
if (column.uidt === UITypes.Rating) {
// unset rating is considered as NULL
if (ge_op === '<=' || (ge_op === '>=' && val === 0)) {
qb = qb.orWhereNull(field);
}
}
break;
case 'in':
qb = qb.whereIn(
@ -375,7 +495,9 @@ const parseConditionV2 = async (
else if (filter.value === 'empty')
qb = qb.where(customWhereClause || field, '');
else if (filter.value === 'notempty')
qb = qb.whereNot(customWhereClause || field, '');
qb = qb
.whereNot(customWhereClause || field, '')
.orWhereNull(field);
else if (filter.value === 'true')
qb = qb.where(customWhereClause || field, true);
else if (filter.value === 'false')
@ -396,13 +518,26 @@ const parseConditionV2 = async (
qb = qb.whereNot(customWhereClause || field, false);
break;
case 'lt':
qb = qb.where(field, customWhereClause ? '>' : '<', val);
const lt_op = customWhereClause ? '>' : '<';
qb = qb.where(field, lt_op, val);
if (column.uidt === UITypes.Rating) {
// unset number is considered as NULL
if (lt_op === '<' && val > 0) {
qb = qb.orWhereNull(field);
}
}
break;
case 'le':
case 'lte':
qb = qb.where(field, customWhereClause ? '>=' : '<=', val);
const le_op = customWhereClause ? '>=' : '<=';
qb = qb.where(field, le_op, val);
if (column.uidt === UITypes.Rating) {
// unset number is considered as NULL
if (le_op === '<=' || (le_op === '>=' && val === 0)) {
qb = qb.orWhereNull(field);
}
}
break;
case 'empty':
if (column.uidt === UITypes.Formula) {
[field, val] = [val, field];
@ -413,7 +548,7 @@ const parseConditionV2 = async (
if (column.uidt === UITypes.Formula) {
[field, val] = [val, field];
}
qb = qb.whereNot(field, val);
qb = qb.whereNot(field, val).orWhereNull(field);
break;
case 'null':
qb = qb.whereNull(customWhereClause || field);
@ -421,6 +556,32 @@ const parseConditionV2 = async (
case 'notnull':
qb = qb.whereNotNull(customWhereClause || field);
break;
case 'blank':
if (column.uidt === UITypes.Attachment) {
qb = qb
.whereNull(customWhereClause || field)
.orWhere(field, '[]')
.orWhere(field, 'null');
} else {
qb = qb.whereNull(customWhereClause || field);
if (!isNumericCol(column.uidt)) {
qb = qb.orWhere(field, '');
}
}
break;
case 'notblank':
if (column.uidt === UITypes.Attachment) {
qb = qb
.whereNotNull(customWhereClause || field)
.whereNot(field, '[]')
.whereNot(field, 'null');
} else {
qb = qb.whereNotNull(customWhereClause || field);
if (!isNumericCol(column.uidt)) {
qb = qb.whereNot(field, '');
}
}
break;
case 'checked':
qb = qb.where(customWhereClause || field, true);
break;
@ -480,6 +641,15 @@ async function generateLookupCondition(
qb.select(`${alias}.${childColumn.column_name}`);
if (filter.comparison_op === 'blank') {
return (qbP: Knex.QueryBuilder) => {
qbP.whereNotIn(childColumn.column_name, qb);
};
} else if (filter.comparison_op === 'notblank') {
return (qbP: Knex.QueryBuilder) => {
qbP.whereIn(childColumn.column_name, qb);
};
} else {
await nestedConditionJoin(
{
...filter,
@ -493,6 +663,7 @@ async function generateLookupCondition(
alias,
aliasCount
);
}
return (qbP: Knex.QueryBuilder) => {
if (filter.comparison_op in negatedMapping)
@ -503,6 +674,15 @@ async function generateLookupCondition(
qb = knex(`${parentModel.table_name} as ${alias}`);
qb.select(`${alias}.${parentColumn.column_name}`);
if (filter.comparison_op === 'blank') {
return (qbP: Knex.QueryBuilder) => {
qbP.whereNotIn(childColumn.column_name, qb);
};
} else if (filter.comparison_op === 'notblank') {
return (qbP: Knex.QueryBuilder) => {
qbP.whereIn(childColumn.column_name, qb);
};
} else {
await nestedConditionJoin(
{
...filter,
@ -516,6 +696,7 @@ async function generateLookupCondition(
alias,
aliasCount
);
}
return (qbP: Knex.QueryBuilder) => {
if (filter.comparison_op in negatedMapping)
@ -537,6 +718,15 @@ async function generateLookupCondition(
`${childAlias}.${parentColumn.column_name}`
);
if (filter.comparison_op === 'blank') {
return (qbP: Knex.QueryBuilder) => {
qbP.whereNotIn(childColumn.column_name, qb);
};
} else if (filter.comparison_op === 'notblank') {
return (qbP: Knex.QueryBuilder) => {
qbP.whereIn(childColumn.column_name, qb);
};
} else {
await nestedConditionJoin(
{
...filter,
@ -550,6 +740,7 @@ async function generateLookupCondition(
childAlias,
aliasCount
);
}
return (qbP: Knex.QueryBuilder) => {
if (filter.comparison_op in negatedMapping)

10
packages/nocodb/src/lib/meta/api/projectApis.ts

@ -19,6 +19,7 @@ import { extractPropsAndSanitize } from '../helpers/extractProps';
import NcConfigFactory from '../../utils/NcConfigFactory';
import { promisify } from 'util';
import { populateMeta } from './helpers';
import Filter from '../../models/Filter';
const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz_', 4);
@ -238,6 +239,10 @@ export async function projectCost(req, res) {
res.json({ cost });
}
export async function hasEmptyOrNullFilters(req, res) {
res.json(await Filter.hasEmptyOrNullFilters(req.params.projectId));
}
export default (router) => {
router.get(
'/api/v1/db/meta/projects/:projectId/info',
@ -274,4 +279,9 @@ export default (router) => {
metaApiMetrics,
ncMetaAclMw(projectList, 'projectList')
);
router.get(
'/api/v1/db/meta/projects/:projectId/has-empty-or-null-filters',
metaApiMetrics,
ncMetaAclMw(hasEmptyOrNullFilters, 'hasEmptyOrNullFilters')
);
};

17
packages/nocodb/src/lib/meta/api/sync/helpers/job.ts

@ -1976,8 +1976,10 @@ export default async (
isNotEmpty: 'notempty',
contains: 'like',
doesNotContain: 'nlike',
isAnyOf: 'eq',
isNoneOf: 'neq',
isAnyOf: 'anyof',
isNoneOf: 'nanyof',
'|': 'anyof',
'&': 'allof',
};
async function nc_configureFilters(viewId, f) {
@ -2017,18 +2019,23 @@ export default async (
datatype === UITypes.SingleSelect ||
datatype === UITypes.MultiSelect
) {
if (filter.operator === 'doesNotContain') {
filter.operator = 'isNoneOf';
}
// if array, break it down to multiple filters
if (Array.isArray(filter.value)) {
for (let i = 0; i < filter.value.length; i++) {
const fx = {
fk_column_id: columnId,
logical_op: f.conjunction,
comparison_op: filterMap[filter.operator],
value: await sMap.getNcNameFromAtId(filter.value[i]),
value: (
await Promise.all(
filter.value.map(async (f) => await sMap.getNcNameFromAtId(f))
)
).join(','),
};
ncFilters.push(fx);
}
}
// not array - add as is
else if (filter.value) {
const fx = {

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

@ -54,12 +54,14 @@ export async function validateCondition(filters: Filter[], data: any) {
-1;
break;
case 'empty':
case 'blank':
res =
data[field] === '' ||
data[field] === null ||
data[field] === undefined;
break;
case 'notempty':
case 'notblank':
res = !(
data[field] === '' ||
data[field] === null ||

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

@ -12,6 +12,7 @@ import View from './View';
import { FilterType, UITypes } from 'nocodb-sdk';
import NocoCache from '../cache/NocoCache';
import { NcError } from '../meta/helpers/catchError';
import { extractProps } from '../meta/helpers/extractProps';
export default class Filter {
id: string;
@ -34,6 +35,8 @@ export default class Filter {
| 'notnull'
| 'checked'
| 'notchecked'
| 'blank'
| 'notblank'
| 'allof'
| 'anyof'
| 'nallof'
@ -216,15 +219,14 @@ export default class Filter {
}
static async update(id, filter: Partial<Filter>, ncMeta = Noco.ncMeta) {
const updateObj = {
fk_column_id: filter.fk_column_id,
comparison_op: filter.comparison_op,
value: filter.value,
fk_parent_id: filter.fk_parent_id,
is_group: filter.is_group,
logical_op: filter.logical_op,
};
const updateObj = extractProps(filter, [
'fk_column_id',
'comparison_op',
'value',
'fk_parent_id',
'is_group',
'logical_op',
]);
// get existing cache
const key = `${CacheScope.FILTER_EXP}:${id}`;
let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
@ -239,7 +241,7 @@ export default class Filter {
}
static async delete(id: string, ncMeta = Noco.ncMeta) {
const filter = await this.get(id);
const filter = await this.get(id, ncMeta);
const deleteRecursively = async (filter: Filter) => {
if (!filter) return;
@ -525,4 +527,42 @@ export default class Filter {
}
return filterObjs?.map((f) => new Filter(f));
}
static async hasEmptyOrNullFilters(projectId: string, ncMeta = Noco.ncMeta) {
const emptyOrNullFilterObjs = await ncMeta.metaList2(
null,
null,
MetaTable.FILTER_EXP,
{
condition: {
project_id: projectId,
},
xcCondition: {
_or: [
{
comparison_op: {
eq: 'null',
},
},
{
comparison_op: {
eq: 'notnull',
},
},
{
comparison_op: {
eq: 'empty',
},
},
{
comparison_op: {
eq: 'notempty',
},
},
],
},
}
);
return emptyOrNullFilterObjs.length > 0;
}
}

2
packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts

@ -12,6 +12,7 @@ import ncFilterUpgrader from './ncFilterUpgrader';
import ncAttachmentUpgrader from './ncAttachmentUpgrader';
import ncAttachmentUpgrader_0104002 from './ncAttachmentUpgrader_0104002';
import ncStickyColumnUpgrader from './ncStickyColumnUpgrader';
import ncFilterUpgrader_0104004 from './ncFilterUpgrader_0104004';
const log = debug('nc:version-upgrader');
import boxen from 'boxen';
@ -43,6 +44,7 @@ export default class NcUpgrader {
{ name: '0101002', handler: ncAttachmentUpgrader },
{ name: '0104002', handler: ncAttachmentUpgrader_0104002 },
{ name: '0104003', handler: ncStickyColumnUpgrader },
{ name: '0104004', handler: ncFilterUpgrader_0104004 },
];
if (!(await ctx.ncMeta.knexConnection?.schema?.hasTable?.('nc_store'))) {
return;

358
packages/nocodb/src/lib/version-upgrader/ncFilterUpgrader_0104004.ts

@ -0,0 +1,358 @@
import { NcUpgraderCtx } from './NcUpgrader';
import { MetaTable } from '../utils/globals';
import NcMetaIO from '../meta/NcMetaIO';
import Column from '../models/Column';
import Filter from '../models/Filter';
import Project from '../models/Project';
import { UITypes, SelectOptionsType } from 'nocodb-sdk';
// as of 0.104.3, almost all filter operators are available to all column types
// while some of them aren't supposed to be shown
// this upgrader is to remove those unsupported filters / migrate to the correct filter
// Change Summary:
// - Text-based columns:
// - remove `>`, `<`, `>=`, `<=`
// - Numeric-based / SingleSelect columns:
// - remove `like`
// - migrate `null`, and `empty` to `blank`
// - Checkbox columns:
// - remove `equal`
// - migrate `empty` and `null` to `notchecked`
// - MultiSelect columns:
// - remove `like`
// - migrate `equal`, `null`, `empty`
// - Attachment columns:
// - remove `>`, `<`, `>=`, `<=`, `equal`
// - migrate `empty`, `null` to `blank`
// - LTAR columns:
// - remove `>`, `<`, `>=`, `<=`
// - migrate `empty`, `null` to `blank`
// - Lookup columns:
// - migrate `empty`, `null` to `blank`
// - Duration columns:
// - remove `like`
// - migrate `empty`, `null` to `blank`
const removeEqualFilters = (filter, ncMeta) => {
let actions = [];
// remove `is equal`, `is not equal`
if (['eq', 'neq'].includes(filter.comparison_op)) {
actions.push(Filter.delete(filter.id, ncMeta));
}
return actions;
};
const removeArithmeticFilters = (filter, ncMeta) => {
let actions = [];
// remove `>`, `<`, `>=`, `<=`
if (['gt', 'lt', 'gte', 'lte'].includes(filter.comparison_op)) {
actions.push(Filter.delete(filter.id, ncMeta));
}
return actions;
};
const removeLikeFilters = (filter, ncMeta) => {
let actions = [];
// remove `is like`, `is not like`
if (['like', 'nlike'].includes(filter.comparison_op)) {
actions.push(Filter.delete(filter.id, ncMeta));
}
return actions;
};
const migrateNullAndEmptyToBlankFilters = (filter, ncMeta) => {
let actions = [];
if (['empty', 'null'].includes(filter.comparison_op)) {
// migrate to blank
actions.push(
Filter.update(
filter.id,
{
comparison_op: 'blank',
},
ncMeta
)
);
} else if (['notempty', 'notnull'].includes(filter.comparison_op)) {
// migrate to not blank
actions.push(
Filter.update(
filter.id,
{
comparison_op: 'notblank',
},
ncMeta
)
);
}
return actions;
};
const migrateMultiSelectEq = async (filter, col: Column, ncMeta) => {
// only allow eq / neq
if (!['eq', 'neq'].includes(filter.comparison_op)) return;
// if there is no value -> delete this filter
if (!filter.value) {
return await Filter.delete(filter.id, ncMeta);
}
// options inputted from users
const options = filter.value.split(',');
// retrieve the possible col options
const colOptions = (await col.getColOptions()) as SelectOptionsType;
// only include valid options as the input value becomes dropdown type now
let validOptions = [];
for (const option of options) {
if (colOptions.options.includes(option)) {
validOptions.push(option);
}
}
const newFilterValue = validOptions.join(',');
// if all inputted options are invalid -> delete this filter
if (!newFilterValue) {
return await Filter.delete(filter.id, ncMeta);
}
let actions = [];
if (filter.comparison_op === 'eq') {
// migrate to `contains all of`
actions.push(
Filter.update(
filter.id,
{
comparison_op: 'anyof',
value: newFilterValue,
},
ncMeta
)
);
} else if (filter.comparison_op === 'neq') {
// migrate to `doesn't contain all of`
actions.push(
Filter.update(
filter.id,
{
comparison_op: 'nanyof',
value: newFilterValue,
},
ncMeta
)
);
}
return await Promise.all(actions);
};
const migrateToCheckboxFilter = (filter, ncMeta) => {
let actions = [];
const possibleTrueValues = ['true', 'True', '1', 'T', 'Y'];
const possibleFalseValues = ['false', 'False', '0', 'F', 'N'];
if (['empty', 'null'].includes(filter.comparison_op)) {
// migrate to not checked
actions.push(
Filter.update(
filter.id,
{
comparison_op: 'notchecked',
},
ncMeta
)
);
} else if (['notempty', 'notnull'].includes(filter.comparison_op)) {
// migrate to checked
actions.push(
Filter.update(
filter.id,
{
comparison_op: 'checked',
},
ncMeta
)
);
} else if (filter.comparison_op === 'eq') {
if (possibleTrueValues.includes(filter.value)) {
// migrate to checked
actions.push(
Filter.update(
filter.id,
{
comparison_op: 'checked',
value: '',
},
ncMeta
)
);
} else if (possibleFalseValues.includes(filter.value)) {
// migrate to notchecked
actions.push(
Filter.update(
filter.id,
{
comparison_op: 'notchecked',
value: '',
},
ncMeta
)
);
} else {
// invalid value - good to delete
actions.push(Filter.delete(filter.id, ncMeta));
}
} else if (filter.comparison_op === 'neq') {
if (possibleFalseValues.includes(filter.value)) {
// migrate to checked
actions.push(
Filter.update(
filter.id,
{
comparison_op: 'checked',
value: '',
},
ncMeta
)
);
} else if (possibleTrueValues.includes(filter.value)) {
// migrate to not checked
actions.push(
Filter.update(
filter.id,
{
comparison_op: 'notchecked',
value: '',
},
ncMeta
)
);
} else {
// invalid value - good to delete
actions.push(Filter.delete(filter.id, ncMeta));
}
}
return actions;
};
async function migrateFilters(ncMeta: NcMetaIO) {
const filters = await ncMeta.metaList2(null, null, MetaTable.FILTER_EXP);
for (const filter of filters) {
if (!filter.fk_column_id || filter.is_group) {
continue;
}
const col = await Column.get({ colId: filter.fk_column_id }, ncMeta);
if (
[
UITypes.SingleLineText,
UITypes.LongText,
UITypes.PhoneNumber,
UITypes.Email,
UITypes.URL,
].includes(col.uidt)
) {
await Promise.all(removeArithmeticFilters(filter, ncMeta));
} else if (
[
// numeric fields
UITypes.Duration,
UITypes.Currency,
UITypes.Percent,
UITypes.Number,
UITypes.Decimal,
UITypes.Rating,
UITypes.Rollup,
// select fields
UITypes.SingleSelect,
].includes(col.uidt)
) {
await Promise.all([
...removeLikeFilters(filter, ncMeta),
...migrateNullAndEmptyToBlankFilters(filter, ncMeta),
]);
} else if (col.uidt === UITypes.Checkbox) {
await Promise.all(migrateToCheckboxFilter(filter, ncMeta));
} else if (col.uidt === UITypes.MultiSelect) {
await Promise.all([
...removeLikeFilters(filter, ncMeta),
...migrateNullAndEmptyToBlankFilters(filter, ncMeta),
]);
await migrateMultiSelectEq(filter, col, ncMeta);
} else if (col.uidt === UITypes.Attachment) {
await Promise.all([
...removeArithmeticFilters(filter, ncMeta),
...removeEqualFilters(filter, ncMeta),
...migrateNullAndEmptyToBlankFilters(filter, ncMeta),
]);
} else if (col.uidt === UITypes.LinkToAnotherRecord) {
await Promise.all([
...removeArithmeticFilters(filter, ncMeta),
...migrateNullAndEmptyToBlankFilters(filter, ncMeta),
]);
} else if (col.uidt === UITypes.Lookup) {
await Promise.all([
...removeArithmeticFilters(filter, ncMeta),
...migrateNullAndEmptyToBlankFilters(filter, ncMeta),
]);
} else if (col.uidt === UITypes.Duration) {
await Promise.all([
...removeLikeFilters(filter, ncMeta),
...migrateNullAndEmptyToBlankFilters(filter, ncMeta),
]);
}
}
}
async function updateProjectMeta(ncMeta: NcMetaIO) {
const projectHasEmptyOrFilters: Record<string, boolean> = {};
const filters = await ncMeta.metaList2(null, null, MetaTable.FILTER_EXP);
let actions = [];
for (const filter of filters) {
if (
['notempty', 'notnull', 'empty', 'null'].includes(filter.comparison_op)
) {
projectHasEmptyOrFilters[filter.project_id] = true;
}
}
const projects = await ncMeta.metaList2(null, null, MetaTable.PROJECT);
const defaultProjectMeta = {
showNullAndEmptyInFilter: false,
};
for (const project of projects) {
const oldProjectMeta = project.meta;
let newProjectMeta = defaultProjectMeta;
try {
newProjectMeta =
(typeof oldProjectMeta === 'string'
? JSON.parse(oldProjectMeta)
: oldProjectMeta) ?? defaultProjectMeta;
} catch {}
newProjectMeta = {
...newProjectMeta,
showNullAndEmptyInFilter: projectHasEmptyOrFilters[project.id] ?? false,
};
actions.push(
Project.update(
project.id,
{
meta: JSON.stringify(newProjectMeta),
},
ncMeta
)
);
}
await Promise.all(actions);
}
export default async function ({ ncMeta }: NcUpgraderCtx) {
// fix the existing filter behaviours or
// migrate `null` or `empty` filters to `blank`
await migrateFilters(ncMeta);
// enrich `showNullAndEmptyInFilter` in project meta
// if there is empty / null filters in existing projects,
// then set `showNullAndEmptyInFilter` to true
// else set to false
await updateProjectMeta(ncMeta);
}

18
packages/nocodb/tests/unit/rest/tests/filter.test.ts

@ -96,17 +96,23 @@ async function retrieveRecordsAndValidate(
);
break;
case 'lt':
expectedRecords = unfilteredRecords.filter(
(record) =>
(toFloat ? parseFloat(record[title]) : record[title]) <
expectedRecords = unfilteredRecords.filter((record) =>
title === 'Rating'
? (toFloat ? parseFloat(record[title]) : record[title]) <
(toFloat ? parseFloat(filter.value) : filter.value) ||
record[title] === null
: (toFloat ? parseFloat(record[title]) : record[title]) <
(toFloat ? parseFloat(filter.value) : filter.value) &&
record[title] !== null
);
break;
case 'lte':
expectedRecords = unfilteredRecords.filter(
(record) =>
(toFloat ? parseFloat(record[title]) : record[title]) <=
expectedRecords = unfilteredRecords.filter((record) =>
title === 'Rating'
? (toFloat ? parseFloat(record[title]) : record[title]) <=
(toFloat ? parseFloat(filter.value) : filter.value) ||
record[title] === null
: (toFloat ? parseFloat(record[title]) : record[title]) <=
(toFloat ? parseFloat(filter.value) : filter.value) &&
record[title] !== null
);

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

@ -675,8 +675,7 @@ function tableTest() {
logical_op: 'and',
fk_column_id: activeColumn?.id,
status: 'create',
comparison_op: 'eq',
value: 1,
comparison_op: 'checked',
},
],
},

30
scripts/sdk/swagger.json

@ -3367,6 +3367,36 @@
}
}
},
"/api/v1/db/meta/projects/{projectId}/has-empty-or-null-filters": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "projectId",
"in": "path",
"required": true
}
],
"get": {
"summary": "",
"operationId": "project-has-empty-or-null-filters",
"parameters": [],
"tags": [
"Project"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/api/v1/db/meta/projects/{projectId}/meta-diff/{baseId}": {
"parameters": [
{

54
tests/playwright/package-lock.json generated

@ -11,6 +11,7 @@
"dependencies": {
"body-parser": "^1.20.1",
"express": "^4.18.2",
"nocodb-sdk": "^0.104.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
@ -2032,7 +2033,6 @@
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"dev": true,
"funding": [
{
"type": "individual",
@ -2713,6 +2713,14 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsep": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.3.8.tgz",
"integrity": "sha512-qofGylTGgYj9gZFsHuyWAN4jr35eJ66qJCK4eKDnldohuUoQFbU3iZn2zjvEbd9wOAhP9Wx5DsAAduTyE1PSWQ==",
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@ -3165,6 +3173,23 @@
"node": ">= 0.6"
}
},
"node_modules/nocodb-sdk": {
"version": "0.104.3",
"resolved": "https://registry.npmjs.org/nocodb-sdk/-/nocodb-sdk-0.104.3.tgz",
"integrity": "sha512-C6uIeexVz2aMWmabpaut3Y/sI8Myp2cRDyKZIjRXmMjSdV1F8BIRrPWVTLGSgHxe6YECFtl+eLR5ma70eJsqCw==",
"dependencies": {
"axios": "^0.21.1",
"jsep": "^1.3.6"
}
},
"node_modules/nocodb-sdk/node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/node-pre-gyp": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz",
@ -6431,8 +6456,7 @@
"follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"dev": true
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="
},
"forwarded": {
"version": "0.2.0",
@ -6898,6 +6922,11 @@
"esprima": "^4.0.0"
}
},
"jsep": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.3.8.tgz",
"integrity": "sha512-qofGylTGgYj9gZFsHuyWAN4jr35eJ66qJCK4eKDnldohuUoQFbU3iZn2zjvEbd9wOAhP9Wx5DsAAduTyE1PSWQ=="
},
"json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@ -7248,6 +7277,25 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="
},
"nocodb-sdk": {
"version": "0.104.3",
"resolved": "https://registry.npmjs.org/nocodb-sdk/-/nocodb-sdk-0.104.3.tgz",
"integrity": "sha512-C6uIeexVz2aMWmabpaut3Y/sI8Myp2cRDyKZIjRXmMjSdV1F8BIRrPWVTLGSgHxe6YECFtl+eLR5ma70eJsqCw==",
"requires": {
"axios": "^0.21.1",
"jsep": "^1.3.6"
},
"dependencies": {
"axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"requires": {
"follow-redirects": "^1.14.0"
}
}
}
},
"node-pre-gyp": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz",

1
tests/playwright/package.json

@ -45,6 +45,7 @@
"dependencies": {
"body-parser": "^1.20.1",
"express": "^4.18.2",
"nocodb-sdk": "^0.104.3",
"xlsx": "^0.18.5"
}
}

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

@ -201,6 +201,25 @@ export class GridPage extends BasePage {
await this.dashboard.waitForLoaderToDisappear();
}
async verifyTotalRowCount({ count }: { count: number }) {
// wait for 100 ms and try again : 5 times
let i = 0;
await this.get().locator(`.nc-pagination`).waitFor();
let records = await this.get().locator(`[data-testid="grid-pagination"]`).allInnerTexts();
let recordCnt = records[0].split(' ')[0];
while (parseInt(recordCnt) !== count && i < 5) {
await this.get().locator(`.nc-pagination`).waitFor();
records = await this.get().locator(`[data-testid="grid-pagination"]`).allInnerTexts();
recordCnt = records[0].split(' ')[0];
// to ensure page loading is complete
await this.rootPage.waitForTimeout(500);
i++;
}
expect(parseInt(recordCnt)).toEqual(count);
}
private async pagination({ page }: { page: string }) {
await this.get().locator(`.nc-pagination`).waitFor();

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

@ -21,4 +21,8 @@ export class MiscSettingsPage extends BasePage {
httpMethodsToMatch: ['GET'],
});
}
async clickShowNullEmptyFilters() {
await this.get().locator('input[type="checkbox"]').last().click();
}
}

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

@ -49,4 +49,10 @@ export class SettingsPage extends BasePage {
await this.get().locator('[data-testid="settings-modal-close-button"]').click();
await this.get().waitFor({ state: 'hidden' });
}
async toggleNullEmptyFilters() {
await this.selectTab({ tab: SettingTab.ProjectSettings, subTab: SettingsSubTab.Miscellaneous });
await this.miscellaneous.clickShowNullEmptyFilters();
await this.close();
}
}

15
tests/playwright/pages/Dashboard/TreeView.ts

@ -36,7 +36,15 @@ export class TreeViewPage extends BasePage {
// assumption: first view rendered is always GRID
//
async openTable({ title, mode = 'standard' }: { title: string; mode?: string }) {
async openTable({
title,
mode = 'standard',
networkResponse = true,
}: {
title: string;
mode?: string;
networkResponse?: boolean;
}) {
if ((await this.get().locator('.active.nc-project-tree-tbl').count()) > 0) {
if ((await this.get().locator('.active.nc-project-tree-tbl').innerText()) === title) {
// table already open
@ -44,6 +52,7 @@ export class TreeViewPage extends BasePage {
}
}
if (networkResponse === true) {
await this.waitForResponse({
uiAction: this.get().locator(`.nc-project-tree-tbl-${title}`).click(),
httpMethodsToMatch: ['GET'],
@ -51,6 +60,10 @@ export class TreeViewPage extends BasePage {
responseJsonMatcher: json => json.pageInfo,
});
await this.dashboard.waitForTabRender({ title, mode });
} else {
await this.get().locator(`.nc-project-tree-tbl-${title}`).click();
await this.rootPage.waitForTimeout(3000);
}
}
async createTable({ title, skipOpeningModal }: { title: string; skipOpeningModal?: boolean }) {

2
tests/playwright/pages/Dashboard/WebhookForm/index.ts

@ -87,7 +87,7 @@ export class WebhookFormPage extends BasePage {
await this.rootPage.waitForTimeout(1500);
if (operator != 'is null' && operator != 'is not null') {
await modal.locator('input.nc-filter-value-select').fill(value);
await modal.locator('.nc-filter-value-select > input').fill(value);
}
if (save) {

4
tests/playwright/pages/Dashboard/common/Cell/SelectOptionCell.ts

@ -65,7 +65,9 @@ export class SelectOptionCellPageObject extends BasePage {
await this.get({ index, columnHeader }).click();
await this.rootPage.locator('.ant-select-single > .ant-select-clear').click();
await this.cell.get({ index, columnHeader }).click();
// Press `Escape` to close the dropdown
await this.rootPage.keyboard.press('Escape');
await this.rootPage.locator(`.nc-dropdown-single-select-cell`).waitFor({ state: 'hidden' });
}

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

@ -118,9 +118,11 @@ export class CellPageObject extends BasePage {
}).allInnerTexts();
const cellText = typeof innerTexts === 'string' ? [innerTexts] : innerTexts;
if (cellText.includes(text) || cellText[0].includes(text)) {
if (cellText) {
if (cellText?.includes(text) || cellText[0]?.includes(text)) {
return;
}
}
await this.rootPage.waitForTimeout(1000);
count++;
if (count === 5) throw new Error(`Cell text ${text} not found`);

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

@ -1,6 +1,7 @@
import { expect } from '@playwright/test';
import BasePage from '../../../Base';
import { ToolbarPage } from './index';
import { UITypes } from 'nocodb-sdk';
export class ToolbarFilterPage extends BasePage {
readonly toolbar: ToolbarPage;
@ -18,7 +19,7 @@ export class ToolbarFilterPage extends BasePage {
await expect(this.get().locator('.nc-filter-field-select').nth(index)).toHaveText(column);
await expect(this.get().locator('.nc-filter-operation-select').nth(index)).toHaveText(operator);
await expect
.poll(async () => await this.get().locator('input.nc-filter-value-select').nth(index).inputValue())
.poll(async () => await this.get().locator('.nc-filter-value-select > input').nth(index).inputValue())
.toBe(value);
}
@ -33,45 +34,110 @@ export class ToolbarFilterPage extends BasePage {
opType,
value,
isLocallySaved,
dataType,
}: {
columnTitle: string;
opType: string;
value?: string;
isLocallySaved: boolean;
dataType?: string;
}) {
await this.get().locator(`button:has-text("Add Filter")`).first().click();
const selectedField = await this.rootPage.locator('.nc-filter-field-select').textContent();
if (selectedField !== columnTitle) {
await this.rootPage.locator('.nc-filter-field-select').last().click();
const selectColumn = this.rootPage
await this.rootPage
.locator('div.ant-select-dropdown.nc-dropdown-toolbar-field-list')
.locator(`div[label="${columnTitle}"][aria-selected="false"]:visible`)
.locator(`div[label="${columnTitle}"]:visible`)
.click();
await this.waitForResponse({
uiAction: selectColumn,
httpMethodsToMatch: isLocallySaved ? ['GET'] : ['POST', 'PATCH'],
requestUrlPathToMatch: isLocallySaved ? `/api/v1/db/public/` : `/filters`,
});
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
}
// network request will be triggered only after filter value is configured
//
// const selectColumn = this.rootPage
// .locator('div.ant-select-dropdown.nc-dropdown-toolbar-field-list')
// .locator(`div[label="${columnTitle}"]`)
// .click();
// await this.waitForResponse({
// uiAction: selectColumn,
// httpMethodsToMatch: isLocallySaved ? ['GET'] : ['POST', 'PATCH'],
// requestUrlPathToMatch: isLocallySaved ? `/api/v1/db/public/` : `/filters`,
// });
// await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
const selectedOpType = await this.rootPage.locator('.nc-filter-operation-select').textContent();
if (selectedOpType !== opType) {
await this.rootPage.locator('.nc-filter-operation-select').last().click();
const selectOpType = this.rootPage
await this.rootPage.locator('.nc-filter-operation-select').click();
// first() : filter list has >, >=
await this.rootPage
.locator('.nc-dropdown-filter-comp-op')
.locator(`.ant-select-item:has-text("${opType}")`)
.first()
.click();
await this.waitForResponse({
uiAction: selectOpType,
httpMethodsToMatch: isLocallySaved ? ['GET'] : ['POST', 'PATCH'],
requestUrlPathToMatch: isLocallySaved ? `/api/v1/db/public/` : `/filters`,
});
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
}
// if (selectedOpType !== opType) {
// await this.rootPage.locator('.nc-filter-operation-select').last().click();
// // first() : filter list has >, >=
// const selectOpType = this.rootPage
// .locator('.nc-dropdown-filter-comp-op')
// .locator(`.ant-select-item:has-text("${opType}")`)
// .first()
// .click();
//
// await this.waitForResponse({
// uiAction: selectOpType,
// httpMethodsToMatch: isLocallySaved ? ['GET'] : ['POST', 'PATCH'],
// requestUrlPathToMatch: isLocallySaved ? `/api/v1/db/public/` : `/filters`,
// });
// 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);
let fillFilter: any = null;
switch (dataType) {
case UITypes.Duration:
await this.get().locator('.nc-filter-value-select').locator('input').fill(value);
break;
case UITypes.Rating:
await this.get()
.locator('.ant-rate-star > div')
.nth(parseInt(value) - 1)
.click();
break;
case UITypes.MultiSelect:
await this.get().locator('.nc-filter-value-select').click();
// eslint-disable-next-line no-case-declarations
const v = value.split(',');
for (let i = 0; i < v.length; i++) {
await this.rootPage
.locator(`.nc-dropdown-multi-select-cell`)
.locator(`.nc-select-option-MultiSelect-${v[i]}`)
.click();
}
break;
case UITypes.SingleSelect:
await this.get().locator('.nc-filter-value-select').click();
// check if value was an array
// eslint-disable-next-line no-case-declarations
const val = value.split(',');
if (val.length > 1) {
for (let i = 0; i < val.length; i++) {
await this.rootPage
.locator(`.nc-dropdown-multi-select-cell`)
.locator(`.nc-select-option-SingleSelect-${val[i]}`)
.click();
}
} else {
await this.rootPage
.locator(`.nc-dropdown-single-select-cell`)
.locator(`.nc-select-option-SingleSelect-${value}`)
.click();
}
break;
default:
fillFilter = this.rootPage.locator('.nc-filter-value-select > input').last().fill(value);
await this.waitForResponse({
uiAction: fillFilter,
httpMethodsToMatch: ['GET'],
@ -79,16 +145,48 @@ export class ToolbarFilterPage extends BasePage {
});
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
await this.toolbar.parent.waitLoading();
break;
}
}
}
async reset() {
async reset({ networkValidation = true }: { networkValidation?: boolean } = {}) {
await this.toolbar.clickFilter();
if (networkValidation) {
await this.waitForResponse({
uiAction: this.get().locator('.nc-filter-item-remove-btn').click(),
httpMethodsToMatch: ['DELETE'],
requestUrlPathToMatch: '/api/v1/db/meta/filters/',
});
} else {
await this.get().locator('.nc-filter-item-remove-btn').click();
}
await this.toolbar.clickFilter();
}
async columnOperatorList(param: { columnTitle: string }) {
await this.get().locator(`button:has-text("Add Filter")`).first().click();
const selectedField = await this.rootPage.locator('.nc-filter-field-select').textContent();
if (selectedField !== param.columnTitle) {
await this.rootPage.locator('.nc-filter-field-select').last().click();
await this.rootPage
.locator('div.ant-select-dropdown.nc-dropdown-toolbar-field-list')
.locator(`div[label="${param.columnTitle}"]:visible`)
.click();
}
await this.rootPage.locator('.nc-filter-operation-select').click();
const opList = await this.rootPage
.locator('.nc-dropdown-filter-comp-op')
.locator(`.ant-select-item > .ant-select-item-option-content`);
// extract text from each element & put them in an array
const opListText = [];
for (let i = 0; i < (await opList.count()); i++) {
opListText.push(await opList.nth(i).textContent());
}
return opListText;
}
}

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

@ -35,18 +35,30 @@ export class ToolbarSortPage extends BasePage {
await this.get().locator(`button:has-text("Add Sort Option")`).click();
// read content of the dropdown
const col = await this.rootPage.locator('.nc-sort-field-select').textContent();
if (col !== columnTitle) {
await this.rootPage.locator('.nc-sort-field-select').last().click();
const selectColumn = this.rootPage
await this.rootPage
.locator('div.ant-select-dropdown.nc-dropdown-toolbar-field-list')
.locator(`div[label="${columnTitle}"]`)
.last()
.click();
await this.waitForResponse({
uiAction: selectColumn,
httpMethodsToMatch: isLocallySaved ? ['GET'] : ['POST', 'PATCH'],
requestUrlPathToMatch: isLocallySaved ? `/api/v1/db/public/` : `/sorts`,
});
await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
}
// network request will be triggered only after dir-select is clicked
//
// const selectColumn = this.rootPage
// .locator('div.ant-select-dropdown.nc-dropdown-toolbar-field-list')
// .locator(`div[label="${columnTitle}"]`)
// .last()
// .click();
// await this.waitForResponse({
// uiAction: selectColumn,
// httpMethodsToMatch: isLocallySaved ? ['GET'] : ['POST', 'PATCH'],
// requestUrlPathToMatch: isLocallySaved ? `/api/v1/db/public/` : `/sorts`,
// });
// await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
await this.rootPage.locator('.nc-sort-dir-select').last().click();
const selectSortDirection = this.rootPage

135
tests/playwright/setup/xcdb-records.ts

@ -0,0 +1,135 @@
import { ColumnType, UITypes } from 'nocodb-sdk';
const rowMixedValue = (column: ColumnType, index: number) => {
// Array of country names
const countries = [
'Afghanistan',
'Albania',
'',
'Andorra',
'Angola',
'Antigua and Barbuda',
'Argentina',
null,
'Armenia',
'Australia',
'Austria',
'',
null,
];
// Array of sample random paragraphs (comma separated list of cities and countries). Not more than 200 characters
const longText = [
'Aberdeen, United Kingdom',
'Abidjan, Côte d’Ivoire',
'Abuja, Nigeria',
'',
'Addis Ababa, Ethiopia',
'Adelaide, Australia',
'Ahmedabad, India',
'Albuquerque, United States',
null,
'Alexandria, Egypt',
'Algiers, Algeria',
'Allahabad, India',
'',
null,
];
// Array of random integers, not more than 10000
const numbers = [33, null, 456, 333, 267, 34, 8754, 3234, 44, 33, null];
const decimals = [33.3, 456.34, 333.3, null, 267.5674, 34.0, 8754.0, 3234.547, 44.2647, 33.98, null];
const duration = [60, 120, 180, 3600, 3660, 3720, null, 3780, 60, 120, null];
const rating = [0, 1, 2, 3, null, 0, 4, 5, 0, 1, null];
// Array of random sample email strings (not more than 100 characters)
const emails = [
'jbutt@gmail.com',
'josephine_darakjy@darakjy.org',
'art@venere.org',
'',
null,
'donette.foller@cox.net',
'simona@morasca.com',
'mitsue_tollner@yahoo.com',
'leota@hotmail.com',
'sage_wieser@cox.net',
'',
null,
];
// Array of random sample phone numbers
const phoneNumbers = [
'1-541-754-3010',
'504-621-8927',
'810-292-9388',
'856-636-8749',
'907-385-4412',
'513-570-1893',
'419-503-2484',
'773-573-6914',
'',
null,
];
// Array of random sample URLs
const urls = [
'https://www.google.com',
'https://www.facebook.com',
'https://www.youtube.com',
'https://www.amazon.com',
'https://www.wikipedia.org',
'https://www.twitter.com',
'https://www.instagram.com',
'https://www.linkedin.com',
'https://www.reddit.com',
'https://www.tiktok.com',
'https://www.pinterest.com',
'https://www.netflix.com',
'https://www.microsoft.com',
'https://www.apple.com',
'',
null,
];
const singleSelect = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec', null];
const multiSelect = ['jan,feb,mar', 'apr,may,jun', 'jul,aug,sep', 'oct,nov,dec', 'jan,feb,mar', null];
const checkbox = [true, false, false, true, false, true, false, false, true, true];
switch (column.uidt) {
case UITypes.Checkbox:
return checkbox[index % checkbox.length];
case UITypes.Number:
case UITypes.Percent:
return numbers[index % numbers.length];
case UITypes.Decimal:
case UITypes.Currency:
return decimals[index % decimals.length];
case UITypes.Duration:
return duration[index % duration.length];
case UITypes.Rating:
return rating[index % rating.length];
case UITypes.SingleLineText:
return countries[index % countries.length];
case UITypes.Email:
return emails[index % emails.length];
case UITypes.PhoneNumber:
return phoneNumbers[index % phoneNumbers.length];
case UITypes.LongText:
return longText[index % longText.length];
case UITypes.Date:
return '2020-01-01';
case UITypes.URL:
return urls[index % urls.length];
case UITypes.SingleSelect:
return singleSelect[index % singleSelect.length];
case UITypes.MultiSelect:
return multiSelect[index % multiSelect.length];
default:
return `test-${index}`;
}
};
export { rowMixedValue };

9
tests/playwright/tests/columnCheckbox.spec.ts

@ -26,6 +26,7 @@ test.describe('Checkbox - cell, filter, sort', () => {
opType: param.opType,
value: param.value,
isLocallySaved: false,
dataType: 'Checkbox',
});
await toolbar.clickFilter();
@ -86,10 +87,10 @@ test.describe('Checkbox - cell, filter, sort', () => {
// 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', '1d', '1e'] });
await verifyFilter({ opType: 'is not equal', value: '1', result: ['1b', '1d', '1e'] });
await verifyFilter({ opType: 'is null', result: [] });
await verifyFilter({ opType: 'is not null', result: ['1a', '1b', '1c', '1d', '1e', '1f'] });
// await verifyFilter({ opType: 'is equal', value: '0', result: ['1b', '1d', '1e'] });
// await verifyFilter({ opType: 'is not equal', value: '1', result: ['1b', '1d', '1e'] });
// await verifyFilter({ opType: 'is null', result: [] });
// await verifyFilter({ opType: 'is not null', result: ['1a', '1b', '1c', '1d', '1e', '1f'] });
// Sort column
await toolbar.sort.add({

9
tests/playwright/tests/columnMultiSelect.spec.ts

@ -245,6 +245,7 @@ test.describe('Multi select - filters', () => {
opType: param.opType,
value: param.value,
isLocallySaved: false,
dataType: 'MultiSelect',
});
await toolbar.clickFilter();
@ -258,10 +259,10 @@ test.describe('Multi select - filters', () => {
await verifyFilter({ opType: 'contains all of', value: 'foo', result: ['2', '5', '6'] });
await verifyFilter({ opType: 'contains any of', value: 'foo,bar', result: ['2', '3', '5', '6'] });
await verifyFilter({ opType: 'contains all of', value: 'foo,bar', result: ['5', '6'] });
await verifyFilter({ opType: 'is equal', value: 'foo,bar', result: ['5'] });
await verifyFilter({ opType: 'is equal', value: 'bar', result: ['3'] });
await verifyFilter({ opType: 'is not null', result: ['2', '3', '4', '5', '6'] });
await verifyFilter({ opType: 'is null', result: ['1'] });
// await verifyFilter({ opType: 'is equal', value: 'foo,bar', result: ['5'] });
// await verifyFilter({ opType: 'is equal', value: 'bar', result: ['3'] });
// await verifyFilter({ opType: 'is not null', result: ['2', '3', '4', '5', '6'] });
// await verifyFilter({ opType: 'is null', result: ['1'] });
await verifyFilter({ opType: 'does not contain any of', value: 'baz', result: ['1', '2', '3', '5'] });
// Sort column

17
tests/playwright/tests/columnRating.spec.ts

@ -26,6 +26,7 @@ test.describe('Rating - cell, filter, sort', () => {
opType: param.opType,
value: param.value,
isLocallySaved: false,
dataType: 'Rating',
});
await toolbar.clickFilter();
@ -74,15 +75,15 @@ test.describe('Rating - cell, filter, sort', () => {
// 1f : 1
// Filter column
await verifyFilter({ opType: 'is equal', value: '3', result: ['1a'] });
await verifyFilter({ opType: 'is not equal', value: '3', result: ['1b', '1c', '1d', '1e', '1f'] });
await verifyFilter({ opType: 'is like', value: '2', result: ['1c'] });
await verifyFilter({ opType: 'is not like', value: '2', result: ['1a', '1b', '1d', '1e', '1f'] });
await verifyFilter({ opType: 'is null', result: [] });
await verifyFilter({ opType: 'is not null', result: ['1a', '1b', '1c', '1d', '1e', '1f'] });
// await verifyFilter({ opType: '>', value: '1', result: ['1a', '1c'] });
await verifyFilter({ opType: '=', value: '3', result: ['1a'] });
await verifyFilter({ opType: '!=', value: '3', result: ['1b', '1c', '1d', '1e', '1f'] });
// await verifyFilter({ opType: 'is like', value: '2', result: ['1c'] });
// await verifyFilter({ opType: 'is not like', value: '2', result: ['1a', '1b', '1d', '1e', '1f'] });
// await verifyFilter({ opType: 'is null', result: [] });
// await verifyFilter({ opType: 'is not null', result: ['1a', '1b', '1c', '1d', '1e', '1f'] });
await verifyFilter({ opType: '>', value: '1', result: ['1a', '1c'] });
await verifyFilter({ opType: '>=', value: '1', result: ['1a', '1c', '1f'] });
// await verifyFilter({ opType: '<', value: '1', result: [] });
await verifyFilter({ opType: '<', value: '1', result: [] });
await verifyFilter({ opType: '<=', value: '1', result: ['1b', '1d', '1e', '1f'] });
// Sort column

6
tests/playwright/tests/columnSingleSelect.spec.ts

@ -154,6 +154,7 @@ test.describe('Single select - filter & sort', () => {
opType: param.opType,
value: param.value,
isLocallySaved: false,
dataType: 'SingleSelect',
});
await toolbar.clickFilter();
@ -164,8 +165,9 @@ test.describe('Single select - filter & sort', () => {
}
test('Select and clear options and rename options', async () => {
await verifyFilter({ opType: 'contains any of', value: 'foo,bar', result: ['2', '3'] });
await verifyFilter({ opType: 'does not contain any of', value: 'foo,bar', result: ['1', '4'] });
// fix me! single select filter value doesn't support selecting multiple options
// await verifyFilter({ opType: 'contains any of', value: 'foo,bar', result: ['2', '3'] });
// await verifyFilter({ opType: 'does not contain any of', value: 'foo,bar', result: ['1', '4'] });
// Sort column
await toolbar.sort.add({

770
tests/playwright/tests/filters.spec.ts

@ -0,0 +1,770 @@
import { expect, test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard';
import setup from '../setup';
import { ToolbarPage } from '../pages/Dashboard/common/Toolbar';
import { UITypes } from 'nocodb-sdk';
import { Api } from 'nocodb-sdk';
import { rowMixedValue } from '../setup/xcdb-records';
let dashboard: DashboardPage, toolbar: ToolbarPage;
let context: any;
let api: Api<any>;
let records = [];
const skipList = {
Number: ['is null', 'is not null'],
Decimal: ['is null', 'is not null'],
Percent: ['is null', 'is not null'],
Currency: ['is null', 'is not null'],
Rating: ['is null', 'is not null', 'is blank', 'is not blank'],
Duration: ['is null', 'is not null'],
SingleLineText: [],
MultiLineText: [],
Email: [],
PhoneNumber: [],
URL: [],
SingleSelect: ['contains all of', 'does not contain all of'],
MultiSelect: ['is', 'is not'],
};
async function verifyFilterOperatorList(param: { column: string; opType: string[] }) {
await toolbar.clickFilter({ networkValidation: false });
const opList = await toolbar.filter.columnOperatorList({
columnTitle: param.column,
});
await toolbar.clickFilter({ networkValidation: false });
await toolbar.filter.reset({ networkValidation: false });
expect(opList).toEqual(param.opType);
}
// define validateRowArray function
async function validateRowArray(param) {
const { rowCount } = param;
await dashboard.grid.verifyTotalRowCount({ count: rowCount });
}
async function verifyFilter(param: {
column: string;
opType: string;
value?: string;
result: { rowCount: number };
dataType?: string;
}) {
// if opType was included in skip list, skip it
if (skipList[param.column]?.includes(param.opType)) {
return;
}
await toolbar.clickFilter({ networkValidation: false });
await toolbar.filter.add({
columnTitle: param.column,
opType: param.opType,
value: param.value,
isLocallySaved: false,
dataType: param?.dataType,
});
await toolbar.clickFilter({ networkValidation: false });
// verify filtered rows
await validateRowArray({
rowCount: param.result.rowCount,
});
// Reset filter
await toolbar.filter.reset({ networkValidation: false });
}
// Number based filters
//
test.describe('Filter Tests: Numerical', () => {
async function numBasedFilterTest(dataType, eqString, isLikeString) {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'numberBased' });
// Enable NULL & EMPTY filters
await dashboard.gotoSettings();
await dashboard.settings.toggleNullEmptyFilters();
let eqStringDerived = eqString;
let isLikeStringDerived = isLikeString;
if (dataType === 'Duration') {
// convert from hh:mm to seconds
eqStringDerived = parseInt(eqString.split(':')[0]) * 3600 + parseInt(eqString.split(':')[1]) * 60;
isLikeStringDerived = parseInt(isLikeString.split(':')[0]) * 3600 + parseInt(isLikeString.split(':')[1]) * 60;
}
const filterList = [
{
op: '=',
value: eqString,
rowCount: records.list.filter(r => parseFloat(r[dataType]) === parseFloat(eqStringDerived)).length,
},
{
op: '!=',
value: eqString,
rowCount: records.list.filter(r => parseFloat(r[dataType]) !== parseFloat(eqStringDerived)).length,
},
{
op: 'is null',
value: '',
rowCount: records.list.filter(r => r[dataType] === null).length,
},
{
op: 'is not null',
value: '',
rowCount: records.list.filter(r => r[dataType] !== null).length,
},
{
op: 'is blank',
value: '',
rowCount: records.list.filter(r => r[dataType] === null).length,
},
{
op: 'is not blank',
value: '',
rowCount: records.list.filter(r => r[dataType] !== null).length,
},
{
op: '>',
value: isLikeString,
rowCount: records.list.filter(
r => parseFloat(r[dataType]) > parseFloat(isLikeStringDerived) && r[dataType] != null
).length,
},
{
op: '>=',
value: isLikeString,
rowCount: records.list.filter(
r => parseFloat(r[dataType]) >= parseFloat(isLikeStringDerived) && r[dataType] != null
).length,
},
{
op: '<',
value: isLikeString,
rowCount:
dataType === 'Rating'
? records.list.filter(
r => parseFloat(r[dataType]) < parseFloat(isLikeStringDerived) || r[dataType] === null
).length
: records.list.filter(r => parseFloat(r[dataType]) < parseFloat(isLikeStringDerived) && r[dataType] != null)
.length,
},
{
op: '<=',
value: isLikeString,
rowCount:
dataType === 'Rating'
? records.list.filter(
r => parseFloat(r[dataType]) <= parseFloat(isLikeStringDerived) || r[dataType] === null
).length
: records.list.filter(
r => parseFloat(r[dataType]) <= parseFloat(isLikeStringDerived) && r[dataType] != null
).length,
},
];
for (let i = 0; i < filterList.length; i++) {
await verifyFilter({
column: dataType,
opType: filterList[i].op,
value: filterList[i].value,
result: { rowCount: filterList[i].rowCount },
dataType: dataType,
});
}
}
test.beforeEach(async ({ page }) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
toolbar = dashboard.grid.toolbar;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Number',
title: 'Number',
uidt: UITypes.Number,
},
{
column_name: 'Decimal',
title: 'Decimal',
uidt: UITypes.Decimal,
},
{
column_name: 'Currency',
title: 'Currency',
uidt: UITypes.Currency,
},
{
column_name: 'Percent',
title: 'Percent',
uidt: UITypes.Percent,
},
{
column_name: 'Duration',
title: 'Duration',
uidt: UITypes.Duration,
},
{
column_name: 'Rating',
title: 'Rating',
uidt: UITypes.Rating,
},
];
try {
const project = await api.project.read(context.project.id);
const table = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'numberBased',
title: 'numberBased',
columns: columns,
});
const rowAttributes = [];
for (let i = 0; i < 400; i++) {
const row = {
Number: rowMixedValue(columns[1], i),
Decimal: rowMixedValue(columns[2], i),
Currency: rowMixedValue(columns[3], i),
Percent: rowMixedValue(columns[4], i),
Duration: rowMixedValue(columns[5], i),
Rating: rowMixedValue(columns[6], i),
};
rowAttributes.push(row);
}
await api.dbTableRow.bulkCreate('noco', context.project.id, table.id, rowAttributes);
records = await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 400 });
} catch (e) {
console.error(e);
}
});
test('Filter: Number', async () => {
await numBasedFilterTest('Number', '33', '44');
});
test('Filter: Decimal', async () => {
await numBasedFilterTest('Decimal', '33.3', '44.26');
});
test('Filter: Percent', async () => {
await numBasedFilterTest('Percent', '33', '44');
});
test('Filter: Currency', async () => {
await numBasedFilterTest('Currency', '33.3', '44.26');
});
test('Filter: Rating', async () => {
await numBasedFilterTest('Rating', '3', '2');
});
test('Filter: Duration', async () => {
await numBasedFilterTest('Duration', '00:01', '01:03');
});
});
// Text based filters
//
test.describe('Filter Tests: Text based', () => {
async function textBasedFilterTest(dataType, eqString, isLikeString) {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'textBased' });
// Enable NULL & EMPTY filters
await dashboard.gotoSettings();
await dashboard.settings.toggleNullEmptyFilters();
const filterList = [
{
op: 'is equal',
value: eqString,
rowCount: records.list.filter(r => r[dataType] === eqString).length,
},
{
op: 'is not equal',
value: eqString,
rowCount: records.list.filter(r => r[dataType] !== eqString).length,
},
{
op: 'is null',
value: '',
rowCount: records.list.filter(r => r[dataType] === null).length,
},
{
op: 'is not null',
value: '',
rowCount: records.list.filter(r => r[dataType] !== null).length,
},
{
op: 'is empty',
value: '',
rowCount: records.list.filter(r => r[dataType] === '').length,
},
{
op: 'is not empty',
value: '',
rowCount: records.list.filter(r => r[dataType] !== '').length,
},
{
op: 'is blank',
value: '',
rowCount: records.list.filter(r => r[dataType] === '' || r[dataType] === null).length,
},
{
op: 'is not blank',
value: '',
rowCount: records.list.filter(r => r[dataType] !== '' && r[dataType] !== null).length,
},
{
op: 'is like',
value: isLikeString,
rowCount: records.list.filter(r => r[dataType]?.includes(isLikeString)).length,
},
{
op: 'is not like',
value: isLikeString,
rowCount: records.list.filter(r => !r[dataType]?.includes(isLikeString)).length,
},
];
for (let i = 0; i < filterList.length; i++) {
await verifyFilter({
column: dataType,
opType: filterList[i].op,
value: filterList[i].value,
result: { rowCount: filterList[i].rowCount },
});
}
}
test.beforeEach(async ({ page }) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
toolbar = dashboard.grid.toolbar;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'SingleLineText',
title: 'SingleLineText',
uidt: UITypes.SingleLineText,
},
{
column_name: 'MultiLineText',
title: 'MultiLineText',
uidt: UITypes.LongText,
},
{
column_name: 'Email',
title: 'Email',
uidt: UITypes.Email,
},
{
column_name: 'PhoneNumber',
title: 'PhoneNumber',
uidt: UITypes.PhoneNumber,
},
{
column_name: 'URL',
title: 'URL',
uidt: UITypes.URL,
},
];
try {
const project = await api.project.read(context.project.id);
const table = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'textBased',
title: 'textBased',
columns: columns,
});
const rowAttributes = [];
for (let i = 0; i < 400; i++) {
const row = {
SingleLineText: rowMixedValue(columns[1], i),
MultiLineText: rowMixedValue(columns[2], i),
Email: rowMixedValue(columns[3], i),
PhoneNumber: rowMixedValue(columns[4], i),
URL: rowMixedValue(columns[5], i),
};
rowAttributes.push(row);
}
await api.dbTableRow.bulkCreate('noco', context.project.id, table.id, rowAttributes);
records = await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 400 });
} catch (e) {
console.error(e);
}
});
test('Filter: Single Line Text', async () => {
await textBasedFilterTest('SingleLineText', 'Afghanistan', 'Au');
});
test('Filter: Long Text', async () => {
await textBasedFilterTest('MultiLineText', 'Aberdeen, United Kingdom', 'abad');
});
test('Filter: Email', async () => {
await textBasedFilterTest('Email', 'leota@hotmail.com', 'cox.net');
});
test('Filter: PhoneNumber', async () => {
await textBasedFilterTest('PhoneNumber', '504-621-8927', '504');
});
test('Filter: URL', async () => {
await textBasedFilterTest('URL', 'https://www.youtube.com', 'e.com');
});
});
// Select Based
//
test.describe('Filter Tests: Select based', () => {
async function selectBasedFilterTest(dataType, is, anyof, allof) {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'selectBased' });
// Enable NULL & EMPTY filters
await dashboard.gotoSettings();
await dashboard.settings.toggleNullEmptyFilters();
const filterList = [
{
op: 'is',
value: is,
rowCount: records.list.filter(r => r[dataType] === is).length,
},
{
op: 'is not',
value: is,
rowCount: records.list.filter(r => r[dataType] !== is).length,
},
{
op: 'contains any of',
value: anyof,
rowCount: records.list.filter(r => {
const values = anyof.split(',');
const recordValue = r[dataType]?.split(',');
return values.some(value => recordValue?.includes(value));
}).length,
},
{
op: 'contains all of',
value: allof,
rowCount: records.list.filter(r => {
const values = allof.split(',');
return values.every(value => r[dataType]?.includes(value));
}).length,
},
{
op: 'does not contain any of',
value: anyof,
rowCount: records.list.filter(r => {
const values = anyof.split(',');
const recordValue = r[dataType]?.split(',');
return !values.some(value => recordValue?.includes(value));
}).length,
},
{
op: 'does not contain all of',
value: allof,
rowCount: records.list.filter(r => {
const values = allof.split(',');
return !values.every(value => r[dataType]?.includes(value));
}).length,
},
{
op: 'is blank',
value: '',
rowCount: records.list.filter(r => r[dataType] === '' || r[dataType] === null).length,
},
{
op: 'is not blank',
value: '',
rowCount: records.list.filter(r => r[dataType] !== '' && r[dataType] !== null).length,
},
];
for (let i = 0; i < filterList.length; i++) {
await verifyFilter({
column: dataType,
opType: filterList[i].op,
value: filterList[i].value,
result: { rowCount: filterList[i].rowCount },
dataType: dataType,
});
}
}
test.beforeEach(async ({ page }) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
toolbar = dashboard.grid.toolbar;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'SingleSelect',
title: 'SingleSelect',
uidt: UITypes.SingleSelect,
dtxp: "'jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'",
},
{
column_name: 'MultiSelect',
title: 'MultiSelect',
uidt: UITypes.MultiSelect,
dtxp: "'jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'",
},
];
try {
const project = await api.project.read(context.project.id);
const table = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'selectBased',
title: 'selectBased',
columns: columns,
});
const rowAttributes = [];
for (let i = 0; i < 400; i++) {
const row = {
SingleSelect: rowMixedValue(columns[1], i),
MultiSelect: rowMixedValue(columns[2], i),
};
rowAttributes.push(row);
}
await api.dbTableRow.bulkCreate('noco', context.project.id, table.id, rowAttributes);
records = await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 400 });
} catch (e) {
console.error(e);
}
});
test('Filter: Single Select', async () => {
await selectBasedFilterTest('SingleSelect', 'jan', 'jan,feb,mar', '');
});
test('Filter: Multi Select', async () => {
await selectBasedFilterTest('MultiSelect', '', 'jan,feb,mar', 'jan,feb,mar');
});
});
// Misc : Checkbox
//
test.describe('Filter Tests: AddOn', () => {
async function addOnFilterTest(dataType) {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'addOnTypes', networkResponse: false });
// Enable NULL & EMPTY filters
await dashboard.gotoSettings();
await dashboard.settings.toggleNullEmptyFilters();
const filterList = [
{
op: 'is checked',
value: null,
rowCount: records.list.filter(r => {
return r[dataType] === (context.dbType === 'pg' ? true : 1);
}).length,
},
{
op: 'is not checked',
value: null,
rowCount: records.list.filter(r => {
return r[dataType] !== (context.dbType === 'pg' ? true : 1);
}).length,
},
];
for (let i = 0; i < filterList.length; i++) {
await verifyFilter({
column: dataType,
opType: filterList[i].op,
value: filterList[i].value,
result: { rowCount: filterList[i].rowCount },
dataType: dataType,
});
}
}
test.beforeEach(async ({ page }) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
toolbar = dashboard.grid.toolbar;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'SingleLineText',
title: 'SingleLineText',
uidt: UITypes.SingleLineText,
},
{
column_name: 'Checkbox',
title: 'Checkbox',
uidt: UITypes.Checkbox,
},
];
try {
const project = await api.project.read(context.project.id);
const table = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'addOnTypes',
title: 'addOnTypes',
columns: columns,
});
const rowAttributes = [];
for (let i = 0; i < 400; i++) {
const row = {
SingleLineText: rowMixedValue(columns[1], i),
Checkbox: rowMixedValue(columns[2], i),
};
rowAttributes.push(row);
}
await api.dbTableRow.bulkCreate('noco', context.project.id, table.id, rowAttributes);
records = await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 400 });
} catch (e) {
console.error(e);
}
});
test('Filter: Checkbox', async () => {
await addOnFilterTest('Checkbox');
});
});
// Rest of tests
//
test.describe('Filter Tests: Toggle button', () => {
/**
* Steps
*
* 1. Open table
* 2. Verify filter options : should not include NULL & EMPTY options
* 3. Enable `Show NULL & EMPTY in Filter` in Project Settings
* 4. Verify filter options : should include NULL & EMPTY options
* 5. Add NULL & EMPTY filters
* 6. Disable `Show NULL & EMPTY in Filter` in Project Settings : should not be allowed
* 7. Remove the NULL & EMPTY filters
* 8. Disable `Show NULL & EMPTY in Filter` in Project Settings again : should be allowed
*
*/
test.beforeEach(async ({ page }) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
toolbar = dashboard.grid.toolbar;
});
test('Filter: Toggle NULL & EMPTY button', async () => {
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'Country', networkResponse: false });
// Verify filter options
await verifyFilterOperatorList({
column: 'Country',
opType: ['is equal', 'is not equal', 'is like', 'is not like', 'is blank', 'is not blank'],
});
// Enable NULL & EMPTY button
await dashboard.gotoSettings();
await dashboard.settings.toggleNullEmptyFilters();
// Verify filter options
await verifyFilterOperatorList({
column: 'Country',
opType: [
'is equal',
'is not equal',
'is like',
'is not like',
'is empty',
'is not empty',
'is null',
'is not null',
'is blank',
'is not blank',
],
});
await toolbar.clickFilter({ networkValidation: false });
await toolbar.filter.add({
columnTitle: 'Country',
opType: 'is null',
value: null,
isLocallySaved: false,
dataType: 'SingleLineText',
});
await toolbar.clickFilter({ networkValidation: false });
// Disable NULL & EMPTY button
await dashboard.gotoSettings();
await dashboard.settings.toggleNullEmptyFilters();
// wait for toast message
await dashboard.verifyToast({ message: 'Null / Empty filters exist. Please remove them first.' });
// remove filter
await toolbar.filter.reset({ networkValidation: false });
// Disable NULL & EMPTY button
await dashboard.gotoSettings();
await dashboard.settings.toggleNullEmptyFilters();
});
});

10
tests/playwright/tests/viewGridShare.spec.ts

@ -101,6 +101,7 @@ test.describe('Shared view', () => {
**/
// create new sort & filter criteria in shared view
await sharedPage.grid.toolbar.sort.reset();
await sharedPage.grid.toolbar.sort.add({
columnTitle: 'Address',
isAscending: true,
@ -231,6 +232,14 @@ const expectedRecords = [
{ index: 2, columnHeader: 'Phone', value: '648482415405' },
];
// const sqliteExpectedRecords = [
// { index: 0, columnHeader: 'Address', value: '669 Firozabad Loop' },
// { index: 1, columnHeader: 'Address', value: '48 Maracabo Place' },
// { index: 2, columnHeader: 'Address', value: '44 Najafabad Way' },
// { index: 0, columnHeader: 'PostalCode', value: '92265' },
// { index: 1, columnHeader: 'PostalCode', value: '1570' },
// { index: 2, columnHeader: 'PostalCode', value: '61391' },
// ];
const sqliteExpectedRecords = [
{ index: 0, columnHeader: 'Address', value: '217 Botshabelo Place' },
{ index: 1, columnHeader: 'Address', value: '17 Kabul Boulevard' },
@ -239,7 +248,6 @@ const sqliteExpectedRecords = [
{ index: 1, columnHeader: 'PostalCode', value: '38594' },
{ index: 2, columnHeader: 'PostalCode', value: '20936' },
];
const expectedRecords2 = [
{ index: 0, columnHeader: 'Address', value: '1661 Abha Drive' },
{ index: 1, columnHeader: 'Address', value: '1993 Tabuk Lane' },

Loading…
Cancel
Save