Skip to content

Commit

Permalink
Merge branch 'credential-engine' into scott/training-page-skeleton
Browse files Browse the repository at this point in the history
  • Loading branch information
scwambach authored Jan 21, 2025
2 parents c52a89f + 9dfffc3 commit dab8d25
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 75 deletions.
107 changes: 54 additions & 53 deletions backend/src/domain/search/searchTrainings.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
import NodeCache from "node-cache";
// import * as Sentry from "@sentry/node";
import {SearchTrainings} from "../types";
import {credentialEngineAPI} from "../../credentialengine/CredentialEngineAPI";
import {credentialEngineUtils} from "../../credentialengine/CredentialEngineUtils";
import {CTDLResource} from "../credentialengine/CredentialEngine";
import {getLocalExceptionCounties} from "../utils/getLocalExceptionCounties";
import {DataClient} from "../DataClient";
import {getHighlight} from "../utils/getHighlight";
import {TrainingData, TrainingResult} from "../training/TrainingResult";
import { SearchTrainings } from "../types";
import { credentialEngineAPI } from "../../credentialengine/CredentialEngineAPI";
import { credentialEngineUtils } from "../../credentialengine/CredentialEngineUtils";
import { CTDLResource } from "../credentialengine/CredentialEngine";
import { getLocalExceptionCounties } from "../utils/getLocalExceptionCounties";
import { DataClient } from "../DataClient";
import { getHighlight } from "../utils/getHighlight";
import { TrainingData, TrainingResult } from "../training/TrainingResult";
import zipcodeJson from "../utils/zip-county.json";
import zipcodes, {ZipCode} from "zipcodes";
import { convertZipCodeToCounty } from "../utils/convertZipCodeToCounty";
import {DeliveryType} from "../DeliveryType";
import {normalizeCipCode} from "../utils/normalizeCipCode";
import {normalizeSocCode} from "../utils/normalizeSocCode";
import { DeliveryType } from "../DeliveryType";
import { normalizeCipCode } from "../utils/normalizeCipCode";
import { normalizeSocCode } from "../utils/normalizeSocCode";

// Initializing a simple in-memory cache
const cache = new NodeCache({ stdTTL: 300, checkperiod: 120 });

