Skip to content

Commit 344ab8b

Browse files
committed
Improved comment filter date handling
ref https://linear.app/ghost/issue/BER-3505/migrate-comment-moderation-filters-to-new-filter-primitives Aligned comments and members on the shared date codec and removed divergent comment component tests before the filter state refactor.
1 parent 03037e1 commit 344ab8b

22 files changed

Lines changed: 339 additions & 521 deletions

apps/posts/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"react-dom": "18.3.1",
6363
"react-router": "7.14.0",
6464
"sonner": "2.0.7",
65+
"temporal-polyfill": "0.3.0",
6566
"use-debounce": "10.1.1",
6667
"zod": "4.1.12"
6768
},

apps/posts/src/views/comments/comment-fields.ts

Lines changed: 5 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,9 @@
1-
import moment from 'moment-timezone';
1+
import {DATE_FILTER_OPERATORS, DEFAULT_DATE_OPERATOR} from '../filters/filter-date';
2+
import {dateCodec, scalarCodec, textCodec} from '../filters/filter-codecs';
23
import {defineFields} from '../filters/filter-types';
34
import {extractComparator} from '../filters/filter-ast';
4-
import {getDayBoundsInUtc} from '../filters/filter-normalization';
5-
import {scalarCodec, textCodec} from '../filters/filter-codecs';
65
import type {FilterCodec} from '../filters/filter-types';
76

