@@ -353,6 +366,22 @@ const deselectText = computed(() => {
content: "enter to select";
}
}
+
+ .show-more-indicator {
+ display: flex;
+ font-style: italic;
+ padding-left: 0.5rem;
+ color: darken($gray-400, 20%);
+
+ button::after {
+ content: none;
+ }
+
+ button {
+ color: $brand-info;
+ text-decoration: underline;
+ }
+ }
}
.options-list.selected {
diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.d.ts b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.d.ts
index d1d13aa30cff..48cfa6a4989f 100644
--- a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.d.ts
+++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.d.ts
@@ -20,6 +20,8 @@ export interface UseSelectManyOptions {
export interface UseSelectManyReturn {
unselectedOptionsFiltered: Ref;
selectedOptionsFiltered: Ref;
+ moreUnselected: Ref;
+ moreSelected: Ref;
}
/**
diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js
index b0c8255ebc79..b6c041a922bd 100644
--- a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js
+++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js
@@ -26,6 +26,8 @@ export function useSelectMany({
const unselectedOptionsFiltered = ref([]);
const selectedOptionsFiltered = ref([]);
+ const moreSelected = ref(false);
+ const moreUnselected = ref(false);
const running = ref(false);
const post = (message) => {
@@ -76,6 +78,8 @@ export function useSelectMany({
if (message.type === "result") {
unselectedOptionsFiltered.value = message.unselectedOptionsFiltered;
selectedOptionsFiltered.value = message.selectedOptionsFiltered;
+ moreSelected.value = message.moreSelected;
+ moreUnselected.value = message.moreUnselected;
running.value = false;
}
});
@@ -83,6 +87,8 @@ export function useSelectMany({
return {
unselectedOptionsFiltered,
selectedOptionsFiltered,
+ moreSelected,
+ moreUnselected,
running,
};
}
diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts b/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts
index 125a53f662c1..0f90b8f5094d 100644
--- a/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts
+++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts
@@ -27,6 +27,9 @@ export function main(options: UnwrapNestedRefs): UnwrapNes
const selectedValues = options.selected.map(stringifyObject);
+ let moreUnselected = false;
+ let moreSelected = false;
+
for (let index = 0; index < filteredSelectOptions.length; index++) {
const option = filteredSelectOptions[index]!;
@@ -34,23 +37,33 @@ export function main(options: UnwrapNestedRefs): UnwrapNes
const isSelected = selectedValues.includes(value);
- if (isSelected && selectedOptionsFiltered.length < options.selectedDisplayCount) {
- selectedOptionsFiltered.push(option);
- } else if (unselectedOptionsFiltered.length < options.unselectedDisplayCount) {
- unselectedOptionsFiltered.push(option);
- }
-
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,
};
}
From 62fb1fe4e234ad0e55eab2985b94a50ff350fa61 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Mon, 20 Nov 2023 18:23:30 +0100
Subject: [PATCH 11/37] add highlight selection
---
.../FormSelectMany/FormSelectMany.vue | 51 +++++++--
.../Elements/FormSelectMany/useHighlight.ts | 107 ++++++++++++++++++
2 files changed, 146 insertions(+), 12 deletions(-)
create mode 100644 client/src/components/Form/Elements/FormSelectMany/useHighlight.ts
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
index 8493596bea62..a0f3a697f2cf 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -4,10 +4,11 @@ import { faLongArrowAltLeft, faLongArrowAltRight } from "@fortawesome/free-solid
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { refDebounced } from "@vueuse/core";
import { BButton, BFormInput, BInputGroup } from "bootstrap-vue";
-import { computed, nextTick, type PropType, ref } from "vue";
+import { computed, nextTick, type PropType, reactive, ref } from "vue";
import { useUid } from "@/composables/utils/uid";
+import { useHighlight } from "./useHighlight";
import { filterOptions } from "./worker/filterOptions";
import { useSelectMany } from "./worker/selectMany";
@@ -130,10 +131,12 @@ async function deselectOption(index: number) {
}
function selectAll() {
- if (searchValue.value === "") {
+ if (highlightUnselected.highlightedIndexes.length > 0) {
+ const highlightedValues = highlightUnselected.highlightedOptions.map((o) => o.value);
+ const selectedSet = new Set([...selected.value, ...highlightedValues]);
+ selected.value = Array.from(selectedSet);
+ } else if (searchValue.value === "") {
selected.value = props.options.map((o) => o.value);
- } else if (highlightedUnselected.value.length > 0) {
- // todo
} else {
const filteredValues = filterOptions(
props.options,
@@ -153,7 +156,7 @@ function selectAll() {
function deselectAll() {
if (searchValue.value === "") {
selected.value = [];
- } else if (highlightedSelected.value.length > 0) {
+ //} else if (highlightedSelected.value.length > 0) {
// todo
} else {
const selectedSet = new Set(selected.value);
@@ -183,11 +186,10 @@ function optionOnKey(selected: "selected" | "unselected", event: KeyboardEvent,
document.getElementById(`${props.id}-${selected}-${nextIndex}`)?.focus();
}
-const highlightedUnselected = ref([]);
-const highlightedSelected = ref([]);
+const highlightUnselected = reactive(useHighlight(unselectedOptionsFiltered));
const selectText = computed(() => {
- if (highlightedUnselected.value.length > 0) {
+ if (highlightUnselected.highlightedIndexes.length > 0) {
return "Select highlighted";
} else if (searchValue.value === "") {
return "Select all";
@@ -197,9 +199,9 @@ const selectText = computed(() => {
});
const deselectText = computed(() => {
- if (highlightedSelected.value.length > 0) {
+ /*if (highlightedSelected.value.length > 0) {
return "Deselect highlighted";
- } else if (searchValue.value === "") {
+ } else*/ if (searchValue.value === "") {
return "Deselect all";
} else {
return "Deselect filtered";
@@ -246,13 +248,23 @@ const deselectText = computed(() => {
-
+
+
+
@@ -343,6 +355,21 @@ const deselectText = computed(() => {
display: flex;
justify-content: space-between;
+ &.highlighted {
+ background-color: $brand-info;
+ border-radius: 0;
+ color: $white;
+
+ &:hover,
+ &:focus {
+ background-color: $brand-primary;
+
+ &::after {
+ color: $white;
+ }
+ }
+ }
+
&:focus-visible {
outline-color: $brand-primary;
outline-width: 2px;
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..cee6bbed70bf
--- /dev/null
+++ b/client/src/components/Form/Elements/FormSelectMany/useHighlight.ts
@@ -0,0 +1,107 @@
+import { type Ref, ref, watch } from "vue";
+
+import { assertDefined } from "@/utils/assertions";
+
+import type { SelectOption } from "./worker/selectMany";
+
+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 onRangeHighlight = (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 onRangeRemoveHighlight = (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,
+ onRangeHighlight,
+ onRangeRemoveHighlight,
+ toggleHighlight,
+ abortHighlight,
+ addHighlight,
+ removeHighlight,
+ };
+}
From 7fc977f5b54ce830b3f92893d6eec4bb5f24614e Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 10:17:54 +0100
Subject: [PATCH 12/37] add highlighting to selected fix blinking options on
selecting highlighted
---
.../FormSelectMany/FormSelectMany.vue | 36 +++++++++++++------
1 file changed, 26 insertions(+), 10 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
index a0f3a697f2cf..1429f75a288f 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -135,8 +135,12 @@ function selectAll() {
const highlightedValues = highlightUnselected.highlightedOptions.map((o) => o.value);
const selectedSet = new Set([...selected.value, ...highlightedValues]);
selected.value = Array.from(selectedSet);
+
+ unselectedOptionsFiltered.value.filter((o) => highlightedValues.includes(o.value));
} else if (searchValue.value === "") {
selected.value = props.options.map((o) => o.value);
+
+ unselectedOptionsFiltered.value = [];
} else {
const filteredValues = filterOptions(
props.options,
@@ -148,16 +152,23 @@ function selectAll() {
const selectedSet = new Set([...selected.value, ...filteredValues]);
selected.value = Array.from(selectedSet);
- }
- unselectedOptionsFiltered.value = [];
+ unselectedOptionsFiltered.value = [];
+ }
}
function deselectAll() {
- if (searchValue.value === "") {
+ if (highlightSelected.highlightedIndexes.length > 0) {
+ const selectedSet = new Set(selected.value);
+ const highlightedValues = highlightSelected.highlightedOptions.map((o) => o.value);
+
+ highlightedValues.forEach((v) => selectedSet.delete(v));
+ selected.value = Array.from(selectedSet);
+
+ selectedOptionsFiltered.value.filter((o) => highlightedValues.includes(o.value));
+ } else if (searchValue.value === "") {
selected.value = [];
- //} else if (highlightedSelected.value.length > 0) {
- // todo
+ selectedOptionsFiltered.value = [];
} else {
const selectedSet = new Set(selected.value);
const filteredValues = filterOptions(
@@ -170,9 +181,9 @@ function deselectAll() {
filteredValues.forEach((v) => selectedSet.delete(v));
selected.value = Array.from(selectedSet);
- }
- selectedOptionsFiltered.value = [];
+ selectedOptionsFiltered.value = [];
+ }
}
function optionOnKey(selected: "selected" | "unselected", event: KeyboardEvent, index: number) {
@@ -187,6 +198,7 @@ function optionOnKey(selected: "selected" | "unselected", event: KeyboardEvent,
}
const highlightUnselected = reactive(useHighlight(unselectedOptionsFiltered));
+const highlightSelected = reactive(useHighlight(selectedOptionsFiltered));
const selectText = computed(() => {
if (highlightUnselected.highlightedIndexes.length > 0) {
@@ -199,9 +211,9 @@ const selectText = computed(() => {
});
const deselectText = computed(() => {
- /*if (highlightedSelected.value.length > 0) {
+ if (highlightSelected.highlightedIndexes.length > 0) {
return "Deselect highlighted";
- } else*/ if (searchValue.value === "") {
+ } else if (searchValue.value === "") {
return "Deselect all";
} else {
return "Deselect filtered";
@@ -287,7 +299,11 @@ const deselectText = computed(() => {
:id="`${props.id}-selected-${i}`"
:key="option.label"
:tabindex="i === 0 ? 0 : -1"
- @click="deselectOption(i)"
+ :class="{ highlighted: highlightSelected.highlightedIndexes.includes(i) }"
+ @click.shift.exact="highlightSelected.onRangeHighlight(i)"
+ @click.shift.ctrl.exact="highlightSelected.onRangeRemoveHighlight(i)"
+ @click.ctrl.exact="highlightSelected.toggleHighlight(i)"
+ @click.exact="deselectOption(i)"
@keydown="(e) => optionOnKey('selected', e, i)">
{{ option.label }}
From f59e71d50bbe5affe3bd936b8396aa1f975c5179 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 10:39:01 +0100
Subject: [PATCH 13/37] fix keyboard highlight multiple
---
.../FormSelectMany/FormSelectMany.vue | 108 ++++++++++++------
.../Elements/FormSelectMany/useHighlight.ts | 8 +-
2 files changed, 76 insertions(+), 40 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
index 1429f75a288f..bec3aa8a5015 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -4,7 +4,7 @@ import { faLongArrowAltLeft, faLongArrowAltRight } from "@fortawesome/free-solid
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { refDebounced } from "@vueuse/core";
import { BButton, BFormInput, BInputGroup } from "bootstrap-vue";
-import { computed, nextTick, type PropType, reactive, ref } from "vue";
+import { computed, nextTick, type PropType, reactive, ref, type UnwrapRef } from "vue";
import { useUid } from "@/composables/utils/uid";
@@ -93,40 +93,69 @@ const { unselectedOptionsFiltered, selectedOptionsFiltered, running, moreUnselec
const workerRunning = refDebounced(running, 1000);
-async function selectOption(index: number) {
- const [option] = unselectedOptionsFiltered.value.splice(index, 1);
-
- if (option) {
- selected.value.push(option.value);
+function handleHighlight(
+ event: MouseEvent | KeyboardEvent,
+ index: number,
+ highlightHandler: UnwrapRef>
+) {
+ if (event.shiftKey && event.ctrlKey) {
+ highlightHandler.rangeRemoveHighlight(index);
+ } else if (event.shiftKey) {
+ highlightHandler.rangeHighlight(index);
+ } else if (event.ctrlKey) {
+ highlightHandler.toggleHighlight(index);
}
+}
- // select the element which now is where the removed element just was
- // to improve keyboard navigation
- await nextTick();
-
- const el = document.getElementById(`${props.id}-unselected-${index}`);
- if (el) {
- el.focus();
+async function selectOption(event: MouseEvent, index: number): Promise {
+ if (event.shiftKey || event.ctrlKey) {
+ handleHighlight(event, index, highlightUnselected);
} else {
- document.getElementById(`${props.id}-unselected-${index - 1}`)?.focus();
+ const [option] = unselectedOptionsFiltered.value.splice(index, 1);
+
+ if (option) {
+ selected.value.push(option.value);
+ }
+
+ // select the element which now is where the removed element just was
+ // to improve keyboard navigation
+ await nextTick();
+
+ const el = document.getElementById(`${props.id}-unselected-${index}`);
+ if (el) {
+ el.focus();
+ } else {
+ document.getElementById(`${props.id}-unselected-${index - 1}`)?.focus();
+ }
}
}
-async function deselectOption(index: number) {
- const [option] = selectedOptionsFiltered.value.splice(index, 1);
+async function deselectOption(event: MouseEvent, index: number) {
+ if (event.shiftKey || event.ctrlKey) {
+ handleHighlight(event, index, highlightSelected);
+ } else {
+ const [option] = selectedOptionsFiltered.value.splice(index, 1);
- if (option) {
- const i = selected.value.indexOf(option.value);
- selected.value.splice(i, 1);
- }
+ if (option) {
+ const i = selected.value.indexOf(option.value);
+ selected.value.splice(i, 1);
+ }
- await nextTick();
+ await nextTick();
- const el = document.getElementById(`${props.id}-selected-${index}`);
- if (el) {
- el.focus();
- } else {
- document.getElementById(`${props.id}-selected-${index - 1}`)?.focus();
+ const el = document.getElementById(`${props.id}-selected-${index}`);
+ if (el) {
+ el.focus();
+ } else {
+ document.getElementById(`${props.id}-selected-${index - 1}`)?.focus();
+ }
+ }
+}
+
+function onOptionListKeyup(selected: "selected" | "unselected", event: KeyboardEvent) {
+ if (event.key === "Shift") {
+ const highlightHandler = selected === "selected" ? highlightSelected : highlightUnselected;
+ highlightHandler.abortHighlight();
}
}
@@ -187,6 +216,13 @@ function deselectAll() {
}
function optionOnKey(selected: "selected" | "unselected", event: KeyboardEvent, index: number) {
+ if ([" ", "Enter"].includes(event.key) && (event.shiftKey || event.ctrlKey)) {
+ const highlightHandler = selected === "selected" ? highlightSelected : highlightUnselected;
+ handleHighlight(event, index, highlightHandler);
+ event.preventDefault();
+ return;
+ }
+
if (!["ArrowUp", "ArrowDown"].includes(event.key)) {
return;
}
@@ -266,17 +302,14 @@ const deselectText = computed(() => {
class="options-list unselected border-right"
tabindex="-1"
@keydown.up.down.prevent
- @keyup.shift="highlightUnselected.abortHighlight()">
+ @keyup="(e) => onOptionListKeyup('unselected', e)">
@@ -293,17 +326,20 @@ const deselectText = computed(() => {
{{ deselectText }}
-
+
+
+
onOptionListKeyup('selected', e)">
diff --git a/client/src/components/Form/Elements/FormSelectMany/useHighlight.ts b/client/src/components/Form/Elements/FormSelectMany/useHighlight.ts
index cee6bbed70bf..2be5b64baeff 100644
--- a/client/src/components/Form/Elements/FormSelectMany/useHighlight.ts
+++ b/client/src/components/Form/Elements/FormSelectMany/useHighlight.ts
@@ -21,7 +21,7 @@ export function useHighlight(options: Ref) {
let highlightIndexStart = -1;
- const onRangeHighlight = (index: number) => {
+ const rangeHighlight = (index: number) => {
if (highlightIndexStart === -1) {
highlightIndexStart = index;
addHighlight(index);
@@ -38,7 +38,7 @@ export function useHighlight(options: Ref) {
highlightIndexStart = -1;
};
- const onRangeRemoveHighlight = (index: number) => {
+ const rangeRemoveHighlight = (index: number) => {
if (highlightIndexStart === -1) {
highlightIndexStart = index;
removeHighlight(index);
@@ -97,8 +97,8 @@ export function useHighlight(options: Ref) {
highlightedOptions,
highlightedIndexes,
reset,
- onRangeHighlight,
- onRangeRemoveHighlight,
+ rangeHighlight,
+ rangeRemoveHighlight,
toggleHighlight,
abortHighlight,
addHighlight,
From 4c6e9b76f99d68ba82bf67bef196beb0925c5bd7 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 10:45:30 +0100
Subject: [PATCH 14/37] add comments decrease worker running debounce time
---
.../Form/Elements/FormSelectMany/FormSelectMany.vue | 11 ++++++++++-
.../Form/Elements/FormSelectMany/useHighlight.ts | 4 ++++
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
index bec3aa8a5015..611fb687b742 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -66,6 +66,7 @@ const searchRegex = computed(() => {
}
});
+/** Wraps value prop so it can be set, and always returns an array */
const selected = computed({
get() {
return Array.isArray(props.value) ? props.value : [props.value];
@@ -76,11 +77,14 @@ const selected = computed({
});
const regexInvalid = computed(() => useRegex.value && searchRegex.value === null);
+/** Tells the worker thread if the search value should be treated as a regex */
const asRegex = computed(() => searchRegex.value !== null);
+// Limits amount of displayed options
const selectedDisplayCount = ref(500);
const unselectedDisplayCount = ref(500);
+// binding to worker thread
const { unselectedOptionsFiltered, selectedOptionsFiltered, running, moreUnselected, moreSelected } = useSelectMany({
optionsArray: computed(() => props.options),
filter: searchValue,
@@ -91,8 +95,10 @@ const { unselectedOptionsFiltered, selectedOptionsFiltered, running, moreUnselec
caseSensitive,
});
-const workerRunning = refDebounced(running, 1000);
+// debounced to it doesn't blink, and only appears when relevant
+const workerRunning = refDebounced(running, 800);
+/** generic event handler to handle highlighting of options */
function handleHighlight(
event: MouseEvent | KeyboardEvent,
index: number,
@@ -153,6 +159,7 @@ async function deselectOption(event: MouseEvent, index: number) {
}
function onOptionListKeyup(selected: "selected" | "unselected", event: KeyboardEvent) {
+ // reset highlight range mode when shift is released
if (event.key === "Shift") {
const highlightHandler = selected === "selected" ? highlightSelected : highlightUnselected;
highlightHandler.abortHighlight();
@@ -216,6 +223,7 @@ function deselectAll() {
}
function optionOnKey(selected: "selected" | "unselected", event: KeyboardEvent, index: number) {
+ // handle highlighting
if ([" ", "Enter"].includes(event.key) && (event.shiftKey || event.ctrlKey)) {
const highlightHandler = selected === "selected" ? highlightSelected : highlightUnselected;
handleHighlight(event, index, highlightHandler);
@@ -229,6 +237,7 @@ function optionOnKey(selected: "selected" | "unselected", event: KeyboardEvent,
event.preventDefault();
+ // handle arrow navigation
const nextIndex = event.key === "ArrowUp" ? index - 1 : index + 1;
document.getElementById(`${props.id}-${selected}-${nextIndex}`)?.focus();
}
diff --git a/client/src/components/Form/Elements/FormSelectMany/useHighlight.ts b/client/src/components/Form/Elements/FormSelectMany/useHighlight.ts
index 2be5b64baeff..8feb91eef706 100644
--- a/client/src/components/Form/Elements/FormSelectMany/useHighlight.ts
+++ b/client/src/components/Form/Elements/FormSelectMany/useHighlight.ts
@@ -4,6 +4,10 @@ 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([]);
From 5e41be19ff4461678419674c0d979e4f75e4f453 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 10:57:43 +0100
Subject: [PATCH 15/37] move highlight abort to blur
---
.../Elements/FormSelectMany/FormSelectMany.vue | 14 ++------------
1 file changed, 2 insertions(+), 12 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
index 611fb687b742..677695469e03 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -158,14 +158,6 @@ async function deselectOption(event: MouseEvent, index: number) {
}
}
-function onOptionListKeyup(selected: "selected" | "unselected", event: KeyboardEvent) {
- // reset highlight range mode when shift is released
- if (event.key === "Shift") {
- const highlightHandler = selected === "selected" ? highlightSelected : highlightUnselected;
- highlightHandler.abortHighlight();
- }
-}
-
function selectAll() {
if (highlightUnselected.highlightedIndexes.length > 0) {
const highlightedValues = highlightUnselected.highlightedOptions.map((o) => o.value);
@@ -306,12 +298,11 @@ const deselectText = computed(() => {
-
onOptionListKeyup('unselected', e)">
+ @blur="highlightUnselected.abortHighlight">
-
onOptionListKeyup('selected', e)">
+ @blur="highlightSelected.abortHighlight">
@@ -345,13 +345,13 @@ const deselectText = computed(() => {
Limited to {{ selectedDisplayCount }} options.
- Show more
+ Show more
Shift to highlight range. Ctrl to highlight multiple
- Processing...
+ Processing...
@@ -451,11 +451,12 @@ const deselectText = computed(() => {
padding-left: 0.5rem;
color: darken($gray-400, 20%);
- button::after {
+ .show-more-button:hover::after,
+ .show-more-button:focus::after {
content: none;
}
- button {
+ .show-more-button {
color: $brand-info;
text-decoration: underline;
}
@@ -485,5 +486,9 @@ const deselectText = computed(() => {
width: 100%;
display: flex;
justify-content: space-between;
+
+ .working-indicator {
+ color: $brand-primary;
+ }
}
From f4eb4ce34b947f1e33af3b31272bd3c7ee630d58 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 11:18:44 +0100
Subject: [PATCH 17/37] show amount of options in columns increase default
display limits to 1000
---
.../FormSelectMany/FormSelectMany.vue | 38 +++++++++++++++++--
1 file changed, 34 insertions(+), 4 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
index bf5687af500b..3ae761f5c151 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -81,8 +81,8 @@ const regexInvalid = computed(() => useRegex.value && searchRegex.value === null
const asRegex = computed(() => searchRegex.value !== null);
// Limits amount of displayed options
-const selectedDisplayCount = ref(500);
-const unselectedDisplayCount = ref(500);
+const selectedDisplayCount = ref(1000);
+const unselectedDisplayCount = ref(1000);
// binding to worker thread
const { unselectedOptionsFiltered, selectedOptionsFiltered, running, moreUnselected, moreSelected } = useSelectMany({
@@ -256,6 +256,30 @@ const deselectText = computed(() => {
return "Deselect filtered";
}
});
+
+const unselectedCount = computed(() => {
+ if (searchValue.value === "") {
+ return `${props.options.length - selected.value.length}`;
+ } else {
+ let countString = `${unselectedOptionsFiltered.value.length}`;
+ if (moreUnselected.value) {
+ countString += "+";
+ }
+ return countString;
+ }
+});
+
+const selectedCount = computed(() => {
+ if (searchValue.value === "") {
+ return `${selected.value.length}`;
+ } else {
+ let countString = `${selectedOptionsFiltered.value.length}`;
+ if (moreSelected.value) {
+ countString += "+";
+ }
+ return countString;
+ }
+});
@@ -291,7 +315,10 @@ const deselectText = computed(() => {
- Unselected
+
+ Unselected
+ ({{ unselectedCount }})
+
{{ selectText }}
@@ -320,7 +347,10 @@ const deselectText = computed(() => {
-
Selected
+
+ Selected
+ ({{ selectedCount }})
+
{{ deselectText }}
From b3260d0acd2244ba36248ecda7e4efb1a2b01e44 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 11:29:12 +0100
Subject: [PATCH 18/37] fix select button text alignment
---
.../components/Form/Elements/FormSelectMany/FormSelectMany.vue | 3 +++
1 file changed, 3 insertions(+)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
index 3ae761f5c151..c42299fdb5e7 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -418,6 +418,9 @@ const selectedCount = computed(() => {
.selection-button {
height: 20px;
padding: 0 0.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
}
}
}
From bf65855062c35c6894176d83ccd57bccf1f8be90 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 12:20:11 +0100
Subject: [PATCH 19/37] allow for switching and add local preferences
---
.../FormSelectMany/FormSelectMany.vue | 4 -
.../Form/Elements/FormSelection.vue | 88 +++++++++++++++++--
client/src/stores/userFlagsStore.ts | 7 ++
3 files changed, 89 insertions(+), 10 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
index c42299fdb5e7..5f669e5c3a45 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -27,10 +27,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
- optional: {
- type: Boolean,
- default: false,
- },
options: {
type: Array as PropType>,
required: true,
diff --git a/client/src/components/Form/Elements/FormSelection.vue b/client/src/components/Form/Elements/FormSelection.vue
index 42d5a762e0d6..5cd7f6f5fceb 100644
--- a/client/src/components/Form/Elements/FormSelection.vue
+++ b/client/src/components/Form/Elements/FormSelection.vue
@@ -1,11 +1,21 @@
-
-
-
+
+
+
+
+
+
+
+ switch to column select
+
+ switch to tag select
+
+
+
+
+
+ select element preferences
+
+
+ No preference
+
+
+ Default to tag select
+
+
+ Default to column select
+
+
+
+
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,
};
});
From 9b99845cde177702f366364c03de531182583ce5 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 12:32:59 +0100
Subject: [PATCH 20/37] add condition based on amount of options
---
client/src/components/Form/Elements/FormSelection.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/client/src/components/Form/Elements/FormSelection.vue b/client/src/components/Form/Elements/FormSelection.vue
index 5cd7f6f5fceb..f807c31dab8c 100644
--- a/client/src/components/Form/Elements/FormSelection.vue
+++ b/client/src/components/Form/Elements/FormSelection.vue
@@ -90,7 +90,7 @@ watch(
}
if (newValue === "none") {
- if (Array.isArray(props.value) && props.value.length >= 15) {
+ if ((Array.isArray(props.value) && props.value.length >= 15) || props.options.length >= 500) {
useMany.value = true;
} else {
useMany.value = false;
From 3071ef5fa5d8198fef556b30d26dd74c97125a7e Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 14:22:22 +0100
Subject: [PATCH 21/37] explicit object return to resolve codeQL false positive
---
.../worker/selectMany.worker.js | 20 ++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js
index b426f9224e8f..e1babeca7d87 100644
--- a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js
+++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js
@@ -2,15 +2,17 @@ import { main } from "./selectManyMain";
// glue code to run `main` in a thread
-const createOptions = () => ({
- optionsArray: [],
- filter: "",
- selected: [],
- unselectedDisplayCount: 1000,
- selectedDisplayCount: 1000,
- asRegex: false,
- caseSensitive: false,
-});
+const createOptions = () => {
+ return {
+ optionsArray: [],
+ filter: "",
+ selected: [],
+ unselectedDisplayCount: 1000,
+ selectedDisplayCount: 1000,
+ asRegex: false,
+ caseSensitive: false,
+ };
+};
const optionsById = {};
const timerById = {};
From d7f948090cb5d572ab346ebe14849c1fc212ce74 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 14:33:44 +0100
Subject: [PATCH 22/37] add testing pinia to FormSelect test
---
client/src/components/Form/Elements/FormSelect.test.js | 4 ++++
1 file changed, 4 insertions(+)
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,
});
}
From fc48edca5dbfec7c403b102ce24615d4c293cbaf Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 15:42:06 +0100
Subject: [PATCH 23/37] replace object with map to fix codeQl warning
---
.../FormSelectMany/worker/selectMany.worker.js | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js
index e1babeca7d87..bae4faf9a5f4 100644
--- a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js
+++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js
@@ -14,17 +14,17 @@ const createOptions = () => {
};
};
-const optionsById = {};
+const optionsById = new Map();
const timerById = {};
self.addEventListener("message", (e) => {
const message = e.data;
- if (!(message.id in optionsById)) {
- optionsById[message.id] = createOptions();
+ if (optionsById.has(message.id)) {
+ optionsById.set(message.id, createOptions());
}
- const options = optionsById[message.id];
+ const options = optionsById.get(message.id);
switch (message.type) {
case "setArray":
@@ -47,7 +47,7 @@ self.addEventListener("message", (e) => {
break;
case "clear":
- delete optionsById[message.id];
+ optionsById.delete(message.id);
return;
default:
From 77e6fe99a9da721eaa7d07709d93f2bcba177a95 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 16:00:01 +0100
Subject: [PATCH 24/37] undo filter composable changes
---
client/src/composables/filter/filter.d.ts | 4 +--
client/src/composables/filter/filter.js | 10 -------
.../src/composables/filter/filter.worker.js | 5 +---
.../src/composables/filter/filterFunction.ts | 26 +++----------------
4 files changed, 5 insertions(+), 40 deletions(-)
diff --git a/client/src/composables/filter/filter.d.ts b/client/src/composables/filter/filter.d.ts
index f629299ab34d..6efd44fb92d3 100644
--- a/client/src/composables/filter/filter.d.ts
+++ b/client/src/composables/filter/filter.d.ts
@@ -7,11 +7,9 @@ import type { Ref } from "vue";
* @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 asRegex when true, treats the filter as an regex. defaults to false
*/
export declare function useFilterObjectArray(
array: MaybeRefOrGetter>,
filter: MaybeRefOrGetter,
- objectFields: MaybeRefOrGetter>,
- asRegex: MaybeRefOrGetter = false
+ objectFields: MaybeRefOrGetter>
): Ref;
diff --git a/client/src/composables/filter/filter.js b/client/src/composables/filter/filter.js
index a8d7bc2d67de..15f630150a84 100644
--- a/client/src/composables/filter/filter.js
+++ b/client/src/composables/filter/filter.js
@@ -41,16 +41,6 @@ export function useFilterObjectArray(array, filter, objectFields, asRegex = fals
}
);
- watch(
- () => toValue(asRegex),
- (r) => {
- post({ type: "setAsRegex", asRegex: r });
- },
- {
- immediate: true,
- }
- );
-
worker.onmessage = (e) => {
const message = e.data;
diff --git a/client/src/composables/filter/filter.worker.js b/client/src/composables/filter/filter.worker.js
index f66134b0db58..470bf897de5b 100644
--- a/client/src/composables/filter/filter.worker.js
+++ b/client/src/composables/filter/filter.worker.js
@@ -3,7 +3,6 @@ import { runFilter } from "@/composables/filter/filterFunction";
let array = [];
let filter = "";
let fields = [];
-let asRegex = false;
self.addEventListener("message", (e) => {
const message = e.data;
@@ -14,12 +13,10 @@ self.addEventListener("message", (e) => {
fields = message.fields;
} else if (message.type === "setFilter") {
filter = message.filter;
- } else if (message.type === "setAsRegex") {
- asRegex = message.asRegex;
}
if (array.length > 0 && fields.length > 0) {
- const filtered = runFilter(filter, array, fields, asRegex);
+ const filtered = runFilter(filter, array, fields);
self.postMessage({ type: "result", filtered });
}
});
diff --git a/client/src/composables/filter/filterFunction.ts b/client/src/composables/filter/filterFunction.ts
index 480bf43fdabe..be92ad46b162 100644
--- a/client/src/composables/filter/filterFunction.ts
+++ b/client/src/composables/filter/filterFunction.ts
@@ -1,14 +1,4 @@
-export function runFilter(f: string, arr: O[], fields: K[], asRegex = false) {
- let regex: RegExp | null = null;
-
- if (asRegex) {
- try {
- regex = new RegExp(f);
- } catch (e) {
- // ignore
- }
- }
-
+export function runFilter(f: string, arr: O[], fields: K[]) {
if (f === "") {
return arr;
} else {
@@ -17,21 +7,11 @@ export function runFilter(f: string, arr: O
const val = obj[field];
if (typeof val === "string") {
- if (regex) {
- return val.match(regex);
- } else if (val.toLowerCase().includes(f.toLocaleLowerCase())) {
+ if (val.toLowerCase().includes(f.toLocaleLowerCase())) {
return true;
}
} else if (Array.isArray(val)) {
- if (regex) {
- return val.some((v) => {
- if (typeof v === "string") {
- return v.match(regex!);
- } else {
- return false;
- }
- });
- } else if (val.includes(f)) {
+ if (val.includes(f)) {
return true;
}
}
From 7d42a1453c3389fc035eac19a38b915ee9ff2479 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 16:06:55 +0100
Subject: [PATCH 25/37] pass regex instead of reconstructing
---
.../FormSelectMany/FormSelectMany.vue | 23 ++++---------------
.../FormSelectMany/worker/filterOptions.ts | 18 +++++----------
.../FormSelectMany/worker/selectMany.d.ts | 3 +--
.../FormSelectMany/worker/selectMany.js | 2 --
.../worker/selectMany.worker.js | 4 +---
.../FormSelectMany/worker/selectManyMain.ts | 18 +--------------
6 files changed, 14 insertions(+), 54 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
index 5f669e5c3a45..e76308fcafe0 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -73,21 +73,20 @@ const selected = computed({
});
const regexInvalid = computed(() => useRegex.value && searchRegex.value === null);
-/** Tells the worker thread if the search value should be treated as a regex */
-const asRegex = computed(() => searchRegex.value !== null);
// Limits amount of displayed options
const selectedDisplayCount = ref(1000);
const unselectedDisplayCount = ref(1000);
+const filter = computed(() => searchRegex.value ?? searchValue.value);
+
// binding to worker thread
const { unselectedOptionsFiltered, selectedOptionsFiltered, running, moreUnselected, moreSelected } = useSelectMany({
optionsArray: computed(() => props.options),
- filter: searchValue,
+ filter,
selected,
selectedDisplayCount,
unselectedDisplayCount,
- asRegex,
caseSensitive,
});
@@ -166,13 +165,7 @@ function selectAll() {
unselectedOptionsFiltered.value = [];
} else {
- const filteredValues = filterOptions(
- props.options,
- searchValue.value,
- asRegex.value,
- caseSensitive.value,
- searchRegex.value
- ).map((o) => o.value);
+ const filteredValues = filterOptions(props.options, filter.value, caseSensitive.value).map((o) => o.value);
const selectedSet = new Set([...selected.value, ...filteredValues]);
selected.value = Array.from(selectedSet);
@@ -195,13 +188,7 @@ function deselectAll() {
selectedOptionsFiltered.value = [];
} else {
const selectedSet = new Set(selected.value);
- const filteredValues = filterOptions(
- props.options,
- searchValue.value,
- asRegex.value,
- caseSensitive.value,
- searchRegex.value
- ).map((o) => o.value);
+ const filteredValues = filterOptions(props.options, filter.value, caseSensitive.value).map((o) => o.value);
filteredValues.forEach((v) => selectedSet.delete(v));
selected.value = Array.from(selectedSet);
diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/filterOptions.ts b/client/src/components/Form/Elements/FormSelectMany/worker/filterOptions.ts
index fe4a6486d35a..6c3c26f3a38c 100644
--- a/client/src/components/Form/Elements/FormSelectMany/worker/filterOptions.ts
+++ b/client/src/components/Form/Elements/FormSelectMany/worker/filterOptions.ts
@@ -1,21 +1,15 @@
import { SelectOption } from "./selectMany";
-export function filterOptions(
- options: SelectOption[],
- filterString: string,
- asRegex: boolean,
- caseSensitive: boolean,
- filterRegex?: RegExp | null
-) {
+export function filterOptions(options: SelectOption[], filter: string | RegExp, caseSensitive: boolean) {
let filteredSelectOptions;
- if (asRegex && filterRegex) {
- filteredSelectOptions = options.filter((option) => Boolean(option.label.match(filterRegex!)));
+ if (filter instanceof RegExp) {
+ filteredSelectOptions = options.filter((option) => Boolean(option.label.match(filter)));
} else if (caseSensitive) {
- filteredSelectOptions = options.filter((option) => option.label.includes(filterString));
+ filteredSelectOptions = options.filter((option) => option.label.includes(filter));
} else {
- const filter = filterString.toLowerCase();
- filteredSelectOptions = options.filter((option) => option.label.toLowerCase().includes(filter));
+ 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
index 48cfa6a4989f..3f3ebf146dde 100644
--- a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.d.ts
+++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.d.ts
@@ -9,11 +9,10 @@ export interface SelectOption {
export interface UseSelectManyOptions {
optionsArray: Ref;
- filter: Ref;
+ filter: Ref;
selected: Ref;
unselectedDisplayCount: Ref;
selectedDisplayCount: Ref;
- asRegex: Ref;
caseSensitive: Ref;
}
diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js
index b6c041a922bd..7b0bdbdb28d5 100644
--- a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js
+++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js
@@ -12,7 +12,6 @@ export function useSelectMany({
selected,
unselectedDisplayCount,
selectedDisplayCount,
- asRegex,
caseSensitive,
}) {
// only start a single worker
@@ -63,7 +62,6 @@ export function useSelectMany({
type: "setSettings",
unselectedDisplayCount: unselectedDisplayCount.value,
selectedDisplayCount: selectedDisplayCount.value,
- asRegex: asRegex.value,
caseSensitive: caseSensitive.value,
});
});
diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js
index bae4faf9a5f4..6eaed4c7638d 100644
--- a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js
+++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.worker.js
@@ -9,7 +9,6 @@ const createOptions = () => {
selected: [],
unselectedDisplayCount: 1000,
selectedDisplayCount: 1000,
- asRegex: false,
caseSensitive: false,
};
};
@@ -20,7 +19,7 @@ const timerById = {};
self.addEventListener("message", (e) => {
const message = e.data;
- if (optionsById.has(message.id)) {
+ if (!optionsById.has(message.id)) {
optionsById.set(message.id, createOptions());
}
@@ -42,7 +41,6 @@ self.addEventListener("message", (e) => {
case "setSettings":
options.unselectedDisplayCount = message.unselectedDisplayCount;
options.selectedDisplayCount = message.selectedDisplayCount;
- options.asRegex = message.asRegex;
options.caseSensitive = message.caseSensitive;
break;
diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts b/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts
index 0f90b8f5094d..2d8ea95b23ed 100644
--- a/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts
+++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts
@@ -7,23 +7,7 @@ export function main(options: UnwrapNestedRefs): UnwrapNes
const unselectedOptionsFiltered: SelectOption[] = [];
const selectedOptionsFiltered: SelectOption[] = [];
- let filterRegex: RegExp | undefined;
-
- if (options.asRegex) {
- try {
- filterRegex = new RegExp(options.filter, options.caseSensitive ? undefined : "i");
- } catch (e) {
- // ignore
- }
- }
-
- const filteredSelectOptions = filterOptions(
- options.optionsArray,
- options.filter,
- options.asRegex,
- options.caseSensitive,
- filterRegex
- );
+ const filteredSelectOptions = filterOptions(options.optionsArray, options.filter, options.caseSensitive);
const selectedValues = options.selected.map(stringifyObject);
From 3ffa1d1c9300f67507ec927a02dc9a3493ec2cc8 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 16:17:13 +0100
Subject: [PATCH 26/37] greatly improve performance by using a Set
---
.../Form/Elements/FormSelectMany/worker/selectManyMain.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts b/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts
index 2d8ea95b23ed..0a35d9e04223 100644
--- a/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts
+++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectManyMain.ts
@@ -9,7 +9,7 @@ export function main(options: UnwrapNestedRefs): UnwrapNes
const filteredSelectOptions = filterOptions(options.optionsArray, options.filter, options.caseSensitive);
- const selectedValues = options.selected.map(stringifyObject);
+ const selectedValues = new Set(options.selected.map(stringifyObject));
let moreUnselected = false;
let moreSelected = false;
@@ -19,7 +19,7 @@ export function main(options: UnwrapNestedRefs): UnwrapNes
const value = stringifyObject(option.value);
- const isSelected = selectedValues.includes(value);
+ const isSelected = selectedValues.has(value);
if (
unselectedOptionsFiltered.length > options.unselectedDisplayCount &&
From 1f82d87f9d03c4bac0be3fe293b8a7c08dceff13 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 17:42:34 +0100
Subject: [PATCH 27/37] fix axe violation
---
.../Form/Elements/FormSelectMany/FormSelectMany.vue | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
index e76308fcafe0..72521fb0c341 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -279,7 +279,7 @@ const selectedCount = computed(() => {
class="toggle-button"
:variant="caseSensitive ? 'primary' : 'outline-primary'"
role="switch"
- :aria-checked="caseSensitive"
+ :aria-checked="`${caseSensitive}`"
title="case sensitive"
@click="caseSensitive = !caseSensitive">
Aa
@@ -288,7 +288,7 @@ const selectedCount = computed(() => {
class="toggle-button"
:variant="useRegex ? 'primary' : 'outline-primary'"
role="switch"
- :aria-checked="useRegex"
+ :aria-checked="`${useRegex}`"
title="use regex"
@click="useRegex = !useRegex">
.*
From 68215e3b835e1ff63db7d307869b57cdf38932f2 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 21 Nov 2023 18:43:49 +0100
Subject: [PATCH 28/37] rename tag select to simple select
---
client/src/components/Form/Elements/FormSelection.vue | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelection.vue b/client/src/components/Form/Elements/FormSelection.vue
index f807c31dab8c..e9466445b639 100644
--- a/client/src/components/Form/Elements/FormSelection.vue
+++ b/client/src/components/Form/Elements/FormSelection.vue
@@ -115,7 +115,7 @@ watch(
switch to column select
- switch to tag select
+ switch to simple select
@@ -131,7 +131,7 @@ watch(
- Default to tag select
+ Default to simple select
Date: Wed, 22 Nov 2023 13:13:34 +0100
Subject: [PATCH 29/37] fix horizontal scroll bar on chromium browsers
---
.../components/Form/Elements/FormSelectMany/FormSelectMany.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
index 72521fb0c341..b770d874a661 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -409,7 +409,7 @@ const selectedCount = computed(() => {
}
.options-list {
- overflow: scroll;
+ overflow-y: scroll;
display: flex;
flex-direction: column;
From 0575855ffa727a310987ac9ce95460dff55b4357 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Wed, 22 Nov 2023 13:15:25 +0100
Subject: [PATCH 30/37] only useMany when multiple is an option
---
.../Form/Elements/FormSelection.vue | 20 ++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelection.vue b/client/src/components/Form/Elements/FormSelection.vue
index e9466445b639..ca328723e6b0 100644
--- a/client/src/components/Form/Elements/FormSelection.vue
+++ b/client/src/components/Form/Elements/FormSelection.vue
@@ -72,14 +72,6 @@ const currentOptions = computed(() => {
const useMany = ref(false);
-const showManyButton = computed(
- () => props.multiple && props.display !== "checkboxes" && props.display !== "radio" && !useMany.value
-);
-
-const showMultiButton = computed(
- () => props.multiple && props.display !== "checkboxes" && props.display !== "radio" && useMany.value
-);
-
const { preferredFormSelectElement } = storeToRefs(useUserFlagsStore());
watch(
@@ -103,13 +95,23 @@ watch(
},
{ immediate: true }
);
+
+const displayMany = computed(
+ () => props.multiple && props.display !== "checkboxes" && props.display !== "radio" && useMany.value
+);
+
+const showManyButton = computed(
+ () => props.multiple && props.display !== "checkboxes" && props.display !== "radio" && !useMany.value
+);
+
+const showMultiButton = computed(() => displayMany.value);
-
+
From da46e69263697b4d13caee9d5f468166a0a81e15 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Wed, 22 Nov 2023 14:01:51 +0100
Subject: [PATCH 31/37] remove event listener in cleanup code
---
.../Form/Elements/FormSelectMany/worker/selectMany.js | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js
index 7b0bdbdb28d5..485e55bd23fc 100644
--- a/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js
+++ b/client/src/components/Form/Elements/FormSelectMany/worker/selectMany.js
@@ -42,6 +42,7 @@ export function useSelectMany({
worker = null;
} else {
post({ type: "clear" });
+ worker.removeEventListener("message", onMessage);
}
});
@@ -66,7 +67,7 @@ export function useSelectMany({
});
});
- worker.addEventListener("message", (e) => {
+ const onMessage = (e) => {
const message = e.data;
if (message.id !== id) {
@@ -80,7 +81,9 @@ export function useSelectMany({
moreUnselected.value = message.moreUnselected;
running.value = false;
}
- });
+ };
+
+ worker.addEventListener("message", onMessage);
return {
unselectedOptionsFiltered,
From d20bd8c461e44ca16ccfddd33362b3541feb7e03 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Thu, 23 Nov 2023 15:25:17 +0100
Subject: [PATCH 32/37] add test cases
---
.../FormSelectMany/FormSelectMany.test.ts | 348 ++++++++++++++++++
.../FormSelectMany/FormSelectMany.vue | 17 +-
.../worker/__mocks__/selectMany.ts | 37 ++
3 files changed, 394 insertions(+), 8 deletions(-)
create mode 100644 client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts
create mode 100644 client/src/components/Form/Elements/FormSelectMany/worker/__mocks__/selectMany.ts
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..6e5db7a7f12f
--- /dev/null
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts
@@ -0,0 +1,348 @@
+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 { wait } from "@/utils/utils";
+
+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: ".select-many-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;
+}
+
+async function search(wrapper: ReturnType, value: string) {
+ const searchInput = wrapper.find(selectors.search);
+ await searchInput.setValue(value);
+ await wait(310); // outwait input debounce
+}
+
+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 to 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
index b770d874a661..da8418126dcf 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -115,7 +115,7 @@ async function selectOption(event: MouseEvent, index: number): Promise {
const [option] = unselectedOptionsFiltered.value.splice(index, 1);
if (option) {
- selected.value.push(option.value);
+ selected.value = [...selected.value, option.value];
}
// select the element which now is where the removed element just was
@@ -139,7 +139,7 @@ async function deselectOption(event: MouseEvent, index: number) {
if (option) {
const i = selected.value.indexOf(option.value);
- selected.value.splice(i, 1);
+ selected.value = selected.value.flatMap((value, index) => (index === i ? [] : [value]));
}
await nextTick();
@@ -270,13 +270,14 @@ const selectedCount = computed(() => {
{
Aa
{
Unselected
- ({{ unselectedCount }})
+ ({{ unselectedCount }})
-
+
{{ selectText }}
@@ -332,9 +333,9 @@ const selectedCount = computed(() => {
Selected
- ({{ selectedCount }})
+ ({{ selectedCount }})
-
+
{{ deselectText }}
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,
+ };
+};
From 89a7d31537e48bef878463852277085fc8cb7853 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Fri, 24 Nov 2023 12:59:01 +0100
Subject: [PATCH 33/37] fix typo
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: David López <46503462+davelopez@users.noreply.github.com>
---
.../Form/Elements/FormSelectMany/FormSelectMany.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts
index 6e5db7a7f12f..6abcf9e13954 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts
@@ -148,7 +148,7 @@ describe("FormSelectMany", () => {
}
});
- it("shows the amount to selected options", async () => {
+ 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"] });
From 67120a038cf36ffb888f266a96c333eace2b228b Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Fri, 24 Nov 2023 13:02:24 +0100
Subject: [PATCH 34/37] use fake timers instead of waiting for debounce
---
.../Form/Elements/FormSelectMany/FormSelectMany.test.ts | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts
index 6abcf9e13954..6db9aabe065a 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts
@@ -5,8 +5,6 @@ import { getLocalVue } from "@tests/jest/helpers";
import { mount } from "@vue/test-utils";
import { PropType } from "vue";
-import { wait } from "@/utils/utils";
-
import type { SelectOption } from "./worker/selectMany";
import FormSelectMany from "./FormSelectMany.vue";
@@ -74,10 +72,13 @@ async function emittedInput(wrapper: ReturnType) {
return latestValue;
}
+// circumvent input debounce
+jest.useFakeTimers();
+
async function search(wrapper: ReturnType, value: string) {
const searchInput = wrapper.find(selectors.search);
await searchInput.setValue(value);
- await wait(310); // outwait input debounce
+ jest.runAllTimers();
}
describe("FormSelectMany", () => {
From d99d52a2c2210c45b89ae872f1f791d263ae72d2 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Fri, 24 Nov 2023 13:07:01 +0100
Subject: [PATCH 35/37] move option focus to function
---
.../FormSelectMany/FormSelectMany.vue | 26 +++++++++----------
1 file changed, 12 insertions(+), 14 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
index da8418126dcf..472884079648 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -108,6 +108,16 @@ function handleHighlight(
}
}
+/** focus option at given index, or previous option if that doesn't exist */
+function focusOptionAtIndex(selected: "selected" | "unselected", index: number) {
+ const el = document.getElementById(`${props.id}-${selected}-${index}`);
+ if (el) {
+ el.focus();
+ } else {
+ document.getElementById(`${props.id}-${selected}-${index - 1}`)?.focus();
+ }
+}
+
async function selectOption(event: MouseEvent, index: number): Promise {
if (event.shiftKey || event.ctrlKey) {
handleHighlight(event, index, highlightUnselected);
@@ -121,13 +131,7 @@ async function selectOption(event: MouseEvent, index: number): Promise {
// select the element which now is where the removed element just was
// to improve keyboard navigation
await nextTick();
-
- const el = document.getElementById(`${props.id}-unselected-${index}`);
- if (el) {
- el.focus();
- } else {
- document.getElementById(`${props.id}-unselected-${index - 1}`)?.focus();
- }
+ focusOptionAtIndex("unselected", index);
}
}
@@ -143,13 +147,7 @@ async function deselectOption(event: MouseEvent, index: number) {
}
await nextTick();
-
- const el = document.getElementById(`${props.id}-selected-${index}`);
- if (el) {
- el.focus();
- } else {
- document.getElementById(`${props.id}-selected-${index - 1}`)?.focus();
- }
+ focusOptionAtIndex("selected", index);
}
}
From 4c9d7b88a9e4ceb9f8ccb94ef094fb45b82cb1b3 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Fri, 24 Nov 2023 13:49:21 +0100
Subject: [PATCH 36/37] add clear button
---
.../FormSelectMany/FormSelectMany.test.ts | 2 +-
.../FormSelectMany/FormSelectMany.vue | 115 ++++++++++++------
2 files changed, 81 insertions(+), 36 deletions(-)
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts
index 6db9aabe065a..12f36bdf55ef 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.test.ts
@@ -31,7 +31,7 @@ const selectors = {
deselectAll: ".selection-button.deselect",
selectedCount: ".selected-count",
unselectedCount: ".unselected-count",
- search: ".select-many-search",
+ search: "input[type=search]",
caseSensitivity: ".toggle-button.case-sensitivity",
useRegex: ".toggle-button.use-regex",
} as const;
diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
index 472884079648..887bde0ae6f7 100644
--- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
+++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue
@@ -1,9 +1,9 @@
-