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

Autocomplete on search #10

Merged
merged 5 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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,082 changes: 872 additions & 210 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 @@ -13,6 +13,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 @@ -27,6 +30,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 inputName="s" />
</form>
</div>
</div>
Expand Down
173 changes: 6 additions & 167 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,18 +20,9 @@ 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
inputName="s"
initialValue={search || undefined}
/>
</form>
</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, { onlyDirectMatches: true });
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