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