From 4fffed24d3a05c001ec1a0654502fad8e2a01099 Mon Sep 17 00:00:00 2001 From: "graham.smith" <graham.smith@bluechiptechnologies.net> Date: Wed, 7 Sep 2022 15:43:02 +0000 Subject: [PATCH] Bull-1617 fuzzy search on 404 page --- src/views/Err.vue | 101 ++++++++++++++++++++++++++++------- tests/unit/views/Err.spec.js | 62 +++++++++++++++++++++ 2 files changed, 145 insertions(+), 18 deletions(-) create mode 100644 tests/unit/views/Err.spec.js diff --git a/src/views/Err.vue b/src/views/Err.vue index 0bfb16a3..78c004d5 100644 --- a/src/views/Err.vue +++ b/src/views/Err.vue @@ -1,6 +1,5 @@ <template> <div class="error-page"> - <div class="error-page-container"></div> <div class="error-page-content"> <div class="row justify-content-center pt-16"> <v-img :src="YodaFireLogo" max-width="200"></v-img> @@ -14,41 +13,107 @@ <router-link to="/contact-us">contact us</router-link> for help. </p> </div> + + <div class="suggestions-container" v-if="pageSuggestions.length > 0"> + <h3>Some of these related links might get you back on track:</h3> + <div class="d-flex flex-column pa-4"> + <a + v-for="(result, idx) in pageSuggestions.slice(0, 5)" + :key="idx" + class="py-2" + :href="result.ref" + > + {{ result.title }} + </a> + </div> + </div> </div> </div> </template> <script> import YodaFireLogo from "@/assets/images/Yoda_Fire.webp"; +import Search from "@/api/search.js"; +import { routesByPath } from "@/router/routes.js"; +const MAX_SUGGESTIONS = 3; + export default { name: "ErrorComponent", components: {}, data() { return { YodaFireLogo, + pageSuggestions: [], }; }, + mounted() { + const searchApi = new Search(); + // parse out the address bar path into tokens + const pathSearchTokens = decodeURI(this.$route.fullPath) + .replaceAll("/", " ") + .trim() + .split(" "); + + // try increasing edit distances to find potential matches + for (let editDistance = 0; editDistance < 4; editDistance++) { + const tokens = pathSearchTokens.map((t) => `${t}~${editDistance}`); + // run search + const results = searchApi.search(tokens.join(" ")); + // assemble suggestions + this.addSuggestions(results); + if (this.pageSuggestions.length >= MAX_SUGGESTIONS) { + break; + } + } + }, + methods: { + /** + * Adds the search results from `searchResults` to pageSuggestions, + * without adding duplicates + */ + addSuggestions(searchResults) { + // early exit + if (searchResults.length === 0) { + return; + } + // get current keys (ref) + const refs = this.pageSuggestions.map((suggestion) => suggestion.ref); + // add each non-duplicate ref + for (const searchResult of searchResults) { + if (!refs[searchResult.ref]) { + const details = routesByPath[searchResult.ref]; + + this.pageSuggestions.push( + Object.assign(searchResult, { + title: details.meta?.title, + }) + ); + } + } + }, + }, }; </script> -<style lang="scss"> +<style lang="scss" scoped> .error-page { - .error-page-container { - background-image: url(@/assets/images/tech-bg.webp); - position: absolute; - background-attachment: fixed; - background-size: cover; - background-repeat: no-repeat; - background-color: $bottom-bg; - width: 100%; - height: 100%; - bottom: 0; - z-index: 0; + height: 100%; + padding-top: 16px; + padding-bottom: 16px; + + background-image: url(@/assets/images/tech-bg.webp); + background-attachment: fixed; + background-size: cover; + background-repeat: no-repeat; + background-color: $bottom-bg; + .error-title { + font-size: 48px; + line-height: 48px; + text-transform: none; } - .error-page-content { - position: relative; - .error-title { - font-size: 48px; - line-height: 48px; + + .suggestions-container { + h3 { + color: white; text-transform: none; } } diff --git a/tests/unit/views/Err.spec.js b/tests/unit/views/Err.spec.js new file mode 100644 index 00000000..7a40a20a --- /dev/null +++ b/tests/unit/views/Err.spec.js @@ -0,0 +1,62 @@ +import { mount, RouterLinkStub } from "@vue/test-utils"; +import Err from "@/views/Err.vue"; +import Search from "@/api/search.js"; +import { expect } from "../../../node_modules/vitest/dist/index"; + +vi.mock("../../../src/api/search.js"); + +describe("Err.vue", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should attempt multiple searches with increasing fuzzyness", async () => { + const mockSearch = vi.fn().mockReturnValue([]); + Search.mockImplementation(() => ({ + search: mockSearch, + })); + + const wrapper = mount(Err, { + stubs: { + RouterLink: RouterLinkStub, + }, + mocks: { + $route: { + fullPath: "/mock/path", + }, + }, + }); + + expect(mockSearch).toHaveBeenCalledTimes(4); + expect(mockSearch.mock.calls[0][0]).toEqual("mock~0 path~0"); + expect(mockSearch.mock.calls[1][0]).toEqual("mock~1 path~1"); + expect(mockSearch.mock.calls[2][0]).toEqual("mock~2 path~2"); + expect(mockSearch.mock.calls[3][0]).toEqual("mock~3 path~3"); + }); + + it("should add suggestions and exit early", async () => { + const mockSearch = vi + .fn() + .mockReturnValueOnce([{ ref: "/" }, { ref: "/services" }]) + .mockReturnValueOnce([{ ref: "/products/cnap" }]); + Search.mockImplementation(() => ({ + search: mockSearch, + })); + + const wrapper = mount(Err, { + stubs: { + RouterLink: RouterLinkStub, + }, + mocks: { + $route: { + fullPath: "/mock/path", + }, + }, + }); + + expect(mockSearch).toHaveBeenCalledTimes(2); + expect(mockSearch.mock.calls[0][0]).toEqual("mock~0 path~0"); + expect(mockSearch.mock.calls[1][0]).toEqual("mock~1 path~1"); + expect(wrapper.vm.pageSuggestions.length).toEqual(3); + }); +}); -- GitLab