Skip to content
Draft
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
5 changes: 4 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@ APPLICATION_HOST=localhost:8080
TRUST_URI_DOMAINS=localhost
# oidc
OIDC_ENABLED=false
OIDC_ISSUER=
OIDC_ISSUER=

#nomenclature source uri
NOMENCLATURE_SOURCE_URI=
137 changes: 137 additions & 0 deletions src/components/Suggester.test.tsx
Original file line number Diff line number Diff line change
@@ -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) => <span data-testid="pdf-text" style={style}>{children}</span>,
View: ({ children, style }: any) => <div data-testid="pdf-view" style={style}>{children}</div>,
StyleSheet: {
create: (styles: any) => styles,
},
}));


vi.mock('./ValueWithLabel', () => ({
ValueWithLabel: ({ children, label, interpret }: any) => {
const interpretedLabel = interpret ? interpret(label) : label;

return (
<div data-testid="value-with-label">
{interpretedLabel && <div data-testid="label">{interpretedLabel}</div>}
{children}
</div>
);
},
}));

vi.mock('./LunaticComponent', () => ({
LunaticComponent: ({ component }: any) => (
<div data-testid="lunatic-component">{component.componentType}</div>
),
}));

vi.mock('../utils/markdownParser', () => ({
renderContent: vi.fn((interpret, label, style) => {
const interpretedLabel = interpret(label);
return <span data-testid="table-content">{interpretedLabel}</span>;
})
}));


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(<Suggester {...mockProps} />);

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(<Suggester {...mockProps} />);

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(<Suggester {...mockPropsNoStore} />);
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');
});

});
21 changes: 18 additions & 3 deletions src/components/Suggester.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ValueWithLabel interpret={interpret} label={label}>
<Text style={styles.answer}>{interpret(response.name) ?? "__"} </Text>
<Text style={styles.answer}>
{nomenclatureLabel ?? collectedValue ?? "__"}
</Text>
</ValueWithLabel>
);
}
}
142 changes: 142 additions & 0 deletions src/controllers/pdf.controller.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();
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(
<LunaticQuestionnaire source={source} data={data.data} />
);
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();
}
};
9 changes: 9 additions & 0 deletions src/models/nomenclature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type NomenclatureItem = {
id: string;
label: string
}

export type Nomenclature = {
id: string;
items: NomenclatureItem[];
}
9 changes: 7 additions & 2 deletions src/preview/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PDFViewer>
{/* @ts-ignore*/}
<LunaticQuestionnaire source={source} data={interrogationData.data} />
<LunaticQuestionnaire source={source} data={interrogationData.data} nomenclatures={nomenclatures} />
</PDFViewer>
);
Loading