From 672111335c0eb5864d94dfc970d68966112528fa Mon Sep 17 00:00:00 2001 From: Samuel Duhaime-Morissette <78976679+samuel-duhaime@users.noreply.github.com> Date: Mon, 5 Feb 2024 14:21:21 -0500 Subject: [PATCH] Add Generator to Evolution (#364) --- .gitignore | 6 + .vscode/tasks.json | 186 ++++++++++++ generator/{generator.md => README.md} | 20 +- generator/common/customWidgets.tsx | 28 ++ generator/common/defaultInputBase.tsx | 147 ++++++++++ generator/common/validations.tsx | 135 +++++++++ .../examples/Example_Generate_Survey.xlsx | Bin 0 -> 11464 bytes .../examples/generatorExampleConfigs.yaml | 26 ++ generator/helpers/generator_helpers.py | 156 ++++++++++ generator/requirements.txt | 20 ++ generator/scripts/generate_choices.py | 124 ++++++++ generator/scripts/generate_conditionals.py | 132 +++++++++ generator/scripts/generate_excel.py | 47 +++ generator/scripts/generate_input_range.py | 115 ++++++++ generator/scripts/generate_libelles.py | 234 +++++++++++++++ generator/scripts/generate_survey.py | 79 ++++++ generator/scripts/generate_widgets.py | 267 ++++++++++++++++++ generator/tests/test_generate_choices.py | 240 ++++++++++++++++ generator/tests/test_generate_input_range.py | 192 +++++++++++++ package.json | 4 +- .../common/defaultConditional.tsx | 12 + .../helpers/createConditionals.tsx | 145 ++++++++++ .../surveyGenerator/types/inputTypes.ts | 245 ++++++++++++++++ 23 files changed, 2553 insertions(+), 7 deletions(-) create mode 100644 .vscode/tasks.json rename generator/{generator.md => README.md} (97%) create mode 100644 generator/common/customWidgets.tsx create mode 100644 generator/common/defaultInputBase.tsx create mode 100644 generator/common/validations.tsx create mode 100644 generator/examples/Example_Generate_Survey.xlsx create mode 100644 generator/examples/generatorExampleConfigs.yaml create mode 100644 generator/helpers/generator_helpers.py create mode 100644 generator/requirements.txt create mode 100644 generator/scripts/generate_choices.py create mode 100644 generator/scripts/generate_conditionals.py create mode 100644 generator/scripts/generate_excel.py create mode 100644 generator/scripts/generate_input_range.py create mode 100644 generator/scripts/generate_libelles.py create mode 100644 generator/scripts/generate_survey.py create mode 100644 generator/scripts/generate_widgets.py create mode 100644 generator/tests/test_generate_choices.py create mode 100644 generator/tests/test_generate_input_range.py create mode 100644 packages/evolution-common/src/services/surveyGenerator/common/defaultConditional.tsx create mode 100644 packages/evolution-common/src/services/surveyGenerator/helpers/createConditionals.tsx create mode 100644 packages/evolution-common/src/services/surveyGenerator/types/inputTypes.ts diff --git a/.gitignore b/.gitignore index 783f152a..65382b95 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,9 @@ profilingData* # ignore macos DS_Store files .DS_Store + +# Python cache +__pycache__/ + +# Generator survey example +generator/examples/survey/ \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..e7eabb09 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,186 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Update Main", + "dependsOrder": "sequence", + "dependsOn": [ + "Git Checkout Main", + "Git Pull Origin Main", + "Yarn Reset Submodules", + "Yarn Install Dependencies", + "Yarn Compile", + "Yarn Migrate" + ], + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "Start Dev Terminals", + "type": "shell", + "command": "", + "group": { + "kind": "build", + "isDefault": true + }, + "dependsOn": [ + "Compile Dev", + "Build Dev", + "Start" + ] + }, + { + "label": "Start Dev Admin Terminals", + "type": "shell", + "dependsOn": [ + "Compile Dev", + "Build Dev Admin", + "Start Admin" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "command": "yarn build:admin:dev && yarn start:admin --port 8082" + }, + { + "label": "Compile Dev", + "type": "shell", + "command": "yarn", + "args": [ + "compile:dev" + ], + "group": { + "kind": "build", + "isDefault": false + } + }, + { + "label": "Build Dev", + "type": "shell", + "command": "yarn", + "args": [ + "build:dev" + ], + "group": { + "kind": "build", + "isDefault": false + } + }, + { + "label": "Build Dev Admin", + "type": "shell", + "command": "yarn", + "args": [ + "build:admin:dev" + ], + "group": { + "kind": "build", + "isDefault": false + } + }, + { + "label": "Start", + "type": "shell", + "command": "yarn", + "args": [ + "start" + ], + "group": { + "kind": "build", + "isDefault": false + } + }, + { + "label": "Start Admin", + "type": "shell", + "command": "yarn", + "args": [ + "start:admin", + "--port", + "8082" + ], + "group": { + "kind": "build", + "isDefault": false + } + }, + { + "label": "Git Checkout Main", + "type": "shell", + "command": "git", + "args": [ + "checkout", + "main" + ], + "group": { + "kind": "build", + "isDefault": false + } + }, + { + "label": "Git Pull Origin Main", + "type": "shell", + "command": "git", + "args": [ + "pull", + "origin", + "main" + ], + "group": { + "kind": "build", + "isDefault": false + } + }, + { + "label": "Yarn Reset Submodules", + "type": "shell", + "command": "yarn", + "args": [ + "reset-submodules" + ], + "group": { + "kind": "build", + "isDefault": false + } + }, + { + "label": "Yarn Install Dependencies", + "type": "shell", + "command": "yarn", + "args": [ + "install" + ], + "group": { + "kind": "build", + "isDefault": false + } + }, + { + "label": "Yarn Compile", + "type": "shell", + "command": "yarn", + "args": [ + "compile" + ], + "group": { + "kind": "build", + "isDefault": false + } + }, + { + "label": "Yarn Migrate", + "type": "shell", + "command": "yarn", + "args": [ + "migrate" + ], + "group": { + "kind": "build", + "isDefault": false + } + } + ] +} \ No newline at end of file diff --git a/generator/generator.md b/generator/README.md similarity index 97% rename from generator/generator.md rename to generator/README.md index a4fc9acd..85fe6888 100644 --- a/generator/generator.md +++ b/generator/README.md @@ -29,7 +29,15 @@ The Generator is designed to simplify and expedite your workflow. It allows for To run this script, follow these steps: -1. Copy `generateSurveyExample.xlsx` to your project. +1. Install all the Python dependencies from `requirements.txt` + + ```bash + pip install -r /generator/requirements.txt + ``` + + + +2. Copy `generateSurveyExample.xlsx` to your project. For Windows users: @@ -45,7 +53,7 @@ To run this script, follow these steps: cp ./evolution/generator/example/generateSurveyExample.xlsx ./survey/src/survey/references/generateSurveyExample.xlsx ``` -2. Copy `generatorConfig.yaml` to your project. +3. Copy `generatorConfig.yaml` to your project. For Windows users: @@ -61,7 +69,7 @@ To run this script, follow these steps: cp ./evolution/generator/example/generatorConfig.yaml ./survey/src/survey/config/generatorConfig.yaml ``` -3. Navigate to the root folder of your project and run the following command: +4. Navigate to the root folder of your project and run the following command: ```bash yarn generateSurvey @@ -135,7 +143,7 @@ export const end_email: inputTypes.InputString = { ...defaultInputBase.inputStringBase, path: 'end.email', label: (t: TFunction) => t('end:end.email'), - conditional: customConditionals.hasAcceptGivingEmailConditional, + conditional: conditionals.hasAcceptGivingEmailConditional, validations: validations.emailValidation }; ``` @@ -175,10 +183,10 @@ In this example, we are creating a conditional named `hasDrivingLicenseCondition The corresponding TypeScript code for this conditional is shown below: ```typescript -// customConditionals.tsx +// conditionals.tsx export const hasDrivingLicenseConditional: Conditional = (interview, path) => { const relativePath = path.substring(0, path.lastIndexOf(".")); // Remove the last key from the path - return checkConditionals({ + return createConditionals({ interview, conditionals: [ { diff --git a/generator/common/customWidgets.tsx b/generator/common/customWidgets.tsx new file mode 100644 index 00000000..a548de4d --- /dev/null +++ b/generator/common/customWidgets.tsx @@ -0,0 +1,28 @@ +/* + * Copyright 2024, Polytechnique Montreal and contributors + * + * This file is licensed under the MIT License. + * License text available at https://opensource.org/licenses/MIT + */ +import { TFunction } from 'i18next'; +import { getI18nContext } from 'evolution-interviewer/lib/client/config/i18nextExtra.config'; +import defaultConditional from 'evolution-common/lib/services/surveyGenerator/common/defaultConditional'; +import * as inputTypes from 'evolution-common/lib/services/surveyGenerator/types/inputTypes'; +import * as validations from './validations'; + +export const home_postalCode: inputTypes.InputString = { + type: 'question', + inputType: 'string', + path: 'home.postalCode', + datatype: 'string', + twoColumns: false, + textTransform: 'uppercase', + containsHtml: true, + label: (t: TFunction) => t('home:home.postalCode', { context: getI18nContext() }), + validations: validations.postalCodeValidation, + conditional: defaultConditional, + inputFilter: (value) => { + // Only accept postal code from Quebec + return value.replace(/[^a-zA-Z0-9]/g, '').substring(0, 6); + } +}; diff --git a/generator/common/defaultInputBase.tsx b/generator/common/defaultInputBase.tsx new file mode 100644 index 00000000..56a30dca --- /dev/null +++ b/generator/common/defaultInputBase.tsx @@ -0,0 +1,147 @@ +/* + * Copyright 2024, Polytechnique Montreal and contributors + * + * This file is licensed under the MIT License. + * License text available at https://opensource.org/licenses/MIT + */ +import { TFunction } from 'i18next'; +import { faCheckCircle } from '@fortawesome/free-solid-svg-icons'; +import config from 'chaire-lib-common/lib/config/shared/project.config'; +import { _isBlank } from 'chaire-lib-common/lib/utils/LodashExtensions'; +import surveyHelper from 'evolution-legacy/lib/helpers/survey/survey'; +import * as surveyHelperNew from 'evolution-common/lib/utils/helpers'; +import * as inputTypes from "evolution-common/lib/services/surveyGenerator/types/inputTypes"; + +// TODO: Make sure to add tests for these default inputs + +// Input Radio default params +export const inputRadioBase: inputTypes.InputRadioBase = { + type: 'question', + inputType: 'radio', + datatype: 'string', + containsHtml: true, + twoColumns: false, + columns: 1 +}; + +// Input String default params +export const inputStringBase: inputTypes.InputStringBase = { + type: 'question', + inputType: 'string', + datatype: 'string', + containsHtml: true, + twoColumns: false +}; + +// Input Number default params +export const inputNumberBase: inputTypes.InputStringBase = { + type: 'question', + inputType: 'string', + datatype: 'integer', + containsHtml: true, + twoColumns: false, + size: 'small', + inputFilter: (value) => { + // Remove all non-numeric characters + return value.replace(/[^0-9]/g, ''); + }, + // FIXME: numericKeyboard doesn't seem to work + numericKeyboard: true +}; + +// Text default params +export const inputTextBase: inputTypes.InputTextBase = { + type: 'text', + containsHtml: true +}; + +// InputRange default params +export const inputRangeBase: inputTypes.InputRangeBase = { + type: 'question', + inputType: 'slider', + containsHtml: true, + twoColumns: false, + initValue: null, + trackClassName: 'input-slider-blue', +}; + +// Checkbox default params +export const inputCheckboxBase: inputTypes.InputCheckboxBase = { + type: 'question', + inputType: 'checkbox', + datatype: 'string', + containsHtml: true, + twoColumns: false, + multiple: true, + columns: 1 +}; + +// Select default params +export const inputSelectBase: inputTypes.InputSelectBase = { + type: 'question', + inputType: 'select', + datatype: 'string', + twoColumns: false, + hasGroups: true +}; + +// Next button default params +export const buttonNextBase: inputTypes.InputButtonBase = { + type: 'button', + color: 'green', + hideWhenRefreshing: true, + // FIXME: Fix import icon + icon: faCheckCircle, + align: 'left', + action: surveyHelper.validateButtonActionWithCompleteSection +}; + +// TextArea default params +export const textAreaBase: inputTypes.TextAreaBase = { + type: 'question', + inputType: 'text', + datatype: 'text', + containsHtml: true, + twoColumns: false +}; + +// Find map place default params +export const inputMapFindPlaceBase: inputTypes.InputMapFindPlaceBase = { + type: 'question', + inputType: 'mapFindPlace', + datatype: 'geojson', + height: '20rem', + containsHtml: true, + autoConfirmIfSingleResult: true, + placesIcon: { + url: () => '/dist/images/activities_icons/default_marker.svg', + size: [70, 70] + }, + defaultCenter: config.mapDefaultCenter, + refreshGeocodingLabel: (t: TFunction) => t('customLibelle:RefreshGeocodingLabel'), + showSearchPlaceButton: () => true, + afterRefreshButtonText: (t: TFunction) => t('customLibelle:GeographyAfterRefresh'), + validations: (value, _customValue, interview, path) => { + const geography: any = surveyHelperNew.getResponse(interview, path, null); + return [ + { + validation: _isBlank(value), + errorMessage: { + fr: 'Le positionnement du domicile est requis.', + en: 'Home location is required.' + } + }, + { + validation: + geography && + geography.properties.lastAction && + geography.properties.lastAction === 'mapClicked' && + geography.properties.zoom < 14, + errorMessage: { + fr: 'Le positionnement du lieu n\'est pas assez précis. Utilisez le zoom + pour vous rapprocher davantage, puis précisez la localisation en déplaçant l\'icône.', + en: 'The positioning of the place is not precise enough. Please use the + zoom and drag the icon marker to confirm the precise location.' + } + } + ]; + } +}; diff --git a/generator/common/validations.tsx b/generator/common/validations.tsx new file mode 100644 index 00000000..64d6fecb --- /dev/null +++ b/generator/common/validations.tsx @@ -0,0 +1,135 @@ +/* + * Copyright 2024, Polytechnique Montreal and contributors + * + * This file is licensed under the MIT License. + * License text available at https://opensource.org/licenses/MIT + */ +import { _isBlank } from 'chaire-lib-common/lib/utils/LodashExtensions'; +import { Validations } from "evolution-common/lib/services/surveyGenerator/types/inputTypes"; + +// Make sure the question is answered +export const requiredValidation: Validations = (value) => { + return [ + { + validation: _isBlank(value), + errorMessage: { + fr: 'Cette réponse est requise.', + en: 'This answer is required.' + } + } + ]; +}; + +// Optional question +export const optionalValidation: Validations = () => [{ validation: false }]; + +// Make sure the InputRange is answered with a positive number +export const inputRangeValidation: Validations = (value) => { + return [ + { + validation: !(Number(value) >= 0), + errorMessage: { + fr: 'Cette réponse doit être d\'une valeur minimum de 0.', + en: 'This answer must be a minimum value of 0.' + } + }, + { + validation: _isBlank(value), + errorMessage: { + fr: 'Cette réponse est requise.', + en: 'This answer is required.' + } + } + ]; +}; + +// Verify if the value is a valid age +export const ageValidation: Validations = (value) => { + return [ + { + validation: _isBlank(value), + errorMessage: { + fr: 'L\'âge est requis.', + en: 'Age is required.' + } + }, + { + validation: isNaN(Number(value)) || !Number.isInteger(Number(value)), + errorMessage: { + fr: 'L\'âge est invalide.', + en: 'Age is invalid.' + } + }, + { + validation: Number(value) < 0, + errorMessage: { + fr: 'L\'âge doit être au moins de 0.', + en: 'Age must be at least 0.' + } + }, + { + validation: Number(value) > 115, + errorMessage: { + fr: 'L\'âge est trop élevé, veuillez vérifier.', + en: 'Age is too high, please validate.' + } + } + ]; +}; + +// Verify if the value is a valid email +export const emailValidation: Validations = (value) => { + return [ + { + validation: _isBlank(value), + errorMessage: { + fr: 'Le courriel est requis.', + en: 'Email is required.' + } + }, + { + validation: + !_isBlank(value) && + !/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + String(value) + ), + errorMessage: { + fr: 'Le courriel est invalide.', + en: 'Email is invalid' + } + } + ]; +}; + +// Verify if the value is a valid phone number. This validation is optional. +export const phoneValidation: Validations = (value) => { + return [ + { + validation: !_isBlank(value) && !/^\d{3}[-\s]?\d{3}[-\s]?\d{4}$/.test(String(value)), // Accept 3 numbers, a dash space or nothing, 3 numbers, a dash space or nothing, 4 numbers + errorMessage: { + fr: 'Le numéro de téléphone est invalide. (ex.: 514-123-1234).', + en: 'Phone number is invalid (e.g.: 514-123-1234).' + } + } + ]; +}; + +// Verify the value is a valid postal code +export const postalCodeValidation: Validations = (value) => { + return [ + { + validation: _isBlank(value), + errorMessage: { + fr: 'Veuillez spécifier votre code postal.', + en: 'Please specify your postal code.' + } + }, + { + validation: !/^[GHJKghjk][0-9][A-Za-z]( )?[0-9][A-Za-z][0-9]\s*$/.test(String(value)), + errorMessage: { + fr: 'Le code postal est invalide. Vous devez résider au Canada pour compléter ce questionnaire.', + en: 'Postal code is invalid. You must live in Canada to fill this questionnaire.' + } + } + ]; +}; diff --git a/generator/examples/Example_Generate_Survey.xlsx b/generator/examples/Example_Generate_Survey.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..3fc8e442820a20eae32805fa4c0d768a373967db GIT binary patch literal 11464 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 ROT7dYDETFSqj>r6{{VvG!eIaa literal 0 HcmV?d00001 diff --git a/generator/examples/generatorExampleConfigs.yaml b/generator/examples/generatorExampleConfigs.yaml new file mode 100644 index 00000000..53d05156 --- /dev/null +++ b/generator/examples/generatorExampleConfigs.yaml @@ -0,0 +1,26 @@ +survey: + excel_file: generator/examples/Example_Generate_Survey.xlsx + +excel: + active_script: false + +widgets: + output_info_list: + - output_folder: generator/examples/survey/sections/home + section: home + - output_folder: generator/examples/survey/sections/end + section: end + +conditionals: + output_file: generator/examples/survey/common/conditionals.tsx + +choices: + output_file: generator/examples/survey/common/choices.tsx + +input_range: + output_file: generator/examples/survey/common/inputRange.tsx + +libelles: + output_folder: generator/examples/survey/locales + overwrite: true + section: null diff --git a/generator/helpers/generator_helpers.py b/generator/helpers/generator_helpers.py new file mode 100644 index 00000000..3d5a5883 --- /dev/null +++ b/generator/helpers/generator_helpers.py @@ -0,0 +1,156 @@ +# Copyright 2024, Polytechnique Montreal and contributors +# This file is licensed under the MIT License. +# License text available at https://opensource.org/licenses/MIT + +# Note: This script includes functions that help generate and test Generator scripts. +import os # File system operations +import openpyxl # Read data from Excel +import openpyxl # Read data from Excel +from openpyxl import Workbook # Read data from Excel, File system operations +from typing import List, Union # Types for Python + +# Define constants +MOCKER_EXCEL_FILE = "generator/examples/test.xlsx" + + +# TODO: Add types for rows and headers +# Read data from Excel and return rows and headers +def get_data_from_excel(input_file: str, sheet_name: str) -> tuple: + try: + # Load Excel file + workbook: Workbook = openpyxl.load_workbook(input_file, data_only=True) + sheet = workbook[sheet_name] # Get InputRange sheet + rows = list(sheet.rows) # Get all rows in the sheet + headers = [cell.value for cell in rows[0]] # Get headers from the first row + + # Error when header has spaces + if any(" " in header for header in headers): + raise Exception("Header has spaces") + + # Error when header is None + if None in headers: + raise Exception("Header is None") + + return rows, headers + + except Exception as e: + print(f"Error reading Excel in {sheet_name} sheet: {e}") + raise e + +# TODO: Add types for rows and headers +# Get values from the row +def get_values_from_row(row, headers) -> tuple: + try: + # Create a dictionary from the row values and headers + row_dict = dict(zip(headers, (cell.value for cell in row))) + values = [] # List of values from the row + + # Get values from the row dictionary + for header in headers: + header = row_dict[header] + values.append(header) + + return values + + except Exception as e: + print(f"Error getting values from row: {e}") + raise e + + +# Error when any required fields values are None +def error_when_missing_required_fields(required_fields_names, required_fields_values, row_number: int): + if any(field is None for field in required_fields_values): + missing_fields = [field_name for field_value, field_name in zip(required_fields_values, required_fields_names) if field_value is None] + raise Exception( + f"Required field is missing in row {row_number}. Missing fields: {missing_fields}" + ) + + +# Generate output file +def generate_output_file(ts_code: str, output_file: str): + try: + with open(output_file, mode="w", encoding="utf-8", newline="\n") as ts_file: + ts_file.write(ts_code) + + print(f"Generate {output_file} successfully") + + except Exception as e: + print(f"Error generating {output_file}: {e}") + raise e + + +# 4-space indentation +def indent(level: int) -> str: + return " " * 4 * level + + +# Create mocked Excel data for testing +def create_mocked_excel_data( + sheet_name: str, headers: List[str], rows_data: List[List[Union[str, int, float]]] +) -> Workbook: + workbook: Workbook = openpyxl.Workbook() # Create a workbook + sheet = workbook.active # Get the active sheet + sheet.title = sheet_name # Change sheet title + + # Add headers + sheet.append(headers) + + # Iterate through each row data + for row_data in rows_data: + sheet.append(row_data) # Add row data + + # Create the excel file + workbook.save(MOCKER_EXCEL_FILE) + + # Return the workbook + return workbook + + +# Delete file if exists +def delete_file_if_exists(file_path: str) -> None: + if os.path.isfile(file_path): + os.remove(file_path) + + +# Check if the input file is an Excel file +def is_excel_file(file: str) -> None: + if not file.endswith(".xlsx"): + raise Exception( + f"Invalid input file extension for {file} : must be an Excel .xlsx file" + ) + + +# Check if the output file is an TypeScript file +def is_ts_file(file: str) -> None: + if not file.endswith(".tsx"): + raise Exception( + f"Invalid output file extension for {file} : must be an TypeScript .tsx file" + ) + + +# Check if the sheet exists +def sheet_exists(workbook: Workbook, sheet_name: str) -> None: + if sheet_name not in workbook.sheetnames: + raise Exception(f"Invalid sheet name in {sheet_name} sheet") + + +# Get workbook from Excel file +def get_workbook(input_file: str) -> Workbook: + workbook = openpyxl.load_workbook(input_file, data_only=True) + return workbook + + +# Get headers from the first row +def get_headers(sheet, expected_headers: List[str], sheet_name: str) -> List[str]: + # Get headers from the first row + current_headers = [cell.value for cell in list(sheet.rows)[0]] + + # Check if the good numbers of headers + if len(current_headers) != len(expected_headers): + raise Exception(f"Invalid number of column in {sheet_name} sheet") + + # Check if the headers are valid + if current_headers != expected_headers: + raise Exception(f"Invalid headers in {sheet_name} sheet") + + return current_headers diff --git a/generator/requirements.txt b/generator/requirements.txt new file mode 100644 index 00000000..839ce944 --- /dev/null +++ b/generator/requirements.txt @@ -0,0 +1,20 @@ +certifi==2023.11.17 +cffi==1.16.0 +charset-normalizer==3.3.2 +cryptography==41.0.7 +et-xmlfile==1.1.0 +idna==3.6 +msal==1.26.0 +Office365-REST-Python-Client==2.5.2 +openpyxl==3.1.2 +pycparser==2.21 +PyJWT==2.8.0 +python-dotenv==1.0.0 +pytz==2023.3.post1 +PyYAML==6.0.1 +requests==2.31.0 +ruamel.yaml==0.18.5 +ruamel.yaml.clib==0.2.8 +typing_extensions==4.8.0 +urllib3==2.1.0 +pytest==7.4.2 \ No newline at end of file diff --git a/generator/scripts/generate_choices.py b/generator/scripts/generate_choices.py new file mode 100644 index 00000000..219f3bd6 --- /dev/null +++ b/generator/scripts/generate_choices.py @@ -0,0 +1,124 @@ +# Copyright 2024, Polytechnique Montreal and contributors +# This file is licensed under the MIT License. +# License text available at https://opensource.org/licenses/MIT + +# Note: This script includes functions that generate the choices.tsx file. +# These functions are intended to be invoked from the generate_survey.py script. +from collections import defaultdict +from generator.helpers.generator_helpers import ( + is_excel_file, + is_ts_file, + get_workbook, + sheet_exists, + get_headers, +) + + +# Function to replace single quotes and stringify text +def replaces_quotes_and_stringify(text): + if text is not None: + return str(text).replace("'", "\\'") # Replace single quotes + return None + + +# Function to generate choices.tsx +def generate_choices(input_file: str, output_file: str): + try: + is_excel_file(input_file) # Check if the input file is an Excel file + is_ts_file(output_file) # Check if the output file is an TypeScript file + + # Read data from Excel and group choices by choiceName + choices_by_name = defaultdict(list) + + workbook = get_workbook(input_file) # Get workbook from Excel file + + sheet_exists(workbook, "Choices") # Check if the sheet exists + sheet = workbook["Choices"] # Get Choices sheet + + # Get headers from the first row + headers = get_headers( + sheet, + expected_headers=[ + "choicesName", + "value", + "fr", + "en", + "spreadChoicesName", + "conditional", + ], + sheet_name="Choices", + ) + + # Iterate through each row in the sheet, starting from the second row + for row in list(sheet.rows)[1:]: + # Create a dictionary from the row values and headers + row_dict = dict(zip(headers, (cell.value for cell in row))) + + # Get values from the row dictionary + choice_name = row_dict["choicesName"] + value = row_dict["value"] + label_fr = replaces_quotes_and_stringify(row_dict["fr"]) + label_en = replaces_quotes_and_stringify(row_dict["en"]) + spread_choices_name = row_dict["spreadChoicesName"] + conditional = row_dict["conditional"] + + # Create choice object with value and language-specific labels + choice = { + "value": value, + "label": {"fr": label_fr, "en": label_en}, + "spread_choices_name": spread_choices_name, + } + + # Add the 'conditional' field only if it exists + if "conditional": + choice["conditional"] = conditional + + # Group choices by choiceName using defaultdict + choices_by_name[choice_name].append(choice) + + # TODO: Separate the following code into a separate function + # Generate TypeScript code + ts_code: str = "" # TypeScript code to be written to file + indentation: str = " " # 4-space indentation + + # Add imports + ts_code = f"import {{ Choices }} from 'evolution-common/lib/services/surveyGenerator/types/inputTypes';\n" + ts_code += f"import * as conditionals from './conditionals';\n\n" + + for choice_name, choices in choices_by_name.items(): + # Create a TypeScript const statement for each choiceName + ts_code += f"export const {choice_name}: Choices = [\n" + for index, choice in enumerate(choices): + if choice["spread_choices_name"] is not None: + # Spread choices from another choiceName when spread_choices_name is not None + ts_code += f"{indentation}...{choice['spread_choices_name']}" + else: + ts_code += ( + f"{indentation}{{\n" + f"{indentation}{indentation}value: '{choice['value']}',\n" + f"{indentation}{indentation}label: {{\n" + f"{indentation}{indentation}{indentation}fr: '{choice['label']['fr']}',\n" + f"{indentation}{indentation}{indentation}en: '{choice['label']['en']}'\n" + f"{indentation}{indentation}}}{',' if choice['conditional'] else ''}\n" + ) + # Add the 'conditional' field only if it exists + if "conditional" in choice and choice["conditional"] is not None: + ts_code += f"{indentation}{indentation}conditional: conditionals.{choice['conditional']},\n" + + ts_code += f"{indentation}}}" + if index < len(choices) - 1: + # Add a comma for each choice except the last one + ts_code += "," + ts_code += "\n" + ts_code += "];\n\n" + + # Write TypeScript code to a file + with open(output_file, mode="w", encoding="utf-8", newline="\n") as ts_file: + ts_file.write(ts_code) + + print(f"Generate {output_file} successfully") + + except Exception as e: + # Handle any other exceptions that might occur during script execution + print(f"An error occurred: {e}") + raise e diff --git a/generator/scripts/generate_conditionals.py b/generator/scripts/generate_conditionals.py new file mode 100644 index 00000000..e37e17c6 --- /dev/null +++ b/generator/scripts/generate_conditionals.py @@ -0,0 +1,132 @@ +# Copyright 2024, Polytechnique Montreal and contributors +# This file is licensed under the MIT License. +# License text available at https://opensource.org/licenses/MIT + +# Note: This script includes functions that generate the conditionals.tsx file. +# These functions are intended to be invoked from the generate_survey.py script. +# We use importation without "/" to avoid problems when using the package.json script. +from collections import defaultdict # Group data by name +from ..helpers.generator_helpers import get_data_from_excel # Read data from Excel +from ..helpers.generator_helpers import get_values_from_row # Get values from the row +from ..helpers.generator_helpers import ( + error_when_missing_required_fields, +) # Error when any required fields are None +from ..helpers.generator_helpers import generate_output_file # Generate output file +from ..helpers.generator_helpers import indent # 4-space indentation + + +# Extract conditionals and group them by name +def extract_conditionals_from_data(rows, headers) -> defaultdict: + conditional_by_name = defaultdict(list) # Group conditionals by name + + try: + # Iterate through each row in the sheet, starting from the second row + for row_number, row in enumerate(rows[1:], start=2): + # Get values from the row + ( + conditional_name, + logical_operator, + path, + comparison_operator, + value, + parentheses, + ) = get_values_from_row(row, headers) + + # Error when any required fields are None + error_when_missing_required_fields( + required_fields_names=["conditional_name", "path", "comparison_operator", "value"], + required_fields_values=[conditional_name, path, comparison_operator, value], + row_number=row_number, + ) + + # Create conditional object + conditional = { + "logical_operator": logical_operator, + "path": path, + "comparison_operator": comparison_operator, + "value": value, + "parentheses": parentheses, + } + + # Group conditionals by name using defaultdict + conditional_by_name[conditional_name].append(conditional) + + except Exception as e: + print(f"Error extracting conditionals from Excel data: {e}") + raise e + + return conditional_by_name + + +# Generate TypeScript code based on conditionals grouped by name +def generate_typescript_code(conditional_by_name: defaultdict) -> str: + try: + NEWLINE = "\n" + ts_code = "" + + # Add imports + ts_code += f"import {{ createConditionals }} from 'evolution-common/lib/services/surveyGenerator/createConditionals';{NEWLINE}" + ts_code += f"import {{ Conditional }} from 'evolution-common/lib/services/surveyGenerator/types/inputTypes';{NEWLINE}" + + # Create a TypeScript function for each conditional_name + for conditional_name, conditionals in conditional_by_name.items(): + # Check if any conditional has a path that contains "${relativePath}" + conditionals_has_path = any( + "${relativePath}" in conditional["path"] for conditional in conditionals + ) + declare_relative_path = f"{indent(1)}const relativePath = path.substring(0, path.lastIndexOf('.')); // Remove the last key from the path{NEWLINE}" + + ts_code += f"\nexport const {conditional_name}: Conditional = (interview{', path' if conditionals_has_path else ''}) => {{{NEWLINE}" + ts_code += declare_relative_path if conditionals_has_path else "" + ts_code += indent(1) + "return createConditionals({" + NEWLINE + ts_code += indent(2) + "interview," + NEWLINE + ts_code += indent(2) + "conditionals: [" + NEWLINE + + # Add conditionals + for index, conditional in enumerate(conditionals): + new_value = ( + int(conditional["value"]) + if str(conditional["value"]).isdigit() + else f"'{conditional['value']}'" + ) + conditional_has_path = "${relativePath}" in conditional["path"] + quote = "`" if conditional_has_path else "'" + + ts_code += f"{indent(3)}{{{NEWLINE}" + if conditional["logical_operator"]: + ts_code += f"{indent(4)}logicalOperator: '{conditional['logical_operator']}',{NEWLINE}" + ts_code += ( + f"{indent(4)}path: {quote}{conditional['path']}{quote},{NEWLINE}" + ) + ts_code += f"{indent(4)}comparisonOperator: '{conditional['comparison_operator']}',{NEWLINE}" + ts_code += f"{indent(4)}value: {new_value},{NEWLINE}" + if conditional["parentheses"]: + ts_code += f"{indent(4)}parentheses: '{conditional['parentheses']}',{NEWLINE}" + ts_code += f"{indent(3)}}}" + ts_code += "," if index < len(conditionals) - 1 else "" + ts_code += f"{NEWLINE}" + + ts_code += f"{indent(2)}]{NEWLINE}" + ts_code += f"{indent(1)}}});{NEWLINE}" + ts_code += f"}};{NEWLINE}" + + except Exception as e: + print(f"Error generating conditionals TypeScript code: {e}") + raise e + + return ts_code + + +# Generate conditionals.tsx file based on input Excel file +def generate_conditionals(input_file: str, output_file: str): + # Read data from Excel and return rows and headers + rows, headers = get_data_from_excel(input_file, sheet_name="Conditionals") + + # Extract conditionals and group them by name + conditional_by_name = extract_conditionals_from_data(rows, headers) + + # Generate TypeScript code based on conditionals grouped by name + ts_code = generate_typescript_code(conditional_by_name) + + # Generate conditionals.tsx file + generate_output_file(ts_code, output_file) diff --git a/generator/scripts/generate_excel.py b/generator/scripts/generate_excel.py new file mode 100644 index 00000000..8c622c59 --- /dev/null +++ b/generator/scripts/generate_excel.py @@ -0,0 +1,47 @@ +# Copyright 2024, Polytechnique Montreal and contributors +# This file is licensed under the MIT License. +# License text available at https://opensource.org/licenses/MIT + +# Note: This script includes functions that generate the Excel file from a SharePoint Excel file. +# These functions are intended to be invoked from the generate_survey.py script. +from office365.runtime.auth.authentication_context import AuthenticationContext +from office365.sharepoint.client_context import ClientContext +from office365.sharepoint.files.file import File + + +# Generate the excel file from the SharePoint file +def generate_excel( + sharepoint_url, + excel_input_file_path, + excel_output_file_path, + office365_username, + office365_password, +): + # Function to check if the .env file is correctly configured + def check_env_var(var, env_var_name): + if var is None: + raise ValueError(f"{env_var_name} is not defined in the .env file") + + # Check if the .env file is correctly configured + check_env_var(sharepoint_url, "SHAREPOINT_URL") + check_env_var(excel_input_file_path, "EXCEL_FILE_PATH") + check_env_var(office365_username, "OFFICE365_USERNAME_EMAIL") + check_env_var(office365_password, "OFFICE365_PASSWORD") + + try: + # Authenticate + auth_ctx = AuthenticationContext(sharepoint_url) + if auth_ctx.acquire_token_for_user(office365_username, office365_password): + client_ctx = ClientContext(sharepoint_url, auth_ctx) + + # Download the file + response = File.open_binary(client_ctx, excel_input_file_path) + with open(excel_output_file_path, "wb") as local_file: + local_file.write(response.content) + + print("Generate Excel file successfully") + else: + print(auth_ctx.get_last_error()) + except Exception as e: + print(f"An error occurred with generateExcelScript: {e}") + raise e diff --git a/generator/scripts/generate_input_range.py b/generator/scripts/generate_input_range.py new file mode 100644 index 00000000..2a5de008 --- /dev/null +++ b/generator/scripts/generate_input_range.py @@ -0,0 +1,115 @@ +# Copyright 2024, Polytechnique Montreal and contributors +# This file is licensed under the MIT License. +# License text available at https://opensource.org/licenses/MIT + +# Note: This script includes functions that generate the inputRange.tsx file. +# These functions are intended to be invoked from the generate_survey.py script. +from generator.helpers.generator_helpers import ( + is_excel_file, + is_ts_file, + get_workbook, + sheet_exists, + get_headers, +) + + +# Function to replace single quotes and stringify text +def replaces_quotes_and_stringify(text): + if text is not None: + return str(text).replace("'", "\\'") # Replace single quotes + return None + + +# Function to generate inputRange.tsx +def generate_input_range(input_file: str, output_file: str): + try: + is_excel_file(input_file) # Check if the input file is an Excel file + is_ts_file(output_file) # Check if the output file is an TypeScript file + workbook = get_workbook(input_file) # Get workbook from Excel file + sheet_exists(workbook, "InputRange") # Check if the sheet exists + sheet = workbook["InputRange"] # Get InputRange sheet + + # Get headers from the first row + headers = get_headers( + sheet, + expected_headers=[ + "inputRangeName", + "labelFrMin", + "labelFrMax", + "labelEnMin", + "labelEnMax", + "minValue", + "maxValue", + "unitFr", + "unitEn", + ], + sheet_name="InputRange", + ) + + # Generate TypeScript codedict + ts_code: str = "" # TypeScript code to be written to file + indentation: str = " " # 4-space indentation + + # Add imports + ts_code += "import { InputRangeConfig } from 'evolution-common/lib/services/surveyGenerator/types/inputTypes';\n\n" + + # Iterate through each row in the sheet, starting from the second row + for row in list(sheet.rows)[1:]: + # Create a dictionary from the row values and headers + row_dict = dict(zip(headers, (cell.value for cell in row))) + + # Get values from the row dictionary + input_range_name = row_dict["inputRangeName"] + label_fr_min = replaces_quotes_and_stringify(row_dict["labelFrMin"]) + label_fr_max = replaces_quotes_and_stringify(row_dict["labelFrMax"]) + label_en_min = replaces_quotes_and_stringify(row_dict["labelEnMin"]) + label_en_max = replaces_quotes_and_stringify(row_dict["labelEnMax"]) + min_value = str(row_dict["minValue"]) + max_value = str(row_dict["maxValue"]) + unit_fr = replaces_quotes_and_stringify(row_dict["unitFr"]) + unit_en = replaces_quotes_and_stringify(row_dict["unitEn"]) + + # Check if the row is valid + if ( + input_range_name is None + or label_fr_min is None + or label_fr_max is None + or label_en_min is None + or label_en_max is None + or min_value is None + or max_value is None + or unit_fr is None + or unit_en is None + ): + raise Exception("Invalid row data in InputRange sheet") + + # Generate TypeScript code + ts_code += f"export const {input_range_name}: InputRangeConfig = {{\n" + ts_code += f"{indentation}labels: [\n" + ts_code += f"{indentation}{indentation}{{\n" + ts_code += f"{indentation}{indentation}{indentation}fr: '{label_fr_min}',\n" + ts_code += f"{indentation}{indentation}{indentation}en: '{label_en_min}'\n" + ts_code += f"{indentation}{indentation}}},\n" + ts_code += f"{indentation}{indentation}{{\n" + ts_code += f"{indentation}{indentation}{indentation}fr: '{label_fr_max}',\n" + ts_code += f"{indentation}{indentation}{indentation}en: '{label_en_max}'\n" + ts_code += f"{indentation}{indentation}}}\n" + ts_code += f"{indentation}],\n" + ts_code += f"{indentation}minValue: {min_value},\n" + ts_code += f"{indentation}maxValue: {max_value},\n" + ts_code += f"{indentation}formatLabel: (value, language) => {{\n" + ts_code += f"{indentation}{indentation}return value < 0 ? '' : `${{value}} ${{language === 'fr' ? '{unit_fr}' : language === 'en' ? '{unit_en}' : ''}}`;\n" + # ts_code += f"{indentation}{indentation}return value + ' ' + (language === 'fr' ? '{unit_fr}' : '{unit_en}');\n" + ts_code += f"{indentation}}}\n" + ts_code += "};\n\n" + + # Write TypeScript code to a file + with open(output_file, mode="w", encoding="utf-8", newline="\n") as ts_file: + ts_file.write(ts_code) + + print(f"Generate {output_file} successfully") + + except Exception as e: + # Handle any other exceptions that might occur during script execution + print(f"Error with inputRange.tsx: {e}") + raise e diff --git a/generator/scripts/generate_libelles.py b/generator/scripts/generate_libelles.py new file mode 100644 index 00000000..11fb5497 --- /dev/null +++ b/generator/scripts/generate_libelles.py @@ -0,0 +1,234 @@ +# Copyright 2024, Polytechnique Montreal and contributors +# This file is licensed under the MIT License. +# License text available at https://opensource.org/licenses/MIT + +# Note: This script includes functions that generate the locales libelles files. +# These functions are intended to be invoked from the generate_survey.py script. +import os # For interacting with the operating system +from glob import glob, escape # For file path matching +import ruamel.yaml # For working with YAML files +import openpyxl # For reading Excel files + +# Initialize YAML parser +yaml = ruamel.yaml.YAML() +yaml.indent(sequence=4, offset=4, mapping=4) +yaml.width = 80 + + +# Class for handling various text formatting notations +class ValueReplacer: + # Various HTML and markdown notations + startBoldHtml = "" + endBoldHtml = "" + boldNotation = "**" + + startOblique = '' + endOblique = "" + obliqueNotation = "__" + + startGreen = '' + endGreen = "" + greenNotation = "_green_" + + startRed = '' + endRed = "" + redNotation = "_red_" + + # Static methods for replacing notations with proper HTML tags + @staticmethod + def replaceStartEnd(string, notation, startReplaced, endReplaced): + # Replaces notations with corresponding start/end tags in the string + replacedStr = string + if notation in replacedStr and replacedStr.count(notation) % 2 == 0: + replacedCount = 0 + while notation in replacedStr: + replaceWith = startReplaced if replacedCount % 2 == 0 else endReplaced + replacedStr = replacedStr.replace(notation, replaceWith, 1) + replacedCount += 1 + return replacedStr + + # Main replace function applying all notations + @staticmethod + def replace(string): + # Replaces newlines with
tags and applies other notations + replacedStr = string.replace("\n", "
") + # replaced each bold, oblique, green and red notations by proper tags + replacedStr = ValueReplacer.replaceStartEnd( + replacedStr, + ValueReplacer.boldNotation, + ValueReplacer.startBoldHtml, + ValueReplacer.endBoldHtml, + ) + replacedStr = ValueReplacer.replaceStartEnd( + replacedStr, + ValueReplacer.obliqueNotation, + ValueReplacer.startOblique, + ValueReplacer.endOblique, + ) + replacedStr = ValueReplacer.replaceStartEnd( + replacedStr, + ValueReplacer.greenNotation, + ValueReplacer.startGreen, + ValueReplacer.endGreen, + ) + replacedStr = ValueReplacer.replaceStartEnd( + replacedStr, + ValueReplacer.redNotation, + ValueReplacer.startRed, + ValueReplacer.endRed, + ) + return replacedStr + + +# Class for managing translations in a specific language and section +class TranslationLangNs: + def __init__(self, inputFile): + self.modified = False + self.data = {} + self.file = inputFile + self.startBoldHtml = "" + self.endBoldHtml = "" + + # Convert string to YAML format + def stringToYaml(self, str): + if "\n" in str: + return ruamel.yaml.scalarstring.FoldedScalarString(str) + if len(str) > 76: + return ruamel.yaml.scalarstring.FoldedScalarString(str) + return str + + # Load existing translations from the YAML file + def loadCurrentTranslations(self): + with open(self.file, mode="r") as stream: + try: + translationData = yaml.load(stream) + self.data = {} + for path in translationData: + self.data[path] = self.stringToYaml(translationData[path]) + except Exception as err: + print(f"Error loading yaml file {err}") + raise Exception("Error loading translation yaml file " + self.file) + + # Save modifications back to the YAML file + def save(self): + if self.modified: + with open(self.file, "w", encoding="utf-8") as file: + yaml.dump(self.data, file) + print(f"Generate {self.file.replace('\\', '/')} successfully") + + # Add a new translation or update an existing one + def addTranslation(self, path, value, overwrite, keepMarkdown): + if not overwrite and path in self.data: + return + + value = value.replace("[nom]", r"{{nickname}}") + + # Replace with HTML tags + if not keepMarkdown: + value = ValueReplacer.replace(value) + + self.data[path] = self.stringToYaml(value) + self.modified = True + + +# Class for managing translations for all languages and sections +class TranslationData: + def __init__(self, localesPath): + self.translations = {} # Dictionary to store translations + self.localesPath = localesPath # Path to the locales directory + + # Add translations for a specific language and section + def addTranslations(self, lang, section, translations): + if not lang in self.translations: + self.translations[lang] = {} + self.translations[lang][section] = translations + + # Save all translations to their respective files + def save(self): + for lang in self.translations: + for section in self.translations[lang]: + self.translations[lang][section].save() + + # Add a new translation to the specified language, section, and path + def addTranslation(self, lang, section, path, value, overwrite, keepMarkdown): + try: + if not lang in self.translations: + self.translations[lang] = {} + if not section in self.translations[lang]: + self.translations[lang][section] = TranslationLangNs( + os.path.join(self.localesPath, lang, section + ".yml") + ) + self.translations[lang][section].addTranslation( + path, value, overwrite, keepMarkdown + ) + except Exception as e: + print(f"Exception occurred for {lang} {section} {path}: {e}") + raise e + + +# Class for managing the overall translation process +class FillLocalesTranslations: + def __init__(self, inputFile, localesPath, overwrite, section): + self.inputFile = inputFile + self.localesPath = localesPath + self.overwrite = overwrite + self.section = section + self.allTranslations = TranslationData(localesPath) + super().__init__() + + # Load existing translations from YAML files + def loadCurrentTranslations(self): + ymlFiles = glob(escape(self.localesPath) + "/**/*.yml") + for translationFile in ymlFiles: + path = os.path.normpath(os.path.dirname(translationFile)) + paths = path.split(os.sep) + lang = paths[len(paths) - 1] + section = os.path.splitext(os.path.basename(translationFile))[0] + translationNs = TranslationLangNs(translationFile) + translationNs.loadCurrentTranslations() + self.allTranslations.addTranslations(lang, section, translationNs) + + # Save all translations back to YAML files + def saveAllTranslations(self): + self.allTranslations.save() + + # Function to add translations from Excel input file to the translations data + def addTranslationsFromExcel(self): + try: + workbook = openpyxl.load_workbook(self.inputFile, data_only=True) + sheet = workbook["Widgets"] # Get Widgets sheet + + for row in sheet.iter_rows(min_row=2, values_only=True): + section = row[3] + path = row[4] + fr = row[5] + en = row[6] + # keepMarkdown = row[11] + keepMarkdown = False + + if fr is not None: + self.allTranslations.addTranslation( + "fr", section, path, fr, self.overwrite, keepMarkdown + ) + if en is not None: + self.allTranslations.addTranslation( + "en", section, path, en, self.overwrite, keepMarkdown + ) + + except Exception as e: + print(f"Exception occurred in addTranslationsFromExcel: {e}") + raise e + + +# Function to generate the libelles locales files +def generate_libelles(inputFile, localesPath, overwrite=False, section=None): + try: + # Initialize the FillLocalesTranslations task with provided parameters + task = FillLocalesTranslations(inputFile, localesPath, overwrite, section) + task.loadCurrentTranslations() + task.addTranslationsFromExcel() + task.saveAllTranslations() + print("Generate translations successfully") + except Exception as e: + print(f"An error occurred: {e}") + raise e diff --git a/generator/scripts/generate_survey.py b/generator/scripts/generate_survey.py new file mode 100644 index 00000000..501b0318 --- /dev/null +++ b/generator/scripts/generate_survey.py @@ -0,0 +1,79 @@ +# Copyright 2024, Polytechnique Montreal and contributors +# This file is licensed under the MIT License. +# License text available at https://opensource.org/licenses/MIT + +# Note: This script generate the survey with multiple scripts. +# These functions are intended to be invoked with the config YAML file. +import argparse # For command-line arguments +from dotenv import load_dotenv # For environment variables +import os # For environment variables +import yaml # For reading the yaml file +from .generate_excel import generate_excel +from .generate_widgets import generate_widgets +from .generate_conditionals import generate_conditionals +from .generate_choices import generate_choices +from .generate_input_range import generate_input_range +from .generate_libelles import generate_libelles + +# TODO: Add some validation for the config file +# Generate the survey from the config file +def generate_survey(config_path): + # Load environment variables from .env file + load_dotenv() + + # Load the data from the YAML file + with open(config_path, "r") as file: + surveyGenerator = yaml.safe_load(file) + + # Get the data from the YAML file + survey = surveyGenerator["survey"] + excel = surveyGenerator["excel"] + widgets = surveyGenerator["widgets"] + conditionals = surveyGenerator["conditionals"] + choices = surveyGenerator["choices"] + input_range = surveyGenerator["input_range"] + libelles = surveyGenerator["libelles"] + + # Call the generate_excel function to generate the Excel file if active script + if excel["active_script"]: + generate_excel( + os.getenv("SHAREPOINT_URL"), + os.getenv("EXCEL_FILE_PATH"), + survey["excel_file"], + os.getenv("OFFICE365_USERNAME_EMAIL"), + os.getenv("OFFICE365_PASSWORD"), + ) + + # Call the generate_widgets function to generate widgets.tsx for each section + generate_widgets(survey["excel_file"], widgets["output_info_list"]) + + # Call the generate_conditionals function to generate conditionals.tsx + generate_conditionals(survey["excel_file"], conditionals["output_file"]) + + # Call the generate_choices function to generate choices.tsx + generate_choices(survey["excel_file"], choices["output_file"]) + + # Call the generate_input_range function to generate labels.tsx + generate_input_range(survey["excel_file"], input_range["output_file"]) + + # Call the generate_libelles function to generate the libelles locales folder + generate_libelles( + survey["excel_file"], + libelles["output_folder"], + libelles["overwrite"], + libelles["section"], + ) + + +# Call the generate_survey function with the config_path argument +if __name__ == "__main__": + # Parse command-line arguments + parser = argparse.ArgumentParser() + parser.add_argument( + "--config_path", required=True, help="Path to the Generator config file" + ) + args = parser.parse_args() + config_path = args.config_path + + # Call the generate_survey function with the config_path argument + generate_survey(config_path) diff --git a/generator/scripts/generate_widgets.py b/generator/scripts/generate_widgets.py new file mode 100644 index 00000000..4f2e4492 --- /dev/null +++ b/generator/scripts/generate_widgets.py @@ -0,0 +1,267 @@ +# Copyright 2024, Polytechnique Montreal and contributors +# This file is licensed under the MIT License. +# License text available at https://opensource.org/licenses/MIT + +# Note: This script includes functions that generate the widgets.tsx file. +# These functions are intended to be invoked from the generate_survey.py script. +import openpyxl # Read data from Excel + +indentation = " " # Indentation of 4 spaces + +# Function to generate widgets.tsx for each section +def generate_widgets(input_path, output_info_list): + try: + workbook = openpyxl.load_workbook(input_path, data_only=True) + sheet = workbook['Widgets'] # Get Widgets sheet + rows = list(sheet.rows) + + # Transform Excel content into TypeScript code + def convert_excel_to_typescript(section): + headers = [cell.value for cell in rows[0]] + + section_rows = [] + for row in rows[1:]: + values = [cell.value if cell.value is not None else '' for cell in row] + + if len(values) != len(headers): + print(f"Skipping row {row}: Number of values ({len(values)}) does not match the number of headers ({len(headers)}).") + continue + + row_dict = dict(zip(headers, values)) + section_rows.append(row_dict) + + # Filter rows based on section + section_rows = [row for row in section_rows if row['section'] == section] + + # Loop the section rows to check if we need to import choices, custom conditionals, input_range, or custom widgets + has_choices_import = False + has_conditionals_import = False + has_input_range_import = False + has_custom_widgets_import = False + has_help_popup_import = False + for row in section_rows: + if row['choices']: + has_choices_import = True + if row['conditional']: + has_conditionals_import = True + if row['inputRange']: + has_input_range_import = True + if row['inputType'] == 'Custom': + has_custom_widgets_import = True + if row['help_popup']: + has_help_popup_import = True + + # Generate import statements + import_statements = generate_import_statements(has_choices_import, has_conditionals_import, has_input_range_import, has_custom_widgets_import, has_help_popup_import) + + # Generate widgets statements + widgets_statements = [generate_widget_statement(row) for row in section_rows] + widgets_statements = f"{import_statements}\n{'\n\n'.join(widgets_statements)}\n" + + # Generate widgets names + widgets_names = '\n'.join([generate_widget_name(row, is_last_row=(index == len(section_rows) - 1)) for index, row in enumerate(section_rows)]) + widgets_names_statements = f"export const widgetsNames = [\n{''.join(widgets_names)}\n];\n" + + return {'widgetsStatements': widgets_statements, 'widgetsNamesStatements': widgets_names_statements} + + # Process the output files based on sections + for output_info in output_info_list: + transformed_content = convert_excel_to_typescript(output_info['section']) + + # Write the transformed content to the widgets output file + with open(output_info['output_folder'] + '/widgets.tsx', mode='w', encoding='utf-8', newline='\n') as f: + f.write(transformed_content['widgetsStatements']) + print(f"Generate {output_info['output_folder']}widgets.tsx successfully") + + # Write the transformed content to the widgetsNames output file + with open(output_info['output_folder'] + '/widgetsNames.ts', mode='w', encoding='utf-8', newline='\n') as f: + f.write(transformed_content['widgetsNamesStatements']) + print(f"Generate {output_info['output_folder']}widgetsNames.ts successfully") + + except Exception as e: + print(f"Error with widgets: {e}") + raise e + +# Generate widget statement for a row +def generate_widget_statement(row): + question_name = row['questionName'] + input_type = row['inputType'] + section = row['section'] + path = row['path'] + conditional = row['conditional'] + validation = row['validation'] + input_range = row['inputRange'] + help_popup = row['help_popup'] + choices = row['choices'] + + widget : str = '' + if input_type == 'Custom': + widget = generate_custom_widget(question_name) + elif input_type == 'Radio': + widget = generate_radio_widget(question_name, section, path, choices, help_popup, conditional, validation) + elif input_type == 'Select': + widget = generate_select_widget(question_name, section, path, choices, conditional, validation) + elif input_type == 'String': + widget = generate_string_widget(question_name, section, path, conditional, validation) + elif input_type == 'Number': + widget = generate_number_widget(question_name, section, path, conditional, validation) + elif input_type == 'Text': + widget = generate_text_widget(question_name, section, path, conditional) + elif input_type == 'Range': + widget = generate_range_widget(question_name, section, path, input_range, conditional, validation) + elif input_type == 'Checkbox': + widget = generate_checkbox_widget(question_name, section, path, choices, help_popup, conditional, validation) + elif input_type == 'NextButton': + widget = generate_next_button_widget(question_name, section, path) + elif input_type == 'TextArea': + widget = generate_text_area_widget(question_name, section, path, conditional, validation) + else: + widget = f"// {question_name}" + + return widget + +# Generate import statement if needed +def generate_import_statements(has_choices_import, has_conditionals_import, has_input_range_import, has_custom_widgets_import, has_help_popup_import): + choices_import = ("// " if not has_choices_import else "") + "import * as choices from '../../common/choices';\n" + conditionals_import = ("// " if not has_conditionals_import else "") + "import * as conditionals from '../../common/conditionals';\n" + custom_widgets_import = ("// " if not has_custom_widgets_import else "") + "import * as customWidgets from '../../common/customWidgets';\n" + help_popup_import = ("// " if not has_help_popup_import else "") + "import * as helpPopup from '../../common/helpPopup';\n" + input_range_import = ("// " if not has_input_range_import else "") + "import * as inputRange from '../../common/inputRange';\n" + return f"import {{ TFunction }} from 'i18next';\n" \ + f"import * as defaultInputBase from '../../common/defaultInputBase';\n" \ + f"import {{ defaultConditional }} from '../../common/defaultConditional';\n" \ + f"{choices_import}" \ + f"{conditionals_import}" \ + f"{custom_widgets_import}" \ + f"{help_popup_import}" \ + f"import * as inputTypes from 'evolution-common/lib/services/surveyGenerator/types/inputTypes';\n" \ + f"{input_range_import}" \ + f"import * as validations from '../../common/validations';\n" + +# Generate widgetsNames +def generate_widget_name(row, is_last_row=False): + question_name = row['questionName'] + active = row['active'] + + if active: + return f"{indentation}'{question_name}'" if is_last_row else f"{indentation}'{question_name}'," + else: + return f"{indentation}// '{question_name}'" if is_last_row else f"{indentation}// '{question_name}'," + +# Generate Custom widget +def generate_custom_widget(question_name): + return f"export const {question_name} = customWidgets.{question_name};" + +# Generate comma and skip line +def generate_comma(comma): return f"{',' if comma else ''}" +def generate_skip_line(skip_line): return f"{'\n' if skip_line else ''}" + +# Generate all the widget parts +def generate_constExport(question_name, input_type): return f"export const {question_name}: inputTypes.{input_type} = {{" +def generate_defaultInputBase(defaultInputBase): return f"{indentation}...defaultInputBase.{defaultInputBase}" +def generate_path(path): return f"{indentation}path: '{path}'" +def generate_label(section, path): return f"{indentation}label: (t: TFunction) => t('{section}:{path}')" +def generate_help_popup(help_popup, comma=True, skip_line=True): + if help_popup: + return f"{indentation}helpPopup: helpPopup.{help_popup}{generate_comma(comma)}{generate_skip_line(skip_line)}" + else: + return "" +def generate_text(section, path): return f"{indentation}text: (t: TFunction) => `

${{t('{section}:{path}')}}

`" +def generate_choices(choices): return f"{indentation}choices: choices.{choices}" +def generate_conditional(conditional): + return f"{indentation}{"conditional: conditionals." + conditional if conditional else "conditional: defaultConditional"}" +def generate_validation(validation): + return f"{indentation}validations: validations.{validation if validation else "requiredValidation"}" + +# Generate InputRadio widget +def generate_radio_widget(question_name, section, path, choices, help_popup, conditional, validation): + return f"{generate_constExport(question_name, 'InputRadio')}\n" \ + f"{generate_defaultInputBase('inputRadioBase')},\n" \ + f"{generate_path(path)},\n" \ + f"{generate_label(section, path)},\n" \ + f"{generate_help_popup(help_popup)}" \ + f"{generate_choices(choices)},\n" \ + f"{generate_conditional(conditional)},\n" \ + f"{generate_validation(validation)}\n" \ + f"}};" + +# Generate Select widget +def generate_select_widget(question_name, section, path, choices, conditional, validation): + return f"{generate_constExport(question_name, 'InputSelect')}\n" \ + f"{generate_defaultInputBase('inputSelectBase')},\n" \ + f"{generate_path(path)},\n" \ + f"{generate_label(section, path)},\n" \ + f"{generate_choices(choices)},\n" \ + f"{generate_conditional(conditional)},\n" \ + f"{generate_validation(validation)}\n" \ + f"}};" + +# Generate InputString widget +def generate_string_widget(question_name, section, path, conditional, validation): + return f"{generate_constExport(question_name, 'InputString')}\n" \ + f"{generate_defaultInputBase('inputStringBase')},\n" \ + f"{generate_path(path)},\n" \ + f"{generate_label(section, path)},\n" \ + f"{generate_conditional(conditional)},\n" \ + f"{generate_validation(validation)}\n" \ + f"}};" + +# Generate InputNumber widget +def generate_number_widget(question_name, section, path, conditional, validation): + return f"{generate_constExport(question_name, 'InputString')}\n" \ + f"{generate_defaultInputBase('inputNumberBase')},\n" \ + f"{generate_path(path)},\n" \ + f"{generate_label(section, path)},\n" \ + f"{generate_conditional(conditional)},\n" \ + f"{generate_validation(validation)}\n" \ + f"}};" + +# Generate Text widget +def generate_text_widget(question_name, section, path, conditional): + return f"{generate_constExport(question_name, 'InputText')}\n" \ + f"{generate_defaultInputBase('inputTextBase')},\n" \ + f"{generate_path(path)},\n" \ + f"{generate_text(section, path)},\n" \ + f"{generate_conditional(conditional)}\n" \ + f"}};" + +# Generate InputRange widget +def generate_range_widget(question_name, section, path, input_range, conditional, validation): + return f"{generate_constExport(question_name, 'InputRange')}\n" \ + f"{generate_defaultInputBase('inputRangeBase')},\n" \ + f"{indentation}...inputRange.{input_range},\n" \ + f"{generate_path(path)},\n" \ + f"{generate_label(section, path)},\n" \ + f"{generate_conditional(conditional)},\n" \ + f"{generate_validation(validation)}\n" \ + f"}};" + +# Generate InputCheckbox widget +def generate_checkbox_widget(question_name, section, path, choices, help_popup, conditional, validation): + return f"{generate_constExport(question_name, 'InputCheckbox')}\n" \ + f"{generate_defaultInputBase('inputCheckboxBase')},\n" \ + f"{generate_path(path)},\n" \ + f"{generate_label(section, path)},\n" \ + f"{generate_help_popup(help_popup)}" \ + f"{generate_choices(choices)},\n" \ + f"{generate_conditional(conditional)},\n" \ + f"{generate_validation(validation)}\n" \ + f"}};" + +# Generate NextButton widget +def generate_next_button_widget(question_name, section, path): + return f"{generate_constExport(question_name, 'InputButton')}\n" \ + f"{generate_defaultInputBase('buttonNextBase')},\n" \ + f"{generate_path(path)},\n" \ + f"{generate_label(section, path)}\n" \ + f"}};" + +# Generate TextArea widget +def generate_text_area_widget(question_name, section, path, conditional, validation): + return f"{generate_constExport(question_name, 'TextArea')}\n" \ + f"{generate_defaultInputBase('textAreaBase')},\n" \ + f"{generate_path(path)},\n" \ + f"{generate_label(section, path)},\n" \ + f"{generate_conditional(conditional)},\n" \ + f"{generate_validation(validation)}\n" \ + f"}};" diff --git a/generator/tests/test_generate_choices.py b/generator/tests/test_generate_choices.py new file mode 100644 index 00000000..c3ebe4a9 --- /dev/null +++ b/generator/tests/test_generate_choices.py @@ -0,0 +1,240 @@ +# Copyright 2024, Polytechnique Montreal and contributors +# This file is licensed under the MIT License. +# License text available at https://opensource.org/licenses/MIT + +# Note: This script tests the generate_choices functions. +import os # File system operations +import pytest # Testing framework +from generator.scripts.generate_choices import generate_choices +from generator.helpers.generator_helpers import ( + create_mocked_excel_data, + delete_file_if_exists, +) + + +# Define constants +MOCKED_EXCEL_FILE = "generator/examples/test.xlsx" +GOOD_INPUT_FILE = "generator/examples/test.xlsx" +BAD_INPUT_FILE = "generator/examples/test.csv" +GOOD_OUPUT_FILE = "generator/examples/survey/common/choices.tsx" +BAD_OUTPUT_FILE = "generator/examples/survey/common/choices.txt" +GOOD_SHEET_NAME = "Choices" +BAD_SHEET_NAME = "ChoicesBad" +GOOD_HEADERS = [ + "choicesName", + "value", + "fr", + "en", + "spreadChoicesName", + "conditional", +] +BAD_HEADERS = [ + "choicesNameBad", + "value", + "fr", + "en", + "spreadChoicesName", + "conditional", +] +GOOD_ROWS_DATA = [ + [ + "yesNoChoices", + "yes", + "Oui", + "Yes", + None, + None, + ], + [ + "yesNoChoices", + "no", + "Non", + "No", + None, + None, + ], + [ + "busCarTransport", + "bus", + "Autobus", + "Bus", + None, + None, + ], + [ + "busCarTransport", + "car", + "Voiture", + "Car", + None, + None, + ], + [ + "transportModesChoices", + None, + None, + None, + "busCarTransport", + None, + ], + [ + "transportModesChoices", + "commuterTrain", + "Train de banlieu", + "Commuter train", + None, + None, + ], + [ + "transportModesChoices", + "metro", + "Métro", + "Metro", + None, + None, + ], + [ + "transportModesChoices", + "rem", + "REM", + "REM", + None, + None, + ], + [ + "transportModesChoices", + "paratransit", + "Transport Adapté", + "Paratransit", + None, + None, + ], +] +BAD_ROWS_DATA = [["badRowData"]] +# BAD_NUMBER_OF_COLUMNS_ROW_DATA = [ +# [ +# "confidentInputRange", +# "Pas du tout confiant", +# "Très confiant", +# "Not at all confident", +# "Very confident", +# -10, +# 100, +# "%", +# "%", +# "tooMuchData", +# ] +# ] + + +@pytest.mark.parametrize( + "sheet_name, headers, row_data, input_file, output_file, expected_error", + [ + # Test that the example works great + ( + None, # No mocked Excel data + None, # No mocked Excel data + None, # No mocked Excel data + "generator/examples/Example_Generate_Survey.xlsx", + GOOD_OUPUT_FILE, + None, # No error expected + ), + # Test that the function works great + ( + GOOD_SHEET_NAME, + GOOD_HEADERS, + GOOD_ROWS_DATA, + GOOD_INPUT_FILE, + GOOD_OUPUT_FILE, + None, # No error expected + ), + # Test that the function catch bad input file type + ( + GOOD_SHEET_NAME, + GOOD_HEADERS, + GOOD_ROWS_DATA, + BAD_INPUT_FILE, + GOOD_OUPUT_FILE, + f"Invalid input file extension for {BAD_INPUT_FILE} : must be an Excel .xlsx file", + ), + # Test that the function catch bad output file type + ( + GOOD_SHEET_NAME, + GOOD_HEADERS, + GOOD_ROWS_DATA, + GOOD_INPUT_FILE, + BAD_OUTPUT_FILE, + f"Invalid output file extension for {BAD_OUTPUT_FILE} : must be an TypeScript .tsx file", + ), + # Test that the function catch bad sheet name + ( + BAD_SHEET_NAME, + GOOD_HEADERS, + GOOD_ROWS_DATA, + GOOD_INPUT_FILE, + GOOD_OUPUT_FILE, + "Invalid sheet name in Choices sheet", + ), + # Test that the function catch bad headers + ( + GOOD_SHEET_NAME, + BAD_HEADERS, + GOOD_ROWS_DATA, + GOOD_INPUT_FILE, + GOOD_OUPUT_FILE, + "Invalid headers in Choices sheet", + ), + # # TODO: Test that the function catch bad row data + # ( + # GOOD_SHEET_NAME, + # GOOD_HEADERS, + # BAD_ROWS_DATA, + # GOOD_INPUT_FILE, + # GOOD_OUPUT_FILE, + # "Invalid row data in Choices sheet", + # ), + # # TODO: Test that the function catch bad number of columns in row data + # ( + # GOOD_SHEET_NAME, + # GOOD_HEADERS, + # BAD_NUMBER_OF_COLUMNS_ROW_DATA, + # GOOD_INPUT_FILE, + # GOOD_OUPUT_FILE, + # "Invalid number of column in Choices sheet", + # ), + ], +) +def test_generate_choices( + sheet_name, headers, row_data, input_file, output_file, expected_error +): + # Create mocked Excel data if needed + if sheet_name is not None and headers is not None and row_data is not None: + # Create mocked Excel data + create_mocked_excel_data(sheet_name, headers, row_data) + + # Generate inputRange.tsx + if expected_error is not None: + # Check that the function raises an error + with pytest.raises(Exception) as e_info: + generate_choices(input_file, output_file) + + # Check the error message + assert str(e_info.value) == expected_error + else: + generate_choices(input_file, output_file) + + # Check that the output file is created + assert os.path.isfile(output_file) + + # Read the content of the generated TypeScript file + with open(output_file, mode="r", encoding="utf-8") as ts_file: + ts_code = ts_file.read() + + # Check that the TypeScript code is correct + assert ( + "import { Choices } from 'evolution-common/lib/services/surveyGenerator/types/inputTypes';" + in ts_code + ) + + # Delete mocked Excel data if it exists + delete_file_if_exists(MOCKED_EXCEL_FILE) diff --git a/generator/tests/test_generate_input_range.py b/generator/tests/test_generate_input_range.py new file mode 100644 index 00000000..73e343ac --- /dev/null +++ b/generator/tests/test_generate_input_range.py @@ -0,0 +1,192 @@ +# Copyright 2024, Polytechnique Montreal and contributors +# This file is licensed under the MIT License. +# License text available at https://opensource.org/licenses/MIT + +# Note: This script tests the generate_input_range functions. +import os # File system operations +import pytest # Testing framework +from typing import List, Union, Optional # Types for Python + +from generator.scripts.generate_input_range import generate_input_range +from generator.helpers.generator_helpers import ( + create_mocked_excel_data, + delete_file_if_exists, +) + + +# Define constants +MOCKED_EXCEL_FILE = "generator/examples/test.xlsx" +GOOD_INPUT_FILE = "generator/examples/test.xlsx" +BAD_INPUT_FILE = "generator/examples/test.csv" +GOOD_OUPUT_FILE = "generator/examples/survey/common/inputRange.tsx" +BAD_OUTPUT_FILE = "generator/examples/survey/common/inputRange.txt" +GOOD_SHEET_NAME = "InputRange" +BAD_SHEET_NAME = "InputRangeBad" +GOOD_HEADERS = [ + "inputRangeName", + "labelFrMin", + "labelFrMax", + "labelEnMin", + "labelEnMax", + "minValue", + "maxValue", + "unitFr", + "unitEn", +] +BAD_HEADERS = [ + "inputRangeNameBad", + "labelFrMin", + "labelFrMax", + "labelEnMin", + "labelEnMax", + "minValue", + "maxValue", + "unitFr", + "unitEn", +] +GOOD_ROWS_DATA = [ + [ + "confidentInputRange", + "Pas du tout confiant", + "Très confiant", + "Not at all confident", + "Very confident", + -10, + 100, + "%", + "%", + ] +] +BAD_ROWS_DATA = [["badRowData"]] +BAD_NUMBER_OF_COLUMNS_ROW_DATA = [ + [ + "confidentInputRange", + "Pas du tout confiant", + "Très confiant", + "Not at all confident", + "Very confident", + -10, + 100, + "%", + "%", + "tooMuchData", + ] +] + + +@pytest.mark.parametrize( + "sheet_name, headers, row_data, input_file, output_file, expected_error", + [ + # Test that the example works great + ( + None, # No mocked Excel data + None, # No mocked Excel data + None, # No mocked Excel data + "generator/examples/Example_Generate_Survey.xlsx", + GOOD_OUPUT_FILE, + None, # No error expected + ), + # Test that the function works great + ( + GOOD_SHEET_NAME, + GOOD_HEADERS, + GOOD_ROWS_DATA, + GOOD_INPUT_FILE, + GOOD_OUPUT_FILE, + None, # No error expected + ), + # Test that the function catch bad input file type + ( + GOOD_SHEET_NAME, + GOOD_HEADERS, + GOOD_ROWS_DATA, + BAD_INPUT_FILE, + GOOD_OUPUT_FILE, + f"Invalid input file extension for {BAD_INPUT_FILE} : must be an Excel .xlsx file", + ), + # Test that the function catch bad output file type + ( + GOOD_SHEET_NAME, + GOOD_HEADERS, + GOOD_ROWS_DATA, + GOOD_INPUT_FILE, + BAD_OUTPUT_FILE, + f"Invalid output file extension for {BAD_OUTPUT_FILE} : must be an TypeScript .tsx file", + ), + # Test that the function catch bad sheet name + ( + BAD_SHEET_NAME, + GOOD_HEADERS, + GOOD_ROWS_DATA, + GOOD_INPUT_FILE, + GOOD_OUPUT_FILE, + "Invalid sheet name in InputRange sheet", + ), + # Test that the function catch bad headers + ( + GOOD_SHEET_NAME, + BAD_HEADERS, + GOOD_ROWS_DATA, + GOOD_INPUT_FILE, + GOOD_OUPUT_FILE, + "Invalid headers in InputRange sheet", + ), + # Test that the function catch bad row data + ( + GOOD_SHEET_NAME, + GOOD_HEADERS, + BAD_ROWS_DATA, + GOOD_INPUT_FILE, + GOOD_OUPUT_FILE, + "Invalid row data in InputRange sheet", + ), + # Test that the function catch bad number of columns in row data + ( + GOOD_SHEET_NAME, + GOOD_HEADERS, + BAD_NUMBER_OF_COLUMNS_ROW_DATA, + GOOD_INPUT_FILE, + GOOD_OUPUT_FILE, + "Invalid number of column in InputRange sheet", + ), + ], +) +def test_generate_input_range( + sheet_name: Optional[str], + headers: Optional[List[str]], + row_data: Optional[List[List[Union[str, int, float]]]], + input_file: str, + output_file: str, + expected_error: Optional[str], +) -> None: + # Create mocked Excel data if needed + if sheet_name is not None and headers is not None and row_data is not None: + # Create mocked Excel data + create_mocked_excel_data(sheet_name, headers, row_data) + + # Generate inputRange.tsx + if expected_error is not None: + # Check that the function raises an error + with pytest.raises(Exception) as e_info: + generate_input_range(input_file, output_file) + + # Check the error message + assert str(e_info.value) == expected_error + else: + generate_input_range(input_file, output_file) + + # Check that the output file is created + assert os.path.isfile(output_file) + + # Read the content of the generated TypeScript file + with open(output_file, mode="r", encoding="utf-8") as ts_file: + ts_code = ts_file.read() + + # Check that the TypeScript code is correct + assert ( + "import { InputRangeConfig } from 'evolution-common/lib/services/surveyGenerator/types/inputTypes';" + in ts_code + ) + + # Delete mocked Excel data if it exists + delete_file_if_exists(MOCKED_EXCEL_FILE) diff --git a/package.json b/package.json index 2d401c7f..678a88e4 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,13 @@ "test": "yarn workspace evolution-common run test && yarn workspace evolution-backend run test && yarn workspace evolution-frontend run test && yarn workspace evolution-legacy run test && yarn workspace evolution-interviewer run test", "test:unit": "yarn workspace evolution-common run test:unit && yarn workspace evolution-backend run test:unit && yarn workspace evolution-frontend run test:unit && yarn workspace evolution-legacy run test:unit && yarn workspace evolution-interviewer run test:unit", "test:sequential": "yarn workspace evolution-common run test:sequential && yarn workspace evolution-backend run test:sequential && yarn workspace evolution-frontend run test:sequential && yarn workspace evolution-interviewer run test:sequential", + "test:generator": "python -m pytest -s -W ignore generator/tests/", "lint": "yarn workspace evolution-common run lint && yarn workspace evolution-backend run lint && yarn workspace evolution-frontend run lint && yarn workspace evolution-interviewer run lint", "format": "yarn workspace evolution-common run format && yarn workspace evolution-backend run format && yarn workspace evolution-frontend run format && yarn workspace evolution-interviewer run format", "list-tasks": "yarn workspace evolution-legacy run list-tasks", "generate-migration": "knex migrate:make", - "reset-submodules": "rimraf transition/ && git submodule init && git submodule update" + "reset-submodules": "rimraf transition/ && git submodule init && git submodule update", + "generateSurvey:example": "python -m generator.scripts.generate_survey --config_path=generator/examples/generatorExampleConfigs.yaml" }, "dependencies": {}, "devDependencies": { diff --git a/packages/evolution-common/src/services/surveyGenerator/common/defaultConditional.tsx b/packages/evolution-common/src/services/surveyGenerator/common/defaultConditional.tsx new file mode 100644 index 00000000..341fd691 --- /dev/null +++ b/packages/evolution-common/src/services/surveyGenerator/common/defaultConditional.tsx @@ -0,0 +1,12 @@ +/* + * Copyright 2024, Polytechnique Montreal and contributors + * + * This file is licensed under the MIT License. + * License text available at https://opensource.org/licenses/MIT + */ +import { Conditional } from '../types/inputTypes'; + +// Accept all the time +const defaultConditional: Conditional = () => [true, null]; + +export default defaultConditional; diff --git a/packages/evolution-common/src/services/surveyGenerator/helpers/createConditionals.tsx b/packages/evolution-common/src/services/surveyGenerator/helpers/createConditionals.tsx new file mode 100644 index 00000000..8b449f9c --- /dev/null +++ b/packages/evolution-common/src/services/surveyGenerator/helpers/createConditionals.tsx @@ -0,0 +1,145 @@ +/* + * Copyright 2024, Polytechnique Montreal and contributors + * + * This file is licensed under the MIT License. + * License text available at https://opensource.org/licenses/MIT + /* + * Copyright 2024, Polytechnique Montreal and contributors + * + * This file is licensed under the MIT License. + * License text available at https://opensource.org/licenses/MIT + */ +import * as surveyHelperNew from '../../../utils/helpers'; + +// Type definitions for conditionals, logical operators and comparison operators +type PathType = string; +type ComparisonOperatorsType = '===' | '!==' | '>' | '<' | '>=' | '<='; +type ValueType = string | number | boolean; +type logicalOperatorsType = '&&' | '||'; +type ParenthesesType = '(' | ')'; +type SingleConditionalsType = { + logicalOperator?: logicalOperatorsType; + path: PathType; + comparisonOperator: ComparisonOperatorsType; + value: ValueType; + parentheses?: ParenthesesType; +}; +type ConditionalsType = SingleConditionalsType[]; + +// TODO: Make sure to add tests for this function +// Check interview responses with conditions, returning the result and null. +export const createConditionals = ({ interview, conditionals }: { interview; conditionals: ConditionalsType }) => { + let mathExpression = ''; // Construct the math expression to be evaluated + + // Iterate through the provided conditionals + conditionals.forEach((conditional, index) => { + // Extract components of the conditional + const { logicalOperator, path, comparisonOperator, value, parentheses } = conditional; + const response = surveyHelperNew.getResponse(interview, path, null); + let conditionMet: boolean; + + // Evaluate if the condition is met + if (value === 'null') { + // For value 'null', check if the response is null + switch (comparisonOperator) { + case '===': + if (Array.isArray(response)) { + // For Array + conditionMet = response.length === 0; + } else { + // For String or Boolean + conditionMet = response === null; + } + break; + case '!==': + if (Array.isArray(response)) { + // For Array + conditionMet = response.length !== 0; + } else { + // For String or Boolean + conditionMet = response !== null; + } + break; + default: + conditionMet = false; + break; + } + } else if ( + (typeof response === 'string' && typeof value === 'string') || + (typeof response === 'boolean' && typeof value === 'boolean') + ) { + // For String or Boolean + switch (comparisonOperator) { + case '===': + conditionMet = response === value; + break; + case '!==': + conditionMet = response !== value; + break; + default: + conditionMet = false; + break; + } + } else if (typeof value === 'number') { + // For Number + switch (comparisonOperator) { + case '===': + conditionMet = Number(response) === value; + break; + case '!==': + conditionMet = Number(response) !== value; + break; + case '>': + conditionMet = Number(response) > value; + break; + case '<': + conditionMet = Number(response) < value; + break; + case '>=': + conditionMet = Number(response) >= value; + break; + case '<=': + conditionMet = Number(response) <= value; + break; + default: + conditionMet = false; + break; + } + } else if (Array.isArray(response)) { + // For Array + switch (comparisonOperator) { + case '===': + conditionMet = response.includes(value); + break; + case '!==': + conditionMet = !response.includes(value); + break; + default: + conditionMet = false; + break; + } + } else { + // Handle other response types if necessary + conditionMet = false; + } + + const parenthesesStart = parentheses === '(' ? '(' : ''; // Add an opening parentheses if necessary + const parenthesesEnd = parentheses === ')' ? ')' : ''; // Add a closing parentheses if necessary + + if (index === 0) { + // For the first condition, initialize the result + mathExpression = parenthesesStart + conditionMet + parenthesesEnd; + } else if (logicalOperator === '||') { + mathExpression += ' || ' + parenthesesStart + conditionMet + parenthesesEnd; // Add the result to the final result + } else if (logicalOperator === '&&') { + mathExpression += ' && ' + parenthesesStart + conditionMet + parenthesesEnd; // Add the result to the final result + } + }); + + // FIXME: This eval() is a security risk, and should be replaced with a safer alternative + // Evaluate the final result using eval() to handle logical operators + const finalResult: boolean = eval(mathExpression); + + // Return the final result along with null (as per the function signature) + return [finalResult, null]; +}; diff --git a/packages/evolution-common/src/services/surveyGenerator/types/inputTypes.ts b/packages/evolution-common/src/services/surveyGenerator/types/inputTypes.ts new file mode 100644 index 00000000..a0abfbb1 --- /dev/null +++ b/packages/evolution-common/src/services/surveyGenerator/types/inputTypes.ts @@ -0,0 +1,245 @@ +/* + * Copyright 2024, Polytechnique Montreal and contributors + * + * This file is licensed under the MIT License. + * License text available at https://opensource.org/licenses/MIT + */ +import { TFunction } from 'i18next'; +// import IconDefinition from '@fortawesome/fontawesome-svg-core/definitions/IconDefinition'; + +/* Define types for all the different input types */ +type ContainsHtml = boolean; +type TwoColumns = boolean; +type AddCustom = boolean; +type Columns = 1 | 2; +type Path = string; +type Placeholder = string; +type Text = (t: TFunction) => string; +export type InputFilter = (value) => string | number; +type LabelFunction = (t: TFunction, interview?, path?) => string; +type LabelNotFunction = { en: string; fr: string }; +type Label = LabelFunction | LabelNotFunction; +export type Labels = { + fr: string; + en: string; +}[]; +type Choice = { + value: string | number; + label: { + fr: string; + en: string; + }; + conditional?: Conditional; +}; +type ChoiceFunction = (interview, path?: Path) => Choice[]; +export type Choices = Choice[] | ChoiceFunction; +export type Conditional = (interview: any, path?: Path) => boolean | (boolean | null)[]; +export type Validations = ( + value?: number | string, + customValue?, + interview?, + path?, + customPath? +) => { + validation: boolean; + errorMessage?: { + fr: string; + en: string; + }; +}[]; +export type NameFunction = (groupedObject, sequence, interview) => string; +export type HelpPopup = { + containsHtml?: ContainsHtml; + title: { fr: string; en: string }; + content: { fr: string; en: string }; + // TODO: This is the correct type, but it doesn't work with the current implementation + // title: (t: TFunction) => string; + // content: (t: TFunction) => string; +}; + +// TODO: Place all the correct types in Evolution typescript files +// TODO: Add the correct types for all the different input types +// TODO: Add some missing types for the different input types + +/* InputRadio widgetConfig Type */ +export type InputRadioBase = { + type: 'question'; + inputType: 'radio'; + datatype: 'string' | 'integer'; + containsHtml: ContainsHtml; + twoColumns: TwoColumns; + columns: Columns; +}; +export type InputRadio = InputRadioBase & { + path: Path; + label: Label; + helpPopup?: HelpPopup; + choices: Choices; + conditional: Conditional; + validations?: Validations; + addCustom?: AddCustom; +}; + +/* InputString widgetConfig Type */ +export type InputStringBase = { + type: 'question'; + inputType: 'string'; + datatype: 'string' | 'integer'; + containsHtml: ContainsHtml; + twoColumns: TwoColumns; + size?: 'small' | 'large'; + inputFilter?: InputFilter; + numericKeyboard?: boolean; + maxLength?: number; + placeholder?: Placeholder; +}; +export type InputString = InputStringBase & { + path: Path; + label: Label; + conditional: Conditional; + validations?: Validations; + textTransform?: 'uppercase' | 'lowercase' | 'capitalize'; +}; + +/* InputText widgetConfig Type */ +export type InputTextBase = { + type: 'text'; + containsHtml: ContainsHtml; +}; +export type InputText = InputTextBase & { + path: Path; + text: Text; + conditional: Conditional; +}; + +/* InputRange widgetConfig Type */ +export type InputRangeBase = { + type: 'question'; + inputType: 'slider'; + containsHtml: ContainsHtml; + twoColumns: TwoColumns; + initValue: null; + trackClassName: string; +}; +export type InputRangeConfig = { + labels: Labels; + minValue?: number; + maxValue?: number; + formatLabel?: (value: number, lang: string) => string; +}; +export type InputRange = InputRangeBase & + InputRangeConfig & { + path: Path; + label: Label; + conditional: Conditional; + validations?: Validations; + }; + +/* InputCheckbox widgetConfig Type */ +export type InputCheckboxBase = { + type: 'question'; + inputType: 'checkbox'; + datatype: 'string' | 'integer'; + containsHtml: ContainsHtml; + twoColumns: TwoColumns; + multiple: true; + columns: Columns; +}; +export type InputCheckbox = InputCheckboxBase & { + path: Path; + label: Label; + choices: Choices; + helpPopup?: HelpPopup; + conditional: Conditional; + validations?: Validations; + addCustom?: AddCustom; +}; + +/* InputSelect widgetConfig Type */ +export type InputSelectBase = { + type: 'question'; + inputType: 'select'; + datatype: 'string'; + twoColumns: TwoColumns; + hasGroups: boolean; +}; +export type InputSelect = InputSelectBase & { + path: Path; + label: Label; + choices: Choices; + conditional: Conditional; + validations?: Validations; +}; + +/* InputButton widgetConfig Type */ +export type InputButtonBase = { + type: 'button'; + color: 'green'; + hideWhenRefreshing: boolean; + icon: any; // icon: IconDefinition; + align: 'left' | 'right' | 'center'; + action: () => void; +}; +export type InputButton = InputButtonBase & { + path: Path; + label: Label; + confirmPopup?: { + shortname: string; + content: { fr: (interview, path) => string; en: (interview, path) => string }; + showConfirmButton: boolean; + cancelButtonColor: 'blue' | 'green'; + cancelButtonLabel: { fr: string; en: string }; + conditional: Conditional; + }; + saveCallback?: () => void; +}; + +/* InputText textArea widgetConfig Type */ +export type TextAreaBase = { + type: 'question'; + inputType: 'text'; + datatype: 'text'; + containsHtml?: ContainsHtml; + twoColumns: false; +}; +export type TextArea = TextAreaBase & { + path: Path; + label: Label; + conditional: Conditional; + validations?: Validations; +}; + +/* Group type */ +export type Group = { + type: 'group'; + path: Path; + groupShortname: string; + shortname: string; + groupName: { fr: string; en: string }; + name: { fr: NameFunction; en: NameFunction }; + conditional?: Conditional; +}; + +/* InputMapFindPlace widgetConfig Type */ +export type InputMapFindPlaceBase = { + type: 'question'; + inputType: 'mapFindPlace'; + datatype: 'geojson'; + height: string; + containsHtml: ContainsHtml; + autoConfirmIfSingleResult: boolean; + placesIcon: { url: (interview?, path?) => string; size: [number, number] }; + defaultValue?: (interview) => void; + defaultCenter: { lat: number; lon: number }; + refreshGeocodingLabel: Label; + showSearchPlaceButton: (interview?, path?) => boolean; + afterRefreshButtonText: Text; + validations?: Validations; +}; +export type InputMapFindPlace = InputMapFindPlaceBase & { + path: Path; + label: Label; + icon: { url: string; size: [number, number] }; + geocodingQueryString: (interview, path?) => void; + conditional: Conditional; +};