const fetchAllCerts = async (query: object, offset = 0, limit = 10): Promise<{ allCerts: CTDLResource[]; totalResults: number }> => {
const searchTrainingPrograms = async (query: object, offset = 0, limit = 10): Promise<{ allCerts: CTDLResource[]; totalResults: number }> => {
try {
console.log(`FETCHING RECORD with offset ${offset} and limit ${limit}`);
// console.log(`FETCHING RECORD with offset ${offset} and limit ${limit}`);
const response = await credentialEngineAPI.getResults(query, offset, limit);
return {
allCerts: response.data.data || [],
Expand All @@ -34,19 +34,19 @@ const fetchAllCerts = async (query: object, offset = 0, limit = 10): Promise<{ a
};


const fetchAllCertsInBatches = async (query: object, batchSize = 100) => {
const searchTrainingProgramsInBatches = async (query: object, batchSize = 100) => {
const allCerts: CTDLResource[] = [];
const initialResponse = await fetchAllCerts(query, 0, batchSize);
const initialResponse = await searchTrainingPrograms(query, 0, batchSize);
const totalResults = initialResponse.totalResults;
allCerts.push(...initialResponse.allCerts);

const fetchBatch = async (offset: number) => {
try {
const response = await fetchAllCerts(query, offset, batchSize);
const response = await searchTrainingPrograms(query, offset, batchSize);
return response.allCerts;
} catch (error) {
console.error(`Error fetching batch at offset ${offset}:`, error);
return []; // Skip this batch
return [];
}
};

Expand Down Expand Up @@ -81,6 +81,7 @@ const filterCerts = async (
services?: string[]
) => {
let filteredResults = results;

if (cip_code) {
const normalizedCip = normalizeCipCode(cip_code);
filteredResults = filteredResults.filter(
Expand Down Expand Up @@ -110,27 +111,24 @@ const filterCerts = async (
}

if (format && format.length > 0) {
// Define a mapping from `format` to `DeliveryType` terms
const deliveryTypeMapping: Record<string, DeliveryType> = {
"in-person": DeliveryType.InPerson,
"inperson": DeliveryType.InPerson,
"online": DeliveryType.OnlineOnly,
"blended": DeliveryType.BlendedDelivery,
};

// Convert format to the corresponding DeliveryType terms
const mappedClassFormats = format
.map(f => deliveryTypeMapping[f.toLowerCase()])
.map((f) => deliveryTypeMapping[f.toLowerCase() as keyof typeof deliveryTypeMapping])
.filter(Boolean);


// Filter results based on the mapped delivery types
filteredResults = filteredResults.filter(result => {
const deliveryTypes = result.deliveryTypes || [];
return mappedClassFormats.some(mappedFormat => deliveryTypes.includes(mappedFormat));
});
}


if (county) {
filteredResults = filteredResults.filter(result => {
const zipCodes = result.availableAt?.map(address => address.zipCode).filter(Boolean) || [];
Expand Down Expand Up @@ -263,7 +261,7 @@ export const searchTrainingsFactory = (dataClient: DataClient): SearchTrainings
// If unfiltered results are not in cache, fetch the results
if (!unFilteredResults) {
console.log(`Fetching results for query: ${params.searchQuery}`);
const { allCerts } = await fetchAllCertsInBatches(query);
const { allCerts } = await searchTrainingProgramsInBatches(query);
unFilteredResults = await Promise.all(
allCerts.map((certificate) =>
transformCertificateToTraining(dataClient, certificate, params.searchQuery)
Expand Down Expand Up @@ -327,50 +325,55 @@ function buildQuery(params: {
const isZipCode = zipcodes.lookup(params.searchQuery);
const isCounty = Object.keys(zipcodeJson.byCounty).includes(params.searchQuery);

/* const miles = params.miles;
const zipcode = params.zipcode;*/
/*
let zipcodesList: string[] | zipcodes.ZipCode[] = []
if (isZipCode) {
zipcodesList = [params.searchQuery]
} else if (isCounty) {
zipcodesList = zipcodeJson.byCounty[params.searchQuery as keyof typeof zipcodeJson.byCounty]
}
if (params.county) {
zipcodesList = zipcodeJson.byCounty[params.county as keyof typeof zipcodeJson.byCounty]
}
if (miles && miles > 0 && zipcode) {
const zipcodesInRadius = zipcodes.radius(zipcode, miles);
zipcodesList = zipcodesInRadius;
}*/

const queryParts = params.searchQuery.split('+').map(part => part.trim());
const hasMultipleParts = queryParts.length > 1;
const [ownedByPart, trainingPart] = queryParts;

let termGroup: TermGroup = {
"search:operator": "search:orTerms",
...(isSOC || isCIP || !!isZipCode || isCounty ? undefined : {
"ceterms:name": { "search:value": params.searchQuery, "search:matchType": "search:contains" },
"ceterms:description": { "search:value": params.searchQuery, "search:matchType": "search:contains" },
"ceterms:ownedBy": { "ceterms:name": { "search:value": params.searchQuery, "search:matchType": "search:contains" } }
"ceterms:name": [
{ "search:value": params.searchQuery, "search:matchType": "search:exact" },
{ "search:value": params.searchQuery, "search:matchType": "search:contains" },
],
"ceterms:description": [
{ "search:value": params.searchQuery, "search:matchType": "search:exact" },
{ "search:value": params.searchQuery, "search:matchType": "search:contains" },
],
"ceterms:ownedBy": {
"ceterms:name": {
"search:value": params.searchQuery,
"search:matchType": "search:contains"
}
}
}),
"ceterms:occupationType": isSOC ? {
"ceterms:codedNotation": { "search:value": params.searchQuery, "search:matchType": "search:startsWith" }
"ceterms:codedNotation": {
"search:value": params.searchQuery,
"search:matchType": "search:startsWith"
}
} : undefined,
"ceterms:instructionalProgramType": isCIP ? {
"ceterms:codedNotation": { "search:value": params.searchQuery, "search:matchType": "search:startsWith" }
"ceterms:instructionalProgramType": isCIP ?
{"ceterms:codedNotation": {
"search:value": params.searchQuery,
"search:matchType": "search:startsWith"
}
} : undefined
};

if (hasMultipleParts) {
termGroup = {
"search:operator": "search:andTerms",
"ceterms:ownedBy": { "ceterms:name": { "search:value": ownedByPart, "search:matchType": "search:contains" } },
"ceterms:name": { "search:value": trainingPart, "search:matchType": "search:contains" }
"ceterms:ownedBy": {
"ceterms:name": {
"search:value": ownedByPart,
"search:matchType": "search:contains"
}
},
"ceterms:name": {
"search:value": trainingPart,
"search:matchType": "search:contains"
}
};
}

Expand Down Expand Up @@ -452,8 +455,6 @@ async function transformCertificateToTraining(
}
}



function packageResults(page: number, limit: number, results: TrainingResult[], totalResults: number): TrainingData {
const totalPages = Math.ceil(totalResults / limit);
const hasPreviousPage = page > 1;
Expand Down
63 changes: 48 additions & 15 deletions frontend/src/components/SearchBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import { InlineIcon } from "./InlineIcon";
import { ContentfulRichText } from "../types/contentful";
import { ContentfulRichText as RichText } from "./ContentfulRichText";
import { CipDrawerContent } from "./CipDrawerContent";
import {useTranslation} from "react-i18next";
import {DeliveryType} from "../domain/Training";

export const SearchBlock = ({ drawerContent }: { drawerContent?: ContentfulRichText }) => {
const [inPerson, setInPerson] = useState<boolean>(false);
const { t } = useTranslation();

const [maxCost, setMaxCost] = useState<string>("");
const [miles, setMiles] = useState<string>("");
const [online, setOnline] = useState<boolean>(false);
const [deliveryTypes, setDeliveryTypes] = useState<Set<DeliveryType>>(new Set());
const [zipCode, setZipCode] = useState<string>("");
const [searchTerm, setSearchTerm] = useState<string>("");
const [searchUrl, setSearchUrl] = useState<string>("");
Expand All @@ -20,6 +23,18 @@ export const SearchBlock = ({ drawerContent }: { drawerContent?: ContentfulRichT
const [socDrawerOpen, setSocDrawerOpen] = useState<boolean>(false);
const [cipDrawerOpen, setCipDrawerOpen] = useState<boolean>(false);

const toggleDeliveryType = (deliveryType: DeliveryType) => {
setDeliveryTypes((prevTypes) => {
const updatedTypes = new Set(prevTypes);
if (updatedTypes.has(deliveryType)) {
updatedTypes.delete(deliveryType);
} else {
updatedTypes.add(deliveryType);
}
return updatedTypes;
});
};

const sanitizedValue = (value: string) => DOMPurify.sanitize(value);

const clearAllInputs = () => {
Expand All @@ -37,10 +52,9 @@ export const SearchBlock = ({ drawerContent }: { drawerContent?: ContentfulRichT
select.value = "Miles";
});
// clear state
setInPerson(false);
setDeliveryTypes(new Set());
setMaxCost("");
setMiles("");
setOnline(false);
setZipCode("");
setSearchTerm("");
};
Expand Down Expand Up @@ -73,18 +87,27 @@ export const SearchBlock = ({ drawerContent }: { drawerContent?: ContentfulRichT

useEffect(() => {
const params = [];
const formatArray = [];

const deliveryTypeMapping: Record<string, string> = {
[DeliveryType.InPerson]: "inperson",
[DeliveryType.OnlineOnly]: "online",
[DeliveryType.BlendedDelivery]: "blended",
};


const formatArray = Array.from(deliveryTypes)
.map((type) => deliveryTypeMapping[type as keyof typeof deliveryTypeMapping])
.filter(Boolean);

if (maxCost) params.push(`maxCost=${encodeURIComponent(maxCost)}`);
if (miles) params.push(`miles=${encodeURIComponent(miles)}`);
if (zipCode) params.push(`zipcode=${encodeURIComponent(zipCode)}`);
if (inPerson) formatArray.push("inperson");
if (online) formatArray.push("online");
if (formatArray.length > 0) params.push(`format=${formatArray.join(",")}`);

const encodedSearchTerm = encodeURIComponent(searchTerm);
const url = `/training/search?q=${encodedSearchTerm}${params.length > 0 ? "&" : ""}${params.join("&")}`;
setSearchUrl(url);
}, [searchTerm, inPerson, maxCost, miles, online, zipCode]);
}, [searchTerm, deliveryTypes, maxCost, miles, zipCode]);

useEffect(() => {
if (typeof window !== "undefined") {
Expand Down Expand Up @@ -243,16 +266,15 @@ export const SearchBlock = ({ drawerContent }: { drawerContent?: ContentfulRichT
/>
</div>
<div className="format">
<div className="label">Class format</div>
<div className="label">{t("SearchResultsPage.classFormatLabel")}</div>
<div className="checks">
<div className="usa-checkbox">
<input
className="usa-checkbox__input"
id="in-person"
type="checkbox"
onChange={() => {
setInPerson(!inPerson);
}}
checked={deliveryTypes.has(DeliveryType.InPerson)}
onChange={() => toggleDeliveryType(DeliveryType.InPerson)}
/>
<label className="usa-checkbox__label" htmlFor="in-person">
In-person
Expand All @@ -263,14 +285,25 @@ export const SearchBlock = ({ drawerContent }: { drawerContent?: ContentfulRichT
className="usa-checkbox__input"
id="online"
type="checkbox"
onChange={() => {
setOnline(!online);
}}
checked={deliveryTypes.has(DeliveryType.OnlineOnly)}
onChange={() => toggleDeliveryType(DeliveryType.OnlineOnly)}
/>
<label className="usa-checkbox__label" htmlFor="online">
Online
</label>
</div>
<div className="usa-checkbox">
<input
className="usa-checkbox__input"
id="blended"
type="checkbox"
checked={deliveryTypes.has(DeliveryType.BlendedDelivery)}
onChange={() => toggleDeliveryType(DeliveryType.BlendedDelivery)}
/>
<label className="usa-checkbox__label" htmlFor="blended">
Blended
</label>
</div>
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/filtering/filterLists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,4 @@ export const classFormatList = [
},
]

export type ClassFormatProps = "online" | "inperson";
export type ClassFormatProps = "online" | "inperson" | "blended";
3 changes: 0 additions & 3 deletions frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,6 @@ export const en = {
inDemandFilterLabel: "Show In-Demand Trainings Only",
costFilterLabel: "Cost",
maxCostLabel: "Max Cost",
classFormatFilterLabel: "Class Format",
classFormatInPersonLabel: "In-Person",
classFormatOnlineLabel: "Online",
timeToCompleteFilterLabel: "Time to Complete",
Expand Down Expand Up @@ -243,8 +242,6 @@ export const en = {
searchHelperHeader: "What Can I Search for?",
searchHelperText: "Here are some examples that may improve your search results:",
boldText1: "Training Providers:",
helperText1:
'If you\'re searching for a training provider, try using only the provider\'s name and exclude words like "university" or "college".',
boldText2: "Occupations:",
helperText2:
"If you're looking for training for a job, you can type the job directly into the search box.",
Expand Down
3 changes: 0 additions & 3 deletions frontend/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,6 @@ export const es = {
inDemandFilterLabel: "Mostrar solo capacitaciones bajo demanda",
costFilterLabel: "Costo",
maxCostLabel: "Costo máximo",
classFormatFilterLabel: "Formato de clase",
classFormatInPersonLabel: "En persona",
classFormatOnlineLabel: "En línea",
timeToCompleteFilterLabel: "Tiempo para completar",
Expand Down Expand Up @@ -253,8 +252,6 @@ export const es = {
searchHelperHeader: "¿Qué puedo buscar?",
searchHelperText: "Estos son algunos ejemplos que pueden mejorar sus resultados de búsqueda:",
boldText1: "Proveedores de capacitación:",
helperText1:
'si está buscando un proveedor de capacitación, intente usar solo el nombre del proveedor y excluya palabras como "universidad" o "facultad".',
boldText2: "Ocupaciones:",
helperText2:
"si está buscando capacitación para un trabajo, puede escribir el trabajo directamente en el cuadro de búsqueda.",
Expand Down

0 comments on commit dab8d25

Please sign in to comment.