Merge pull request #1139 from dealerweb/fix/filter-rule-wrap

fix: wrap a filter condition's values in the expanded rule view
This commit is contained in:
Linus Rath
2026-10-01 10:59:50 +02:00
2 changed files with 91 additions and 20 deletions
@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import React from 'react';
import { VisualRuleSummary } from '../filter-settings';
import type { FilterRule } from '@/lib/jmap/sieve-types';
const rule = (overrides: Partial<FilterRule>): FilterRule => ({
id: 'r1',
name: 'Rule',
enabled: true,
matchType: 'all',
conditions: [],
actions: [],
stopProcessing: false,
...overrides,
});
/** The value items in the chip of the condition on `field` (next-intl returns keys). */
const valuesOf = (field: string) => {
const chip = screen.getByText(`condition_fields.${field}`).parentElement!;
return { chip, values: [...chip.lastElementChild!.children].map((el) => el.textContent) };
};
// The expanded view wraps a condition's values between each other, so each
// value is its own item and each "or" leads the value after it - a wrapped
// line then starts with "or", and no value is split from its "or".
describe('filter rule summary (expanded view)', () => {
it('keeps the values of one condition apart, each "or" leading the value after it', () => {
render(<VisualRuleSummary rule={rule({
matchType: 'any',
conditions: [{
field: 'from',
comparator: 'contains',
value: ['ReportingEMail@autoscout24.com', 'gebrauchtwagen.expert', 'noreply@webmobil24.com'],
}],
actions: [{ type: 'move', value: 'INBOX/Boersen' }],
})} />);
expect(valuesOf('from').values).toEqual([
'“ReportingEMail@autoscout24.com”',
'or “gebrauchtwagen.expert”',
'or “noreply@webmobil24.com”',
]);
});
it('keeps "or" inside the condition, apart from the "and" between conditions', () => {
render(<VisualRuleSummary rule={rule({
matchType: 'all',
conditions: [
{ field: 'from', comparator: 'contains', value: ['a@example.com', 'b@example.com'] },
{ field: 'body', comparator: 'contains', value: 'Jochen Seith' },
],
})} />);
const from = valuesOf('from');
expect(from.chip).toHaveTextContent('or “b@example.com”');
expect(from.chip).not.toHaveTextContent('and');
expect(valuesOf('body').values).toEqual(['“Jochen Seith”']);
expect(from.chip.contains(screen.getByText('and'))).toBe(false);
});
it('shows no value for a condition that has none', () => {
render(<VisualRuleSummary rule={rule({
conditions: [{ field: 'attachment', comparator: 'has_any', value: '' }],
})} />);
const chip = screen.getByText('condition_fields.attachment').parentElement!;
expect([...chip.children].map((el) => el.textContent)).toEqual([
'condition_fields.attachment',
'comparators.has_any',
]);
});
});
+21 -20
View File
@@ -86,7 +86,7 @@ function RuleSummary({ rule }: { rule: FilterRule }) {
);
}
function VisualRuleSummary({ rule }: { rule: FilterRule }) {
export function VisualRuleSummary({ rule }: { rule: FilterRule }) {
const t = useTranslations("settings.filters");
const joiner = rule.matchType === "all" ? t("and") : t("or");
@@ -106,23 +106,24 @@ function VisualRuleSummary({ rule }: { rule: FilterRule }) {
{i > 0 && (
<span className="text-[10px] text-muted-foreground/70 italic">{joiner}</span>
)}
<span className="inline-flex items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
<span className="font-medium text-blue-600 dark:text-blue-400">{field}</span>
<span className="text-muted-foreground">{comparator}</span>
<span className="inline-flex min-w-0 max-w-full items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
<span className="shrink-0 font-medium text-blue-600 dark:text-blue-400">{field}</span>
<span className="shrink-0 text-muted-foreground">{comparator}</span>
{!(c.field === "attachment" && c.comparator === "has_any") && (
<span className="text-foreground">
{Array.isArray(c.value)
? c.value.map((v, k) => (
<span key={k}>
{k > 0 && (
<span className="text-muted-foreground/70 italic mx-0.5">
{t("or")}
</span>
)}
“{v}”
</span>
))
: <>“{c.value}”</>}
// The values of one condition stay in its chip, where "or"
// cannot be mistaken for the joiner between conditions. They
// wrap between each other under the first value, the "or"
// leading the next line; a single value too long for any
// line breaks inside itself instead of leaving the card.
<span className="flex min-w-0 flex-wrap items-baseline gap-x-1 text-foreground">
{(Array.isArray(c.value) ? c.value : [c.value]).map((v, k) => (
<span key={k} className="min-w-0 wrap-anywhere">
{k > 0 && (
<span className="text-muted-foreground/70 italic">{t("or")} </span>
)}
“{v}”
</span>
))}
</span>
)}
</span>
@@ -143,9 +144,9 @@ function VisualRuleSummary({ rule }: { rule: FilterRule }) {
{i > 0 && (
<span className="text-muted-foreground/50">›</span>
)}
<span className="inline-flex items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
<span className="font-medium text-emerald-600 dark:text-emerald-400">{action}</span>
{a.value && <span className="text-muted-foreground">“{a.value}”</span>}
<span className="inline-flex min-w-0 max-w-full items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
<span className="shrink-0 font-medium text-emerald-600 dark:text-emerald-400">{action}</span>
{a.value && <span className="min-w-0 wrap-anywhere text-muted-foreground">“{a.value}”</span>}
</span>
</span>
);