8-
function formatCommentDateValue(value: unknown, timezone: string): string | null {
9-
if (typeof value !== 'string' || !value) {
10-
return null;
11-
}
12-
13-
const parsed = moment.tz(value, [moment.ISO_8601, 'YYYY-MM-DD'], true, timezone);
14-
15-
if (!parsed.isValid()) {
16-
return null;
17-
}
18-
19-
return parsed.format('YYYY-MM-DD');
20-
}
21-
22-
const commentDateCodec: FilterCodec = {
23-
parse(node, ctx) {
24-
const comparator = extractComparator(node as Record<string, unknown>);
25-
26-
if (!comparator || comparator.field !== ctx.key) {
27-
return null;
28-
}
29-
30-
const value = formatCommentDateValue(comparator.value, ctx.timezone);
31-
32-
if (!value) {
33-
return null;
34-
}
35-
36-
if (comparator.operator === '$lt') {
37-
return {
38-
field: ctx.key,
39-
operator: 'before',
40-
values: [value]
41-
};
42-
}
43-
44-
if (comparator.operator === '$gt') {
45-
return {
46-
field: ctx.key,
47-
operator: 'after',
48-
values: [value]
49-
};
50-
}
51-
52-
return null;
53-
},
54-
serialize(predicate, ctx) {
55-
const value = predicate.values[0];
56-
57-
if (typeof value !== 'string' || !value) {
58-
return null;
59-
}
60-
61-
const {start, end} = getDayBoundsInUtc(value, ctx.timezone);
62-
63-
if (predicate.operator === 'before') {
64-
return [`${ctx.key}:<'${start}'`];
65-
}
66-
67-
if (predicate.operator === 'after') {
68-
return [`${ctx.key}:>'${end}'`];
69-
}
70-
71-
if (predicate.operator === 'is') {
72-
return [
73-
`${ctx.key}:>='${start}'`,
74-
`${ctx.key}:<='${end}'`
75-
];
76-
}
77-
78-
return null;
79-
}
80-
};
81-
827
const reportedCodec: FilterCodec = {
838
parse(node, ctx) {
849
const comparator = extractComparator(node as Record<string, unknown>);
@@ -140,13 +65,14 @@ export const commentFields = defineFields({
14065
codec: scalarCodec()
14166
},
14267
created_at: {
143-
operators: ['is', 'before', 'after'],
68+
operators: DATE_FILTER_OPERATORS,
14469
ui: {
14570
label: 'Date',
71+
defaultOperator: DEFAULT_DATE_OPERATOR,
14672
type: 'date',
14773
className: 'w-full max-w-32'
14874
},
149-
codec: commentDateCodec
75+
codec: dateCodec()
15076
},
15177
body: {
15278
operators: ['contains', 'does-not-contain'],

apps/posts/src/views/comments/comment-filter-query.ts

Lines changed: 4 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,18 @@
1-
import moment from 'moment-timezone';
21
import {commentFields} from './comment-fields';
3-
import {dispatchSimpleNodes, parseFilterToAst, serializePredicates, stampPredicates} from '../filters/filter-query-core';
4-
import {getDayBoundsInUtc} from '../filters/filter-normalization';
2+
import {dispatchSimpleNodes, getFieldKeysByType, hasFieldKey, parseFilterToAst, serializePredicates, stampPredicates} from '../filters/filter-query-core';
53
import type {AstNode} from '../filters/filter-ast';
64
import type {FilterPredicate, ParsedPredicate} from '../filters/filter-types';
75

8-
function extractCreatedAtComparator(node: AstNode): {operator: string; value: string} | null {
9-
const createdAt = node.created_at;
10-
11-
if (!createdAt || typeof createdAt !== 'object' || Array.isArray(createdAt)) {
12-
return null;
13-
}
14-
15-
const [operator, value] = Object.entries(createdAt as Record<string, unknown>)[0] ?? [];
16-
17-
if (!operator || typeof value !== 'string') {
18-
return null;
19-
}
20-
21-
return {operator, value};
22-
}
23-
24-
function matchExactDateCompound(children: AstNode[], timezone: string): {predicate: ParsedPredicate | null; remainingChildren: AstNode[]} {
25-
for (let index = 0; index < children.length; index += 1) {
26-
const lowerBound = extractCreatedAtComparator(children[index]);
27-
28-
if (!lowerBound || lowerBound.operator !== '$gte') {
29-
continue;
30-
}
31-
32-
const parsed = moment.tz(lowerBound.value, moment.ISO_8601, true, timezone);
33-
34-
if (!parsed.isValid()) {
35-
continue;
36-
}
37-
38-
const date = parsed.format('YYYY-MM-DD');
39-
const {start, end} = getDayBoundsInUtc(date, timezone);
40-
41-
if (lowerBound.value !== start) {
42-
continue;
43-
}
44-
45-
const upperBoundIndex = children.findIndex((child, candidateIndex) => {
46-
if (candidateIndex === index) {
47-
return false;
48-
}
49-
50-
const comparator = extractCreatedAtComparator(child);
51-
return comparator?.operator === '$lte' && comparator.value === end;
52-
});
53-
54-
if (upperBoundIndex === -1) {
55-
continue;
56-
}
57-
58-
return {
59-
predicate: {
60-
field: 'created_at',
61-
operator: 'is',
62-
values: [date]
63-
},
64-
remainingChildren: children.filter((_, candidateIndex) => candidateIndex !== index && candidateIndex !== upperBoundIndex)
65-
};
66-
}
67-
68-
return {
69-
predicate: null,
70-
remainingChildren: children
71-
};
72-
}
6+
const TIMEZONE_SENSITIVE_COMMENT_FIELDS = getFieldKeysByType(commentFields, 'date');
737

748
function parseCommentNode(node: AstNode, timezone: string): ParsedPredicate[] {
759
if (Array.isArray(node.$and)) {
76-
const {predicate, remainingChildren} = matchExactDateCompound(node.$and as AstNode[], timezone);
77-
const parsedChildren = remainingChildren.flatMap(child => parseCommentNode(child, timezone));
78-
79-
return predicate ? [predicate, ...parsedChildren] : parsedChildren;
10+
return (node.$and as AstNode[]).flatMap(child => parseCommentNode(child, timezone));
8011
}
8112

8213
return dispatchSimpleNodes([node], commentFields, timezone);
8314
}
8415

85-
function hasTimezoneSensitiveCommentField(node: AstNode): boolean {
86-
if (Object.keys(node).includes('created_at')) {
87-
return true;
88-
}
89-
90-
return Object.values(node).some((value) => {
91-
if (Array.isArray(value)) {
92-
return value.some(child => child !== null && typeof child === 'object' && hasTimezoneSensitiveCommentField(child as AstNode));
93-
}
94-
95-
return value !== null && typeof value === 'object' && hasTimezoneSensitiveCommentField(value as AstNode);
96-
});
97-
}
98-
9916
export function parseCommentFilter(filter: string | undefined, timezone: string): FilterPredicate[] {
10017
const ast = parseFilterToAst(filter ?? '');
10118

@@ -113,7 +30,7 @@ export function hasTimezoneSensitiveCommentFilter(filter: string | undefined): b
11330
return false;
11431
}
11532

116-
return hasTimezoneSensitiveCommentField(ast);
33+
return hasFieldKey(ast, TIMEZONE_SENSITIVE_COMMENT_FIELDS);
11734
}
11835

11936
export function serializeCommentFilters(predicates: FilterPredicate[], timezone: string): string | undefined {

apps/posts/src/views/comments/legacy-comment-filter-query.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import {Filter} from '@tryghost/shade/patterns';
44
const LEGACY_COMMENT_FILTER_FIELDS = ['status', 'created_at', 'body', 'post', 'author', 'reported'] as const;
55
const LEGACY_OPERATOR_MAP: Record<string, string> = {
66
is_not: 'is-not',
7-
not_contains: 'does-not-contain'
7+
not_contains: 'does-not-contain',
8+
before: 'is-less',
9+
after: 'is-greater',
10+
on_or_before: 'is-or-less',
11+
on_or_after: 'is-or-greater'
812
};
913

1014
function parseLegacyFilterValue(queryValue: string): {operator: string; value: string} | null {

apps/posts/src/views/comments/use-comment-filter-fields.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import React, {useMemo} from 'react';
2-
import moment from 'moment-timezone';
2+
import {DATE_OPERATOR_LABELS} from '../filters/filter-date';
33
import {FilterFieldConfig, ValueSource} from '@tryghost/shade/patterns';
44
import {LucideIcon} from '@tryghost/shade/utils';
55
import {commentFields} from './comment-fields';
66
import {createOperatorOptions} from '../filters/filter-operator-options';
7+
import {getTodayInTimezone} from '../filters/filter-normalization';
78

89
interface UseCommentFilterFieldsOptions {
910
postValueSource: ValueSource<string>;
@@ -38,7 +39,7 @@ export function useCommentFilterFields({
3839
siteTimezone = 'UTC'
3940
}: UseCommentFilterFieldsOptions): FilterFieldConfig[] {
4041
return useMemo(() => {
41-
const today = moment.tz(siteTimezone).format('YYYY-MM-DD');
42+
const today = getTodayInTimezone(siteTimezone);
4243

4344
return COMMENT_FIELD_ORDER.map((key) => {
4445
const field = commentFields[key];
@@ -47,7 +48,7 @@ export function useCommentFilterFields({
4748
key,
4849
...field.ui,
4950
icon: getFieldIcon(key),
50-
operators: createOperatorOptions(field.operators),
51+
operators: createOperatorOptions(field.operators, {labels: DATE_OPERATOR_LABELS}),
5152
...('options' in field && field.options ? {options: field.options} : {}),
5253
...(key === 'created_at' ? {defaultValue: today} : {}),
5354
...(key === 'author' ? {valueSource: memberValueSource} : {}),

apps/posts/src/views/filters/filter-codecs.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import nql from '@tryghost/nql-lang';
2+
import {dateCodec, numberCodec, scalarCodec, setCodec, textCodec} from './filter-codecs';
23
import {describe, expect, it} from 'vitest';
3-
import {numberCodec, scalarCodec, setCodec, textCodec} from './filter-codecs';
44
import type {CodecContext, FilterPredicate} from './filter-types';
55

66
const statusContext: CodecContext = {
@@ -52,6 +52,13 @@ const countContext: CodecContext = {
5252
timezone: 'UTC'
5353
};
5454

55+
const dateContext: CodecContext = {
56+
key: 'created_at',
57+
pattern: 'created_at',
58+
params: {},
59+
timezone: 'UTC'
60+
};
61+
5562
describe('scalarCodec', () => {
5663
it('parses simple scalar comparisons', () => {
5764
expect(scalarCodec().parse(nql.parse('status:paid') as never, statusContext)).toEqual({
@@ -335,3 +342,41 @@ describe('numberCodec', () => {
335342
expect(numberCodec().serialize(predicate, countContext)).toEqual(['email_count:<=10']);
336343
});
337344
});
345+
346+
describe('dateCodec', () => {
347+
it('parses date comparison operators', () => {
348+
expect(dateCodec().parse(nql.parse('created_at:<=\'2024-01-01T23:59:59.999Z\'') as never, dateContext)).toEqual({
349+
field: 'created_at',
350+
operator: 'is-or-less',
351+
values: ['2024-01-01']
352+
});
353+
354+
expect(dateCodec().parse(nql.parse('created_at:>\'2024-01-01T23:59:59.999Z\'') as never, dateContext)).toEqual({
355+
field: 'created_at',
356+
operator: 'is-greater',
357+
values: ['2024-01-01']
358+
});
359+
});
360+
361+
it('serializes date comparison operators using site timezone day bounds', () => {
362+
expect(dateCodec().serialize({
363+
id: '1',
364+
field: 'created_at',
365+
operator: 'is-or-less',
366+
values: ['2024-02-01']
367+
}, {
368+
...dateContext,
369+
timezone: 'Europe/Stockholm'
370+
})).toEqual(['created_at:<=\'2024-02-01T22:59:59.999Z\'']);
371+
});
372+
373+
it('returns null for invalid date values', () => {
374+
expect(dateCodec().parse(nql.parse('created_at:<=\'not-a-date\'') as never, dateContext)).toBeNull();
375+
expect(dateCodec().serialize({
376+
id: '1',
377+
field: 'created_at',
378+
operator: 'is-or-less',
379+
values: ['not-a-date']
380+
}, dateContext)).toBeNull();
381+
});
382+
});

0 commit comments

Comments
 (0)