diff --git a/client/src/components/Form/Elements/FormSelect.test.js b/client/src/components/Form/Elements/FormSelect.test.js index 5d662edf7a50..1854a23698ac 100644 --- a/client/src/components/Form/Elements/FormSelect.test.js +++ b/client/src/components/Form/Elements/FormSelect.test.js @@ -1,3 +1,4 @@ +import { createTestingPinia } from "@pinia/testing"; import { mount } from "@vue/test-utils"; import { getLocalVue } from "tests/jest/helpers"; @@ -6,9 +7,12 @@ import MountTarget from "./FormSelection.vue"; const localVue = getLocalVue(true); function createTarget(propsData) { + const pinia = createTestingPinia(); + return mount(MountTarget, { localVue, propsData, + pinia, }); } diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts new file mode 100644 index 000000000000..12f36bdf55ef --- /dev/null +++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts @@ -0,0 +1,349 @@ +import "./worker/__mocks__/selectMany"; + +import { createTestingPinia } from "@pinia/testing"; +import { getLocalVue } from "@tests/jest/helpers"; +import { mount } from "@vue/test-utils"; +import { PropType } from "vue"; + +import type { SelectOption } from "./worker/selectMany"; + +import FormSelectMany from "./FormSelectMany.vue"; + +const pinia = createTestingPinia(); +const localVue = getLocalVue(); + +jest.mock("@/components/Form/Elements/FormSelectMany/worker/selectMany"); + +function mountSelectMany(props: Partial>) { + return mount(FormSelectMany as any, { + propsData: { options: [], value: [], ...props }, + pinia, + localVue, + }); +} + +const selectors = { + unselectedOptions: ".options-list.unselected > button", + unselectedHighlighted: ".options-list.unselected > button.highlighted", + selectedOptions: ".options-list:not(.unselected) > button", + selectedHighlighted: ".options-list:not(.unselected) > button.highlighted", + selectAll: ".selection-button.select", + deselectAll: ".selection-button.deselect", + selectedCount: ".selected-count", + unselectedCount: ".unselected-count", + search: "input[type=search]", + caseSensitivity: ".toggle-button.case-sensitivity", + useRegex: ".toggle-button.use-regex", +} as const; + +function generateOptionsFromArrays(matrix: Array>): SelectOption[] { + const combineTwo = (a: string[], b: string[]) => { + const combined = [] as string[]; + + a.forEach((aValue) => { + b.forEach((bValue) => { + combined.push(`${aValue}${bValue}`); + }); + }); + + return combined; + }; + + const combined = matrix.reduce((accumulator, current) => combineTwo(accumulator, current), [""]); + + return combined.map((v) => ({ label: v, value: v })); +} + +/** gets the latest input event value and reflects it to props */ +async function emittedInput(wrapper: ReturnType) { + const emittedEvents = wrapper.emitted()?.["input"]; + + if (!emittedEvents) { + return undefined; + } + + const latestValue = emittedEvents[emittedEvents.length - 1]?.[0]; + + if (latestValue === undefined) { + return undefined; + } + + await wrapper.setProps({ ...wrapper.props(), value: latestValue }); + return latestValue; +} + +// circumvent input debounce +jest.useFakeTimers(); + +async function search(wrapper: ReturnType, value: string) { + const searchInput = wrapper.find(selectors.search); + await searchInput.setValue(value); + jest.runAllTimers(); +} + +describe("FormSelectMany", () => { + it("displays all options", async () => { + const options = generateOptionsFromArrays([["foo", "bar", "baz"], ["@"], ["galaxy"], [".com", ".org"]]); + const wrapper = mountSelectMany({ options }); + + const unselectedOptions = wrapper.findAll(selectors.unselectedOptions); + expect(unselectedOptions.length).toBe(6); + + options.forEach((option, i) => { + expect(unselectedOptions.at(i).text()).toBe(option.label); + }); + }); + + it("emits selected options", async () => { + const options = generateOptionsFromArrays([["foo", "bar", "baz"], ["@"], ["galaxy"], [".com", ".org"]]); + + const wrapper = mountSelectMany({ options }); + + { + const firstOption = wrapper.findAll(selectors.unselectedOptions).at(0); + await firstOption.trigger("click"); + + const emitted = await emittedInput(wrapper); + expect(emitted).toEqual(["foo@galaxy.com"]); + } + + { + const firstOption = wrapper.findAll(selectors.unselectedOptions).at(0); + await firstOption.trigger("click"); + + const emitted = await emittedInput(wrapper); + expect(emitted).toEqual(["foo@galaxy.com", "foo@galaxy.org"]); + } + }); + + it("displays selected values in the selected column", async () => { + const options = generateOptionsFromArrays([["foo", "bar", "baz"], ["@"], ["galaxy"], [".com", ".org"]]); + const wrapper = mountSelectMany({ options, value: ["foo@galaxy.com", "foo@galaxy.org"] }); + + { + const selectedOptions = wrapper.findAll(selectors.selectedOptions); + expect(selectedOptions.length).toBe(2); + expect(selectedOptions.at(0).text()).toBe("foo@galaxy.com"); + expect(selectedOptions.at(1).text()).toBe("foo@galaxy.org"); + + const unselectedOptions = wrapper.findAll(selectors.unselectedOptions); + unselectedOptions.wrappers.forEach((unselectedOption) => { + expect(unselectedOption.text()).not.toBe("foo@galaxy.com"); + expect(unselectedOption.text()).not.toBe("foo@galaxy.org"); + }); + } + + const firstOption = wrapper.findAll(selectors.unselectedOptions).at(0); + await firstOption.trigger("click"); + const emitted = await emittedInput(wrapper); + + { + const selectedOptions = wrapper.findAll(selectors.selectedOptions); + expect(selectedOptions.length).toBe(3); + expect(selectedOptions.at(2).text()).toBe(emitted[2]); + + const unselectedOptions = wrapper.findAll(selectors.unselectedOptions); + unselectedOptions.wrappers.forEach((unselectedOption) => { + expect(unselectedOption.text()).not.toBe(emitted[2]); + }); + } + }); + + it("shows the amount of selected options", async () => { + const options = generateOptionsFromArrays([["foo", "bar", "baz"], ["@"], ["galaxy"], [".com", ".org"]]); + const wrapper = mountSelectMany({ options, value: ["foo@galaxy.com", "foo@galaxy.org"] }); + + { + const selectedCount = wrapper.find(selectors.selectedCount); + const unselectedCount = wrapper.find(selectors.unselectedCount); + + expect(selectedCount.text()).toBe("(2)"); + expect(unselectedCount.text()).toBe("(4)"); + } + + const firstOption = wrapper.findAll(selectors.unselectedOptions).at(0); + await firstOption.trigger("click"); + await emittedInput(wrapper); + + { + const selectedCount = wrapper.find(selectors.selectedCount); + const unselectedCount = wrapper.find(selectors.unselectedCount); + + expect(selectedCount.text()).toBe("(3)"); + expect(unselectedCount.text()).toBe("(3)"); + } + }); + + it("selects all options", async () => { + const options = generateOptionsFromArrays([["foo", "bar", "baz"], ["@"], ["galaxy"], [".com", ".org"]]); + const wrapper = mountSelectMany({ options, value: ["foo@galaxy.com", "foo@galaxy.org"] }); + + const selectAllButton = wrapper.find(selectors.selectAll); + await selectAllButton.trigger("click"); + await emittedInput(wrapper); + + { + const selectedOptions = wrapper.findAll(selectors.selectedOptions); + const unselectedOptions = wrapper.findAll(selectors.unselectedOptions); + + expect(unselectedOptions.length).toBe(0); + expect(selectedOptions.length).toBe(6); + } + + const deselectAllButton = wrapper.find(selectors.deselectAll); + await deselectAllButton.trigger("click"); + await emittedInput(wrapper); + + { + const selectedOptions = wrapper.findAll(selectors.selectedOptions); + const unselectedOptions = wrapper.findAll(selectors.unselectedOptions); + + expect(unselectedOptions.length).toBe(6); + expect(selectedOptions.length).toBe(0); + } + }); + + it("filters options", async () => { + const options = generateOptionsFromArrays([["foo", "BAR", "baz"], ["@"], ["galaxy"], [".com", ".org"]]); + const wrapper = mountSelectMany({ options }); + + await search(wrapper, "bar"); + + { + const unselectedOptions = wrapper.findAll(selectors.unselectedOptions); + expect(unselectedOptions.length).toBe(2); + + const unselectedCount = wrapper.find(selectors.unselectedCount); + expect(unselectedCount.text()).toBe("(2)"); + } + + const caseSensitivityButton = wrapper.find(selectors.caseSensitivity); + await caseSensitivityButton.trigger("click"); + + { + const unselectedOptions = wrapper.findAll(selectors.unselectedOptions); + expect(unselectedOptions.length).toBe(0); + } + + await search(wrapper, "BAR"); + + { + const unselectedOptions = wrapper.findAll(selectors.unselectedOptions); + expect(unselectedOptions.length).toBe(2); + } + + const useRegexButton = wrapper.find(selectors.useRegex); + await useRegexButton.trigger("click"); + + { + const unselectedOptions = wrapper.findAll(selectors.unselectedOptions); + expect(unselectedOptions.length).toBe(2); + } + + await search(wrapper, "^[a-z]+@"); + + { + const unselectedOptions = wrapper.findAll(selectors.unselectedOptions); + expect(unselectedOptions.length).toBe(4); + } + + await caseSensitivityButton.trigger("click"); + + { + const unselectedOptions = wrapper.findAll(selectors.unselectedOptions); + expect(unselectedOptions.length).toBe(6); + } + }); + + it("selects filtered", async () => { + const options = generateOptionsFromArrays([["foo", "BAR", "baz"], ["@"], ["galaxy"], [".com", ".org"]]); + const wrapper = mountSelectMany({ options }); + + await search(wrapper, "bar"); + + const selectAllButton = wrapper.find(selectors.selectAll); + await selectAllButton.trigger("click"); + await emittedInput(wrapper); + + await search(wrapper, ""); + + { + const selectedOptions = wrapper.findAll(selectors.selectedOptions); + expect(selectedOptions.length).toBe(2); + } + + await search(wrapper, ".org"); + + const deselectAllButton = wrapper.find(selectors.deselectAll); + await deselectAllButton.trigger("click"); + await emittedInput(wrapper); + + await search(wrapper, ""); + + { + const selectedOptions = wrapper.findAll(selectors.selectedOptions); + expect(selectedOptions.length).toBe(1); + } + }); + + it("allows for highlighting ranges", async () => { + const options = generateOptionsFromArrays([["foo", "BAR", "baz", "bar"], ["@"], ["galaxy"], [".com", ".org"]]); + const wrapper = mountSelectMany({ options }); + + { + const unselectedOptions = wrapper.findAll(selectors.unselectedOptions); + await unselectedOptions.at(0).trigger("click", { shiftKey: true }); + await unselectedOptions.at(7).trigger("click", { shiftKey: true }); + + { + const highlightedOptions = wrapper.findAll(selectors.unselectedHighlighted); + expect(highlightedOptions.length).toBe(8); + } + + await unselectedOptions.at(1).trigger("click", { ctrlKey: true }); + await unselectedOptions.at(2).trigger("click", { ctrlKey: true }); + + { + const highlightedOptions = wrapper.findAll(selectors.unselectedHighlighted); + expect(highlightedOptions.length).toBe(6); + } + } + + const selectAllButton = wrapper.find(selectors.selectAll); + await selectAllButton.trigger("click"); + await emittedInput(wrapper); + + { + const selectedOptions = wrapper.findAll(selectors.selectedOptions); + expect(selectedOptions.length).toBe(6); + + await selectedOptions.at(0).trigger("click", { shiftKey: true }); + await selectedOptions.at(5).trigger("click", { shiftKey: true }); + + { + const highlightedOptions = wrapper.findAll(selectors.selectedHighlighted); + expect(highlightedOptions.length).toBe(6); + } + + await selectedOptions.at(2).trigger("click", { shiftKey: true, ctrlKey: true }); + await selectedOptions.at(5).trigger("click", { shiftKey: true, ctrlKey: true }); + + { + const highlightedOptions = wrapper.findAll(selectors.selectedHighlighted); + expect(highlightedOptions.length).toBe(2); + } + } + + const deselectAllButton = wrapper.find(selectors.deselectAll); + await deselectAllButton.trigger("click"); + await emittedInput(wrapper); + + { + const unselectedOptions = wrapper.findAll(selectors.unselectedOptions); + expect(unselectedOptions.length).toBe(4); + + const selectedOptions = wrapper.findAll(selectors.selectedOptions); + expect(selectedOptions.length).toBe(4); + } + }); +}); diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue new file mode 100644 index 000000000000..5491a4742b85 --- /dev/null +++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue @@ -0,0 +1,554 @@ + + + + + diff --git a/client/src/components/Form/Elements/FormSelectMany/useHighlight.ts b/client/src/components/Form/Elements/FormSelectMany/useHighlight.ts new file mode 100644 index 000000000000..8feb91eef706 --- /dev/null +++ b/client/src/components/Form/Elements/FormSelectMany/useHighlight.ts @@ -0,0 +1,111 @@ +import { type Ref, ref, watch } from "vue"; + +import { assertDefined } from "@/utils/assertions"; + +import type { SelectOption } from "./worker/selectMany"; + +/** + * Handles logic required for highlighting options + * @param options Array of select options to handle highlighting for + */ +export function useHighlight(options: Ref) { + const highlightedOptions = ref([]); + const highlightedIndexes = ref([]); + + const reset = () => { + highlightedOptions.value = []; + highlightedIndexes.value = []; + abortHighlight(); + }; + + watch( + () => options.value, + () => reset() + ); + + let highlightIndexStart = -1; + + const rangeHighlight = (index: number) => { + if (highlightIndexStart === -1) { + highlightIndexStart = index; + addHighlight(index); + return; + } + + const from = Math.min(highlightIndexStart, index); + const to = Math.max(highlightIndexStart, index); + + for (let i = from; i <= to; i++) { + addHighlight(i); + } + + highlightIndexStart = -1; + }; + + const rangeRemoveHighlight = (index: number) => { + if (highlightIndexStart === -1) { + highlightIndexStart = index; + removeHighlight(index); + return; + } + + const from = Math.min(highlightIndexStart, index); + const to = Math.max(highlightIndexStart, index); + + for (let i = from; i <= to; i++) { + removeHighlight(i); + } + + highlightIndexStart = -1; + }; + + const removeHighlight = (index: number) => { + const position = highlightedIndexes.value.indexOf(index); + + if (position !== -1) { + highlightedIndexes.value.splice(position, 1); + highlightedOptions.value.splice(position, 1); + } + }; + + const addHighlight = (index: number) => { + const position = highlightedIndexes.value.indexOf(index); + + if (position === -1) { + highlightedIndexes.value.push(index); + const option = options.value[index]; + assertDefined(option); + highlightedOptions.value.push(option); + } + }; + + const toggleHighlight = (index: number) => { + const position = highlightedIndexes.value.indexOf(index); + + if (position === -1) { + highlightedIndexes.value.push(index); + const option = options.value[index]; + assertDefined(option); + highlightedOptions.value.push(option); + } else { + highlightedIndexes.value.splice(position, 1); + highlightedOptions.value.splice(position, 1); + } + }; + + const abortHighlight = () => { + highlightIndexStart = -1; + }; + + return { + highlightedOptions, + highlightedIndexes, + reset, + rangeHighlight, + rangeRemoveHighlight, + toggleHighlight, + abortHighlight, + addHighlight, + removeHighlight, + }; +} diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/__mocks__/selectMany.ts b/client/src/components/Form/Elements/FormSelectMany/worker/__mocks__/selectMany.ts new file mode 100644 index 000000000000..d040fb824da8 --- /dev/null +++ b/client/src/components/Form/Elements/FormSelectMany/worker/__mocks__/selectMany.ts @@ -0,0 +1,37 @@ +import { reactive, ref, watch } from "vue"; + +import type { SelectOption, useSelectMany as UseSelectMany } from "../selectMany"; +import { main } from "../selectManyMain"; + +jest.mock("@/components/Form/Elements/FormSelectMany/worker/selectMany", () => ({ + useSelectMany, +})); + +export const useSelectMany: typeof UseSelectMany = (options) => { + const unselectedOptionsFiltered = ref([] as SelectOption[]); + const selectedOptionsFiltered = ref([] as SelectOption[]); + const moreSelected = ref(false); + const moreUnselected = ref(false); + const running = ref(false); + + watch( + [...Object.values(options)], + () => { + const result = main(reactive(options)); + + unselectedOptionsFiltered.value = result.unselectedOptionsFiltered; + selectedOptionsFiltered.value = result.selectedOptionsFiltered; + moreSelected.value = result.moreSelected; + moreUnselected.value = result.moreUnselected; + }, + { immediate: true } + ); + + return { + unselectedOptionsFiltered, + selectedOptionsFiltered, + moreSelected, + moreUnselected, + running, + }; +}; diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/filterOptions.ts b/client/src/components/Form/Elements/FormSelectMany/worker/filterOptions.ts new file mode 100644 index 000000000000..6c3c26f3a38c --- /dev/null +++ b/client/src/components/Form/Elements/FormSelectMany/worker/filterOptions.ts @@ -0,0 +1,16 @@ +import { SelectOption } from "./selectMany"; + +export function filterOptions(options: SelectOption[], filter: string | RegExp, caseSensitive: boolean) { + let filteredSelectOptions; + + if (filter instanceof RegExp) { + filteredSelectOptions = options.filter((option) => Boolean(option.label.match(filter))); + } else if (caseSensitive) { + filteredSelectOptions = options.filter((option) => option.label.includes(filter)); + } else { + const filterLowercase = filter.toLowerCase(); + filteredSelectOptions = options.filter((option) => option.label.toLowerCase().includes(filterLowercase)); + } + + return filteredSelectOptions; +} diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.d.ts b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.d.ts new file mode 100644 index 000000000000..3f3ebf146dde --- /dev/null +++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.d.ts @@ -0,0 +1,30 @@ +import type { Ref } from "vue"; + +export type SelectValue = Record | string | number | null; + +export interface SelectOption { + label: string; + value: SelectValue; +} + +export interface UseSelectManyOptions { + optionsArray: Ref; + filter: Ref; + selected: Ref; + unselectedDisplayCount: Ref; + selectedDisplayCount: Ref; + caseSensitive: Ref; +} + +export interface UseSelectManyReturn { + unselectedOptionsFiltered: Ref; + selectedOptionsFiltered: Ref; + moreUnselected: Ref; + moreSelected: Ref; +} + +/** + * Filter and grouping logic for FormSelectMany component. + * Runs in a thread, which is why it is abstracted in a composable. + */ +export declare function useSelectMany(options: UseSelectManyOptions): UseSelectManyReturn & { running: Ref }; diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js new file mode 100644 index 000000000000..485e55bd23fc --- /dev/null +++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js @@ -0,0 +1,95 @@ +import { onScopeDispose, ref, watchEffect } from "vue"; + +// glue code together with the .worker.js file, to run `main` in a thread + +let worker; +let idCounter = 0; +let workerReferenceCount = 0; + +export function useSelectMany({ + optionsArray, + filter, + selected, + unselectedDisplayCount, + selectedDisplayCount, + caseSensitive, +}) { + // only start a single worker + if (!worker) { + worker = new Worker(new URL("./selectMany.worker.js", import.meta.url)); + } + + workerReferenceCount += 1; + + const id = idCounter++; + + const unselectedOptionsFiltered = ref([]); + const selectedOptionsFiltered = ref([]); + const moreSelected = ref(false); + const moreUnselected = ref(false); + const running = ref(false); + + const post = (message) => { + worker.postMessage({ id, ...message }); + running.value = true; + }; + + onScopeDispose(() => { + workerReferenceCount -= 1; + + if (workerReferenceCount === 0) { + worker.terminate(); + worker = null; + } else { + post({ type: "clear" }); + worker.removeEventListener("message", onMessage); + } + }); + + watchEffect(() => { + post({ type: "setArray", array: optionsArray.value }); + }); + + watchEffect(() => { + post({ type: "setFilter", filter: filter.value }); + }); + + watchEffect(() => { + post({ type: "setSelected", selected: selected.value }); + }); + + watchEffect(() => { + post({ + type: "setSettings", + unselectedDisplayCount: unselectedDisplayCount.value, + selectedDisplayCount: selectedDisplayCount.value, + caseSensitive: caseSensitive.value, + }); + }); + + const onMessage = (e) => { + const message = e.data; + + if (message.id !== id) { + return; + } + + if (message.type === "result") { + unselectedOptionsFiltered.value = message.unselectedOptionsFiltered; + selectedOptionsFiltered.value = message.selectedOptionsFiltered; + moreSelected.value = message.moreSelected; + moreUnselected.value = message.moreUnselected; + running.value = false; + } + }; + + worker.addEventListener("message", onMessage); + + return { + unselectedOptionsFiltered, + selectedOptionsFiltered, + moreSelected, + moreUnselected, + running, + }; +} diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js new file mode 100644 index 000000000000..6eaed4c7638d --- /dev/null +++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js @@ -0,0 +1,63 @@ +import { main } from "./selectManyMain"; + +// glue code to run `main` in a thread + +const createOptions = () => { + return { + optionsArray: [], + filter: "", + selected: [], + unselectedDisplayCount: 1000, + selectedDisplayCount: 1000, + caseSensitive: false, + }; +}; + +const optionsById = new Map(); +const timerById = {}; + +self.addEventListener("message", (e) => { + const message = e.data; + + if (!optionsById.has(message.id)) { + optionsById.set(message.id, createOptions()); + } + + const options = optionsById.get(message.id); + + switch (message.type) { + case "setArray": + options.optionsArray = message.array; + break; + + case "setFilter": + options.filter = message.filter; + break; + + case "setSelected": + options.selected = message.selected; + break; + + case "setSettings": + options.unselectedDisplayCount = message.unselectedDisplayCount; + options.selectedDisplayCount = message.selectedDisplayCount; + options.caseSensitive = message.caseSensitive; + break; + + case "clear": + optionsById.delete(message.id); + return; + + default: + break; + } + + if (message.id in timerById) { + clearTimeout(timerById[message.id]); + } + + timerById[message.id] = setTimeout(() => { + const result = main(options); + self.postMessage({ id: message.id, type: "result", ...result }); + }, 10); +}); diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts b/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts new file mode 100644 index 000000000000..0a35d9e04223 --- /dev/null +++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts @@ -0,0 +1,61 @@ +import type { UnwrapNestedRefs } from "vue"; + +import { filterOptions } from "./filterOptions"; +import type { SelectOption, SelectValue, UseSelectManyOptions, UseSelectManyReturn } from "./selectMany"; + +export function main(options: UnwrapNestedRefs): UnwrapNestedRefs { + const unselectedOptionsFiltered: SelectOption[] = []; + const selectedOptionsFiltered: SelectOption[] = []; + + const filteredSelectOptions = filterOptions(options.optionsArray, options.filter, options.caseSensitive); + + const selectedValues = new Set(options.selected.map(stringifyObject)); + + let moreUnselected = false; + let moreSelected = false; + + for (let index = 0; index < filteredSelectOptions.length; index++) { + const option = filteredSelectOptions[index]!; + + const value = stringifyObject(option.value); + + const isSelected = selectedValues.has(value); + + if ( + unselectedOptionsFiltered.length > options.unselectedDisplayCount && + selectedOptionsFiltered.length > options.selectedDisplayCount + ) { + break; + } + + if (isSelected) { + if (selectedOptionsFiltered.length < options.selectedDisplayCount) { + selectedOptionsFiltered.push(option); + } else { + moreSelected = true; + } + } else { + if (unselectedOptionsFiltered.length < options.unselectedDisplayCount) { + unselectedOptionsFiltered.push(option); + } else { + moreUnselected = true; + } + } + } + + return { + unselectedOptionsFiltered, + selectedOptionsFiltered, + moreUnselected, + moreSelected, + }; +} + +/** + * Convert object to strings, leaving every other value unchanged + * @param value + * @returns + */ +function stringifyObject(value: SelectValue) { + return typeof value === "object" && value !== null ? JSON.stringify(value) : value; +} diff --git a/client/src/components/Form/Elements/FormSelection.vue b/client/src/components/Form/Elements/FormSelection.vue index 42d5a762e0d6..ca328723e6b0 100644 --- a/client/src/components/Form/Elements/FormSelection.vue +++ b/client/src/components/Form/Elements/FormSelection.vue @@ -1,11 +1,21 @@ diff --git a/client/src/composables/filter/filter.js b/client/src/composables/filter/filter.js index e908f5d79bf4..15f630150a84 100644 --- a/client/src/composables/filter/filter.js +++ b/client/src/composables/filter/filter.js @@ -1,18 +1,18 @@ -import { resolveUnref } from "@vueuse/core"; +import { toValue } from "@vueuse/core"; import { onScopeDispose, ref, watch } from "vue"; -export function useFilterObjectArray(array, filter, objectFields) { +export function useFilterObjectArray(array, filter, objectFields, asRegex = false) { const worker = new Worker(new URL("./filter.worker.js", import.meta.url)); const filtered = ref([]); - filtered.value = resolveUnref(array); + filtered.value = toValue(array); const post = (message) => { worker.postMessage(message); }; watch( - () => resolveUnref(array), + () => toValue(array), (arr) => { post({ type: "setArray", array: arr }); }, @@ -22,7 +22,7 @@ export function useFilterObjectArray(array, filter, objectFields) { ); watch( - () => resolveUnref(filter), + () => toValue(filter), (f) => { post({ type: "setFilter", filter: f }); }, @@ -32,7 +32,7 @@ export function useFilterObjectArray(array, filter, objectFields) { ); watch( - () => resolveUnref(objectFields), + () => toValue(objectFields), (fields) => { post({ type: "setFields", fields }); }, diff --git a/client/src/stores/userFlagsStore.ts b/client/src/stores/userFlagsStore.ts index 2664e38000d2..a6d52a8d23c9 100644 --- a/client/src/stores/userFlagsStore.ts +++ b/client/src/stores/userFlagsStore.ts @@ -2,8 +2,14 @@ import { defineStore } from "pinia"; import { useUserLocalStorage } from "@/composables/userLocalStorage"; +export type PreferredFormSelect = "none" | "multi" | "many"; + export const useUserFlagsStore = defineStore("userFlagsStore", () => { const showSelectionQueryBreakWarning = useUserLocalStorage("user-flags-store-show-break-warning", true); + const preferredFormSelectElement = useUserLocalStorage( + "user-flags-store-preferred-form-select", + "none" as PreferredFormSelect + ); function ignoreSelectionQueryBreakWarning() { showSelectionQueryBreakWarning.value = false; @@ -12,5 +18,6 @@ export const useUserFlagsStore = defineStore("userFlagsStore", () => { return { showSelectionQueryBreakWarning, ignoreSelectionQueryBreakWarning, + preferredFormSelectElement, }; });