Properly serialize date filters when applying them to the list filter

This commit is contained in:
Maurus Decimus
2026-06-23 17:26:22 +02:00
parent 574de03e42
commit e080a6e061
6 changed files with 271 additions and 25 deletions
+9
View File
@@ -2,6 +2,15 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [1.0.6] - 2026-06-XX
### Added
### Changed
### Fixed
- Properly serialize `date` filters when applying them to the list filter.
## [1.0.5] - 2026-06-21
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stalwart-webui",
"private": true,
"version": "1.0.5",
"version": "1.0.6",
"description": "Stalwart WebUI",
"type": "module",
"scripts": {
+1 -3
View File
@@ -350,9 +350,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
newFieldErrors[ve.property] = validationErrorMessage(ve);
} else if (ve.property) {
const detail = ve.value && ve.value.length > 0 ? ve.value : ve.type;
setGeneralError((prev) =>
prev ? `${prev}\n${ve.property}: ${detail}` : `${ve.property}: ${detail}`,
);
setGeneralError((prev) => (prev ? `${prev}\n${ve.property}: ${detail}` : `${ve.property}: ${detail}`));
}
}
}
+8 -21
View File
@@ -51,6 +51,7 @@ import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { toast } from '@/hooks/use-toast';
import { friendlySetError } from '@/lib/jmapErrors';
import { coerceLabel } from '@/lib/objectOptions';
import { buildJmapFilter } from '@/lib/listFilter';
import { useSchemaStore } from '@/stores/schemaStore';
import { useAuthStore } from '@/stores/authStore';
@@ -385,27 +386,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
}, [viewName]);
const buildFilter = useCallback((): Record<string, unknown> => {
const filter: Record<string, unknown> = {};
const list = resolved?.list;
if (list?.filtersStatic) {
Object.assign(filter, list.filtersStatic);
}
const opSuffix: Record<string, string> = {
eq: '',
gt: 'IsGreaterThan',
gte: 'IsGreaterThanOrEqual',
lt: 'IsLessThan',
lte: 'IsLessThanOrEqual',
};
for (const [key, val] of Object.entries(appliedFilters)) {
if (val === '' || val == null) continue;
if (key.endsWith('Op')) continue;
const op = appliedFilters[`${key}Op`];
const suffix = op ? (opSuffix[op] ?? '') : '';
filter[`${key}${suffix}`] = val;
}
return filter;
}, [appliedFilters, resolved?.list]);
return buildJmapFilter({
appliedFilters,
filters: resolved?.list?.filters,
filtersStatic: resolved?.list?.filtersStatic,
isXPrefixed: resolved?.obj.objectName.startsWith('x:') ?? false,
});
}, [appliedFilters, resolved?.list, resolved?.obj.objectName]);
const buildSort = useCallback((): Record<string, unknown>[] | undefined => {
if (!sort) return undefined;
+155
View File
@@ -0,0 +1,155 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect } from 'vitest';
import { buildJmapFilter } from './listFilter';
import type { Filter } from '@/types/schema';
const dateFilter: Filter = { type: 'date', field: 'timestamp', label: 'Date' };
const textFilter: Filter = { type: 'text', field: 'text', label: 'Text' };
const intFilter: Filter = { type: 'integer', field: 'size', label: 'Size' };
describe('buildJmapFilter', () => {
it('returns an empty object when nothing is applied', () => {
expect(buildJmapFilter({ appliedFilters: {}, isXPrefixed: true })).toEqual({});
});
it('merges filtersStatic', () => {
const filter = buildJmapFilter({
appliedFilters: {},
filtersStatic: { hasErrors: true },
isXPrefixed: true,
});
expect(filter).toEqual({ hasErrors: true });
});
it('skips empty and Op-only keys', () => {
const filter = buildJmapFilter({
appliedFilters: { text: '', textOp: 'eq' },
filters: [textFilter],
isXPrefixed: true,
});
expect(filter).toEqual({});
});
it('passes text filters through verbatim', () => {
const filter = buildJmapFilter({
appliedFilters: { text: 'hello' },
filters: [textFilter],
isXPrefixed: true,
});
expect(filter).toEqual({ text: 'hello' });
});
it('applies the operator suffix for integer filters', () => {
const filter = buildJmapFilter({
appliedFilters: { size: '1024', sizeOp: 'gt' },
filters: [intFilter],
isXPrefixed: true,
});
expect(filter).toEqual({ sizeIsGreaterThan: '1024' });
});
describe('date filters (x-prefixed, with operator UI)', () => {
it('expands a default date-only equality into a full-day range', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-17' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({
timestampIsGreaterThanOrEqual: '2026-06-17T00:00:00Z',
timestampIsLessThan: '2026-06-18T00:00:00Z',
});
});
it('treats an explicit eq operator the same as the default', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-17', timestampOp: 'eq' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({
timestampIsGreaterThanOrEqual: '2026-06-17T00:00:00Z',
timestampIsLessThan: '2026-06-18T00:00:00Z',
});
});
it('appends midnight UTC for the After (gt) operator', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-17', timestampOp: 'gt' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({ timestampIsGreaterThan: '2026-06-17T00:00:00Z' });
});
it('appends midnight UTC for the Before (lt) operator', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-17', timestampOp: 'lt' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({ timestampIsLessThan: '2026-06-17T00:00:00Z' });
});
it('rolls over month boundaries', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-30' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({
timestampIsGreaterThanOrEqual: '2026-06-30T00:00:00Z',
timestampIsLessThan: '2026-07-01T00:00:00Z',
});
});
it('rolls over year boundaries', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-12-31' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({
timestampIsGreaterThanOrEqual: '2026-12-31T00:00:00Z',
timestampIsLessThan: '2027-01-01T00:00:00Z',
});
});
it('handles leap-year February', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2028-02-28' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({
timestampIsGreaterThanOrEqual: '2028-02-28T00:00:00Z',
timestampIsLessThan: '2028-02-29T00:00:00Z',
});
});
it('passes a full datetime through with only the operator suffix applied', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-17T08:30:00Z', timestampOp: 'gt' },
filters: [dateFilter],
isXPrefixed: true,
});
expect(filter).toEqual({ timestampIsGreaterThan: '2026-06-17T08:30:00Z' });
});
});
describe('date filters (non-x-prefixed, no operator UI)', () => {
it('appends midnight UTC as an exact match without range expansion', () => {
const filter = buildJmapFilter({
appliedFilters: { timestamp: '2026-06-17' },
filters: [dateFilter],
isXPrefixed: false,
});
expect(filter).toEqual({ timestamp: '2026-06-17T00:00:00Z' });
});
});
});
+97
View File
@@ -0,0 +1,97 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { Filter } from '@/types/schema';
const OP_SUFFIX: Record<string, string> = {
eq: '',
gt: 'IsGreaterThan',
gte: 'IsGreaterThanOrEqual',
lt: 'IsLessThan',
lte: 'IsLessThanOrEqual',
};
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
function utcDayStart(date: string): string {
return `${date}T00:00:00Z`;
}
function utcNextDayStart(date: string): string {
const dt = new Date(`${date}T00:00:00Z`);
dt.setUTCDate(dt.getUTCDate() + 1);
const y = dt.getUTCFullYear();
const m = String(dt.getUTCMonth() + 1).padStart(2, '0');
const d = String(dt.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${d}T00:00:00Z`;
}
function applyDateFilter(
filter: Record<string, unknown>,
field: string,
value: string,
op: string | undefined,
isXPrefixed: boolean,
): void {
if (!DATE_ONLY.test(value)) {
const suffix = isXPrefixed && op ? (OP_SUFFIX[op] ?? '') : '';
filter[`${field}${suffix}`] = value;
return;
}
if (!isXPrefixed) {
filter[field] = utcDayStart(value);
return;
}
const effectiveOp = op ?? 'eq';
if (effectiveOp === 'eq') {
filter[`${field}IsGreaterThanOrEqual`] = utcDayStart(value);
filter[`${field}IsLessThan`] = utcNextDayStart(value);
return;
}
const suffix = OP_SUFFIX[effectiveOp] ?? '';
filter[`${field}${suffix}`] = utcDayStart(value);
}
export interface BuildJmapFilterArgs {
appliedFilters: Record<string, string>;
filters?: Filter[];
filtersStatic?: Record<string, unknown>;
isXPrefixed: boolean;
}
export function buildJmapFilter({
appliedFilters,
filters,
filtersStatic,
isXPrefixed,
}: BuildJmapFilterArgs): Record<string, unknown> {
const filter: Record<string, unknown> = {};
if (filtersStatic) {
Object.assign(filter, filtersStatic);
}
const dateFields = new Set((filters ?? []).filter((f) => f.type === 'date').map((f) => f.field));
for (const [key, val] of Object.entries(appliedFilters)) {
if (val === '' || val == null) continue;
if (key.endsWith('Op')) continue;
const op = appliedFilters[`${key}Op`];
if (dateFields.has(key)) {
applyDateFilter(filter, key, val, op, isXPrefixed);
continue;
}
const suffix = op ? (OP_SUFFIX[op] ?? '') : '';
filter[`${key}${suffix}`] = val;
}
return filter;
}