Properly serialize date filters when applying them to the list filter
This commit is contained in:
@@ -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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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