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:
@@ -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);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<Button
|
||||
class="rounded-s-none border-s"
|
||||
icon="lucide-x"
|
||||
:aria-label="__('Clear all Filter')"
|
||||
@click.stop="$emit('clear')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
|
||||
252
e2e/tests/tickets/list/filter-field-types.spec.ts
Normal file
252
e2e/tests/tickets/list/filter-field-types.spec.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
71
e2e/tests/tickets/list/filter.spec.ts
Normal file
71
e2e/tests/tickets/list/filter.spec.ts
Normal 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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user