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.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 5b314f5..21dd556 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 { nomenclatureCache } from "../utils/nomenclatureCacheService"; type Props = LunaticComponentProps<"Suggester">; -export function Suggester({ interpret, label, response }: Props) { +export function Suggester({ interpret, label, response, storeName }: Props) { + const collectedValue = interpret(response.name) as string; + + console.log(`Collect value ${collectedValue} for nomenclature ${storeName}`); + + // 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 && collectedValue + ? nomenclatureCache.getLabelFromCache(storeName, collectedValue) + : undefined; + + console.log(`Found label: ${nomenclatureLabel}`); + return ( - {interpret(response.name) ?? "__"} + + {nomenclatureLabel ?? collectedValue ?? "__"} + ); -} +} \ No newline at end of file diff --git a/src/controllers/pdf.controller.tsx b/src/controllers/pdf.controller.tsx new file mode 100644 index 0000000..93b3f3b --- /dev/null +++ b/src/controllers/pdf.controller.tsx @@ -0,0 +1,142 @@ +import { renderToStream } from "@react-pdf/renderer"; +import { Request, Response } from "express"; +import type { LunaticData, LunaticSource } from "@inseefr/lunatic"; +import { LunaticQuestionnaire } from "../components/LunaticQuestionnaire"; +import config from "../config/config"; +import { ErrorCode, errorResponse } from "../error/api"; +import { logger } from "../logger"; +import { nomenclatureCache } from "../utils/nomenclatureCacheService"; +import { nomenclatureLoaderService } from "../utils/NomenclatureLoaderService"; + +const { trustUriDomains } = config; + +const handleError = ( + res: Response, + code: ErrorCode, + message: string, + status = 500, + details?: unknown, + error?: unknown +) => { + console.error(error); + logger.error(message); + return errorResponse(res, code, message, status, details); +}; + +const isUriAuthorized = (uri: string): boolean => { + const url = new URL(uri); + return trustUriDomains.some((trustDomain) => url.host.endsWith(trustDomain)); +}; + +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"})`); + + if (!sourceUri) { + return handleError(res, ErrorCode.INVALID_URI, "Missing source URI", 400); + } + + let url: URL; + try { + url = new URL(sourceUri); + } catch (e) { + return handleError( + res, + ErrorCode.INVALID_URI, + "The provided URI is invalid.", + 400, + { uri: sourceUri }, + { error: e instanceof Error ? e.message : e } + ); + } + + if (!isUriAuthorized(url.toString())) { + return handleError( + res, + ErrorCode.UNAUTHORIZED_HOST, + "The host is not authorized.", + 403, + { host: url.host } + ); + } + + let source: LunaticSource; + try { + const responseSource = await fetch(sourceUri); + if (!responseSource.ok) { + return errorResponse( + res, + ErrorCode.SOURCE_FETCH_ERROR, + "Failed to fetch the source.", + responseSource.status + ); + } + source = await responseSource.json(); + } catch (e) { + return handleError( + res, + ErrorCode.SOURCE_FETCH_ERROR, + "An error occurred while fetching the source.", + 500, + { error: e instanceof Error ? e.message : e } + ); + } + + //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 + ); + nomenclatureCache.setNomenclature(storeName, items); + }) + ); + + try { + const pdfResult = await renderToStream( + + ); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename=export.pdf`); + pdfResult.pipe(res); + pdfResult.on("error", (err) => { + errorResponse( + res, + ErrorCode.PDF_GENERATION_ERROR, + "Failed to generate PDF.", + 500, + { error: err } + ); + }); + logger.info("PDF successfully generated"); + } catch (e) { + return errorResponse( + res, + ErrorCode.PDF_GENERATION_ERROR, + "Failed to generate PDF.", + 500, + { error: e instanceof Error ? e.message : e } + ); + } finally { + nomenclatureCache.clear(); + } +}; 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 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..61d47bc --- /dev/null +++ b/src/utils/nomenclatureCacheService.ts @@ -0,0 +1,63 @@ +import { Nomenclature, NomenclatureItem } from "../models/nomenclature"; + +class NomenclatureCacheService { + readonly 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..8518416 --- /dev/null +++ b/src/utils/nomenclatureLoaderService.ts @@ -0,0 +1,65 @@ +import { Nomenclature, NomenclatureItem } from "../models/nomenclature"; +import { nomenclatureCache } from "./nomenclatureCacheService"; + +class NomenclatureLoaderService { + readonly 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