Skip to content

Commit

Permalink
Merge pull request #18918 from davelopez/24.1_fix_missing_tags_in_sel…
Browse files Browse the repository at this point in the history
…ector

[24.1] Fix display tags in FormSelect when available
  • Loading branch information
ElectronicBlueberry authored Jan 28, 2025
2 parents dda0e9b + 484dfc5 commit f06b0d7
Show file tree
Hide file tree
Showing 5 changed files with 93 additions and 38 deletions.
52 changes: 28 additions & 24 deletions client/src/components/Form/Elements/FormData/FormData.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import "@/composables/__mocks__/filter";

import { createTestingPinia } from "@pinia/testing";
import { mount } from "@vue/test-utils";
import { PiniaVuePlugin } from "pinia";
Expand All @@ -9,6 +11,8 @@ import { useEventStore } from "@/stores/eventStore";

import MountTarget from "./FormData.vue";

jest.mock("@/composables/filter");

const localVue = getLocalVue();
localVue.use(PiniaVuePlugin);

Expand Down Expand Up @@ -49,7 +53,7 @@ const defaultOptions = {
};

const SELECT_OPTIONS = ".multiselect__element";
const SELECTED_VALUE = ".multiselect__option--selected span";
const SELECTED_VALUE = ".multiselect__option--selected";

