Skip to content

Commit

Permalink
feat: autocomplete on search
Browse files Browse the repository at this point in the history
* move csv atc files to json to use native imports rather than node:fs
* add MUI with DSFR integration for autocomplete
* separate DB types from connection

Signed-off-by: Maud Royer <[email protected]>
  • Loading branch information
jillro committed Oct 3, 2024
1 parent a8b9226 commit 4f33428
Show file tree
Hide file tree
Showing 19 changed files with 8,196 additions and 7,481 deletions.
1,049 changes: 837 additions & 212 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
},
"dependencies": {
"@codegouvfr/react-dsfr": "^1.9.22",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/material": "^6.1.1",
"@sentry/nextjs": "^8.28.0",
"csv-parse": "^5.5.6",
"jszip": "^3.10.1",
Expand All @@ -26,6 +29,7 @@
"server-cli-only": "^0.3.2",
"server-only": "^0.0.1",
"sharp": "^0.33.4",
"swr": "^2.2.5",
"windows-1252": "^3.0.4"
},
"devDependencies": {
Expand Down
61 changes: 32 additions & 29 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { defaultColorScheme } from "@/app/defaultColorScheme";
import { StartDsfr } from "@/app/StartDsfr";

import "@/customIcons/customIcons.css";
import MuiDsfrThemeProvider from "@codegouvfr/react-dsfr/mui";

export const metadata: Metadata = {
title: "Infomédicament",
Expand Down Expand Up @@ -44,35 +45,37 @@ export default function RootLayout({
/>
)}
<DsfrProvider lang={lang}>
<Header
brandTop={
<>
MINISTÈRE
<br />
DU TRAVAIL
<br />
DE LA SANTÉ
<br />
ET DES SOLIDARITÉS
</>
}
homeLinkProps={{
href: "/",
title:
"Accueil - Ministère du travail de la santé et des solidarités",
}}
operatorLogo={{
alt: "Info Médicament",
imgUrl: "/logo.svg",
orientation: "horizontal",
}}
serviceTitle="" // hack pour que la tagline soit bien affichée
serviceTagline="La référence officielle sur les données des médicaments"
/>
<main className={fr.cx("fr-container", "fr-pt-2w", "fr-pb-8w")}>
{children}
</main>
<Footer accessibility={"non compliant"} />
<MuiDsfrThemeProvider>
<Header
brandTop={
<>
MINISTÈRE
<br />
DU TRAVAIL
<br />
DE LA SANTÉ
<br />
ET DES SOLIDARITÉS
</>
}
homeLinkProps={{
href: "/",
title:
"Accueil - Ministère du travail de la santé et des solidarités",
}}
operatorLogo={{
alt: "Info Médicament",
imgUrl: "/logo.svg",
orientation: "horizontal",
}}
serviceTitle="" // hack pour que la tagline soit bien affichée
serviceTagline="La référence officielle sur les données des médicaments"
/>
<main className={fr.cx("fr-container", "fr-pt-2w", "fr-pb-8w")}>
{children}
</main>
<Footer accessibility={"non compliant"} />
</MuiDsfrThemeProvider>
</DsfrProvider>
</body>
</html>
Expand Down
24 changes: 12 additions & 12 deletions src/app/medicament/[CIS]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,7 @@ import HTMLParser, { HTMLElement } from "node-html-parser";
import { Nullable, sql } from "kysely";
import { parse as csvParse } from "csv-parse/sync";

import {
pdbmMySQL,
Presentation,
PresentationComm,
PresentationStat,
PresInfoTarif,
SpecComposant,
SpecDelivrance,
SpecElement,
Specialite,
SubstanceNom,
} from "@/db/pdbmMySQL";
import { pdbmMySQL } from "@/db/pdbmMySQL";
import liste_CIS_MVP from "@/liste_CIS_MVP.json";
import DsfrLeafletSection from "@/app/medicament/[CIS]/DsfrLeafletSection";
import { isHtmlElement } from "@/app/medicament/[CIS]/leafletUtils";
Expand All @@ -37,6 +26,17 @@ import {
} from "@/displayUtils";
import Breadcrumb from "@codegouvfr/react-dsfr/Breadcrumb";
import { readFileSync } from "node:fs";
import {
Presentation,
PresentationComm,
PresentationStat,
PresInfoTarif,
SpecComposant,
SpecDelivrance,
SpecElement,
Specialite,
SubstanceNom,
} from "@/db/pdbmMySQL/types";

export async function generateMetadata(
{ params: { CIS } }: { params: { CIS: string } },
Expand Down
20 changes: 2 additions & 18 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { fr } from "@codegouvfr/react-dsfr";
import Input from "@codegouvfr/react-dsfr/Input";
import Button from "@codegouvfr/react-dsfr/Button";
import AutocompleteSearch from "@/components/AutocompleteSearch";

export default async function Page() {
return (
Expand All @@ -10,22 +9,7 @@ export default async function Page() {
<h1 className={fr.cx("fr-h5")}>
Quel médicament cherchez-vous&nbsp;?
</h1>
<Input
label={"Quel médicament cherchez-vous&nbsp;?"}
hideLabel={true}
addon={
<Button
iconId={"fr-icon-search-line"}
title="Recherche"
type="submit"
/>
}
nativeInputProps={{
placeholder: "Rechercher",
name: "s",
type: "search",
}}
/>
<AutocompleteSearch />
</form>
</div>
</div>
Expand Down
172 changes: 4 additions & 168 deletions src/app/rechercher/page.tsx
Original file line number Diff line number Diff line change
@@ -1,162 +1,10 @@
import Link from "next/link";
import { sql } from "kysely";
import Button from "@codegouvfr/react-dsfr/Button";
import Input from "@codegouvfr/react-dsfr/Input";
import { pdbmMySQL, Specialite, SubstanceNom } from "@/db/pdbmMySQL";
import { fr } from "@codegouvfr/react-dsfr";
import Badge from "@codegouvfr/react-dsfr/Badge";
import db, { SearchResult } from "@/db";

import { formatSpecName, groupSpecialites } from "@/displayUtils";
import liste_CIS_MVP from "@/liste_CIS_MVP.json";

type SearchResultItem =
| SubstanceNom
| { groupName: string; specialites: Specialite[] };

async function getSpecialites(specialitesId: string[], substancesId: string[]) {
return specialitesId.length
? await pdbmMySQL
.selectFrom("Specialite")
.leftJoin("Composant", "Specialite.SpecId", "Composant.SpecId")
.where(({ eb }) =>
substancesId.length
? eb.or([
eb("Specialite.SpecId", "in", specialitesId),
eb("Composant.NomId", "in", substancesId),
])
: eb("Specialite.SpecId", "in", specialitesId),
)
.where("Specialite.SpecId", "in", liste_CIS_MVP)
.selectAll("Specialite")
.select(({ fn }) => [
fn<Array<string>>("json_arrayagg", ["NomId"]).as("SubsNomId"),
])
.groupBy("Specialite.SpecId")
.execute()
: [];
}

async function getSubstances(substancesId: string[]) {
const substances: SubstanceNom[] = substancesId.length
? await pdbmMySQL
.selectFrom("Subs_Nom")
.where("NomId", "in", substancesId)
.where(({ eb, selectFrom }) =>
eb(
"NomId",
"in",
selectFrom("Composant")
.select("NomId")
.where("SpecId", "in", liste_CIS_MVP),
),
)
.selectAll()
.execute()
: [];
return substances;
}

/**
* Get search results from the database
*
* The search results are generated and ordered by the following rules:
* 1. We get all substances and specialites matches from the search_index table
* 2. We retrieve all substances, all direct match for specialities,
* and all specialities that have a match with a substance
* 3. We group the specialities by their group name
* 4. The score of each result is the word similarity between the search query and the token,
* for specialities, we sum direct match score and substance match score
*/
async function getResults(query: string): Promise<SearchResultItem[]> {
const dbQuery = db
.selectFrom("search_index")
.selectAll()
.select(({ fn, val }) => [
fn("word_similarity", [fn("unaccent", [val(query)]), "token"]).as("sml"),
])
.where(sql<boolean>`token %> unaccent(${query})`)
.orderBy("sml", "desc")
.orderBy(({ fn }) => fn("length", ["token"]));

const matches = (await dbQuery.execute()) as (SearchResult & {
sml: number;
})[];

if (matches.length === 0) return [];

const specialitesId = matches
.filter((r) => r.table_name === "Specialite")
.map((r) => r.id);
const substancesId = matches
.filter((r) => r.table_name === "Subs_Nom")
.map((r) => r.id);

const specialites = await getSpecialites(specialitesId, substancesId);
const specialiteGroups = Array.from(groupSpecialites(specialites).entries());
const substances = await getSubstances(substancesId);

return matches
.reduce((acc: { score: number; item: SearchResultItem }[], match) => {
if (match.table_name === "Subs_Nom") {
const substance = substances.find(
(s) => s.NomId.trim() === match.id.trim(),
); // if undefined, the substance is not in one of the 500 CIS list
if (substance) {
acc.push({ score: match.sml, item: substance });

specialiteGroups
.filter(([, specialites]) =>
specialites.find(
(s) =>
s.SubsNomId &&
s.SubsNomId.map((id) => id.trim()).includes(
substance.NomId.trim(),
),
),
)
.forEach(([groupName, specialites]) => {
if (
!acc.find(
({ item }) =>
"groupName" in item && item.groupName === groupName,
)
) {
let directMatch = matches.find(
(m) =>
m.table_name === "Specialite" &&
specialites.find((s) => s.SpecId.trim() === m.id.trim()),
);
acc.push({
score: directMatch ? directMatch.sml + match.sml : match.sml,
item: { groupName, specialites },
});
}
});
}
}

if (match.table_name === "Specialite") {
const specialiteGroup = specialiteGroups.find(([, specialites]) =>
specialites.find((s) => s.SpecId.trim() === match.id.trim()),
); // if undefined, the specialite is not in the 500 CIS list
if (
specialiteGroup &&
!acc.find(
({ item }) =>
"groupName" in item && item.groupName === specialiteGroup[0],
)
) {
const [groupName, specialites] = specialiteGroup;
acc.push({ score: match.sml, item: { groupName, specialites } });
}
}

return acc;
}, [])
.sort((a, b) => b.score - a.score)
.map(({ item }) => item);
}
import { formatSpecName } from "@/displayUtils";
import { getResults } from "@/db/search";
import AutocompleteSearch from "@/components/AutocompleteSearch";

export default async function Page({
searchParams,
Expand All @@ -172,19 +20,7 @@ export default async function Page({
<div className={fr.cx("fr-col-12", "fr-col-lg-9", "fr-col-md-10")}>
{" "}
<form action="/rechercher" className={fr.cx("fr-my-4w")}>
<Input
label={"Quel médicament cherchez-vous&nbsp;?"}
hideLabel={true}
addon={
<Button iconId={"fr-icon-search-line"} title="Recherche" />
}
nativeInputProps={{
name: "s",
placeholder: "Rechercher",
...(search ? { defaultValue: search } : {}),
type: "search",
}}
/>
<AutocompleteSearch />
</form>
</div>
</div>
Expand Down
15 changes: 15 additions & 0 deletions src/app/rechercher/results/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { getResults } from "@/db/search";
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams;
const search = searchParams.get("s");
if (!search) {
return NextResponse.json(
{ error: "Missing search parameter" },
{ status: 400 },
);
}
const results = await getResults(search);
return NextResponse.json(results.slice(0, 10));
}
3 changes: 2 additions & 1 deletion src/app/substance/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { fr } from "@codegouvfr/react-dsfr";
import Badge from "@codegouvfr/react-dsfr/Badge";
import Link from "next/link";

import { pdbmMySQL, Specialite, SubstanceNom } from "@/db/pdbmMySQL";
import { pdbmMySQL } from "@/db/pdbmMySQL";
import { formatSpecName, groupSpecialites } from "@/displayUtils";
import liste_CIS_MVP from "@/liste_CIS_MVP.json";
import { Specialite, SubstanceNom } from "@/db/pdbmMySQL/types";

export async function generateStaticParams(): Promise<{ id: string }[]> {
return (
Expand Down
Loading

0 comments on commit 4f33428

Please sign in to comment.