From 2d303063e721660b5066afebd5c6c3f64d73a52e Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Tue, 5 May 2026 00:11:22 +0200 Subject: [PATCH 1/2] feat: add is not empty filter support Signed-off-by: Kostiantyn Miakshyn --- lib/Constants/FilterOperator.php | 1 + lib/Db/Row2Mapper.php | 12 +++++++ .../ncTable/mixins/columnsTypes/datetime.js | 1 + .../mixins/columnsTypes/datetimeDate.js | 1 + .../mixins/columnsTypes/datetimeTime.js | 1 + .../ncTable/mixins/columnsTypes/number.js | 1 + .../mixins/columnsTypes/numberProgress.js | 1 + .../mixins/columnsTypes/numberStars.js | 1 + .../ncTable/mixins/columnsTypes/relation.js | 1 + .../ncTable/mixins/columnsTypes/selection.js | 1 + .../mixins/columnsTypes/selectionMulti.js | 1 + .../ncTable/mixins/columnsTypes/textLine.js | 3 +- .../ncTable/mixins/columnsTypes/textLink.js | 1 + .../ncTable/mixins/columnsTypes/textLong.js | 1 + .../ncTable/mixins/columnsTypes/textRich.js | 1 + .../ncTable/mixins/columnsTypes/usergroup.js | 1 + .../components/ncTable/mixins/filter.js | 34 ++++++++++++------- .../ncTable/partials/FilterLabel.vue | 2 +- .../partials/TableHeaderColumnOptions.vue | 2 +- 19 files changed, 51 insertions(+), 16 deletions(-) diff --git a/lib/Constants/FilterOperator.php b/lib/Constants/FilterOperator.php index 1bf49d8228..3cd28ae18a 100644 --- a/lib/Constants/FilterOperator.php +++ b/lib/Constants/FilterOperator.php @@ -20,4 +20,5 @@ enum FilterOperator: string { case IS_LESS_THAN = 'is-lower-than'; case IS_LESS_THAN_OR_EQUAL = 'is-lower-than-or-equal'; case IS_EMPTY = 'is-empty'; + case IS_NOT_EMPTY = 'is-not-empty'; } diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index a2979afbda..a13ccb5b2b 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -568,6 +568,17 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ $filterExpression = $qb->expr()->isNull('value'); } break; + case 'is-not-empty': + $includeDefault = !empty($defaultValue); + if ($column->getType() === Column::TYPE_TEXT) { + $filterExpression = $qb2->expr()->andX( + $qb->expr()->isNotNull('value'), + $qb->expr()->neq('value', $qb->createNamedParameter('', $paramType)) + ); + } else { + $filterExpression = $qb->expr()->isNotNull('value'); + } + break; default: throw new InternalError('Operator ' . $operator . ' is not supported.'); } @@ -636,6 +647,7 @@ private function getSqlOperator(string $operator, IQueryBuilder $qb, string $col 'is-lower-than' => $qb->expr()->lt($columnName, $qb->createNamedParameter($value, $paramType)), 'is-lower-than-or-equal' => $qb->expr()->lte($columnName, $qb->createNamedParameter($value, $paramType)), 'is-empty' => $qb->expr()->isNull($columnName), + 'is-not-empty' => $qb->expr()->isNotNull($columnName), default => throw new InternalError('Operator ' . $operator . ' is not supported.'), }; } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/datetime.js b/src/shared/components/ncTable/mixins/columnsTypes/datetime.js index e4d32af108..fa96173c33 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/datetime.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/datetime.js @@ -70,6 +70,7 @@ export default class DatetimeColumn extends AbstractDatetimeColumn { [FilterIds.IsLowerThan]() { return filterDate.isAfter(valueDate) }, [FilterIds.IsLowerThanOrEqual]() { return filterDate.isSameOrAfter(valueDate) }, [FilterIds.IsEmpty]() { return !cell.value }, + [FilterIds.IsNotEmpty]() { return !!cell.value }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/datetimeDate.js b/src/shared/components/ncTable/mixins/columnsTypes/datetimeDate.js index db3d9cf6d6..3fe0d67760 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/datetimeDate.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/datetimeDate.js @@ -56,6 +56,7 @@ export default class DatetimeDateColumn extends AbstractDatetimeColumn { [FilterIds.IsLowerThan]() { return filterDate.isAfter(valueDate) }, [FilterIds.IsLowerThanOrEqual]() { return filterDate.isSameOrAfter(valueDate) }, [FilterIds.IsEmpty]() { return !cell.value }, + [FilterIds.IsNotEmpty]() { return !!cell.value }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/datetimeTime.js b/src/shared/components/ncTable/mixins/columnsTypes/datetimeTime.js index 04dc8a3a97..c56afe6226 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/datetimeTime.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/datetimeTime.js @@ -56,6 +56,7 @@ export default class DatetimeTimeColumn extends AbstractDatetimeColumn { [FilterIds.IsLowerThan]() { return filterTime.isAfter(valueTime) }, [FilterIds.IsLowerThanOrEqual]() { return filterTime.isSameOrAfter(valueTime) }, [FilterIds.IsEmpty]() { return !cell.value }, + [FilterIds.IsNotEmpty]() { return !!cell.value }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/number.js b/src/shared/components/ncTable/mixins/columnsTypes/number.js index b343817351..9d24dae7a8 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/number.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/number.js @@ -56,6 +56,7 @@ export default class NumberColumn extends AbstractNumberColumn { [FilterIds.IsLowerThan]() { return parseInt(cell.value) < parseInt(filterValue) }, [FilterIds.IsLowerThanOrEqual]() { return parseInt(cell.value) <= parseInt(filterValue) }, [FilterIds.IsEmpty]() { return !cell.value }, + [FilterIds.IsNotEmpty]() { return !!cell.value }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/numberProgress.js b/src/shared/components/ncTable/mixins/columnsTypes/numberProgress.js index a391c62ddd..0b4ab098ee 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/numberProgress.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/numberProgress.js @@ -39,6 +39,7 @@ export default class NumberProgressColumn extends AbstractNumberColumn { [FilterIds.IsLowerThan]() { return parseInt(cell.value) < parseInt(filterValue) }, [FilterIds.IsLowerThanOrEqual]() { return parseInt(cell.value) <= parseInt(filterValue) }, [FilterIds.IsEmpty]() { return !cell.value }, + [FilterIds.IsNotEmpty]() { return !!cell.value }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/numberStars.js b/src/shared/components/ncTable/mixins/columnsTypes/numberStars.js index 754d9f3aea..bbaf66280a 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/numberStars.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/numberStars.js @@ -39,6 +39,7 @@ export default class NumberStarsColumn extends AbstractNumberColumn { [FilterIds.IsLowerThan]() { return parseInt(cell.value ? cell.value : 0) < parseInt(filterValue) }, [FilterIds.IsLowerThanOrEqual]() { return parseInt(cell.value ? cell.value : 0) <= parseInt(filterValue) }, [FilterIds.IsEmpty]() { return !cell.value }, + [FilterIds.IsNotEmpty]() { return !!cell.value }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/relation.js b/src/shared/components/ncTable/mixins/columnsTypes/relation.js index 5c583a161b..b06133212d 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/relation.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/relation.js @@ -87,6 +87,7 @@ export default class RelationColumn extends AbstractColumn { [FilterIds.IsEqual]() { return cellLabel === filterValue }, [FilterIds.IsNotEqual]() { return cellLabel !== filterValue }, [FilterIds.IsEmpty]() { return !cellLabel }, + [FilterIds.IsNotEmpty]() { return !!cellLabel }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/selection.js b/src/shared/components/ncTable/mixins/columnsTypes/selection.js index afd7bec1b2..c153281be5 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/selection.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/selection.js @@ -62,6 +62,7 @@ export default class SelectionColumn extends AbstractSelectionColumn { [FilterIds.IsEqual]() { return cellLabel === filterValue.toLowerCase() }, [FilterIds.IsNotEqual]() { return cellLabel !== filterValue.toLowerCase() }, [FilterIds.IsEmpty]() { return !cellLabel }, + [FilterIds.IsNotEmpty]() { return !!cellLabel }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/selectionMulti.js b/src/shared/components/ncTable/mixins/columnsTypes/selectionMulti.js index 669a9a0eed..ceb68b5e45 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/selectionMulti.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/selectionMulti.js @@ -83,6 +83,7 @@ export default class SelectionMutliColumn extends AbstractSelectionColumn { [FilterIds.IsEqual]() { return valueString === filterValue.toLowerCase() }, [FilterIds.IsNotEqual]() { return valueString !== filterValue.toLowerCase() }, [FilterIds.IsEmpty]() { return !valueString }, + [FilterIds.IsNotEmpty]() { return !!valueString }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/textLine.js b/src/shared/components/ncTable/mixins/columnsTypes/textLine.js index e1408f225f..8e17eed206 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/textLine.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/textLine.js @@ -44,7 +44,7 @@ export default class TextLineColumn extends AbstractTextColumn { isFilterFound(cell, filter) { const filterValue = (filter.magicValuesEnriched ? filter.magicValuesEnriched : filter.value).toLowerCase() const cellValue = cell.value?.toLowerCase() - if (!cellValue & filter.operator.id !== FilterIds.IsEmpty) return false + if (!cellValue && filter.operator.id !== FilterIds.IsEmpty) return false const filterMethod = { [FilterIds.Contains]() { return cellValue.includes(filterValue) }, [FilterIds.DoesNotContain]() { return !cellValue.includes(filterValue) }, @@ -53,6 +53,7 @@ export default class TextLineColumn extends AbstractTextColumn { [FilterIds.IsEqual]() { return cellValue === filterValue }, [FilterIds.IsNotEqual]() { return cellValue !== filterValue }, [FilterIds.IsEmpty]() { return !cellValue }, + [FilterIds.IsNotEmpty]() { return !!cellValue }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) diff --git a/src/shared/components/ncTable/mixins/columnsTypes/textLink.js b/src/shared/components/ncTable/mixins/columnsTypes/textLink.js index 74e75f3ccf..ad8c667b11 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/textLink.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/textLink.js @@ -63,6 +63,7 @@ export default class TextLinkColumn extends AbstractTextColumn { [FilterIds.IsEqual]() { return value === filterValue }, [FilterIds.IsNotEqual]() { return value !== filterValue }, [FilterIds.IsEmpty]() { return !value }, + [FilterIds.IsNotEmpty]() { return !!value }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/textLong.js b/src/shared/components/ncTable/mixins/columnsTypes/textLong.js index 21cc5d71ed..76b40716d1 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/textLong.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/textLong.js @@ -29,6 +29,7 @@ export default class TextLongColumn extends AbstractTextColumn { [FilterIds.Contains]() { return cellValue && cellValue.includes(filterValue) }, [FilterIds.DoesNotContain]() { return cellValue && !cellValue.includes(filterValue) }, [FilterIds.IsEmpty]() { return !cell.value }, + [FilterIds.IsNotEmpty]() { return !!cell.value }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/textRich.js b/src/shared/components/ncTable/mixins/columnsTypes/textRich.js index 11daac3d1e..5fc5fcd3e1 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/textRich.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/textRich.js @@ -25,6 +25,7 @@ export default class TextRichColumn extends AbstractTextColumn { [FilterIds.Contains]() { return cellValue && cellValue.includes(filterValue) }, [FilterIds.DoesNotContain]() { return cellValue && !cellValue.includes(filterValue) }, [FilterIds.IsEmpty]() { return !cell.value }, + [FilterIds.IsNotEmpty]() { return !!cell.value }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/columnsTypes/usergroup.js b/src/shared/components/ncTable/mixins/columnsTypes/usergroup.js index e6ea439a21..fa88e32426 100644 --- a/src/shared/components/ncTable/mixins/columnsTypes/usergroup.js +++ b/src/shared/components/ncTable/mixins/columnsTypes/usergroup.js @@ -55,6 +55,7 @@ export default class UsergroupColumn extends AbstractUsergroupColumn { [FilterIds.IsEqual]() { return valueString === filterValue }, [FilterIds.IsNotEqual]() { return valueString !== filterValue }, [FilterIds.IsEmpty]() { return !valueString }, + [FilterIds.IsNotEmpty]() { return !!valueString }, }[filter.operator.id] return super.isFilterFound(filterMethod, cell) } diff --git a/src/shared/components/ncTable/mixins/filter.js b/src/shared/components/ncTable/mixins/filter.js index e3a4cf9655..56077cac7b 100644 --- a/src/shared/components/ncTable/mixins/filter.js +++ b/src/shared/components/ncTable/mixins/filter.js @@ -50,6 +50,7 @@ export const FilterIds = { IsLowerThan: 'is-lower-than', IsLowerThanOrEqual: 'is-lower-than-or-equal', IsEmpty: 'is-empty', + IsNotEmpty: 'is-not-empty', } export const Filters = { @@ -57,79 +58,86 @@ export const Filters = { id: FilterIds.ContainsItem, label: t('tables', 'Contains items'), goodFor: [ColumnTypes.SelectionMulti, ColumnTypes.Selection], - incompatibleWith: [FilterIds.DoesNotContain, FilterIds.IsEmpty, FilterIds.IsEqual], + incompatibleWith: [FilterIds.DoesNotContain, FilterIds.IsEmpty, FilterIds.IsNotEmpty, FilterIds.IsEqual], }), Contains: new Filter({ id: FilterIds.Contains, label: t('tables', 'Contains'), goodFor: [ColumnTypes.TextLine, ColumnTypes.TextLong, ColumnTypes.TextLink, ColumnTypes.TextRich, ColumnTypes.SelectionMulti, ColumnTypes.Usergroup, ColumnTypes.Selection, ColumnTypes.Relation], - incompatibleWith: [FilterIds.DoesNotContain, FilterIds.IsEmpty, FilterIds.IsEqual], + incompatibleWith: [FilterIds.DoesNotContain, FilterIds.IsEmpty, FilterIds.IsNotEmpty, FilterIds.IsEqual], }), DoesNotContain: new Filter({ id: FilterIds.DoesNotContain, label: t('tables', 'Does not contain'), goodFor: [ColumnTypes.TextLine, ColumnTypes.TextLong, ColumnTypes.TextLink, ColumnTypes.TextRich, ColumnTypes.SelectionMulti, ColumnTypes.Usergroup, ColumnTypes.Selection, ColumnTypes.Relation], - incompatibleWith: [FilterIds.Contains, FilterIds.IsEmpty, FilterIds.IsEqual], + incompatibleWith: [FilterIds.Contains, FilterIds.IsEmpty, FilterIds.IsNotEmpty, FilterIds.IsEqual], }), BeginsWith: new Filter({ id: FilterIds.BeginsWith, label: t('tables', 'Begins with'), goodFor: [ColumnTypes.TextLine, ColumnTypes.TextLink], - incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsEqual, FilterIds.BeginsWith], + incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsNotEmpty, FilterIds.IsEqual, FilterIds.BeginsWith], }), EndsWith: new Filter({ id: FilterIds.EndsWith, label: t('tables', 'Ends with'), goodFor: [ColumnTypes.TextLine, ColumnTypes.TextLink], - incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsEqual, FilterIds.EndsWith], + incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsNotEmpty, FilterIds.IsEqual, FilterIds.EndsWith], }), IsEqual: new Filter({ id: FilterIds.IsEqual, label: t('tables', 'Is equal'), shortLabel: '=', goodFor: [ColumnTypes.TextLine, ColumnTypes.Number, ColumnTypes.SelectionCheck, ColumnTypes.TextLink, ColumnTypes.NumberStars, ColumnTypes.NumberProgress, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime, ColumnTypes.Selection, ColumnTypes.SelectionMulti, ColumnTypes.Usergroup, ColumnTypes.Relation], - incompatibleWith: [FilterIds.IsNotEqual, FilterIds.IsEmpty, FilterIds.IsEqual, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.Contains, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual], + incompatibleWith: [FilterIds.IsNotEqual, FilterIds.IsEmpty, FilterIds.IsNotEmpty, FilterIds.IsEqual, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.Contains, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual], }), IsNotEqual: new Filter({ id: FilterIds.IsNotEqual, label: t('tables', 'Is not equal'), shortLabel: '!=', goodFor: [ColumnTypes.TextLine, ColumnTypes.Number, ColumnTypes.SelectionCheck, ColumnTypes.TextLink, ColumnTypes.NumberStars, ColumnTypes.NumberProgress, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime, ColumnTypes.Selection, ColumnTypes.SelectionMulti, ColumnTypes.Usergroup, ColumnTypes.Relation], - incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsEqual, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.Contains, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual], + incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsNotEmpty, FilterIds.IsEqual, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.Contains, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual], }), IsGreaterThan: new Filter({ id: FilterIds.IsGreaterThan, label: t('tables', 'Is greater than'), shortLabel: '>', goodFor: [ColumnTypes.Number, ColumnTypes.NumberStars, ColumnTypes.NumberProgress, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime], - incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsEqual, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual], + incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsNotEmpty, FilterIds.IsEqual, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual], }), IsGreaterThanOrEqual: new Filter({ id: FilterIds.IsGreaterThanOrEqual, label: t('tables', 'Is greater than or equal'), shortLabel: '>=', goodFor: [ColumnTypes.Number, ColumnTypes.NumberStars, ColumnTypes.NumberProgress, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime], - incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsEqual, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual], + incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsNotEmpty, FilterIds.IsEqual, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual], }), IsLowerThan: new Filter({ id: FilterIds.IsLowerThan, label: t('tables', 'Is lower than'), shortLabel: '<', goodFor: [ColumnTypes.Number, ColumnTypes.NumberStars, ColumnTypes.NumberProgress, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime], - incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual], + incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsNotEmpty, FilterIds.IsEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual], }), IsLowerThanOrEqual: new Filter({ id: FilterIds.IsLowerThanOrEqual, label: t('tables', 'Is lower than or equal'), shortLabel: '<=', goodFor: [ColumnTypes.Number, ColumnTypes.NumberStars, ColumnTypes.NumberProgress, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime], - incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual], + incompatibleWith: [FilterIds.IsEmpty, FilterIds.IsNotEmpty, FilterIds.IsEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual], }), IsEmpty: new Filter({ id: FilterIds.IsEmpty, label: t('tables', 'Is empty'), - goodFor: [ColumnTypes.TextLine, ColumnTypes.TextRich, ColumnTypes.Number, ColumnTypes.TextLink, ColumnTypes.NumberProgress, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime, ColumnTypes.SelectionCheck, ColumnTypes.Usergroup, ColumnTypes.Relation], - incompatibleWith: [FilterIds.Contains, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.IsEqual, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual, FilterIds.IsEmpty], + goodFor: [ColumnTypes.TextLine, ColumnTypes.TextRich, ColumnTypes.Number, ColumnTypes.TextLink, ColumnTypes.NumberProgress, ColumnTypes.NumberStars, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime, ColumnTypes.Selection, ColumnTypes.SelectionMulti, ColumnTypes.SelectionCheck, ColumnTypes.TextLong, ColumnTypes.Usergroup, ColumnTypes.Relation], + incompatibleWith: [FilterIds.Contains, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.IsEqual, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual, FilterIds.IsEmpty, FilterIds.IsNotEmpty], + noSearchValue: true, + }), + IsNotEmpty: new Filter({ + id: FilterIds.IsNotEmpty, + label: t('tables', 'Is not empty'), + goodFor: [ColumnTypes.TextLine, ColumnTypes.TextRich, ColumnTypes.Number, ColumnTypes.TextLink, ColumnTypes.NumberProgress, ColumnTypes.NumberStars, ColumnTypes.DatetimeDate, ColumnTypes.DatetimeTime, ColumnTypes.Datetime, ColumnTypes.Selection, ColumnTypes.SelectionMulti, ColumnTypes.TextLong, ColumnTypes.Usergroup, ColumnTypes.Relation], + incompatibleWith: [FilterIds.Contains, FilterIds.BeginsWith, FilterIds.EndsWith, FilterIds.IsEqual, FilterIds.IsGreaterThan, FilterIds.IsGreaterThanOrEqual, FilterIds.IsLowerThan, FilterIds.IsLowerThanOrEqual, FilterIds.IsEmpty, FilterIds.IsNotEmpty], noSearchValue: true, }), } diff --git a/src/shared/components/ncTable/partials/FilterLabel.vue b/src/shared/components/ncTable/partials/FilterLabel.vue index 6e7fd328c6..33a68b08cd 100644 --- a/src/shared/components/ncTable/partials/FilterLabel.vue +++ b/src/shared/components/ncTable/partials/FilterLabel.vue @@ -56,7 +56,7 @@ export default { return value }, labelText() { - if (this.operator.id === FilterIds.IsEmpty) { + if (this.operator.noSearchValue) { return this.operator.getOperatorLabel() } else if (this.operator.id === FilterIds.ContainsItem) { return this.operator.getOperatorLabel() + ' "' + this.getValue.map(item => item.label).join(', ') + '"' diff --git a/src/shared/components/ncTable/partials/TableHeaderColumnOptions.vue b/src/shared/components/ncTable/partials/TableHeaderColumnOptions.vue index b6bf7af714..ff9a2f6cf7 100644 --- a/src/shared/components/ncTable/partials/TableHeaderColumnOptions.vue +++ b/src/shared/components/ncTable/partials/TableHeaderColumnOptions.vue @@ -362,7 +362,7 @@ export default { changeFilterOperator(op) { this.selectedOperator = op this.selectOperator = false - if (op.id === FilterIds.IsEmpty) { + if (op.noSearchValue) { this.createFilter() } else { this.selectValue = true From ae39ba87f65d2c1f1d8064b7c455c7331668c6ac Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Sun, 30 Aug 2026 13:44:13 +0200 Subject: [PATCH 2/2] feat: add is not empty filter support (code review comments) Signed-off-by: Kostiantyn Miakshyn --- lib/Db/Row2Mapper.php | 10 ++++++++++ tests/unit/Db/Row2MapperFilterTest.php | 10 ++++++++++ tests/unit/Db/Row2MapperTestDependencies.php | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index a13ccb5b2b..b0cfa89d9a 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -564,6 +564,11 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ $qb->expr()->isNull('value'), $qb->expr()->eq('value', $qb->createNamedParameter('', $paramType)) ); + } elseif ($column->getType() === 'selection' && $column->getSubtype() === 'multi') { + $filterExpression = $qb2->expr()->orX( + $qb->expr()->isNull('value'), + $qb->expr()->eq('value', $qb->createNamedParameter('[]', $paramType)) + ); } else { $filterExpression = $qb->expr()->isNull('value'); } @@ -575,6 +580,11 @@ private function getFilterExpression(IQueryBuilder $qb, Column $column, string $ $qb->expr()->isNotNull('value'), $qb->expr()->neq('value', $qb->createNamedParameter('', $paramType)) ); + } elseif ($column->getType() === 'selection' && $column->getSubtype() === 'multi') { + $filterExpression = $qb2->expr()->andX( + $qb->expr()->isNotNull('value'), + $qb->expr()->neq('value', $qb->createNamedParameter('[]', $paramType)) + ); } else { $filterExpression = $qb->expr()->isNotNull('value'); } diff --git a/tests/unit/Db/Row2MapperFilterTest.php b/tests/unit/Db/Row2MapperFilterTest.php index 9f6a88de34..9cd4d0d23c 100644 --- a/tests/unit/Db/Row2MapperFilterTest.php +++ b/tests/unit/Db/Row2MapperFilterTest.php @@ -218,6 +218,16 @@ public static function filterDataProvider(): array { ['Charlie'], 'Filter skills equal to exactly Python (id 3)' ], + 'skills is-empty' => [ + [['columnId' => 'skills', 'operator' => 'is-empty', 'value' => '']], + ['Diana'], + 'Filter empty skills' + ], + 'skills is-not-empty' => [ + [['columnId' => 'skills', 'operator' => 'is-not-empty', 'value' => '']], + ['Alice', 'Bob', 'Charlie', 'Eve'], + 'Filter non-empty skills' + ], // Selection checkbox column (is_available) 'available is checked' => [ diff --git a/tests/unit/Db/Row2MapperTestDependencies.php b/tests/unit/Db/Row2MapperTestDependencies.php index 916ed8ca1e..a3dd5c34fb 100644 --- a/tests/unit/Db/Row2MapperTestDependencies.php +++ b/tests/unit/Db/Row2MapperTestDependencies.php @@ -157,7 +157,7 @@ private function initializeTestData(): void { 'department' => 'Finance', 'score' => 88.7, 'status' => 'Pending', - 'skills' => ['Excel', 'Accounting', 'Analysis'], + 'skills' => [], 'is_available' => '"false"', 'experience_years' => 3 ]