From e080a6e061197d870724dec0e094aadd77ac6321 Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:26:22 +0200 Subject: [PATCH] Properly serialize `date` filters when applying them to the list filter --- CHANGELOG.md | 9 ++ package.json | 2 +- src/components/forms/DynamicForm.tsx | 4 +- src/components/lists/DynamicList.tsx | 29 ++--- src/lib/listFilter.test.ts | 155 +++++++++++++++++++++++++++ src/lib/listFilter.ts | 97 +++++++++++++++++ 6 files changed, 271 insertions(+), 25 deletions(-) create mode 100644 src/lib/listFilter.test.ts create mode 100644 src/lib/listFilter.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 77dfe23..ac38e74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index c7334b5..a90ab52 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stalwart-webui", "private": true, - "version": "1.0.5", + "version": "1.0.6", "description": "Stalwart WebUI", "type": "module", "scripts": { diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index 1f90d37..5e1a034 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -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}`)); } } } diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index fe65d93..24083c2 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -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 => { - const filter: Record = {}; - const list = resolved?.list; - if (list?.filtersStatic) { - Object.assign(filter, list.filtersStatic); - } - const opSuffix: Record = { - 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[] | undefined => { if (!sort) return undefined; diff --git a/src/lib/listFilter.test.ts b/src/lib/listFilter.test.ts new file mode 100644 index 0000000..b06a48f --- /dev/null +++ b/src/lib/listFilter.test.ts @@ -0,0 +1,155 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * 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' }); + }); + }); +}); diff --git a/src/lib/listFilter.ts b/src/lib/listFilter.ts new file mode 100644 index 0000000..720237e --- /dev/null +++ b/src/lib/listFilter.ts @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import type { Filter } from '@/types/schema'; + +const OP_SUFFIX: Record = { + 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, + 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; + filters?: Filter[]; + filtersStatic?: Record; + isXPrefixed: boolean; +} + +export function buildJmapFilter({ + appliedFilters, + filters, + filtersStatic, + isXPrefixed, +}: BuildJmapFilterArgs): Record { + const filter: Record = {}; + 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; +}