Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(fe:FSADT1-1566): Clear predictive search results whenever input changes #1284

Merged
merged 2 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 8 additions & 13 deletions frontend/src/components/DataFetcher.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ const response = ref<any>();
const loading = ref<boolean>();

const lastUpdateRequestTime = ref<number>(0);
let debounceTimer: number | null = null;
let debounceTimer: NodeJS.Timeout | null = null;

const initialUrlValue = props.url;
const searchURL = computed(() => props.url);

const { loading: fetchLoading, error, fetch } = useFetchTo(searchURL, response, {
const { error, fetch } = useFetchTo(searchURL, response, {
skip: true,
...props.params,
});
Expand All @@ -53,30 +53,25 @@ if (!props.disabled && props.initFetch) {
});
}


// Watch for changes in the fetch loading state
// Doing like this now due to the debounce
watch(() => fetchLoading.value, (newVal) => {
loading.value = newVal;
});

// Watch for changes in the url, and if the difference is greater than the min length, fetch
watch([() => props.url, () => props.disabled], () => {
if (!props.disabled && calculateStringDifference(initialUrlValue, props.url) >= props.minLength) {

// added a manual loading state to set the loading state when the user types
loading.value = true;
const curRequestTime = Date.now();
lastUpdateRequestTime.value = curRequestTime;

if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
content.value = [];
fetch().then(() => {
// Discard the response from old request when a newer one was already responded.
if (curRequestTime >= lastUpdateRequestTime.value) {
// Discard the response from old request when a newer request has been made.
if (curRequestTime === lastUpdateRequestTime.value) {
loading.value = false;
content.value = response.value;
lastUpdateRequestTime.value = curRequestTime;
}
});
}, props.debounce); // Debounce time
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/SearchPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ const searchResultToCodeNameValue = (
return result;
};

const searchResultToCodeNameValueList = (list: ClientSearchResult[]) => list.map(searchResultToCodeNameValue);
const searchResultToCodeNameValueList = (list: ClientSearchResult[]) =>
list?.map(searchResultToCodeNameValue);

const searchResultToText = (searchResult: ClientSearchResult): string => {
const { clientNumber, clientFullName, clientType, city } = searchResult;
Expand Down
184 changes: 180 additions & 4 deletions frontend/tests/unittests/components/DataFetcher.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { describe, it, expect, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { nextTick, ref } from "vue";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mount, type VueWrapper } from "@vue/test-utils";
import { nextTick, ref, type Ref } from "vue";
import * as fetcher from "@/composables/useFetch";

import DataFetcher from "@/components/DataFetcher.vue";

vi.useFakeTimers();



describe("DataFetcher", () => {
const mockedFetchTo = (url: string, received: any, config: any = {}) => ({
const mockedFetchTo = (url: Ref<string>, received: Ref<any>, config: any = {}) => ({
response: ref({}),
error: ref({}),
data: received,
Expand All @@ -18,6 +20,54 @@ describe("DataFetcher", () => {
},
});

const mockedFetchToFunction =
(
fetchData: (url: string) => Promise<any> = async () => ({
name: "Loaded",
}),
) =>
(url: Ref<string>, received: Ref<any>, config: any = {}) => ({
response: ref({}),
error: ref({}),
data: received,
loading: ref(false),
fetch: async () => {
received.value = await fetchData(url.value);
console.log(received.value);
},
});

const simpleFetchData = async (url: string) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ name: url });
}, 1000);
});
};

const mockFetchSimple = mockedFetchToFunction(simpleFetchData);

let lastResponse: any;

const fetchDataSleepByParam = async (url: string) => {
const regex = /.*\/(.+)/;
const regexResult = regex.exec(url);
const lastParam = regexResult[1];
const time = parseInt(lastParam);

return new Promise((resolve) => {
setTimeout(() => {
const response = { name: url };
resolve(response);

// Just for checking on the tests.
lastResponse = response;
}, time);
});
};

const mockFetchSleepByParam = mockedFetchToFunction(fetchDataSleepByParam);

it("should render", () => {
const wrapper = mount(DataFetcher, {
props: {
Expand Down Expand Up @@ -178,4 +228,130 @@ describe("DataFetcher", () => {
expect(wrapper.html()).toBe("<div>slot content is Loaded</div>");
expect(wrapper.find("div").text()).toBe("slot content is Loaded");
});

it("should clear the previous content once a new fetch starts", async () => {
vi.spyOn(fetcher, "useFetchTo").mockImplementation(mockFetchSimple);

const wrapper = mount(DataFetcher, {
props: {
url: "/api/",
minLength: 1,
initValue: { name: "test" },
debounce: 1,
},
slots: {
default: "<div>slot content is {{ content.name }}</div>",
},
});

expect(wrapper.find("div").text()).toBe("slot content is test");

await wrapper.setProps({
url: "/api/one",
});

await vi.advanceTimersByTimeAsync(1001);

expect(wrapper.find("div").text()).toBe("slot content is /api/one");

await wrapper.setProps({
url: "/api/two",
});

// Not enough time to get the response
await vi.advanceTimersByTimeAsync(10);

// It becomes empty
expect(wrapper.find("div").text()).toBe("slot content is");

// More time but still not enough time to get the response
await vi.advanceTimersByTimeAsync(900);

// It's still empty
expect(wrapper.find("div").text()).toBe("slot content is");

// Remaining time
await vi.advanceTimersByTimeAsync(91);

expect(wrapper.find("div").text()).toBe("slot content is /api/two");
});

describe("when there is a request in progress", () => {
let wrapper: VueWrapper;
beforeEach(async () => {
vi.spyOn(fetcher, "useFetchTo").mockImplementation(mockFetchSleepByParam);

wrapper = mount(DataFetcher, {
props: {
url: "/api/",
minLength: 1,
initValue: { name: "test" },
debounce: 1,
},
slots: {
default: "<div>slot content is {{ content.name }}</div>",
},
});

expect(wrapper.find("div").text()).toBe("slot content is test");

await wrapper.setProps({
url: "/api/one/1000",
});
});

describe("and a new request gets started before the first one gets responded", () => {
beforeEach(async () => {
// Not enough time to get the response from api/one/1000
await vi.advanceTimersByTimeAsync(500);

await wrapper.setProps({
url: "/api/two/1000",
});
});

it("should discard the response from the first request", async () => {
// More time, enough to get the response from /api/one/1000
await vi.advanceTimersByTimeAsync(600);

// It was effectively responded
expect(lastResponse?.name).toEqual("/api/one/1000");

// It should remain empty regardless
expect(wrapper.find("div").text()).toBe("slot content is");

// More time, enough to get the response from /api/two/1000
await vi.advanceTimersByTimeAsync(410);

expect(wrapper.find("div").text()).toBe("slot content is /api/two/1000");
});
});

describe("and a new request gets started and responded before the first one gets responded", () => {
beforeEach(async () => {
// Not enough time to get the response from api/one/1000
await vi.advanceTimersByTimeAsync(100);

await wrapper.setProps({
url: "/api/two/500",
});
});

it("should discard the response from the first request", async () => {
// Enough to get only the response from /api/two/500
await vi.advanceTimersByTimeAsync(600);

expect(wrapper.find("div").text()).toBe("slot content is /api/two/500");

// More time, enough to get the response from /api/one/1000
await vi.advanceTimersByTimeAsync(310);

// It was effectively responded
expect(lastResponse?.name).toEqual("/api/one/1000");

// But it should keep the current value
expect(wrapper.find("div").text()).toBe("slot content is /api/two/500");
});
});
});
});
Loading