Merge pull request #3840 from RitvikSardana/fix/filter-paste-in-values

fix(filter): select every value pasted into the in filter
This commit is contained in:
RitvikSardana
2026-10-05 14:00:23 +05:30
committed by GitHub
10 changed files with 492 additions and 29 deletions

View File

@@ -1,11 +1,11 @@
<template>
<Popover bare side="bottom" align="end">
<template #trigger="{ setOpen }">
<div :ref="() => (openPopoverFn = () => setOpen(true))" class="w-fit">
<Popover ref="popover" bare side="bottom" align="end">
<template #trigger>
<div class="w-fit">
<FilterTrigger
:count="activeFilters.length"
@toggle="resetSteps()"
@clear="clearFilters()"
@clear="clearAllAndClose"
/>
</div>
</template>
@@ -128,6 +128,7 @@
<!-- Step: value — pick operator + value (renders its own header) -->
<FilterValueEditor
v-else-if="step === 'value' && selectedField"
ref="valueEditor"
:key="editSession"
:field="selectedField"
:filter="editingFilter"
@@ -187,7 +188,8 @@ const overviewHeader = ref<HTMLElement | null>(null);
// The high-z layer the operator dropdown teleports into (see #body), so its menu
// sits above the filter popover panel instead of behind it.
const operatorMenuLayer = ref<HTMLElement | null>(null);
let openPopoverFn: (() => void) | null = null;
const popover = ref<InstanceType<typeof Popover> | null>(null);
const valueEditor = ref<InstanceType<typeof FilterValueEditor> | null>(null);
// Row actions stay out of the way until the row is hovered or focused.
const revealOnRowActivity =
@@ -217,7 +219,7 @@ const editingFilter = computed<ActiveFilter | null>(() => {
useShortcut("f", () => {
resetSteps();
openPopoverFn?.();
popover.value?.open();
});
// The popover auto-focuses its first focusable element on open, which would put
@@ -279,6 +281,12 @@ function applyFilter(operator: string, value: any) {
replacingIndex.value = null;
}
function clearAllAndClose() {
valueEditor.value?.discardPendingText();
clearFilters();
popover.value?.close();
}
function clearCurrentFilter() {
if (editingIndex.value === null) return;
removeFilter(editingIndex.value);

View File

@@ -19,6 +19,7 @@
<Button
class="rounded-s-none border-s"
icon="lucide-x"
:aria-label="__('Clear all Filter')"
@click.stop="$emit('clear')"
/>
</div>

View File

@@ -7,9 +7,10 @@
:label="field.label"
size="sm"
class="min-w-0"
@back="emit('back')"
@back="goBack"
/>
<Dropdown
v-model:open="operatorMenuOpen"
side="bottom"
:options="operatorOptions"
:portal-to="operatorMenuTarget || 'body'"
@@ -37,6 +38,7 @@
:placeholder="__('Search...')"
autocomplete="off"
@keydown="onKeydown"
@paste="onSearchPaste"
>
<template #prefix>
<LucideSearch class="size-4 text-ink-gray-5" />
@@ -60,16 +62,24 @@
:aria-selected="isSelected(option.value)"
:class="[
'flex h-8 w-full items-center gap-2 rounded-4 px-1.5 text-base text-ink-gray-8',
index === activeIndex ? 'bg-surface-gray-2' : '',
rowBackground(option.value, index),
]"
@mousemove="activeIndex = index"
@click="pickOption(option.value)"
>
<Checkbox
v-if="isMultiple"
:model-value="isSelected(option.value)"
size="sm"
tabindex="-1"
aria-hidden="true"
class="pointer-events-none"
/>
<span :title="option.label" class="flex-1 truncate text-start">{{
option.label
}}</span>
<LucideCheck
v-if="isSelected(option.value)"
v-if="!isMultiple && isSelected(option.value)"
class="size-4 text-ink-gray-7"
/>
</button>
@@ -87,6 +97,17 @@
{{ __("No results") }}
</div>
</div>
<div
v-if="isMultiple && selectedCount"
class="flex items-center border-t border-outline-gray-1 px-2 py-1.5"
>
<Button
variant="ghost"
size="sm"
:label="__('Clear All')"
@click="clearSelection"
/>
</div>
</template>
<div v-else class="flex flex-col gap-2 p-2">
<div v-if="isRatingPicker" class="px-2 py-1">
@@ -108,7 +129,7 @@
<TextInput
v-else
ref="valueInput"
:type="isNumber ? 'number' : 'text'"
:type="isNumber && !isMultiple ? 'number' : 'text'"
v-model="textValue"
:placeholder="isMultiple ? __('Comma separated values') : __('Value')"
@update:model-value="onTextChange"
@@ -124,6 +145,7 @@ import { useDevice } from "@/composables";
import { __ } from "@/translation";
import { useDebounceFn, useEventListener } from "@vueuse/core";
import {
Checkbox,
DatePicker,
DateRangePicker,
DateTimePicker,
@@ -131,7 +153,7 @@ import {
Rating,
TextInput,
} from "frappe-ui";
import { computed, nextTick, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import {
ActiveFilter,
FilterField,
@@ -166,6 +188,8 @@ const value = ref(props.filter?.value ?? defaultValueFor(operator.value));
const textValue = ref(plainText(value.value));
const search = ref("");
const activeIndex = ref(0);
const operatorMenuOpen = ref(false);
let textApplyPending = false;
const searchInput = ref(null);
const valueInput = ref(null);
const listEl = ref<HTMLElement | null>(null);
@@ -235,7 +259,7 @@ const options = computed<Array<{ label: string; value: string }>>(() => {
}
if (isDate.value)
return timespanOptions.filter((option) => matches(option.label));
if (isLink.value) return linkSearch.results.data || [];
if (isLink.value) return withSelectedOptions(linkSearch.results.data || []);
const values =
props.field.fieldtype === "Check"
? ["Yes", "No"]
@@ -245,6 +269,25 @@ const options = computed<Array<{ label: string; value: string }>>(() => {
.map((option) => ({ label: option, value: option }));
});
// Selected values outside the search results (pasted, or past the first page)
// are pinned on top so they stay visible and can be unselected
function withSelectedOptions(results: Array<{ label: string; value: string }>) {
if (!Array.isArray(value.value)) return results;
const resultValues = new Set(results.map((option) => option.value));
const query = search.value.toLowerCase();
const pinned = value.value
.filter((selected: string) => !resultValues.has(selected))
.filter((selected: string) =>
String(selected).toLowerCase().includes(query)
)
.map((selected: string) => ({ label: selected, value: selected }));
return [...pinned, ...results];
}
const selectedCount = computed(() =>
Array.isArray(value.value) ? value.value.length : 0
);
const dateRangeValue = computed(() =>
Array.isArray(value.value) ? value.value : []
);
@@ -279,6 +322,19 @@ function isSelected(option: string): boolean {
return plainText(value.value) === String(option);
}
function rowBackground(option: string, index: number): string {
const active = index === activeIndex.value;
if (isMultiple.value && isSelected(option))
return active ? "bg-surface-gray-3" : "bg-surface-gray-2";
return active ? "bg-surface-gray-2" : "";
}
function clearSelection() {
value.value = [];
emit("clear");
focusSearch();
}
function pickOption(option: string) {
if (!isMultiple.value) {
// A single-select pick (Equals/Not Equals, Is, Timespan, single assignee)
@@ -299,6 +355,23 @@ function pickOption(option: string) {
focusSearch();
}
// Pasting a list (e.g. ticket IDs) into the search box selects every value
function onSearchPaste(event: ClipboardEvent) {
const pastedValues = splitValues(event.clipboardData?.getData("text") || "");
if (!isMultiple.value || pastedValues.length < 2) return;
event.preventDefault();
const selectedValues = Array.isArray(value.value) ? value.value : [];
applyValue([...new Set([...selectedValues, ...pastedValues])]);
search.value = "";
}
function splitValues(text: string): string[] {
return text
.split(/[,\n]/)
.map((part) => part.trim())
.filter(Boolean);
}
function applyValue(nextValue: any) {
value.value = nextValue;
emit("apply", operator.value, nextValue);
@@ -353,7 +426,7 @@ function onKeydown(event: KeyboardEvent) {
selectActive();
} else if (event.key === "Backspace" && !search.value) {
event.stopPropagation();
emit("back");
goBack();
}
}
@@ -368,7 +441,7 @@ function focusSearch() {
function onTextKeydown(event: KeyboardEvent) {
if (event.key === "Backspace" && !textValue.value) {
event.stopPropagation();
emit("back");
goBack();
}
}
@@ -380,16 +453,38 @@ function onTextChange() {
emit("clear");
return;
}
textApplyPending = true;
debouncedTextApply();
}
const debouncedTextApply = useDebounceFn(() => {
// Leaving flushes typed text first, so a pending debounce can never fire
// after another filter has been opened and land on that one
function goBack() {
applyPendingText();
emit("back");
}
const debouncedTextApply = useDebounceFn(applyPendingText, 500);
// Called before clearing every filter, so closing the editor can't bring one back
function discardPendingText() {
textApplyPending = false;
}
function applyPendingText() {
if (!textApplyPending) return;
textApplyPending = false;
if (!textValue.value) return;
const nextValue = isMultiple.value
? textValue.value.split(",").map((part: string) => part.trim())
? splitValues(textValue.value)
: textValue.value;
if (!hasValue(nextValue)) {
value.value = defaultValueFor(operator.value);
emit("clear");
return;
}
applyValue(nextValue);
}, 500);
}
function hasValue(candidate: any): boolean {
if (Array.isArray(candidate)) return candidate.length > 0;
@@ -410,6 +505,8 @@ watch(activeIndex, (index) => {
});
watch(operator, (newOperator, oldOperator) => {
// typed text belongs to the old operator, so drop it instead of applying it late
discardPendingText();
search.value = "";
activeIndex.value = 0;
if (newOperator === "is") {
@@ -449,14 +546,22 @@ function convertValue(rawValue: any): any {
return single ? [single] : [];
}
// Moving focus while the operator menu is open makes the menu close the whole
// popover, so wait for the menu to close first
watch(
() => isListMode.value,
() => {
focusSearch();
if (!operatorMenuOpen.value) focusSearch();
},
{ immediate: true }
);
watch(operatorMenuOpen, (open) => {
if (!open) focusSearch();
});
onBeforeUnmount(applyPendingText);
function cycleOperator(direction: number) {
const operatorList = operators.value;
if (operatorList.length < 2) return;
@@ -499,7 +604,9 @@ useEventListener(document, "keydown", (event: KeyboardEvent) => {
return;
}
event.preventDefault();
emit("back");
goBack();
}
});
defineExpose({ discardPendingText });
</script>

View File

@@ -1,3 +1,4 @@
import { __ } from "@/translation";
import { useDebounceFn } from "@vueuse/core";
import { createResource } from "frappe-ui";
import { computed, inject, Component, ComputedRef } from "vue";
@@ -330,14 +331,23 @@ function displayValue(filter: ActiveFilter): string {
return Number.isFinite(n) ? String(Math.round(n * 5)) : String(v ?? "");
};
return Array.isArray(value)
? value.map(toStars).join(", ")
? shortList(value.map(toStars))
: toStars(value);
}
if (Array.isArray(value)) return value.join(", ");
if (Array.isArray(value)) return shortList(value);
if (typeof value === "string") return value.replaceAll("%", "");
return String(value ?? "");
}
// "1, 2, 3 and 4 others", so long pasted lists stay readable
function shortList(values: any[]): string {
const shown = values.slice(0, 3).join(", ");
const hidden = values.length - 3;
if (hidden <= 0) return shown;
if (hidden === 1) return __("{0} and 1 other", shown);
return __("{0} and {1} others", shown, hidden);
}
const operatorMap: Record<string, string> = {
is: "is",
"is not": "is not",

View File

@@ -7,7 +7,7 @@ export class TicketList {
async goto() {
await this.page.goto("/helpdesk/tickets");
await expect(this.page.getByRole("button", { name: "Filter" })).toBeVisible();
await expect(this.page.getByRole("button", { name: "Filter", exact: true })).toBeVisible();
}
rows() {
@@ -69,20 +69,34 @@ export class TicketList {
/** Clear every filter from the Filter popover, the way an agent resets the list. */
async clearFilters() {
await this.page.getByRole("button", { name: "Filter" }).click();
await this.page.getByRole("button", { name: "Filter", exact: true }).click();
await this.page.getByRole("dialog", { name: "Filter" }).getByRole("button", { name: "Clear all" }).click();
await this.page.keyboard.press("Escape");
}
async addFilter(field: string, value: string) {
await this.page.getByRole("button", { name: "Filter" }).click();
const popover = this.page.getByRole("dialog", { name: "Filter" });
await this.chooseFilterField(field);
await this.page.getByRole("option", { name: value, exact: true }).click();
}
filterPopover() {
return this.page.getByRole("dialog", { name: "Filter" });
}
/** Open the Filter popover and pick a field, leaving its value editor open. */
async chooseFilterField(field: string) {
await this.page.getByRole("button", { name: "Filter", exact: true }).click();
const popover = this.filterPopover();
await expect(popover).toBeVisible();
const addFilter = popover.getByRole("button", { name: "Add filter" });
if (await addFilter.isVisible()) await addFilter.click();
await popover.getByRole("textbox").fill(field);
await this.page.getByRole("option", { name: field, exact: true }).click();
await this.page.getByRole("option", { name: value, exact: true }).click();
}
async chooseFilterOperator(operator: string) {
await this.filterPopover().locator("button[aria-haspopup]").click();
await this.page.getByRole("menuitem", { name: operator, exact: true }).click();
}
}

View File

@@ -13,7 +13,7 @@ test.describe("logged out", () => {
await page.getByRole("button", { name: "Continue" }).click();
await expect(page).toHaveURL(/\/helpdesk\/tickets$/);
await expect(page.getByRole("button", { name: "Filter" })).toBeVisible();
await expect(page.getByRole("button", { name: "Filter", exact: true })).toBeVisible();
await page.getByRole("button", { name: /^Helpdesk / }).click();
await page.getByRole("menuitem", { name: "Log out" }).click();

View File

@@ -72,7 +72,7 @@ test.describe("onboarding persona form", () => {
const withoutTelemetry = await pageAs("admin");
await withoutTelemetry.goto("/helpdesk/tickets");
await expect(withoutTelemetry.getByRole("button", { name: "Filter" })).toBeVisible();
await expect(withoutTelemetry.getByRole("button", { name: "Filter", exact: true })).toBeVisible();
await expect(withoutTelemetry).toHaveURL(/\/helpdesk\/tickets$/);
});
});

View File

@@ -0,0 +1,252 @@
import type { Api } from "../../../helpers/api";
import { raiseTicket } from "../../../helpers/factories";
import { expect, test, uid, usePersona } from "../../../helpers/fixtures";
import { TicketList, resetDefaultTicketView } from "../../../helpers/list";
import { personas } from "../../../helpers/personas";
usePersona("manager");
test.beforeEach(async ({ api }) => {
await resetDefaultTicketView(api, personas.manager.email);
});
test.afterEach(async ({ api }) => {
await resetDefaultTicketView(api, personas.manager.email);
});
const SUBJECTS = { a: "alpha", b: "bravo", c: "charlie" };
type Key = keyof typeof SUBJECTS;
interface Context {
api: Api;
list: TicketList;
id: string;
names: Record<Key, string>;
}
// One case per field type and operator: raise tickets a, b and c, set them up
// over REST, apply the filter in the popover and check which ones stay listed.
interface FilterCase {
field: string;
operator?: string;
setup?: (context: Context) => Promise<void>;
enter: (context: Context) => Promise<void>;
shown: Key[];
hidden: Key[];
}
const pick =
(...labels: string[]) =>
async ({ list }: Context) => {
for (const label of labels) {
await list.filterPopover().getByRole("option", { name: label, exact: true }).click();
}
};
const type =
(text: (context: Context) => string) =>
async (context: Context) => {
await context.list
.filterPopover()
.getByPlaceholder(/^(Value|Comma separated values)$/)
.fill(text(context));
};
const stars = (count: number) => async ({ list }: Context) => {
await list.filterPopover().getByRole("radio", { name: `${count} of 5` }).click();
};
const each = (values: Record<Key, unknown>, field: string) => async ({ api, names }: Context) => {
for (const key of Object.keys(values) as Key[]) {
await api.update("HD Ticket", names[key], { [field]: values[key] });
}
};
const subject = (key: Key) => (context: Context) => `${context.id} ${SUBJECTS[key]}`;
const priorities = each({ a: "High", b: "Medium", c: "Low" }, "priority");
const daysAgo = (days: number) =>
new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
const cases: FilterCase[] = [
{ field: "Subject", enter: type(subject("b")), shown: ["b"], hidden: ["a", "c"] },
{
field: "Subject",
operator: "Not Like",
enter: type(subject("b")),
shown: ["a", "c"],
hidden: ["b"],
},
{
field: "Subject",
operator: "Equals",
enter: type(subject("c")),
shown: ["c"],
hidden: ["a", "b"],
},
{
field: "Subject",
operator: "In",
enter: type((context) => `${subject("a")(context)}, ${subject("c")(context)}`),
shown: ["a", "c"],
hidden: ["b"],
},
{
field: "Subject",
operator: "Not In",
enter: type((context) => `${subject("a")(context)}, ${subject("c")(context)}`),
shown: ["b"],
hidden: ["a", "c"],
},
{ field: "Priority", setup: priorities, enter: pick("High"), shown: ["a"], hidden: ["b", "c"] },
{
field: "Priority",
operator: "Not Equals",
setup: priorities,
enter: pick("High"),
shown: ["b", "c"],
hidden: ["a"],
},
{
field: "Priority",
operator: "In",
setup: priorities,
enter: pick("High", "Low"),
shown: ["a", "c"],
hidden: ["b"],
},
{
field: "Priority",
operator: "Like",
setup: priorities,
enter: type(() => "Hig"),
shown: ["a"],
hidden: ["b", "c"],
},
{
field: "Team",
operator: "Is",
setup: setTeamOnA,
enter: pick("Set"),
shown: ["a"],
hidden: ["b", "c"],
},
{
field: "Team",
operator: "Is",
setup: setTeamOnA,
enter: pick("Not Set"),
shown: ["b", "c"],
hidden: ["a"],
},
{
field: "Via Customer Portal",
setup: each({ a: 1, b: 0, c: 1 }, "via_customer_portal"),
enter: pick("Yes"),
shown: ["a", "c"],
hidden: ["b"],
},
{
field: "Via Customer Portal",
setup: each({ a: 1, b: 0, c: 1 }, "via_customer_portal"),
enter: pick("No"),
shown: ["b"],
hidden: ["a", "c"],
},
{
field: "Opening Date",
setup: each({ a: "2020-01-15", b: daysAgo(3), c: daysAgo(0) }, "opening_date"),
enter: pick("Today"),
shown: ["c"],
hidden: ["a", "b"],
},
{
field: "Opening Date",
setup: each({ a: "2020-01-15", b: daysAgo(3), c: daysAgo(0) }, "opening_date"),
enter: pick("Last 7 Days"),
shown: ["b", "c"],
hidden: ["a"],
},
{
field: "Rating",
setup: each({ a: 0.6, b: 1, c: 0 }, "feedback_rating"),
enter: stars(3),
shown: ["a"],
hidden: ["b", "c"],
},
{
field: "Rating",
operator: "Greater Than",
setup: each({ a: 0.6, b: 1, c: 0 }, "feedback_rating"),
enter: stars(3),
shown: ["b"],
hidden: ["a", "c"],
},
{
field: "Assigned to",
setup: assignManagerToA,
enter: pick("@me"),
shown: ["a"],
hidden: ["b", "c"],
},
{
field: "Tags",
operator: "Is",
setup: tagB,
enter: pick("Set"),
shown: ["b"],
hidden: ["a", "c"],
},
];
for (const filterCase of cases) {
const operator = filterCase.operator ?? "default operator";
const shown = filterCase.shown.map((key) => SUBJECTS[key]).join(", ");
test(`${filterCase.field} with ${operator} keeps ${shown}`, async ({ page, api, apiAs }) => {
const id = uid();
const customer = await apiAs("customer");
const names = {} as Record<Key, string>;
for (const key of Object.keys(SUBJECTS) as Key[]) {
names[key] = String((await raiseTicket(customer, `${id} ${SUBJECTS[key]}`)).name);
}
const list = new TicketList(page);
const context = { api, list, id, names };
await filterCase.setup?.(context);
await list.goto();
await list.chooseFilterField(filterCase.field);
if (filterCase.operator) await list.chooseFilterOperator(filterCase.operator);
await filterCase.enter(context);
// hidden first: it only passes once the filtered list has loaded
for (const key of filterCase.hidden) {
await expect(list.row(`${id} ${SUBJECTS[key]}`)).toHaveCount(0);
}
for (const key of filterCase.shown) {
await expect(list.row(`${id} ${SUBJECTS[key]}`)).toBeVisible();
}
});
}
async function setTeamOnA({ api, id, names }: Context) {
const team = await api.insert("HD Team", {
team_name: `E2E Team ${id}`,
users: [{ user: personas.manager.email }],
});
await api.update("HD Ticket", names.a, { agent_group: team.name });
}
async function assignManagerToA({ api, names }: Context) {
await api.call("frappe.desk.form.assign_to.add", {
doctype: "HD Ticket",
name: names.a,
assign_to: JSON.stringify([personas.manager.email]),
});
}
async function tagB({ api, id, names }: Context) {
await api.call("frappe.desk.doctype.tag.tag.add_tag", {
tag: `e2e-${id}`,
dt: "HD Ticket",
dn: names.b,
});
}

View File

@@ -0,0 +1,71 @@
import { raiseTicket } from "../../../helpers/factories";
import { expect, test, uid, usePersona } from "../../../helpers/fixtures";
import { TicketList, resetDefaultTicketView } from "../../../helpers/list";
import { personas } from "../../../helpers/personas";
usePersona("manager");
test.beforeEach(async ({ api }) => {
await resetDefaultTicketView(api, personas.manager.email);
});
test.afterEach(async ({ api }) => {
await resetDefaultTicketView(api, personas.manager.email);
});
test("pasting ticket IDs into an In filter selects them all and narrows the list", async ({
page,
context,
apiAs,
}) => {
const id = uid();
const customer = await apiAs("customer");
const tickets = [];
for (const n of [1, 2, 3, 4, 5]) tickets.push(await raiseTicket(customer, `${id} ${n}`));
const picked = tickets.slice(0, 4).map((ticket) => String(ticket.name));
const list = new TicketList(page);
await list.goto();
await list.searchSubject(id);
await expect(list.rows()).toHaveCount(5);
await list.chooseFilterField("ID");
await list.chooseFilterOperator("In");
const popover = list.filterPopover();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate((text) => navigator.clipboard.writeText(text), picked.join(", "));
await popover.getByPlaceholder("Search...").press("ControlOrMeta+V");
await expect(list.rows()).toHaveCount(4);
await expect(list.row(`${id} 5`)).toHaveCount(0);
await expect(popover.locator("[role=option][aria-selected=true]")).toHaveCount(4);
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Filter", exact: true }).click();
await expect(popover).toContainText(`ID in ${picked.slice(0, 3).join(", ")} and 1 other`);
});
test("the clear all X empties the filters and closes the popover", async ({
page,
api,
apiAs,
}) => {
const customer = await apiAs("customer");
const ticket = await raiseTicket(customer);
await api.update("HD Ticket", ticket.name, { priority: "High" });
const list = new TicketList(page);
await list.goto();
await list.addFilter("Priority", "High");
const popover = list.filterPopover();
await expect(popover).toContainText("Priority is High");
const clearAll = page.getByRole("button", { name: "Clear all Filter" });
await clearAll.click();
await expect(popover).toBeHidden();
await expect(clearAll).toBeHidden();
await page.keyboard.press("f");
await expect(popover).toBeVisible();
});

View File

@@ -54,7 +54,7 @@ test("filters and quick filters narrow the list and clear back", async ({
await expect(list.rows()).toHaveCount(1);
await expect(list.row(`${id} low`)).toBeVisible();
await page.getByRole("button", { name: "Filter" }).click();
await page.getByRole("button", { name: "Filter", exact: true }).click();
await popover.getByRole("button", { name: "Clear all" }).click();
await expect(page.getByRole("textbox", { name: "Subject" })).toHaveValue("");
await expect(list.rows().nth(2)).toBeVisible();