Skip to content

Commit

Permalink
feat: setup knowledge graph selector - WF-138
Browse files Browse the repository at this point in the history
  • Loading branch information
madeindjs committed Feb 13, 2025
1 parent 2c1c965 commit 3fd3e59
Show file tree
Hide file tree
Showing 19 changed files with 757 additions and 34 deletions.
62 changes: 62 additions & 0 deletions src/ui/src/builder/BuilderGraphSelect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import BuilderGraphSelect from "./BuilderGraphSelect.vue";
import { flushPromises, shallowMount } from "@vue/test-utils";
import { buildMockCore } from "@/tests/mocks";
import injectionKeys from "@/injectionKeys";
import BuilderSelect from "./BuilderSelect.vue";
import WdsTextInput from "@/wds/WdsTextInput.vue";

describe("BuilderGraphSelect", () => {
const graphs = [
{ id: "1", name: "one" },
{ id: "2", name: "two" },
];
let core: ReturnType<typeof buildMockCore>["core"];

beforeEach(() => {
core = buildMockCore().core;
});

it("should fetch and display graphs", async () => {
const sendListResourcesRequest = vi
.spyOn(core, "sendListResourcesRequest")
.mockResolvedValue(graphs);

const wrapper = shallowMount(BuilderGraphSelect, {
props: { modelValue: "1" },
global: {
stubs: {
BuilderSelect: true,
},
provide: {
[injectionKeys.core as symbol]: core,
},
},
});
await flushPromises();
expect(sendListResourcesRequest).toHaveBeenCalledOnce();
expect(wrapper.findComponent(BuilderSelect).exists()).toBe(true);
});

it("should fallback to input", async () => {
const sendListResourcesRequest = vi
.spyOn(core, "sendListResourcesRequest")
.mockRejectedValue(new Error());

const wrapper = shallowMount(BuilderGraphSelect, {
props: { modelValue: "1" },
global: {
stubs: {
BuilderSelect: true,
},
provide: {
[injectionKeys.core as symbol]: core,
},
},
});
await flushPromises();
expect(sendListResourcesRequest).toHaveBeenCalledOnce();
expect(wrapper.findComponent(BuilderSelect).exists()).toBe(false);
expect(wrapper.findComponent(WdsTextInput).exists()).toBe(true);
});
});
87 changes: 87 additions & 0 deletions src/ui/src/builder/BuilderGraphSelect.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<script setup lang="ts">
import injectionKeys from "@/injectionKeys";
import {
computed,
defineAsyncComponent,
inject,
onMounted,
PropType,
} from "vue";
import { useListResources } from "@/composables/useListResources";
import type { Option } from "./BuilderSelect.vue";
import BuilderAsyncLoader from "./BuilderAsyncLoader.vue";
import type { WriterGraph } from "@/writerTypes";
import WdsTextInput from "@/wds/WdsTextInput.vue";
import LoadingSymbol from "@/renderer/LoadingSymbol.vue";
const BuilderSelect = defineAsyncComponent({
loader: () => import("./BuilderSelect.vue"),
loadingComponent: BuilderAsyncLoader,
});
const wf = inject(injectionKeys.core);
defineProps({
enableMultiSelection: { type: Boolean, required: false },
});
const currentValue = defineModel({
type: [String, Array] as PropType<string | string[]>,
required: true,
});
const {
load: loadGraphs,
data: graphs,
isLoading,
} = useListResources<WriterGraph>(wf, "graphs");
onMounted(loadGraphs);
const options = computed(() =>
graphs.value
.map<Option>((graph) => ({
label: graph.name,
detail: graph.description,
value: graph.id,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
);
const currentValueStr = computed<string>({
get() {
if (currentValue.value === undefined) return "";
return typeof currentValue.value === "string"
? currentValue.value
: currentValue.value.join(",");
},
set(value: string) {
currentValue.value = value.split(",");
},
});
</script>

<template>
<div class="BuilderGraphSelect--text">
<BuilderSelect
v-if="graphs.length > 0"
v-model="currentValue"
:options="options"
hide-icons
enable-search
:enable-multi-selection="enableMultiSelection"
/>
<WdsTextInput v-else v-model="currentValueStr" />
<LoadingSymbol v-if="isLoading" />
</div>
</template>

<style scoped>
.BuilderGraphSelect--text {
width: 100%;
display: flex;
gap: 12px;
align-items: center;
}
</style>
54 changes: 54 additions & 0 deletions src/ui/src/builder/BuilderSelect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { flushPromises, mount, shallowMount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";

import BuilderSelect from "./BuilderSelect.vue";
import { Option } from "./BuilderSelect.vue";
import WdsDropdownMenu from "@/wds/WdsDropdownMenu.vue";

describe("BuilderSelect", () => {
const options: Option[] = [
{ label: "Label A", value: "a" },
{ label: "Label B", value: "b" },
{ label: "Label C", value: "c" },
];

it("should display unknow value selected", () => {
const wrapper = shallowMount(BuilderSelect, {
props: {
modelValue: "x",
enableMultiSelection: false,
hideIcons: true,
options,
},
});

expect(wrapper.get(".material-symbols-outlined").text()).toBe(
"help_center",
);
});

it("should support single mode", async () => {
const wrapper = mount(BuilderSelect, {
props: {
modelValue: "a",
enableMultiSelection: false,
options,
},
global: {
stubs: {
WdsDropdownMenu: true,
},
},
});
await flushPromises();

await wrapper.get(".BuilderSelect__trigger").trigger("click");
await flushPromises();

const dropdownMenu = wrapper.getComponent(WdsDropdownMenu);
dropdownMenu.vm.$emit("select", "b");
await flushPromises();

expect(wrapper.emitted("update:modelValue").at(0)).toStrictEqual(["b"]);
});
});
68 changes: 54 additions & 14 deletions src/ui/src/builder/BuilderSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
role="button"
@click="isOpen = !isOpen"
>
<i v-if="!hideIcons" class="material-symbols-outlined">{{
currentIcon
}}</i>
<i
v-if="!hideIcons || hasUnknowOptionSelected"
class="material-symbols-outlined"
>{{ currentIcon }}</i
>
<div
class="BuilderSelect__trigger__label"
data-writer-tooltip-strategy="overflow"
Expand All @@ -19,10 +21,12 @@
<i class="material-symbols-outlined">{{ expandIcon }}</i>
</div>
</button>
<WdsMenu
<WdsDropdownMenu
v-if="isOpen"
ref="dropdown"
:enable-search="enableSearch"
:enable-multi-selection="enableMultiSelection"
:hide-icons="hideIcons"
:options="options"
:selected="currentValue"
:style="floatingStyles"
Expand All @@ -49,45 +53,75 @@ import { useFloating, autoPlacement } from "@floating-ui/vue";
import type { WdsDropdownMenuOption } from "@/wds/WdsDropdownMenu.vue";
import { useFocusWithin } from "@/composables/useFocusWithin";
const WdsMenu = defineAsyncComponent(() => import("@/wds/WdsDropdownMenu.vue"));
const WdsDropdownMenu = defineAsyncComponent(
() => import("@/wds/WdsDropdownMenu.vue"),
);
const props = defineProps({
options: {
type: Array as PropType<WdsDropdownMenuOption[]>,
type: Array as PropType<
WdsDropdownMenuOption[] | Readonly<WdsDropdownMenuOption[]>
>,
default: () => [],
},
defaultIcon: { type: String, required: false, default: undefined },
hideIcons: { type: Boolean, required: false },
enableSearch: { type: Boolean, required: false },
enableMultiSelection: { type: Boolean, required: false },
});
const currentValue = defineModel({ type: String, required: false });
const currentValue = defineModel({
type: [String, Array] as PropType<string | string[]>,
required: true,
default: undefined,
});
const isOpen = ref(false);
const trigger = ref<HTMLElement>();
const dropdown = ref<HTMLElement>();
const middleware = computed(() =>
// avoid placement on the top when search mode is enabled
props.enableSearch
? []
: [autoPlacement({ allowedPlacements: ["bottom", "top"] })],
);
const { floatingStyles, update: updateFloatingStyle } = useFloating(
trigger,
dropdown,
{
placement: "bottom",
middleware: [autoPlacement({ allowedPlacements: ["bottom", "top"] })],
middleware,
},
);
const expandIcon = computed(() =>
isOpen.value ? "keyboard_arrow_up" : "expand_more",
);
const selectedOption = computed(() =>
props.options.find((o) => o.value === currentValue.value),
const selectedOptions = computed(() =>
props.options.filter((o) => isSelected(o.value)),
);
const currentLabel = computed(() => selectedOption.value?.label ?? "");
const hasUnknowOptionSelected = computed(() => {
return currentValue.value && selectedOptions.value.length === 0;
});
const currentLabel = computed(() => {
if (hasUnknowOptionSelected.value) return String(currentValue.value);
return selectedOptions.value
.map((o) => o.label)
.sort()
.join(" / ");
});
const currentIcon = computed(() => {
if (hasUnknowOptionSelected.value) return "help_center";
if (props.hideIcons) return "";
return selectedOption.value?.icon ?? props.defaultIcon ?? "help_center";
return (
selectedOptions.value.at(0)?.icon ?? props.defaultIcon ?? "help_center"
);
});
// close the dropdown when clicking outside
Expand All @@ -103,10 +137,16 @@ watch(
{ immediate: true },
);
function onSelect(value: string) {
isOpen.value = false;
function onSelect(value: string | string[]) {
if (!props.enableMultiSelection) isOpen.value = false;
currentValue.value = value;
}
function isSelected(value: string) {
return Array.isArray(currentValue.value)
? currentValue.value.includes(value)
: currentValue.value === value;
}
</script>

<style scoped>
Expand Down
Loading

0 comments on commit 3fd3e59

Please sign in to comment.