describe("FormData", () => {
it("regular data", async () => {
Expand All @@ -72,19 +76,19 @@ describe("FormData", () => {
expect(options.at(0).classes()).toContain("active");
expect(options.at(0).attributes("title")).toBe("Single dataset");
expect(wrapper.emitted().input[0][0]).toEqual(value_0);
expect(wrapper.find(SELECTED_VALUE).text()).toEqual("dceName4 (as dataset)");
expect(wrapper.find(SELECTED_VALUE).text()).toContain("dceName4 (as dataset)");
await wrapper.setProps({ value: value_0 });
expect(wrapper.emitted().input.length).toEqual(1);
await wrapper.setProps({ value: { values: [{ id: "hda2", src: "hda" }] } });
expect(wrapper.find(SELECTED_VALUE).text()).toEqual("2: hdaName2");
expect(wrapper.find(SELECTED_VALUE).text()).toContain("2: hdaName2");
expect(wrapper.emitted().input.length).toEqual(1);
const elements_0 = wrapper.findAll(SELECT_OPTIONS);
expect(elements_0.length).toEqual(6);
await elements_0.at(2).find("span").trigger("click");
expect(wrapper.emitted().input.length).toEqual(2);
expect(wrapper.emitted().input[1][0]).toEqual(value_1);
await wrapper.setProps({ value: value_1 });
expect(wrapper.find(SELECTED_VALUE).text()).toEqual("4: hdaName4");
expect(wrapper.find(SELECTED_VALUE).text()).toContain("4: hdaName4");
});

it("optional dataset", async () => {
Expand Down Expand Up @@ -126,8 +130,8 @@ describe("FormData", () => {
expect(wrapper.emitted().input.length).toEqual(1);
const selectedValues = wrapper.findAll(SELECTED_VALUE);
expect(selectedValues.length).toBe(2);
expect(selectedValues.at(0).text()).toBe("3: hdaName3");
expect(selectedValues.at(1).text()).toBe("2: hdaName2");
expect(selectedValues.at(0).text()).toContain("3: hdaName3");
expect(selectedValues.at(1).text()).toContain("2: hdaName2");
const value_0 = {
batch: false,
product: false,
Expand Down Expand Up @@ -174,9 +178,9 @@ describe("FormData", () => {
const selectedValues = wrapper.findAll(SELECTED_VALUE);
expect(selectedValues.length).toBe(3);
// the values in the multiselect are sorted by hid DESC
expect(selectedValues.at(0).text()).toBe("3: hdaName3");
expect(selectedValues.at(1).text()).toBe("2: hdaName2");
expect(selectedValues.at(2).text()).toBe("1: hdaName1");
expect(selectedValues.at(0).text()).toContain("3: hdaName3");
expect(selectedValues.at(1).text()).toContain("2: hdaName2");
expect(selectedValues.at(2).text()).toContain("1: hdaName1");
await selectedValues.at(0).trigger("click");
const value_sorted = {
batch: false,
Expand Down Expand Up @@ -224,11 +228,11 @@ describe("FormData", () => {
expect(selectedValues.length).toBe(5);
// when dces are mixed in their values are shown first and are
// ordered by id descending
expect(selectedValues.at(0).text()).toBe("dceName4 (as dataset)");
expect(selectedValues.at(1).text()).toBe("dceName3 (as dataset)");
expect(selectedValues.at(2).text()).toBe("dceName2 (as dataset)");
expect(selectedValues.at(3).text()).toBe("2: hdaName2");
expect(selectedValues.at(4).text()).toBe("1: hdaName1");
expect(selectedValues.at(0).text()).toContain("dceName4 (as dataset)");
expect(selectedValues.at(1).text()).toContain("dceName3 (as dataset)");
expect(selectedValues.at(2).text()).toContain("dceName2 (as dataset)");
expect(selectedValues.at(3).text()).toContain("2: hdaName2");
expect(selectedValues.at(4).text()).toContain("1: hdaName1");
await selectedValues.at(0).trigger("click");
const value_sorted = {
batch: false,
Expand Down Expand Up @@ -259,7 +263,7 @@ describe("FormData", () => {
expect(wrapper.emitted().input.length).toEqual(1);
const selectedValues = wrapper.findAll(SELECTED_VALUE);
expect(selectedValues.length).toBe(1);
expect(selectedValues.at(0).text()).toBe("dceName1 (as dataset)");
expect(selectedValues.at(0).text()).toContain("dceName1 (as dataset)");
});

it("dataset collection element as hdca without map_over_type", async () => {
Expand All @@ -272,7 +276,7 @@ describe("FormData", () => {
await wrapper.vm.$nextTick();
const selectedValues = wrapper.findAll(SELECTED_VALUE);
expect(selectedValues.length).toBe(1);
expect(selectedValues.at(0).text()).toBe("dceName2 (as dataset collection)");
expect(selectedValues.at(0).text()).toContain("dceName2 (as dataset collection)");
});

it("dataset collection element as hdca mapped to batch field", async () => {
Expand All @@ -289,7 +293,7 @@ describe("FormData", () => {
await wrapper.vm.$nextTick();
const selectedValues = wrapper.findAll(SELECTED_VALUE);
expect(selectedValues.length).toBe(1);
expect(selectedValues.at(0).text()).toBe("dceName3 (as dataset collection)");
expect(selectedValues.at(0).text()).toContain("dceName3 (as dataset collection)");
});

it("dataset collection element as hdca mapped to non-batch field", async () => {
Expand All @@ -307,7 +311,7 @@ describe("FormData", () => {
await wrapper.vm.$nextTick();
const selectedValues = wrapper.findAll(SELECTED_VALUE);
expect(selectedValues.length).toBe(1);
expect(selectedValues.at(0).text()).toBe("dceName3 (as dataset collection)");
expect(selectedValues.at(0).text()).toContain("dceName3 (as dataset collection)");
});

it("dataset collection mapped to non-batch field", async () => {
Expand All @@ -325,7 +329,7 @@ describe("FormData", () => {
await wrapper.vm.$nextTick();
const selectedValues = wrapper.findAll(SELECTED_VALUE);
expect(selectedValues.length).toBe(1);
expect(selectedValues.at(0).text()).toBe("5: hdcaName5");
expect(selectedValues.at(0).text()).toContain("5: hdcaName5");
});

it("multiple dataset collection elements (as hdas)", async () => {
Expand Down Expand Up @@ -463,22 +467,22 @@ describe("FormData", () => {
});
const select_0 = wrapper_0.findAll(SELECT_OPTIONS);
expect(select_0.length).toBe(4);
expect(select_0.at(2).text()).toBe("2: hdaName2");
expect(select_0.at(3).text()).toBe("1: hdaName1");
expect(select_0.at(2).text()).toContain("2: hdaName2");
expect(select_0.at(3).text()).toContain("1: hdaName1");
const wrapper_1 = createTarget({
tag: "tag2",
options: defaultOptions,
});
const select_1 = wrapper_1.findAll(SELECT_OPTIONS);
expect(select_1.length).toBe(4);
expect(select_1.at(2).text()).toBe("3: hdaName3");
expect(select_1.at(3).text()).toBe("2: hdaName2");
expect(select_1.at(2).text()).toContain("3: hdaName3");
expect(select_1.at(3).text()).toContain("2: hdaName2");
const wrapper_2 = createTarget({
tag: "tag3",
options: defaultOptions,
});
const select_2 = wrapper_2.findAll(SELECT_OPTIONS);
expect(select_2.length).toBe(3);
expect(select_2.at(2).text()).toBe("3: hdaName3");
expect(select_2.at(2).text()).toContain("3: hdaName3");
});
});
38 changes: 32 additions & 6 deletions client/src/components/Form/Elements/FormSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@
import { library } from "@fortawesome/fontawesome-svg-core";
import { faCheckSquare, faSquare } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { computed, type ComputedRef, onMounted, type PropType, watch } from "vue";
import { computed, type ComputedRef, onMounted, type PropType, ref, watch } from "vue";
import Multiselect from "vue-multiselect";
import { useFilterObjectArray } from "@/composables/filter";
import { useMultiselect } from "@/composables/useMultiselect";
import { uid } from "@/utils/utils";
import StatelessTags from "@/components/TagsMultiselect/StatelessTags.vue";
library.add(faCheckSquare, faSquare);
const { ariaExpanded, onOpen, onClose } = useMultiselect();
type SelectValue = Record<string, unknown> | string | number | null;
type ValueWithTags = SelectValue & { tags: string[] };
interface SelectOption {
label: string;
Expand Down Expand Up @@ -51,19 +55,22 @@ const emit = defineEmits<{
(e: "input", value: SelectValue | Array<SelectValue>): void;
}>();
const filter = ref("");
const filteredOptions = useFilterObjectArray(() => props.options, filter, ["label", ["value", "tags"]]);
/**
* When there are more options than this, push selected options to the end
*/
const optionReorderThreshold = 8;
const reorderedOptions = computed(() => {
if (props.options.length <= optionReorderThreshold) {
return props.options;
if (filteredOptions.value.length <= optionReorderThreshold) {
return filteredOptions.value;
} else {
const selectedOptions: SelectOption[] = [];
const unselectedOptions: SelectOption[] = [];
props.options.forEach((option) => {
filteredOptions.value.forEach((option) => {
if (selectedValues.value.includes(option.value)) {
selectedOptions.push(option);
} else {
Expand Down Expand Up @@ -140,7 +147,9 @@ function setInitialValue(): void {
*/
watch(
() => props.options,
() => setInitialValue()
() => {
setInitialValue();
}
);
/**
Expand All @@ -149,6 +158,14 @@ watch(
onMounted(() => {
setInitialValue();
});
function isValueWithTags(item: SelectValue): item is ValueWithTags {
return item !== null && typeof item === "object" && (item as ValueWithTags).tags !== undefined;
}
function onSearchChange(search: string): void {
filter.value = search;
}
</script>

<template>
Expand All @@ -169,11 +186,20 @@ onMounted(() => {
:selected-label="selectedLabel"
:select-label="null"
track-by="value"
:internal-search="false"
@search-change="onSearchChange"
@open="onOpen"
@close="onClose">
<template v-slot:option="{ option }">
<div class="d-flex align-items-center justify-content-between">
<span>{{ option.label }}</span>
<div>
<span>{{ option.label }}</span>
<StatelessTags
v-if="isValueWithTags(option.value)"
class="tags mt-2"
:value="option.value.tags"
disabled />
</div>
<FontAwesomeIcon v-if="selectedValues.includes(option.value)" :icon="faCheckSquare" />
<FontAwesomeIcon v-else :icon="faSquare" />
</div>
Expand Down
13 changes: 13 additions & 0 deletions client/src/composables/__mocks__/filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { toValue } from "@vueuse/core";
import { computed, Ref } from "vue";

import type { useFilterObjectArray as UseFilterObjectArray } from "@/composables/filter";

jest.mock("@/composables/filter", () => ({
useFilterObjectArray,
}));

export const useFilterObjectArray: typeof UseFilterObjectArray = (array): Ref<any[]> => {
console.debug("USING MOCKED useFilterObjectArray");
return computed(() => toValue(array));
};
4 changes: 2 additions & 2 deletions client/src/composables/filter/filter.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import type { Ref } from "vue";
* All parameters can optionally be refs.
* @param array array of objects to filter
* @param filter string to filter by
* @param objectFields string array of fields to filter by on each object
* @param objectFields string array of fields to filter by on each object. To reach nested fields, use an array of strings (e.g. `["nested", "field"]`)
*/
export declare function useFilterObjectArray<O extends object, K extends keyof O>(
array: MaybeRefOrGetter<Array<O>>,
filter: MaybeRefOrGetter<string>,
objectFields: MaybeRefOrGetter<Array<K>>
objectFields: MaybeRefOrGetter<Array<K | string[]>>
): Ref<O[]>;
24 changes: 18 additions & 6 deletions client/src/composables/filter/filterFunction.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
export function runFilter<O extends object, K extends keyof O>(f: string, arr: O[], fields: K[]) {
export function runFilter<O extends object, K extends keyof O>(f: string, arr: O[], fields: (K | string[])[]) {
if (f === "") {
return arr;
} else {
return arr.filter((obj) => {
const lowerCaseFilter = f.toLocaleLowerCase();
for (const field of fields) {
const val = obj[field];
let val: unknown;

if (typeof field === "string") {
val = obj[field];
} else if (Array.isArray(field)) {
val = field.reduce((acc: unknown, curr: string) => {
if (acc && typeof acc === "object") {
return (acc as Record<string, unknown>)[curr];
}
return undefined;
}, obj);
}

if (typeof val === "string") {
if (val.toLowerCase().includes(f.toLocaleLowerCase())) {
if (val.toLowerCase().includes(lowerCaseFilter)) {
return true;
}
} else if (Array.isArray(val)) {
if (val.includes(f)) {
return true;
}
return val.find((v) => {
return typeof v === "string" ? v.toLowerCase().includes(lowerCaseFilter) : false;
});
}
}

Expand Down

0 comments on commit f06b0d7

Please sign in to comment.