Properly serialize date filters when applying them to the list filter
This commit is contained in:
@@ -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/).
|
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
|
## [1.0.5] - 2026-06-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stalwart-webui",
|
"name": "stalwart-webui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"description": "Stalwart WebUI",
|
"description": "Stalwart WebUI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -350,9 +350,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
newFieldErrors[ve.property] = validationErrorMessage(ve);
|
newFieldErrors[ve.property] = validationErrorMessage(ve);
|
||||||
} else if (ve.property) {
|
} else if (ve.property) {
|
||||||
const detail = ve.value && ve.value.length > 0 ? ve.value : ve.type;
|
const detail = ve.value && ve.value.length > 0 ? ve.value : ve.type;
|
||||||
setGeneralError((prev) =>
|
setGeneralError((prev) => (prev ? `${prev}\n${ve.property}: ${detail}` : `${ve.property}: ${detail}`));
|
||||||
prev ? `${prev}\n${ve.property}: ${detail}` : `${ve.property}: ${detail}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
|||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { friendlySetError } from '@/lib/jmapErrors';
|
import { friendlySetError } from '@/lib/jmapErrors';
|
||||||
import { coerceLabel } from '@/lib/objectOptions';
|
import { coerceLabel } from '@/lib/objectOptions';
|
||||||
|
import { buildJmapFilter } from '@/lib/listFilter';
|
||||||
|
|
||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
@@ -385,27 +386,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
}, [viewName]);
|
}, [viewName]);
|
||||||
|
|
||||||
const buildFilter = useCallback((): Record<string, unknown> => {
|
const buildFilter = useCallback((): Record<string, unknown> => {
|
||||||
const filter: Record<string, unknown> = {};
|
return buildJmapFilter({
|
||||||
const list = resolved?.list;
|
appliedFilters,
|
||||||
if (list?.filtersStatic) {
|
filters: resolved?.list?.filters,
|
||||||
Object.assign(filter, list.filtersStatic);
|
filtersStatic: resolved?.list?.filtersStatic,
|
||||||
}
|
isXPrefixed: resolved?.obj.objectName.startsWith('x:') ?? false,
|
||||||
const opSuffix: Record<string, string> = {
|
});
|
||||||
eq: '',
|
}, [appliedFilters, resolved?.list, resolved?.obj.objectName]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const buildSort = useCallback((): Record<string, unknown>[] | undefined => {
|
const buildSort = useCallback((): Record<string, unknown>[] | undefined => {
|
||||||
if (!sort) return undefined;
|
if (!sort) return undefined;
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user