diff --git a/client/package.json b/client/package.json index 618b1219969f..07df904d9eea 100644 --- a/client/package.json +++ b/client/package.json @@ -104,6 +104,7 @@ "vue-router": "^3.6.5", "vue-rx": "^6.2.0", "vue-virtual-scroll-list": "^2.3.5", + "vue2-teleport": "^1.0.1", "vuedraggable": "^2.24.3", "winbox": "^0.2.82", "xml-beautifier": "^0.5.0" diff --git a/client/src/components/History/Content/ContentItem.vue b/client/src/components/History/Content/ContentItem.vue index a4bf4c8c398b..4dbc979dae51 100644 --- a/client/src/components/History/Content/ContentItem.vue +++ b/client/src/components/History/Content/ContentItem.vue @@ -84,6 +84,7 @@ :elements-datatypes="item.elements_datatypes" /> - +
-
+
{{ stateText }}
diff --git a/client/src/components/TagsMultiselect/HeadlessMultiselect.test.ts b/client/src/components/TagsMultiselect/HeadlessMultiselect.test.ts new file mode 100644 index 000000000000..ca97b4615bae --- /dev/null +++ b/client/src/components/TagsMultiselect/HeadlessMultiselect.test.ts @@ -0,0 +1,268 @@ +import { mount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; +import { nextTick } from "vue"; + +import HeadlessMultiselect from "./HeadlessMultiselect.vue"; + +describe("HeadlessMultiselect", () => { + // this function is not implemented in jsdom + // mocking it to avoid false errors + Element.prototype.scrollIntoView = jest.fn(); + + const localVue = getLocalVue(); + + type Props = InstanceType["$props"]; + const mountWithProps = (props: Props) => { + return mount(HeadlessMultiselect as any, { + propsData: props, + localVue, + attachTo: document.body, + }); + }; + + const sampleOptions = ["#named", "#named_2", "#named_3", "abc", "def", "ghi"]; + + const selectors = { + openButton: ".toggle-button", + option: ".headless-multiselect__option", + highlighted: ".headless-multiselect__option.highlighted", + input: "fieldset input", + invalid: ".headless-multiselect__option.invalid", + } as const; + + async function keyPress(wrapper: ReturnType, key: string) { + wrapper.trigger("keydown", { + key, + code: key, + }); + await nextTick(); + wrapper.trigger("keyup", { + key, + code: key, + }); + await nextTick(); + } + + async function open(wrapper: ReturnType) { + wrapper.find(selectors.openButton).trigger("click"); + await nextTick(); + return wrapper.find(selectors.input); + } + + async function close(wrapper: ReturnType) { + await keyPress(wrapper.find(selectors.input), "Escape"); + } + + describe("while toggling the popup", () => { + it("shows and hides options", async () => { + const wrapper = mountWithProps({ + options: sampleOptions, + selected: [] as string[], + }); + + let options; + + await open(wrapper); + options = wrapper.findAll(selectors.option); + expect(options.length).toBe(sampleOptions.length); + + await close(wrapper); + options = wrapper.findAll(selectors.option); + expect(options.length).toBe(0); + }); + + it("retains focus", async () => { + const wrapper = mountWithProps({ + options: sampleOptions, + selected: [] as string[], + }); + + const input = await open(wrapper); + expect(input.element).toBe(document.activeElement); + + await close(wrapper); + const button = wrapper.find(selectors.openButton); + expect(button.element).toBe(document.activeElement); + }); + }); + + describe("while inputting text", () => { + it("filters options", async () => { + const wrapper = mountWithProps({ + options: sampleOptions, + selected: [] as string[], + }); + + let options; + + const input = await open(wrapper); + + await input.setValue("a"); + options = wrapper.findAll(selectors.option); + expect(options.length).toBe(5); + + await input.setValue("na"); + options = wrapper.findAll(selectors.option); + expect(options.length).toBe(4); + + await input.setValue(""); + options = wrapper.findAll(selectors.option); + expect(options.length).toBe(6); + }); + + it("shows the search value on top", async () => { + const wrapper = mountWithProps({ + options: sampleOptions, + selected: [] as string[], + }); + + const input = await open(wrapper); + + await input.setValue("bc"); + const options = wrapper.findAll(selectors.option); + + expect(options.at(0).find("span").text()).toBe("bc"); + expect(options.at(1).find("span").text()).toBe("abc"); + }); + + it("allows for switching the highlighted value", async () => { + const wrapper = mountWithProps({ + options: sampleOptions, + selected: [] as string[], + }); + + let highlighted; + + const input = await open(wrapper); + + highlighted = wrapper.find(selectors.highlighted); + expect(highlighted.find("span").text()).toBe("#named"); + + await keyPress(input, "ArrowDown"); + highlighted = wrapper.find(selectors.highlighted); + expect(highlighted.find("span").text()).toBe("#named_2"); + + await keyPress(input, "ArrowDown"); + highlighted = wrapper.find(selectors.highlighted); + expect(highlighted.find("span").text()).toBe("#named_3"); + + await keyPress(input, "ArrowUp"); + highlighted = wrapper.find(selectors.highlighted); + expect(highlighted.find("span").text()).toBe("#named_2"); + }); + + it("resets the highlighted option on input", async () => { + const wrapper = mountWithProps({ + options: sampleOptions, + selected: [] as string[], + }); + + let highlighted; + + const input = await open(wrapper); + + await keyPress(input, "ArrowDown"); + highlighted = wrapper.find(selectors.highlighted); + expect(highlighted.find("span").text()).toBe("#named_2"); + + await input.setValue("a"); + + highlighted = wrapper.find(selectors.highlighted); + expect(highlighted.find("span").text()).toBe("a"); + }); + + it("shows if the input value is valid", async () => { + const wrapper = mountWithProps({ + options: sampleOptions, + selected: [] as string[], + validator: (value: string) => value !== "invalid", + }); + + const input = await open(wrapper); + await input.setValue("valid"); + expect(() => wrapper.get(selectors.invalid)).toThrow(); + + await input.setValue("invalid"); + expect(() => wrapper.get(selectors.invalid)).not.toThrow(); + }); + }); + + describe("when selecting options", () => { + it("selects options via keyboard", async () => { + const wrapper = mountWithProps({ + options: sampleOptions, + selected: [] as string[], + }); + + const input = await open(wrapper); + + await keyPress(input, "Enter"); + expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["#named"]); + + await keyPress(input, "ArrowDown"); + await keyPress(input, "Enter"); + expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["#named_2"]); + }); + + it("deselects options via keyboard", async () => { + const wrapper = mountWithProps({ + options: sampleOptions, + selected: ["#named", "#named_2", "#named_3"], + }); + + const input = await open(wrapper); + + await keyPress(input, "Enter"); + expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["#named_2", "#named_3"]); + + await keyPress(input, "ArrowDown"); + await keyPress(input, "Enter"); + expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["#named", "#named_3"]); + }); + + it("allows for adding new options", async () => { + const wrapper = mountWithProps({ + options: sampleOptions, + selected: [] as string[], + }); + + const input = await open(wrapper); + await input.setValue("123"); + await keyPress(input, "Enter"); + + expect(wrapper.emitted()["addOption"]?.[0]?.[0]).toBe("123"); + }); + + it("selects options with mouse", async () => { + const wrapper = mountWithProps({ + options: sampleOptions, + selected: [] as string[], + }); + + await open(wrapper); + const options = wrapper.findAll(selectors.option); + + await options.at(0).trigger("click"); + expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["#named"]); + + await options.at(1).trigger("click"); + expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["#named_2"]); + }); + + it("deselects options with mouse", async () => { + const wrapper = mountWithProps({ + options: sampleOptions, + selected: ["#named", "#named_2", "#named_3"], + }); + + await open(wrapper); + const options = wrapper.findAll(selectors.option); + + await options.at(0).trigger("click"); + expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["#named_2", "#named_3"]); + + await options.at(1).trigger("click"); + expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["#named", "#named_3"]); + }); + }); +}); diff --git a/client/src/components/TagsMultiselect/HeadlessMultiselect.vue b/client/src/components/TagsMultiselect/HeadlessMultiselect.vue new file mode 100644 index 000000000000..8a3fc5dd5fb3 --- /dev/null +++ b/client/src/components/TagsMultiselect/HeadlessMultiselect.vue @@ -0,0 +1,457 @@ + + + + + diff --git a/client/src/components/TagsMultiselect/StatelessTags.test.js b/client/src/components/TagsMultiselect/StatelessTags.test.js index 09b0226b61b2..7869166cb34b 100644 --- a/client/src/components/TagsMultiselect/StatelessTags.test.js +++ b/client/src/components/TagsMultiselect/StatelessTags.test.js @@ -8,6 +8,7 @@ import { useUserTagsStore } from "@/stores/userTagsStore"; import StatelessTags from "./StatelessTags"; const autocompleteTags = ["#named_user_tag", "abc", "my_tag"]; +const toggleButton = ".toggle-button"; const localVue = getLocalVue(); @@ -33,6 +34,12 @@ useToast.mockReturnValue({ warning: warningMock, }); +const selectors = { + multiselect: ".headless-multiselect", + options: ".headless-multiselect__option", + input: "fieldset input", +}; + describe("StatelessTags", () => { it("shows tags", () => { const wrapper = mountWithProps({ @@ -66,12 +73,14 @@ describe("StatelessTags", () => { disabled: false, }); - const multiselect = wrapper.find(".multiselect"); + wrapper.find(toggleButton).trigger("click"); + await wrapper.vm.$nextTick(); + + const multiselect = wrapper.find(selectors.multiselect); - multiselect.find("button").trigger("click"); await wrapper.vm.$nextTick(); + const options = multiselect.findAll(selectors.options); - const options = multiselect.findAll(".multiselect-option"); const visibleOptions = options.filter((option) => option.isVisible()); expect(visibleOptions.length).toBe(autocompleteTags.length); @@ -86,13 +95,12 @@ describe("StatelessTags", () => { disabled: false, }); - const multiselect = wrapper.find(".multiselect"); - - multiselect.find("button").trigger("click"); + wrapper.find(toggleButton).trigger("click"); await wrapper.vm.$nextTick(); - await multiselect.find("input").setValue("new_tag"); + const multiselect = wrapper.find(selectors.multiselect); + await multiselect.find(selectors.input).setValue("new_tag"); await wrapper.vm.$nextTick(); - multiselect.find(".multiselect-option").trigger("click"); + multiselect.find(selectors.options).trigger("click"); await wrapper.vm.$nextTick(); expect(addLocalTagMock.mock.calls.length).toBe(1); @@ -104,14 +112,13 @@ describe("StatelessTags", () => { disabled: false, }); - const multiselect = wrapper.find(".multiselect"); - - multiselect.find("button").trigger("click"); + wrapper.find(toggleButton).trigger("click"); await wrapper.vm.$nextTick(); - await multiselect.find("input").setValue(":illegal_tag"); + const multiselect = wrapper.find(selectors.multiselect); + await multiselect.find(selectors.input).setValue(":illegal_tag"); await wrapper.vm.$nextTick(); - const option = multiselect.find(".multiselect-option"); + const option = multiselect.find(selectors.options); expect(option.classes()).toContain("invalid"); option.trigger("click"); diff --git a/client/src/components/TagsMultiselect/StatelessTags.vue b/client/src/components/TagsMultiselect/StatelessTags.vue index 71a944340753..3bd10cf7de69 100644 --- a/client/src/components/TagsMultiselect/StatelessTags.vue +++ b/client/src/components/TagsMultiselect/StatelessTags.vue @@ -1,16 +1,13 @@