From 258ed2687a1184f2d814b750a79f082e578614c1 Mon Sep 17 00:00:00 2001 From: Fernando Terra <79578735+fterra-encora@users.noreply.github.com> Date: Thu, 10 Oct 2024 11:23:50 -0300 Subject: [PATCH] feat(fe:FSADT1-1521): create the predictive search (#1223) * feat: add search box with predictive search * fix: fix predictive-search stubs * feat: update style of search controls * docs: update interface name * fix: use placeholder instead of label * test: add search test file * feat: navigate to client details * test: implement tests * refactor: move functions to the GlobalValidators * feat: validate autocomplete while typing * feat: validate keywords * test: validate keywords * feat: prevent selection on AutoComplete * feat: open client details in a new tab * chore: update endpoint stub * test: update tests * feat(fe:FSADT1-1541): Display an error message when the BE is down (#1227) * fix: rename event from click to click:option --------- Co-authored-by: Maria Martinez <77364706+mamartinezmejia@users.noreply.github.com> --- frontend/cypress/e2e/pages/SearchPage.cy.ts | 139 +++++++++++++ frontend/cypress/support/commands.ts | 46 ++++- frontend/cypress/support/cypress.d.ts | 15 +- frontend/package.json | 6 +- frontend/src/assets/styles/global.scss | 34 ++-- .../forms/AutoCompleteInputComponent.vue | 35 +++- frontend/src/dto/CommonTypesDto.ts | 5 +- .../helpers/validators/GlobalValidators.ts | 9 + .../validators/StaffFormValidations.ts | 13 +- frontend/src/pages/SearchPage.vue | 109 +++++++--- .../mappings/client_predictive_search.json | 15 +- .../forms/AutoCompleteInputComponent.spec.ts | 189 ++++++++++++++++-- 12 files changed, 519 insertions(+), 96 deletions(-) create mode 100644 frontend/cypress/e2e/pages/SearchPage.cy.ts diff --git a/frontend/cypress/e2e/pages/SearchPage.cy.ts b/frontend/cypress/e2e/pages/SearchPage.cy.ts new file mode 100644 index 0000000000..2273f9f57e --- /dev/null +++ b/frontend/cypress/e2e/pages/SearchPage.cy.ts @@ -0,0 +1,139 @@ +import type { ClientSearchResult } from "@/dto/CommonTypesDto"; + +describe("Search Page", () => { + const predictiveSearchCounter = { + count: 0, + }; + + const checkDisplayedResults = (clientList: ClientSearchResult[]) => { + clientList.forEach((client) => { + cy.get("#search-box") + .find(`cds-combo-box-item[data-value^="${client.clientNumber}"]`) + .should("exist"); + }); + }; + beforeEach(() => { + // reset counter + predictiveSearchCounter.count = 0; + + cy.intercept("/api/clients/search?keyword=*", (req) => { + predictiveSearchCounter.count++; + req.continue(); + }).as("predictiveSearch"); + + cy.viewport(1920, 1080); + cy.visit("/"); + + cy.login("uattest@gov.bc.ca", "Uat Test", "idir", { + given_name: "James", + family_name: "Baxter", + "cognito:groups": ["CLIENT_VIEWER"], + }); + + // Check if the Client search button is visible + cy.get("#menu-list-search").should("be.visible").click(); + + cy.get("h1").should("be.visible").should("contain", "Client search"); + + cy.window().then((win) => { + cy.stub(win, "open").as("windowOpen"); + }); + }); + + describe("when user fills in the search box with a valid value", () => { + beforeEach(() => { + cy.fillFormEntry("#search-box", "car", { skipBlur: true }); + }); + + it("makes the API call with the entered keywords", () => { + cy.wait("@predictiveSearch").then((interception) => { + expect(interception.request.query.keyword).to.eq("car"); + }); + cy.wrap(predictiveSearchCounter).its("count").should("eq", 1); + }); + + it("displays autocomplete results", () => { + cy.get("#search-box") + .find("cds-combo-box-item") + .should("have.length", 3) + .should("be.visible"); + + cy.wait("@predictiveSearch").then((interception) => { + const data = interception.response.body; + + cy.wrap(data).should("be.an", "array").and("have.length", 3); + + cy.get("#search-box") + .find("cds-combo-box-item") + .should("have.length", data.length) + .should("be.visible"); + + checkDisplayedResults(data); + }); + }); + + describe("and types more characters", () => { + beforeEach(() => { + cy.wait("@predictiveSearch"); + cy.fillFormEntry("#search-box", "d", { skipBlur: true }); + }); + + it("makes another the API call with the updated keywords", () => { + cy.wait("@predictiveSearch").then((interception) => { + expect(interception.request.query.keyword).to.eq("card"); + }); + cy.wrap(predictiveSearchCounter).its("count").should("eq", 2); + }); + + it("updates the autocomplete results", () => { + cy.wait("@predictiveSearch").then((interception) => { + const data = interception.response.body; + + cy.wrap(data).should("be.an", "array").and("have.length.greaterThan", 3); + + cy.get("#search-box") + .find("cds-combo-box-item") + .should("have.length", data.length) + .should("be.visible"); + + checkDisplayedResults(data); + }); + }); + }); + + describe("and user clicks a result", () => { + const clientNumber = "00001297"; + beforeEach(() => { + cy.get("#search-box") + .find("cds-combo-box-item") + .should("have.length", 3) + .should("be.visible"); + + cy.get("#search-box").find(`cds-combo-box-item[data-value^="${clientNumber}"]`).click(); + }); + it("navigates to the client details", () => { + const greenDomain = "green-domain.com"; + cy.get("@windowOpen").should( + "be.calledWith", + `https://${greenDomain}/int/client/client02MaintenanceAction.do?bean.clientNumber=${clientNumber}`, + "_blank", + "noopener", + ); + }); + }); + }); + + describe("when user fills in the search box with an invalid value", () => { + beforeEach(() => { + cy.fillFormEntry("#search-box", "até", { skipBlur: true }); + }); + + it("shows an error message", () => { + cy.contains("The search terms can only contain: A-Z, a-z, 0-9, space or common symbols"); + }); + it("makes no API call", () => { + cy.wait(500); // This time has to be greater than the debouncing time + cy.wrap(predictiveSearchCounter).its("count").should("eq", 0); + }); + }); +}); diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 4a2a4ae9a5..286c1453eb 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -146,15 +146,45 @@ Cypress.Commands.add("getMany", (names: string[]): Cypress.Chainable => { return cy.wrap(values); }); -Cypress.Commands.add("fillFormEntry",(field: string, value: string, delayMS: number = 10, area: boolean = false) =>{ +interface FillFormEntryOptions { + delayMS?: number; + area?: boolean; + skipBlur?: boolean; +} + +interface FillFormEntry { + (field: string, value: string, delayMS?: number, area?: boolean): void; + (field: string, value: string, options?: FillFormEntryOptions): void; +} + +const fillFormEntry: FillFormEntry = ( + field: string, + value: string, + arg3: number | FillFormEntryOptions = 10, + arg4: boolean | never = false, +) => { + const options = + typeof arg3 === "object" + ? arg3 + : { + delayMS: arg3, + area: arg4, + }; + const { delayMS, area, skipBlur } = options; cy.get(field) - .should("exist") - .shadow() - .find(area ? "textarea" : "input") - .focus() - .type(value,{ delay: delayMS }) - .blur(); -}); + .should("exist") + .shadow() + .find(area ? "textarea" : "input") + .focus() + .type(value, { delay: delayMS }) + .then((subject) => { + if (!skipBlur) { + cy.wrap(subject).blur(); + } + }); +}; + +Cypress.Commands.add("fillFormEntry", fillFormEntry); Cypress.Commands.add("clearFormEntry",(field: string, area: boolean = false) =>{ cy.get(field) diff --git a/frontend/cypress/support/cypress.d.ts b/frontend/cypress/support/cypress.d.ts index f4fb0c0c1a..99d13bd775 100644 --- a/frontend/cypress/support/cypress.d.ts +++ b/frontend/cypress/support/cypress.d.ts @@ -9,12 +9,23 @@ declare namespace Cypress { login(email: string, name: string, provider: string, extras: any = "{}"): Chainable; logout(): Chainable; getMany(names: string[]): Chainable; - fillFormEntry(field: string, value: string, delayMS: number = 10, area: boolean = false): Chainable; + fillFormEntry( + field: string, + value: string, + delayMS: number = 10, + area: boolean = false, + ): Chainable; + fillFormEntry(field: string, value: string, options: FillFormEntryOptions): Chainable; clearFormEntry(field: string, area: boolean = false): Chainable; selectFormEntry(field: string, value: string, box: boolean): Chainable; markCheckbox(field: string): Chainable; unmarkCheckbox(field: string): Chainable; - selectAutocompleteEntry(field: string, value: string, dataid: string, delayTarget: string =''): Chainable; + selectAutocompleteEntry( + field: string, + value: string, + dataid: string, + delayTarget: string = "", + ): Chainable; checkInputErrorMessage(field: string, message: string): Chainable; checkAutoCompleteErrorMessage(field: string, message: string): Chainable; checkAccordionItemState(additionalSelector: string, open: boolean): Chainable; diff --git a/frontend/package.json b/frontend/package.json index 1e2c548321..9f9b5fb04d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,7 @@ "scripts": { "start": "vite --host --port 3000", "build": "vue-tsc --noEmit && vite build", - "preview": "cross-env VITE_NODE_ENV=test VITE_FEATURE_FLAGS={\\\"STAFF_SEARCH\\\":true} start-server-and-test stub http://127.0.0.1:8080 start", + "preview": "cross-env VITE_NODE_ENV=test VITE_GREEN_DOMAIN=green-domain.com VITE_FEATURE_FLAGS={\\\"STAFF_SEARCH\\\":true} start-server-and-test stub http://127.0.0.1:8080 start", "preview:app": "cross-env VITE_NODE_ENV=test vite --host --port 3000", "typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", "lint": "eslint . --fix --ignore-path .gitignore", @@ -36,12 +36,12 @@ "posttest:component": "mv reports/.nyc_report reports/component", "test:unit": "cross-env VITE_NODE_ENV=test NODE_ENV=test vitest run --coverage", "posttest:unit": "mv reports/.vite_report reports/unit", - "test:e2e": "cross-env VITE_NODE_ENV=test VITE_FEATURE_FLAGS={\\\"STAFF_SEARCH\\\":true} start-server-and-test preview http://127.0.0.1:3000 'cypress run --headless'", + "test:e2e": "cross-env VITE_NODE_ENV=test VITE_GREEN_DOMAIN=green-domain.com VITE_FEATURE_FLAGS={\\\"STAFF_SEARCH\\\":true} start-server-and-test preview http://127.0.0.1:3000 'cypress run --headless'", "posttest:e2e": "mv reports/.nyc_report reports/e2e", "pretest:report:merge": "rm -rf reports-merge && mkdir -p reports-merge && for name in component e2e unit; do cp reports/$name/coverage-final.json reports-merge/$name.json; done", "test:report:merge": "mkdir -p .nyc_output && rm -rf coverage && nyc --config nyc.config.json merge reports-merge && mv coverage.json .nyc_output/out.json && nyc --config nyc.config.json report --reporter lcov --reporter text-summary --report-dir coverage --temp-dir .nyc_output", "test:report:clean": "rm -rf reports && mkdir -p reports/.nyc_output/processinfo && mkdir -p coverage", - "test:build": "cross-env VITE_MODE=test VITE_NODE_ENV=test VITE_FEATURE_FLAGS={\\\"STAFF_SEARCH\\\":true} start-server-and-test preview http://127.0.0.1:3000 'cypress open'", + "test:build": "cross-env VITE_MODE=test VITE_NODE_ENV=test VITE_GREEN_DOMAIN=green-domain.com VITE_FEATURE_FLAGS={\\\"STAFF_SEARCH\\\":true} start-server-and-test preview http://127.0.0.1:3000 'cypress open'", "test:flush": "rm -rf reports && rm -rf .nyc_output && rm -rf coverage && rm -rf reports-merge", "posttest:flush": "npm run coverage", "test:unit:devtools": "cross-env VITE_NODE_ENV=test NODE_ENV=test vitest run --inspect-brk --pool threads --poolOptions.threads.singleThread" diff --git a/frontend/src/assets/styles/global.scss b/frontend/src/assets/styles/global.scss index a56a6467a2..e5a2e01cf0 100644 --- a/frontend/src/assets/styles/global.scss +++ b/frontend/src/assets/styles/global.scss @@ -1609,6 +1609,25 @@ div.internal-grouping-01:has(svg.warning) span.body-compact-01 { background-color: color-mix(in srgb, var(--cds-support-warning) 40%, white); } +#datatable { + position: relative; + right: 0; + overflow-x: scroll; +} + +#search-line { + display: flex; + align-items: flex-end; + + .grouping-02 { + flex-grow: 1; + } +} + +#search-button { + width: 7.875rem; +} + /* Small (up to 671px) */ @media screen and (max-width: 671px) { :root { @@ -1807,11 +1826,6 @@ div.internal-grouping-01:has(svg.warning) span.body-compact-01 { .submission-details--title svg { width: 5rem; } - #datatable { - position: relative; - right: 0; - overflow-x: scroll; - } cds-table { width: 82rem; } @@ -1920,11 +1934,6 @@ div.internal-grouping-01:has(svg.warning) span.body-compact-01 { .paginator { width: 100vw; } - #datatable { - position: relative; - right: 0; - overflow-x: scroll; - } cds-table { width: 82rem; } @@ -1999,11 +2008,6 @@ div.internal-grouping-01:has(svg.warning) span.body-compact-01 { ); } - #datatable { - position: relative; - right: 0; - overflow-x: scroll; - } cds-table { min-width: 66rem; } diff --git a/frontend/src/components/forms/AutoCompleteInputComponent.vue b/frontend/src/components/forms/AutoCompleteInputComponent.vue index 5cb94398c7..d621342871 100644 --- a/frontend/src/components/forms/AutoCompleteInputComponent.vue +++ b/frontend/src/components/forms/AutoCompleteInputComponent.vue @@ -15,6 +15,7 @@ const props = withDefaults( defineProps<{ id: string; label: string; + ariaLabel?: string; tip?: string; placeholder?: string; modelValue: string; @@ -26,6 +27,8 @@ const props = withDefaults( required?: boolean; requiredLabel?: boolean; autocomplete?: string; + validationsOnChange?: boolean | Array; + preventSelection?: boolean; }>(), { showLoadingAfterTime: 2000, @@ -38,6 +41,7 @@ const emit = defineEmits<{ (e: "empty", value: boolean): void; (e: "update:model-value", value: string): void; (e: "update:selected-value", value: BusinessSearchResult | undefined): void; + (e: "click:option", value: string): void; }>(); //We initialize the error message handling for validation @@ -90,6 +94,15 @@ const inputList = computed>(() => { return []; }); +const validationsOnChange = computed(() => { + if (props.validationsOnChange) { + return typeof props.validationsOnChange === "boolean" + ? props.validations + : props.validationsOnChange; + } + return false; +}); + //This function emits the events on update const emitValueChange = (newValue: string, isSelectEvent = false): void => { let selectedValue: BusinessSearchResult | undefined; @@ -127,6 +140,9 @@ watch( watch([inputValue], () => { if (isUserEvent.value) { emitValueChange(inputValue.value); + if (validationsOnChange.value) { + validateInput(inputValue.value, validationsOnChange.value); + } } }); @@ -155,10 +171,10 @@ const setError = (errorObject: string | ValidationMessageType | undefined) => { }; //We call all the validations -const validateInput = (newValue: string) => { - if (props.validations) { +const validateInput = (newValue: string, validations = props.validations) => { + if (validations) { setError( - props.validations + validations .map((validation) => validation(newValue)) .filter((errorMessage) => { if (errorMessage) return true; @@ -175,6 +191,14 @@ const selectAutocompleteItem = (event: any) => { validateInput(newValue); }; +const preSelectAutocompleteItem = (event: any) => { + const newValue = event?.detail?.item?.getAttribute("data-id"); + emit("click:option", newValue); + if (props.preventSelection) { + event?.preventDefault(); + } +}; + const onTyping = (event: any) => { isUserEvent.value = true; inputValue.value = event.srcElement._filterInputValue; @@ -242,7 +266,7 @@ watch( if (input) { // Propagate attributes to the input input.required = props.required; - input.ariaLabel = props.label; + input.ariaLabel = props.ariaLabel || props.label; input.ariaInvalid = ariaInvalidString.value; // Use the helper text as a field description @@ -265,7 +289,7 @@ const safeHelperText = computed(() => props.tip || " "); :class="warning ? 'warning' : ''" :autocomplete="autocomplete" :title-text="label" - :aria-label="label" + :aria-label="ariaLabel || label" :clear-selection-label="`Clear ${label}`" :required="required" :data-required-label="requiredLabel" @@ -279,6 +303,7 @@ const safeHelperText = computed(() => props.tip || " "); :warn="warning" :warn-text="warning && error" @cds-combo-box-selected="selectAutocompleteItem" + @cds-combo-box-beingselected="preSelectAutocompleteItem" v-on:input="onTyping" @focus="isFocused = true" @blur=" diff --git a/frontend/src/dto/CommonTypesDto.ts b/frontend/src/dto/CommonTypesDto.ts index 5c02924080..427c12b933 100644 --- a/frontend/src/dto/CommonTypesDto.ts +++ b/frontend/src/dto/CommonTypesDto.ts @@ -176,7 +176,10 @@ export interface SubmissionList { district: string } -export interface ClientList { +/** + * Each item resulting from the client search. + */ +export interface ClientSearchResult { clientNumber: string clientAcronym: string clientName: string diff --git a/frontend/src/helpers/validators/GlobalValidators.ts b/frontend/src/helpers/validators/GlobalValidators.ts index b5b697bda6..723dce5326 100644 --- a/frontend/src/helpers/validators/GlobalValidators.ts +++ b/frontend/src/helpers/validators/GlobalValidators.ts @@ -470,6 +470,15 @@ export const optional = return value === "" || value === null || value === undefined ? "" : validator(value); }; +export const isMinSizeMsg = (fieldName: string, minSize: number): ((value: string) => string) => + isMinSize(`The ${fieldName} must contain at least ${minSize} characters`)(minSize); + +export const isMaxSizeMsg = (fieldName: string, maxSize: number): ((value: string) => string) => + isMaxSize(`The ${fieldName} has a ${maxSize} character limit`)(maxSize); + +export const isExactSizMsg = (fieldName: string, size: number): ((value: string) => string) => + isExactSize(`The ${fieldName} must contain ${size} characters`)(size); + /** * Retrieves the value of a field in an object or array based on a given path. * If the field is an array, it returns an array of values. diff --git a/frontend/src/helpers/validators/StaffFormValidations.ts b/frontend/src/helpers/validators/StaffFormValidations.ts index 14147cc17b..567cd75953 100644 --- a/frontend/src/helpers/validators/StaffFormValidations.ts +++ b/frontend/src/helpers/validators/StaffFormValidations.ts @@ -10,7 +10,6 @@ import { hasOnlyNamingCharacters, isNoSpecialCharacters, isIdCharacters, - isExactSize, formFieldValidations as externalFormFieldValidations, isAscii, isEmail, @@ -20,6 +19,9 @@ import { isNotEmptyArray, validate as globalValidate, runValidation as globalRunValidation, + isMaxSizeMsg, + isMinSizeMsg, + isExactSizMsg, } from "@/helpers/validators/GlobalValidators"; // Allow externalFormFieldValidations to get populated @@ -37,15 +39,6 @@ const fieldValidations: Record string)[]> = { export const getValidations = (key: string): ((value: any) => string)[] => key ? fieldValidations[key] || [] : []; -const isMinSizeMsg = (fieldName: string, minSize: number) => - isMinSize(`The ${fieldName} must contain at least ${minSize} characters`)(minSize); - -const isMaxSizeMsg = (fieldName: string, maxSize: number) => - isMaxSize(`The ${fieldName} has a ${maxSize} character limit`)(maxSize); - -const isExactSizMsg = (fieldName: string, size: number) => - isExactSize(`The ${fieldName} must contain ${size} characters`)(size); - // Step 1: Business Information fieldValidations["businessInformation.clientType"] = [ isNotEmpty("You must select a client type."), diff --git a/frontend/src/pages/SearchPage.vue b/frontend/src/pages/SearchPage.vue index 6f4cb6d4ef..0a779d4846 100644 --- a/frontend/src/pages/SearchPage.vue +++ b/frontend/src/pages/SearchPage.vue @@ -2,51 +2,52 @@ import ForestClientUserSession from "@/helpers/ForestClientUserSession"; import { ref, computed, watch } from "vue"; import { useFetchTo } from "@/composables/useFetch"; -import type { ClientList } from "@/dto/CommonTypesDto"; +import type { ClientSearchResult, CodeNameType } from "@/dto/CommonTypesDto"; import { adminEmail, getObfuscatedEmailLink } from "@/services/ForestClientService"; import summit from "@carbon/pictograms/es/summit"; import useSvg from "@/composables/useSvg"; // @ts-ignore import Search16 from "@carbon/icons-vue/es/search/16"; +import { greenDomain } from "@/CoreConstants"; +import { + isAscii, + isMaxSizeMsg, + isMinSizeMsg, + optional, +} from "@/helpers/validators/GlobalValidators"; const summitSvg = useSvg(summit); const userhasAuthority = ["CLIENT_VIEWER", "CLIENT_EDITOR", "CLIENT_ADMIN"].some(authority => ForestClientUserSession.authorities.includes(authority)); -const tableReference = ref(""); let networkErrorMsg = ref(""); // Table data -const tableData = ref([]); +const tableData = ref([]); const pageNumber = ref(1); const totalItems = ref(0); const pageSize = ref(10); -const predictiveSearchKeyword = ref(""); -const fullSearchKeyword = ref(""); +const searchKeyword = ref(""); const predictiveSearchUri = computed( - () => - `/api/clients/search?keyword=${encodeURIComponent(predictiveSearchKeyword.value)}${tableReference.value || ''}` + () => `/api/clients/search?keyword=${encodeURIComponent(searchKeyword.value)}`, ); const fullSearchUri = computed( () => - `/api/clients/search?page=${pageNumber.value - 1}&size=${pageSize.value}&keyword=${encodeURIComponent(fullSearchKeyword.value)}${tableReference.value || ''}` + `/api/clients/search?page=${pageNumber.value - 1}&size=${pageSize.value}&keyword=${encodeURIComponent(searchKeyword.value)}`, ); const search = () => { const { response, fetch, loading, error: fetchError } = useFetchTo(fullSearchUri, tableData); if (!loading.value) fetch(); - watch( - response, - () => { - const totalCount = parseInt(response.value.headers["x-total-count"] || "0"); - totalItems.value = totalCount; - } - ); + watch(response, () => { + const totalCount = parseInt(response.value.headers["x-total-count"] || "0"); + totalItems.value = totalCount; + }); watch([fetchError], () => { if (fetchError.value.message) { @@ -55,10 +56,6 @@ const search = () => { }); }; -const selectEntry = (entry: ClientList) => { - //TODO -}; - const tagColor = (status: string) => { switch (status) { case "Active": @@ -74,6 +71,36 @@ const paginate = (event: any) => { pageNumber.value = event.detail.page; pageSize.value = event.detail.pageSize; }; + +/** + * Converts a client search result to a code/name representation. + * @param searchResult The client search result + */ +const searchResultToCodeName = (searchResult: ClientSearchResult): CodeNameType => { + const { clientNumber, clientName, clientType, city, clientStatus } = searchResult; + const result = { + code: clientNumber, + name: `${clientNumber}, ${clientName}, ${clientType}, ${city} (${clientStatus})`, + }; + return result; +}; + +const openClientDetails = (clientCode: string) => { + if (clientCode) { + const url = `https://${greenDomain}/int/client/client02MaintenanceAction.do?bean.clientNumber=${clientCode}`; + window.open(url, "_blank", "noopener"); + } +}; + +const ariaLabel = "Search terms"; + +const lowerCaseLabel = ariaLabel.toLowerCase(); + +const validationsOnChange = [isAscii(lowerCaseLabel), isMaxSizeMsg(lowerCaseLabel, 50)]; + +const validations = [optional(isMinSizeMsg(lowerCaseLabel, 3)), ...validationsOnChange]; + +const valid = ref(!!searchKeyword.value);