From 0744a422959996e251f77b8ef4728c09ba9bc315 Mon Sep 17 00:00:00 2001 From: Mailine Nguyen <64129348+MailineN@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:54:02 +0100 Subject: [PATCH 1/3] feat: display nomenclature label instead of id for suggester values --- .env | 5 ++++- src/components/Suggester.tsx | 19 +++++++++++++++++-- src/config/config.ts | 2 ++ src/controllers/pdf.controller.tsx | 24 ++++++++++++++++++++++++ src/models/nomenclature.ts | 9 +++++++++ 5 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 src/models/nomenclature.ts diff --git a/.env b/.env index 3f8a71a..25bcec7 100644 --- a/.env +++ b/.env @@ -6,4 +6,7 @@ APPLICATION_HOST=localhost:8080 TRUST_URI_DOMAINS=localhost # oidc OIDC_ENABLED=false -OIDC_ISSUER= \ No newline at end of file +OIDC_ISSUER= + +#nomenclature source uri +NOMENCLATURE_SOURCE_URI= diff --git a/src/components/Suggester.tsx b/src/components/Suggester.tsx index 5b314f5..f96ee19 100644 --- a/src/components/Suggester.tsx +++ b/src/components/Suggester.tsx @@ -2,13 +2,28 @@ import { Text } from "@react-pdf/renderer"; import { LunaticComponentProps } from "../types"; import { ValueWithLabel } from "./ValueWithLabel"; import { styles } from "./styles"; +import nomenclatures from "../preview/nomenclature.json" type Props = LunaticComponentProps<"Suggester">; -export function Suggester({ interpret, label, response }: Props) { +export function Suggester({ interpret, label, response, storeName }: Props) { + + console.log('Suggester nomenclature', storeName); + console.log('response:', response); + console.log('Label:', label); + + const collectedValue = interpret(response.name); + console.log('Collected value:', collectedValue); + + const nomenclature = nomenclatures.find(nom => nom.id === storeName); + console.log('Using nomenclature:', nomenclature); + + const nomenclatureLabel = nomenclature?.items.find(item => item.id === collectedValue)?.label; + console.log('Matched nomenclature label:', nomenclatureLabel); + return ( - {interpret(response.name) ?? "__"} + {nomenclatureLabel ?? "__"} ); } diff --git a/src/config/config.ts b/src/config/config.ts index 935e02b..47abffe 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -10,6 +10,7 @@ interface Config { oidcEnabled: boolean, oidcIssuer: string, isProd: boolean, + nomenclatureSourceUri: string, } const config: Config = { @@ -20,6 +21,7 @@ const config: Config = { oidcEnabled: process.env.OIDC_ENABLED === "true", oidcIssuer: process.env.OIDC_ISSUER || "", isProd: process.env.NODE_ENV === "production", + nomenclatureSourceUri: process.env.NOMENCLATURE_SOURCE_URI || "", }; export default config; \ No newline at end of file diff --git a/src/controllers/pdf.controller.tsx b/src/controllers/pdf.controller.tsx index 27787b3..5a1c34b 100644 --- a/src/controllers/pdf.controller.tsx +++ b/src/controllers/pdf.controller.tsx @@ -5,6 +5,7 @@ import { LunaticQuestionnaire } from "../components/LunaticQuestionnaire"; import config from "../config/config"; import { ErrorCode, errorResponse } from "../error/api"; import { logger } from "../logger"; +import { Nomenclature } from "../models/nomenclature"; const { trustUriDomains } = config; @@ -81,6 +82,29 @@ export const generatePdf = async (req: Request, res: Response) => { { error: e instanceof Error ? e.message : e } ); } + + //TODO: cache + let nomenclatures: Nomenclature[]; + try { + const responseNomenclature = await fetch(sourceUri); + if (!responseNomenclature.ok) { + return errorResponse( + res, + ErrorCode.SOURCE_FETCH_ERROR, + "Failed to fetch the nomenclature.", + responseNomenclature.status + ); + } + nomenclatures = await responseNomenclature.json(); + } catch (e) { + return handleError( + res, + ErrorCode.SOURCE_FETCH_ERROR, + "An error occurred while fetching the nomenclature.", + 500, + { error: e instanceof Error ? e.message : e } + ); + } try { const pdfResult = await renderToStream( diff --git a/src/models/nomenclature.ts b/src/models/nomenclature.ts new file mode 100644 index 0000000..bd47acb --- /dev/null +++ b/src/models/nomenclature.ts @@ -0,0 +1,9 @@ +export type NomenclatureItem = { + id: string; + label: string +} + +export type Nomenclature = { + id: string; + items: NomenclatureItem[]; +} \ No newline at end of file From e7fa9caa720514f939bcd6b1a67558245da0333f Mon Sep 17 00:00:00 2001 From: Mailine Nguyen <64129348+MailineN@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:34:22 +0100 Subject: [PATCH 2/3] feat: load & cache nomenclatures --- src/components/Suggester.tsx | 26 +++++------ src/controllers/pdf.controller.tsx | 52 ++++++++++++--------- src/preview/main.tsx | 9 +++- src/utils/nomenclatureCacheService.ts | 63 +++++++++++++++++++++++++ src/utils/nomenclatureLoaderService.ts | 65 ++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 37 deletions(-) create mode 100644 src/utils/nomenclatureCacheService.ts create mode 100644 src/utils/nomenclatureLoaderService.ts diff --git a/src/components/Suggester.tsx b/src/components/Suggester.tsx index f96ee19..ea8ed73 100644 --- a/src/components/Suggester.tsx +++ b/src/components/Suggester.tsx @@ -2,28 +2,28 @@ import { Text } from "@react-pdf/renderer"; import { LunaticComponentProps } from "../types"; import { ValueWithLabel } from "./ValueWithLabel"; import { styles } from "./styles"; -import nomenclatures from "../preview/nomenclature.json" +import { nomenclatureCache } from "../utils/nomenclatureCacheService"; type Props = LunaticComponentProps<"Suggester">; export function Suggester({ interpret, label, response, storeName }: Props) { + const collectedValue = interpret(response.name) as string; - console.log('Suggester nomenclature', storeName); - console.log('response:', response); - console.log('Label:', label); + console.log(`Collect value ${collectedValue} for nomenclature ${storeName}`); - const collectedValue = interpret(response.name); - console.log('Collected value:', collectedValue); + // Get label from cache + // TODO: handle missing nomenclature (async fetch if not in cache) not currently handled if the fetching somehow fails + const nomenclatureLabel = storeName + ? nomenclatureCache.getLabelFromCache(storeName, collectedValue) + : undefined; - const nomenclature = nomenclatures.find(nom => nom.id === storeName); - console.log('Using nomenclature:', nomenclature); - - const nomenclatureLabel = nomenclature?.items.find(item => item.id === collectedValue)?.label; - console.log('Matched nomenclature label:', nomenclatureLabel); + console.log(`Found label: ${nomenclatureLabel}`); return ( - {nomenclatureLabel ?? "__"} + + {nomenclatureLabel ?? collectedValue ?? "__"} + ); -} +} \ No newline at end of file diff --git a/src/controllers/pdf.controller.tsx b/src/controllers/pdf.controller.tsx index 5a1c34b..93b3f3b 100644 --- a/src/controllers/pdf.controller.tsx +++ b/src/controllers/pdf.controller.tsx @@ -5,7 +5,8 @@ import { LunaticQuestionnaire } from "../components/LunaticQuestionnaire"; import config from "../config/config"; import { ErrorCode, errorResponse } from "../error/api"; import { logger } from "../logger"; -import { Nomenclature } from "../models/nomenclature"; +import { nomenclatureCache } from "../utils/nomenclatureCacheService"; +import { nomenclatureLoaderService } from "../utils/NomenclatureLoaderService"; const { trustUriDomains } = config; @@ -30,6 +31,7 @@ const isUriAuthorized = (uri: string): boolean => { export const generatePdf = async (req: Request, res: Response) => { const data = req.body as { data: LunaticData }; let sourceUri = req.query.source as string; + const nomenclatureSourceUri = config.nomenclatureSourceUri || sourceUri; logger.info(`Generating PDF (source=${sourceUri ?? "none"})`); @@ -83,28 +85,32 @@ export const generatePdf = async (req: Request, res: Response) => { ); } - //TODO: cache - let nomenclatures: Nomenclature[]; - try { - const responseNomenclature = await fetch(sourceUri); - if (!responseNomenclature.ok) { - return errorResponse( - res, - ErrorCode.SOURCE_FETCH_ERROR, - "Failed to fetch the nomenclature.", - responseNomenclature.status + //TODO: check if there is a better place to check for nomenclatureStores + const nomenclatureStores = new Set(); + const findSuggesters = (components: any[]): void => { + components.forEach(component => { + if (component.componentType === "Suggester" && component.storeName) { + nomenclatureStores.add(component.storeName); + } + if (component.components && Array.isArray(component.components)) { + findSuggesters(component.components); + } + }); + }; + + findSuggesters(source.components); + + // Fetch and cache all nomenclatures + await Promise.all( + Array.from(nomenclatureStores).map(async storeName => { + const items = await nomenclatureLoaderService.getNomenclatures( + storeName, + nomenclatureSourceUri ); - } - nomenclatures = await responseNomenclature.json(); - } catch (e) { - return handleError( - res, - ErrorCode.SOURCE_FETCH_ERROR, - "An error occurred while fetching the nomenclature.", - 500, - { error: e instanceof Error ? e.message : e } - ); - } + nomenclatureCache.setNomenclature(storeName, items); + }) + ); + try { const pdfResult = await renderToStream( @@ -130,5 +136,7 @@ export const generatePdf = async (req: Request, res: Response) => { 500, { error: e instanceof Error ? e.message : e } ); + } finally { + nomenclatureCache.clear(); } }; diff --git a/src/preview/main.tsx b/src/preview/main.tsx index ff956b3..2b66632 100644 --- a/src/preview/main.tsx +++ b/src/preview/main.tsx @@ -2,12 +2,17 @@ import { createRoot } from "react-dom/client"; import { PDFViewer } from "@react-pdf/renderer"; import source from "./source.json"; import interrogationData from "./data.json"; - +import nomenclatures from "./nomenclatures.json"; import { LunaticQuestionnaire } from "../components/LunaticQuestionnaire"; +import { nomenclatureCache } from "../utils/nomenclatureCacheService"; + + +nomenclatureCache.setAllNomenclatures(nomenclatures); + createRoot(document.getElementById("root")!).render( {/* @ts-ignore*/} - + ); diff --git a/src/utils/nomenclatureCacheService.ts b/src/utils/nomenclatureCacheService.ts new file mode 100644 index 0000000..46ac206 --- /dev/null +++ b/src/utils/nomenclatureCacheService.ts @@ -0,0 +1,63 @@ +import { Nomenclature, NomenclatureItem } from "../models/nomenclature"; + +class NomenclatureCacheService { + private cache: Map = new Map(); + + /** + * Set one nomenclature items in cache + */ + setNomenclature(storeName: string, items: NomenclatureItem[]): void { + console.log(`Added ${items.length} items for ${storeName}`); + this.cache.set(storeName, items); + } + + /** + * Set all nomenclatures into cache (for preview mode) + */ + setAllNomenclatures(nomenclatures: Nomenclature[]): void { + console.log(`Set ${nomenclatures.length} nomenclatures into cache`); + nomenclatures.forEach(nom => { + this.setNomenclature(nom.id, nom.items); + }); + } + + /** + * Get nomenclature items from cache + */ + getNomenclature(storeName: string): NomenclatureItem[] | undefined { + const items = this.cache.get(storeName); + if (items) { + console.log(`Found nomenclature for ${storeName}`); + } else { + console.log(`Missing nomenclature for ${storeName}`); + } + return items; + } + + /** + * Check if nomenclature exists in cache + */ + hasNomenclature(storeName: string): boolean { + return this.cache.has(storeName); + } + + /** + * Get label for a specific item ID + */ + getLabelFromCache(storeName: string, itemId: string): string | undefined { + const items = this.getNomenclature(storeName); + return items?.find(item => item.id === itemId)?.label; + } + + + /** + * Clear the entire cache + */ + clear(): void { + console.log(`Clearing cache`); + this.cache.clear(); + } + +} + +export const nomenclatureCache = new NomenclatureCacheService(); \ No newline at end of file diff --git a/src/utils/nomenclatureLoaderService.ts b/src/utils/nomenclatureLoaderService.ts new file mode 100644 index 0000000..ae7b16d --- /dev/null +++ b/src/utils/nomenclatureLoaderService.ts @@ -0,0 +1,65 @@ +import { Nomenclature, NomenclatureItem } from "../models/nomenclature"; +import { nomenclatureCache } from "./nomenclatureCacheService"; + +class NomenclatureLoaderService { + private fetchPromises: Map> = new Map(); + + /** + * Get nomenclature items by storeName + * Returns cached version if available, otherwise fetches + */ + async getNomenclatures(storeName: string, nomenclatureSourceUri: string): Promise { + const cachedItems = nomenclatureCache.getNomenclature(storeName); + if (cachedItems) { + return cachedItems; + } + + if (this.fetchPromises.has(storeName)) { + return this.fetchPromises.get(storeName)!; + } + + const fetchPromise = this.fetchNomenclature(storeName, nomenclatureSourceUri); + this.fetchPromises.set(storeName, fetchPromise); + + try { + const items = await fetchPromise; + nomenclatureCache.setNomenclature(storeName, items); + return items; + } finally { + this.fetchPromises.delete(storeName); + } + } + + private async fetchNomenclature(storeName: string, nomenclatureSourceUri: string): Promise { + try { + const url = `${nomenclatureSourceUri}/nomenclatures/${storeName}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch nomenclature: ${response.statusText}`); + } + + const nomenclature: Nomenclature = await response.json(); + return nomenclature.items; + } catch (error) { + console.error(`Error fetching nomenclature ${storeName}:`, error); + return []; + } + } + + /** + * Get label for a specific id from a nomenclature + * Not used yet + */ + async getLabel(storeName: string, id: string, nomenclatureSourceUri: string): Promise { + const cachedLabel = nomenclatureCache.getLabelFromCache(storeName, id); + if (cachedLabel) { + return cachedLabel; + } + + const items = await this.getNomenclatures(storeName, nomenclatureSourceUri); + return items.find(item => item.id === id)?.label; + } + +} + +export const nomenclatureLoaderService = new NomenclatureLoaderService(); \ No newline at end of file From adc70390f4a8cb1b16c72063d8563277c9c5fc4d Mon Sep 17 00:00:00 2001 From: Mailine Nguyen <64129348+MailineN@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:19:18 +0100 Subject: [PATCH 3/3] tests: add some tests to the suggester component --- src/components/Suggester.test.tsx | 137 +++++++++++++++++++++++++ src/components/Suggester.tsx | 2 +- src/utils/nomenclatureCacheService.ts | 2 +- src/utils/nomenclatureLoaderService.ts | 2 +- 4 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 src/components/Suggester.test.tsx diff --git a/src/components/Suggester.test.tsx b/src/components/Suggester.test.tsx new file mode 100644 index 0000000..23ab8ab --- /dev/null +++ b/src/components/Suggester.test.tsx @@ -0,0 +1,137 @@ +import { ReactNode } from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Table } from './Table'; +import type { VTLExpression } from '../types'; +import { render, screen } from '@testing-library/react'; + +import '@testing-library/jest-dom'; +import { nomenclatureCache } from '../utils/nomenclatureCacheService'; +import { Suggester } from './Suggester'; +import { Interpreter } from '../utils/vtl'; + +vi.mock('../utils/nomenclatureCacheService', () => ({ + nomenclatureCache: { + getLabelFromCache: vi.fn(), + }, +})); +vi.mock('@react-pdf/renderer', () => ({ + Text: ({ children, style }: any) => {children}, + View: ({ children, style }: any) =>
{children}
, + StyleSheet: { + create: (styles: any) => styles, + }, +})); + + +vi.mock('./ValueWithLabel', () => ({ + ValueWithLabel: ({ children, label, interpret }: any) => { + const interpretedLabel = interpret ? interpret(label) : label; + + return ( +
+ {interpretedLabel &&
{interpretedLabel}
} + {children} +
+ ); + }, +})); + +vi.mock('./LunaticComponent', () => ({ + LunaticComponent: ({ component }: any) => ( +
{component.componentType}
+ ), +})); + +vi.mock('../utils/markdownParser', () => ({ + renderContent: vi.fn((interpret, label, style) => { + const interpretedLabel = interpret(label); + return {interpretedLabel}; + }) +})); + + +const mockInterpret = vi.fn((expr: string | VTLExpression | undefined): ReactNode => { + if (typeof expr === 'string') { + return '12345'; + } + if (expr && typeof expr === 'object' && 'value' in expr) { + return expr.value; + } + return ''; +}); + +describe('Suggester Component', () => { + + beforeEach(() => { + vi.clearAllMocks(); + mockInterpret.mockClear(); + }); + + const mockProps = { + interpret: mockInterpret, + id: 'suggester-1', + label: { value: 'Select your place', type: 'VTL|MD' } as VTLExpression, + response: { name: 'TEST_PLACE' }, + storeName: 'L_PLACES', + componentType: 'Suggester' as const, + } + + it('renders collected value and nomenclature label when available', async () => { + const mockGetLabel = vi.mocked(nomenclatureCache.getLabelFromCache); + mockGetLabel.mockReturnValue('Monoco\'s Station'); + + render(); + + expect(mockInterpret).toHaveBeenCalledWith('TEST_PLACE'); + expect(mockGetLabel).toHaveBeenCalledWith('L_PLACES', '12345'); + + expect(screen.getByTestId('value-with-label')).toBeInTheDocument(); + expect(screen.getByTestId('label')).toHaveTextContent('Select your place'); + expect(screen.getByTestId('pdf-text')).toHaveTextContent("Monoco's Station"); + + }); + + it('should display __ when response is missing', () => { + + const emptyInterpret = vi.fn(() => undefined) as unknown as Interpreter; + + const mockGetLabel = vi.mocked(nomenclatureCache.getLabelFromCache); + mockGetLabel.mockReturnValue(undefined); + + const mockProps = { + interpret: emptyInterpret, + id: 'suggester-1', + label: { value: 'Select your place', type: 'VTL|MD' } as VTLExpression, + response: { name: '' }, + storeName: 'L_PLACES', + componentType: 'Suggester' as const, + } + render(); + + expect(mockGetLabel).not.toHaveBeenCalled(); + + expect(screen.getByTestId('value-with-label')).toBeInTheDocument(); + expect(screen.getByTestId('pdf-text')).toHaveTextContent('__'); + }); + + it('renders collected value when nomenclature is not found', () => { + const mockGetLabel = vi.mocked(nomenclatureCache.getLabelFromCache); + mockGetLabel.mockReturnValue(undefined); + + const mockPropsNoStore = { + interpret: mockInterpret, + id: 'suggester-1', + label: { value: 'Select your place', type: 'VTL|MD' } as VTLExpression, + response: { name: 'TEST_PLACE' }, + storeName: 'UNKNOWN_STORE', + componentType: 'Suggester' as const, + } + render(); + expect(mockInterpret).toHaveBeenCalledWith('TEST_PLACE'); + + expect(screen.getByTestId('value-with-label')).toBeInTheDocument(); + expect(screen.getByTestId('label')).toHaveTextContent('Select your place'); + expect(screen.getByTestId('pdf-text')).toHaveTextContent('12345'); + }); + +}); diff --git a/src/components/Suggester.tsx b/src/components/Suggester.tsx index ea8ed73..21dd556 100644 --- a/src/components/Suggester.tsx +++ b/src/components/Suggester.tsx @@ -13,7 +13,7 @@ export function Suggester({ interpret, label, response, storeName }: Props) { // Get label from cache // TODO: handle missing nomenclature (async fetch if not in cache) not currently handled if the fetching somehow fails - const nomenclatureLabel = storeName + const nomenclatureLabel = storeName && collectedValue ? nomenclatureCache.getLabelFromCache(storeName, collectedValue) : undefined; diff --git a/src/utils/nomenclatureCacheService.ts b/src/utils/nomenclatureCacheService.ts index 46ac206..61d47bc 100644 --- a/src/utils/nomenclatureCacheService.ts +++ b/src/utils/nomenclatureCacheService.ts @@ -1,7 +1,7 @@ import { Nomenclature, NomenclatureItem } from "../models/nomenclature"; class NomenclatureCacheService { - private cache: Map = new Map(); + readonly cache: Map = new Map(); /** * Set one nomenclature items in cache diff --git a/src/utils/nomenclatureLoaderService.ts b/src/utils/nomenclatureLoaderService.ts index ae7b16d..8518416 100644 --- a/src/utils/nomenclatureLoaderService.ts +++ b/src/utils/nomenclatureLoaderService.ts @@ -2,7 +2,7 @@ import { Nomenclature, NomenclatureItem } from "../models/nomenclature"; import { nomenclatureCache } from "./nomenclatureCacheService"; class NomenclatureLoaderService { - private fetchPromises: Map> = new Map(); + readonly fetchPromises: Map> = new Map(); /** * Get nomenclature items by storeName