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: add fuzzy matching notification #1060

Merged
merged 2 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions frontend/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ declare module 'vue' {
DateInputPart: typeof import('./src/components/forms/DateInputComponent/DateInputPart.vue')['default']
DropdownInputComponent: typeof import('./src/components/forms/DropdownInputComponent.vue')['default']
ErrorNotificationGroupingComponent: typeof import('./src/components/grouping/ErrorNotificationGroupingComponent.vue')['default']
FuzzyMatchNotificationGroupingComponent: typeof import('./src/components/grouping/FuzzyMatchNotificationGroupingComponent.vue')['default']
LoadingOverlayComponent: typeof import('./src/components/LoadingOverlayComponent.vue')['default']
MainHeaderComponent: typeof import('./src/components/MainHeaderComponent.vue')['default']
MultiselectInputComponent: typeof import('./src/components/forms/MultiselectInputComponent.vue')['default']
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/assets/styles/global.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1479,11 +1479,14 @@ Useful for scrolling to the *start* of an HTML element without having it covered
top: calc(-1 * var(--header-height));
}

.hide-when-less-than-two-children {
.hide-when-less-than-two-children:not(:has(:nth-child(2))) {
display: none;
}
.hide-when-less-than-two-children:has(:nth-child(2)) {
display: unset;

.errors-container {
display: flex;
flex-direction: column;
gap: 1rem;
}

.invisible {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
// Carbon
import "@carbon/web-components/es/components/notification/index";
// Composables
import { useEventBus } from "@vueuse/core";
// Types
import type { FuzzyMatchResult, FuzzyMatcherData, FuzzyMatcherEvent } from "@/dto/CommonTypesDto";
import { greenDomain } from "@/CoreConstants";
import { convertFieldNameToSentence } from "@/services/ForestClientService";

const props = defineProps<{
id: string;
error?: FuzzyMatcherData;
businessName: string;
}>();

const fuzzyBus = useEventBus<FuzzyMatcherEvent>("fuzzy-error-notification");

const fuzzyMatchedError = ref<FuzzyMatcherData>(
props.error ?? {
show: false,
fuzzy: false,
matches: [],
},
);

const handleFuzzyErrorMessage = (event: FuzzyMatcherEvent | undefined, _payload?: any) => {
if (event && event.matches.length > 0 && event.id === props.id) {
fuzzyMatchedError.value.show = true;
fuzzyMatchedError.value.fuzzy = true;
fuzzyMatchedError.value.matches = [];
for (const match of event.matches) {
if (!match.fuzzy) {
fuzzyMatchedError.value.fuzzy = false;
}
fuzzyMatchedError.value.matches.push(match);
}
} else {
fuzzyMatchedError.value.show = false;
fuzzyMatchedError.value.fuzzy = false;
fuzzyMatchedError.value.matches = [];
}
};

const getListItemContent = ref((match: FuzzyMatchResult) => {
return match && match.match ? renderListItem(match) : "";
});

const getLegacyUrl = (duplicatedClient, label) => {
const encodedClientNumber = encodeURIComponent(duplicatedClient.trim());
switch (label) {
case "contact":
return `https://${greenDomain}/int/client/client06ContactListAction.do?bean.clientNumber=${encodedClientNumber}`;
case "location":
return `https://${greenDomain}/int/client/client07LocationListAction.do?bean.clientNumber=${encodedClientNumber}`;
default:
return `https://${greenDomain}/int/client/client02MaintenanceAction.do?bean.clientNumber=${encodedClientNumber}`;
}
};

const renderListItem = (match: FuzzyMatchResult) => {
let finalLabel = "";
if (match.field === "contact" || match.field === "location") {
finalLabel = "Matching one or more " + match.field + "s";
} else {
finalLabel =
(match.fuzzy ? "Partial m" : "M") +
"atching on " +
convertFieldNameToSentence(match.field).toLowerCase();
}

finalLabel += " - Client number: ";

const clients = [...new Set<string>(match.match.split(","))];
finalLabel += clients
.map(
(clientNumber) =>
'<a target="_blank" href="' +
getLegacyUrl(clientNumber, match.field) +
'">' +
clientNumber +
"</a>",
)
.join(", ");

return finalLabel;
};

/**
* Gets unique client numbers across all the matches
* @param matches
*/
const getUniqueClientNumbers = (matches: FuzzyMatchResult[]) => {
const results: string[] = [];

matches.forEach((data) => {
results.push(...data.match.split(","));
});

return [...new Set(results)];
};

const uniqueClientNumbers = computed(() => getUniqueClientNumbers(fuzzyMatchedError.value.matches));

// watch for fuzzy match error messages
fuzzyBus.on(handleFuzzyErrorMessage);
</script>

<template>
<cds-actionable-notification
v-if="fuzzyMatchedError.show"
v-shadow="true"
low-contrast="true"
hide-close-button="true"
open="true"
:kind="fuzzyMatchedError.fuzzy ? 'warning' : 'error'"
:title="fuzzyMatchedError.fuzzy ? 'Possible matching records found' : 'Client already exists'"
>
<div>
<span class="body-compact-02">
<template v-if="fuzzyMatchedError.fuzzy">
{{ uniqueClientNumbers.length }} similar client
<span v-if="uniqueClientNumbers.length === 1">record was</span>
<span v-else>records were</span>
found. Review their information in the Client Management System to determine if you should
should create a new client:
</template>
<template v-else>
Looks like ”{{ businessName }}” has a client number. Review their information in the
Management System if necessary:
</template>
</span>
<ul>
<!-- eslint-disable-next-line vue/no-v-html -->
<li
v-for="match in fuzzyMatchedError.matches"
:key="match.field"
v-dompurify-html="getListItemContent(match)"
></li>
</ul>
<span v-if="!fuzzyMatchedError.fuzzy" class="body-compact-02">
You must inform the applicant of their number.
</span>
</div>
</cds-actionable-notification>
</template>

<style scoped>
cds-actionable-notification > div {
display: flex;
flex-direction: column;
gap: 1rem;
}
cds-actionable-notification > div > ul {
margin: 0;
padding: 0;
list-style-type: disc;
margin-left: 1.25rem;
}
</style>
17 changes: 17 additions & 0 deletions frontend/src/dto/CommonTypesDto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ export interface ValidationMessageType {
originalValue?: string;
}

export interface FuzzyMatchResult {
field: string;
match: string;
fuzzy: boolean;
}

export interface FuzzyMatcherData {
show: boolean;
fuzzy: boolean;
matches: FuzzyMatchResult[];
}

export interface FuzzyMatcherEvent {
id: string;
matches: FuzzyMatchResult[];
}

export const isEmpty = (receivedValue: any): boolean => {
const value = isRef(receivedValue) ? receivedValue.value : receivedValue;
return value === undefined || value === null || value === "";
Expand Down
73 changes: 62 additions & 11 deletions frontend/src/pages/FormStaffPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
LegalTypeEnum,
type CodeNameType,
type ValidationMessageType,
type FuzzyMatcherEvent,
type FuzzyMatchResult,
} from "@/dto/CommonTypesDto";
import {
locationName as defaultLocation,
Expand Down Expand Up @@ -70,6 +72,7 @@ const clientTypesList: CodeNameType[] = [
const notificationBus = useEventBus<ValidationMessageType | undefined>("error-notification");
const errorBus = useEventBus<ValidationMessageType[]>("submission-error-notification");
const overlayBus = useEventBus<boolean>('overlay-event');
const fuzzyBus = useEventBus<FuzzyMatcherEvent>("fuzzy-error-notification");

// Route related
const router = useRouter();
Expand Down Expand Up @@ -225,18 +228,63 @@ const onCancel = () => {
router.push("/");
};

const lookForMatches = (onEmpty: () => void) => {
overlayBus.emit({ isVisible: true, message: "", showLoading: true });
fuzzyBus.emit(undefined);
errorBus.emit([]);
notificationBus.emit(undefined);

const { response, error, handleErrorDefault } = usePost(
"/api/clients/matches",
toRef(formData).value,
{
skipDefaultErrorHandling: true,
headers: {
"X-STEP": `${currentTab.value + 1}`,
},
},
);

watch([response], () => {
if (response.value.status === 204) {
overlayBus.emit({ isVisible: false, message: "", showLoading: false });
onEmpty();
}
});

watch([error], () => {
// Disable the overlay
overlayBus.emit({ isVisible: false, message: "", showLoading: false });

if (error.value.response?.status === 409) {
fuzzyBus.emit({
id: "global",
matches: error.value.response.data as FuzzyMatchResult[],
});
} else {
handleErrorDefault();
}

setScrollPoint("top-notification");
});
};

const moveToNextStep = () => {
currentTab.value++;
progressData[currentTab.value - 1].kind = "complete";
progressData[currentTab.value].kind = "current";
setScrollPoint("step-title");
};

const onNext = () => {
//This fixes the index
formData.location.addresses.forEach((address: Address,index: number) => address.index = index);
formData.location.contacts.forEach((contact: Contact,index: number) => contact.index = index);
formData.location.addresses.forEach((address: Address, index: number) => (address.index = index));
formData.location.contacts.forEach((contact: Contact, index: number) => (contact.index = index));

notificationBus.emit(undefined);
if (currentTab.value + 1 < progressData.length) {
if (checkStepValidity(currentTab.value)) {
currentTab.value++;
progressData[currentTab.value - 1].kind = "complete";
progressData[currentTab.value].kind = "current";
setScrollPoint("step-title");
lookForMatches(moveToNextStep);
} else {
setScrollPoint("top-notification");
}
Expand Down Expand Up @@ -402,17 +450,20 @@ const submit = () => {
:aria-current="item.step === currentTab ? 'step' : 'false'"
/>
</cds-progress-indicator>
</div>

<div class="hide-when-less-than-two-children">
<div class="form-steps-staff" role="main">
<div class="errors-container hide-when-less-than-two-children">
<!--
The parent div is necessary to avoid the div.header-offset below from interfering in the flex flow.
-->
<div data-scroll="top-notification" class="header-offset"></div>
<error-notification-grouping-component :form-data="formData" />
<fuzzy-match-notification-grouping-component
id="global"
:business-name="formData.businessInformation.businessName"
/>
</div>
</div>

<div class="form-steps-staff" role="main">
<div v-if="currentTab == 0" class="form-steps-01">
<div class="form-steps-section">
<h2 data-focus="focus-0" tabindex="-1">
Expand Down
Loading
Loading