Skip to content

Commit

Permalink
Merge pull request #16522 from ahmedhamidawan/reusable_filters_component
Browse files Browse the repository at this point in the history
Create reusable `FilterMenu` with advanced options
  • Loading branch information
dannon authored Oct 13, 2023
2 parents 8848758 + 1c37fa9 commit 57985f1
Show file tree
Hide file tree
Showing 39 changed files with 1,936 additions and 1,173 deletions.
6 changes: 6 additions & 0 deletions client/src/components/Common/DelayedInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
size="sm"
autocomplete="off"
:placeholder="placeholder"
data-description="filter text input"
@input="delayQuery"
@change="setQuery"
@keydown.esc="setQuery('')" />
Expand Down Expand Up @@ -80,6 +81,11 @@ export default {
this.setQuery(queryNew);
},
},
created() {
if (this.query) {
this.setQuery(this.query);
}
},
methods: {
clearTimer() {
if (this.queryTimer) {
Expand Down
293 changes: 293 additions & 0 deletions client/src/components/Common/FilterMenu.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
import { mount } from "@vue/test-utils";
import { HistoryFilters } from "components/History/HistoryFilters";
import { getLocalVue } from "tests/jest/helpers";
import Filtering, { compare, contains, equals, toBool, toDate } from "utils/filtering";

import FilterMenu from "./FilterMenu";

const localVue = getLocalVue();
const options = [
{ text: "Any", value: "any" },
{ text: "Yes", value: true },
{ text: "No", value: false },
];
const validTestFilters = {
/** A basic name filter (with same placeholder as the key) */
name: { placeholder: "name", type: String, handler: contains("name"), menuItem: true },
/** A filter with different key and placeholder */
filter_key: { placeholder: "item", type: String, handler: contains("filter_key"), menuItem: true },
/** A filter with help component */
has_help: {
placeholder: "value",
type: String,
handler: equals("has_help"),
helpInfo: "Some test help info",
menuItem: true,
},
/** A filter with datalist */
list_item: {
placeholder: "list item",
type: Number,
handler: equals("list_item"),
datalist: ["option1", "option2"],
menuItem: true,
},
/** A ranged date filter */
create_time: {
placeholder: "creation time",
type: Date,
handler: compare("create_time", "le", toDate),
isRangeInput: true,
menuItem: true,
},
/** A ranged number filter */
number: { placeholder: "index", type: Number, handler: equals("number"), isRangeInput: true, menuItem: true },
/** A boolean filter with default boolType */
bool_def: {
placeholder: "Filter by option (any/yes/no)",
type: Boolean,
handler: equals("bool_def", "bool_def", toBool),
menuItem: true,
},
/** A boolean filter with is:filter boolType */
bool_is: {
placeholder: "Filter by option (yes/no)",
type: Boolean,
handler: equals("bool_is", "bool_is", toBool),
menuItem: true,
boolType: "is",
},
/** A valid filter, just not included in menu */
not_included: { handler: contains("not_included"), menuItem: false },
};
const TestFilters = new Filtering(validTestFilters, false);

describe("FilterMenu", () => {
let wrapper;

function setUpWrapper(name, placeholder, filterClass) {
wrapper = mount(FilterMenu, {
propsData: {
name: name,
placeholder: placeholder,
filterClass: filterClass,
filterText: "",
showAdvanced: false,
},
localVue,
stubs: {
icon: { template: "<div></div>" },
},
});
}

async function performSearch() {
// Test: search button (should toggle the view out)
const searchButton = wrapper.find("[data-description='apply filters']");
await searchButton.trigger("click");
}

async function expectCorrectEmits(showAdvanced, filterText, filterClass) {
const filterEmit = wrapper.emitted()["update:filter-text"].length - 1;
const toggleEmit = wrapper.emitted()["update:show-advanced"].length - 1;
expect(wrapper.emitted()["update:show-advanced"][toggleEmit][0]).toEqual(showAdvanced);
await wrapper.setProps({ showAdvanced: wrapper.emitted()["update:show-advanced"][toggleEmit][0] });
const receivedText = wrapper.emitted()["update:filter-text"][filterEmit][0];
const receivedDict = filterClass.getQueryDict(receivedText);
const parsedDict = filterClass.getQueryDict(filterText);
expect(receivedDict).toEqual(parsedDict);
}

it("test generic test items filter panel search", async () => {
setUpWrapper("Test Items", "search test items", TestFilters);
const validFilters = wrapper.vm.$props.filterClass.validFilters;

await wrapper.setProps({ showAdvanced: true });

const expectedFilters = [
{
label: "Filter by name:",
placeholder: "any name",
value: "name-filter",
},
{
label: "Filter by item:",
placeholder: "any item",
value: "item-filter",
},
{
label: "Filter by value:",
placeholder: "any value",
value: "has-help-filter",
},
{
label: "Filter by list item:",
placeholder: "any list item",
value: "1234",
},
];

expect(Object.keys(validFilters).length).toBe(13);
// find all labels for the filters
const labels = wrapper.findAll("small");
expect(labels.length).toBe(8);
// 8 labels, but 15 valid filters
// more valid filters than labels because not all all `menuItem:true`

/**
* Now add filters in all input fields in the advanced menu
* and check that the correct query is emitted
* */

// First 4 filters are normal, non ranged input fields
expectedFilters.forEach((expectedFilter, i) => {
const label = labels.at(i);
expect(label.text()).toBe(expectedFilter.label);
if (i < 4) {
const filterInput = wrapper.find(`[placeholder='${expectedFilter.placeholder}']`);
expect(filterInput.exists()).toBe(true);
filterInput.setValue(expectedFilter.value);
}
});
// `has_help` filter should have help modal button
expect(wrapper.find("[title='Value Help']").classes().includes("btn")).toBe(true);
// ranged time field (has 2 datepickers)
const createdGtInput = wrapper.find("[placeholder='creation time after']");
const createdLtInput = wrapper.find("[placeholder='creation time before']");
createdGtInput.setValue("January 1, 2022");
createdLtInput.setValue("January 1, 2023");
expect(wrapper.findAll(".b-form-datepicker").length).toBe(2);
// ranged number field (has different placeholder: greater instead of after...)
const indexGtInput = wrapper.find("[placeholder='index greater']");
const indexLtInput = wrapper.find("[placeholder='index lower']");
indexGtInput.setValue("1234");
indexLtInput.setValue("5678");
// default bool filter
const radioBtnGrp = wrapper.find("[data-description='filter bool_def']").findAll(".btn-secondary");
expect(radioBtnGrp.length).toBe(options.length);
for (let i = 0; i < options.length; i++) {
expect(radioBtnGrp.at(i).text()).toBe(options[i].text);
expect(radioBtnGrp.at(i).props().value).toBe(options[i].value);
expect(radioBtnGrp.at(i).props().checked).toBe(null);
}
await radioBtnGrp.at(1).find("input").setChecked(); // click "Yes"
// boolean filter
const boolBtnGrp = wrapper.find("[data-description='filter bool_is']").findAll(".btn-secondary");
expect(boolBtnGrp.length).toBe(2);
expect(boolBtnGrp.at(0).text()).toBe("Yes");
expect(boolBtnGrp.at(0).props().value).toBe(true);
expect(boolBtnGrp.at(1).text()).toBe("No");
expect(boolBtnGrp.at(1).props().value).toBe("any");
await boolBtnGrp.at(1).find("input").setChecked(); // click "No"

// perform search
await performSearch();
await expectCorrectEmits(
false,
"create_time>'January 1, 2022' create_time<'January 1, 2023' " +
"filter_key:item-filter has_help:has-help-filter list_item:1234 " +
"number>1234 number<5678 name:name-filter radio:true bool_def:true",
TestFilters
);
});

it("test buttons that navigate menu and keyup.enter/esc events", async () => {
setUpWrapper("Test Items", "search test items", TestFilters);

expect(wrapper.find("[data-description='advanced filters']").exists()).toBe(false);
await wrapper.setProps({ showAdvanced: true });
expect(wrapper.find("[data-description='advanced filters']").exists()).toBe(true);

// only add name filter in the advanced menu
let filterName = wrapper.find("[placeholder='any name']");
if (filterName.vm && filterName.props().type == "text") {
await filterName.setValue("sample name");
}

// -------- Test keyup.enter key: ---------
// toggles view out and performs a search
await filterName.trigger("keyup.enter");
await expectCorrectEmits(false, "name:'sample name'", TestFilters);

// Test: clearing the filterText
const clearButton = wrapper.find("[data-description='reset query']");
await clearButton.trigger("click");
await expectCorrectEmits(false, "", TestFilters);

// Test: toggling view back in
const toggleButton = wrapper.find("[data-description='toggle advanced search']");
await toggleButton.trigger("click");
await expectCorrectEmits(true, "", TestFilters);

// -------- Test keyup.esc key: ---------
// toggles view out only (doesn't cause a new search / doesn't emulate enter)

// find name field again (destroyed because of toggling out) and set value
filterName = wrapper.find("[placeholder='any name']");
if (filterName.vm && filterName.props().type == "text") {
filterName.setValue("newnamefilter");
}

// press esc key from name field (should not change emitted filterText unlike enter key)
await filterName.trigger("keyup.esc");
await expectCorrectEmits(false, "", TestFilters);
});

/**
* Testing the default values of the filters defined in the HistoryFilters: Filtering
* class, ensuring the default values are reflected in the radio-group buttons
*/
it("test radio-group default filters on HistoryFilters", async () => {
setUpWrapper("History Items", "search datasets", HistoryFilters);
// -------- Testing deleted filter first: ---------

await wrapper.setProps({ showAdvanced: true });
const deletedFilterBtnGrp = wrapper.find("[data-description='filter deleted']");
const deletedFilterAnyBtn = deletedFilterBtnGrp.find(".btn-secondary");
expect(deletedFilterAnyBtn.text()).toBe("Any");

// current active button for deleted filter should be "No"
let deletedFilterActiveBtn = deletedFilterBtnGrp.find(".btn-secondary.active");
expect(deletedFilterActiveBtn.text()).toBe("No");

await deletedFilterAnyBtn.find("input").setChecked();

// now active button for deleted filter should be "Any"
deletedFilterActiveBtn = deletedFilterBtnGrp.find(".btn-secondary.active");
expect(deletedFilterActiveBtn.text()).toBe("Any");

// expect "deleted = any" filter to be applied
await performSearch();
await expectCorrectEmits(false, "visible:true", HistoryFilters);

// -------- Testing visible filter now: ---------

const toggleButton = wrapper.find("[data-description='toggle advanced search']");
await toggleButton.trigger("click");
await expectCorrectEmits(true, "visible:true", HistoryFilters);
const visibleFilterBtnGrp = wrapper.find("[data-description='filter visible']");
const visibleFilterAnyBtn = visibleFilterBtnGrp.find(".btn-secondary");
expect(visibleFilterAnyBtn.text()).toBe("Any");

// current active button for visible filter should be "Yes"
let visibleFilterActiveBtn = visibleFilterBtnGrp.find(".btn-secondary.active");
expect(visibleFilterActiveBtn.text()).toBe("Yes");

await visibleFilterAnyBtn.find("input").setChecked();

// now active button for visible filter should be "Any"
visibleFilterActiveBtn = visibleFilterBtnGrp.find(".btn-secondary.active");
expect(visibleFilterActiveBtn.text()).toBe("Any");

// expect "visible = any" filter to be applied
await performSearch();
await expectCorrectEmits(false, "deleted:any visible:any", HistoryFilters);

// -------- Testing repeated search if it prevents bug: ---------
// (bug reported here: https://github.com/galaxyproject/galaxy/issues/16211)
await toggleButton.trigger("click");
await expectCorrectEmits(true, "deleted:any visible:any", HistoryFilters);
await performSearch();
await expectCorrectEmits(false, "deleted:any visible:any", HistoryFilters);
});
});
Loading

0 comments on commit 57985f1

Please sign in to comment.