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