Skip to content
Snippets Groups Projects
SurveyContainerComponent.tsx 12.90 KiB
import { useEffect, useState, useCallback, useContext } from "react";
import { Container } from "react-bootstrap";
import toast, { Toaster } from "react-hot-toast";
import { Model, Serializer } from "survey-core";
import { useParams } from "react-router-dom";
import SurveyComponent from "./SurveyComponent";
import SurveyNavigationComponent from "./SurveyNavigationComponent";
import { VerificationStatus } from './Schema';
import Prompt from "./Prompt";
import "survey-core/modern.min.css";
import './survey.scss';
import useMatomo from "../matomo/UseMatomo";
import { FunctionFactory } from "survey-core";
import { validationFunctions } from "./validation/validation";
import SurveySidebar from "./management/SurveySidebar";
import { userContext } from "../providers/UserProvider";

interface ValidationQuestion {
    name?: string;
    value?: string | number | null;
    data?: ValidationQuestion;
}

// Overrides for questions that need to be validated differently from the default expression in their group
const questionOverrides = {
    data_protection_contact: (..._args) => true, // don't validate the contact field, anything goes..
}

function oldValidateWebsiteUrl(params) {
    let value = params[0];
    if ((value == undefined || value == null || value == '')) {
        return true;
    }
    try {
        value = value.trim();
        if (value.includes(" ")) {
            return false;
        }
        // if there's not a protocol, add one for the test
        if (!value.includes(":/")) {
            value = "https://" + value;
        }

        const url = new URL(value);
        return !!url
    } catch {
        return false;
    }
}

function validateQuestion(this: { question: ValidationQuestion, row?}, params) {
    try {
        const question = this.question;
        const validator = params[0] || undefined;

        const matrix = question.data && 'name' in question.data;

        let questionName;

        if (matrix) {
            questionName = question.data!.name;
        } else {
            questionName = question.name;
        }
        const value = question.value

        const hasOverride = questionOverrides[questionName];
        if (hasOverride) {
            return hasOverride(value, ...params.slice(1));
        }

        const validationFunction = validationFunctions[validator];
        if (!validationFunction) {
            throw new Error(`Validation function ${validator} not found for question ${questionName}`);
        }

        return validationFunction(value, ...params.slice(1));
    } catch (e) {
        console.error(e);
        return false;
    }
}

Serializer.addProperty("itemvalue", "customDescription:text");
Serializer.addProperty("question", "hideCheckboxLabels:boolean");

function SurveyContainerComponent({ loadFrom }) {
    const [surveyModel, setSurveyModel] = useState<Model>();  // note that this is never updated and we abuse that fact by adding extra state to the surveyModel
    const { year, nren } = useParams();  // nren stays empty for inspect and try
    const [error, setError] = useState<string>('loading survey...');
    const { user } = useContext(userContext);

    const loggedIn = !!user.id;
    const isAdmin = loggedIn ? user.permissions.admin : false;

    if (!FunctionFactory.Instance.hasFunction("validateQuestion")) {
        FunctionFactory.Instance.register("validateQuestion", validateQuestion);
    }

    if (!FunctionFactory.Instance.hasFunction("validateWebsiteUrl")) {
        FunctionFactory.Instance.register("validateWebsiteUrl", oldValidateWebsiteUrl);
    }


    const { trackPageView } = useMatomo();

    const beforeUnloadListener = useCallback((event) => {
        event.preventDefault();
        return (event.returnValue = "");
    }, []);

    const pageHideListener = useCallback(() => {
        window.navigator.sendBeacon('/api/response/unlock/' + year + '/' + nren);
    }, []); // eslint-disable-line react-hooks/exhaustive-deps

    const onPageExitThroughRouter = useCallback(() => {
        window.navigator.sendBeacon('/api/response/unlock/' + year + '/' + nren);
        removeEventListener("beforeunload", beforeUnloadListener, { capture: true });
        removeEventListener("pagehide", pageHideListener);
    }, []); // eslint-disable-line react-hooks/exhaustive-deps

    useEffect(() => {
        async function getModel() {
            const response = await fetch(loadFrom + year + (nren ? '/' + nren : ''))
            const json = await response.json();

            if (!response.ok) {

                if ('message' in json) {
                    throw new Error(json.message);
                } else {
                    throw new Error(`Request failed with status ${response.status}`);
                }
            }

            const survey = new Model(json['model']);
            survey.setVariable('surveyyear', year);
            survey.setVariable('previousyear', parseInt(year!) - 1);

            survey.showNavigationButtons = false;
            survey.requiredText = '';

            survey['verificationStatus'] = new Map<string, VerificationStatus>();
            for (const questionName in json["verification_status"]) {
                survey['verificationStatus'].set(questionName, json["verification_status"][questionName]);
            }

            survey.data = json['data'];
            survey.clearIncorrectValues(true);
            survey.currentPageNo = json['page'];
            survey.mode = json['mode'];

            survey['lockedBy'] = json['locked_by'];
            survey['status'] = json['status'];
            survey['editAllowed'] = json['edit_allowed'];

            setSurveyModel(survey);
        }

        getModel().catch(error => setError('Error when loading survey: ' + error.message)).then(() => {
            trackPageView({ documentTitle: `Survey for ${nren} (${year})` });
        })
    }, []); // eslint-disable-line react-hooks/exhaustive-deps

    if (!surveyModel) {
        return error;
    }

    const saveSurveyData = async (survey, newState) => {
        if (!nren) {
            return "Saving not available in inpect/try mode";
        }

        const saveData = {
            lock_uuid: survey.lockUUID,
            new_state: newState,
            data: survey.data,
            page: survey.currentPageNo,
            verification_status: Object.fromEntries(survey.verificationStatus)
        };

        try {
            const response = await fetch(
                '/api/response/save/' + year + '/' + nren,
                { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify(saveData) }
            );
            const json = await response.json();
            if (!response.ok) {
                return json['message'];
            }
            surveyModel.mode = json['mode'];
            surveyModel['lockedBy'] = json['locked_by'];
            surveyModel['status'] = json['status'];
        } catch (e) {
            return "Unknown Error: " + (e as Error).message;
        }
    }

    const validateWithAnswerVerification = (validatorFunction, validateStatus = true) => {
        let firstValidationError = '';
        const verificationValidator = (survey, options) => {
            const status = survey.verificationStatus.get(options.name);
            if (status == VerificationStatus.Unverified) {
                if (firstValidationError == '') {
                    firstValidationError = options.name;
                }
                options.error = 'Please verify that last years data is correct by editing the answer or pressing the "No change from previous year" button!';
            }
        };
        if (validateStatus) surveyModel.onValidateQuestion.add(verificationValidator);
        const validSurvey = validatorFunction();
        if (validateStatus) surveyModel.onValidateQuestion.remove(verificationValidator);
        if (!validSurvey) {
            toast("Validation failed!");
        }
        return validSurvey;
    }

    const surveyActions = {
        'save': async () => {
            const allFieldsValid = validateWithAnswerVerification(surveyModel.validate.bind(surveyModel, true, true), false);
            if (!allFieldsValid) {
                toast("Please correct the invalid fields before saving!");
                return;
            }
            const errorMessage = await saveSurveyData(surveyModel, "editing");
            if (errorMessage) {
                toast("Failed saving survey: " + errorMessage);
            } else {
                toast("Survey saved!");
            }
        },
        'complete': async () => {
            const validSurvey = validateWithAnswerVerification(surveyModel.validate.bind(surveyModel, true, true));
            if (validSurvey) {
                const errorMessage = await saveSurveyData(surveyModel, "completed");
                if (errorMessage) {
                    toast("Failed completing survey: " + errorMessage);
                } else {
                    toast("Survey completed!");
                    removeEventListener("beforeunload", beforeUnloadListener, { capture: true });
                    removeEventListener("pagehide", pageHideListener);
                }
            }
        },
        'saveAndStopEdit': async () => {
            const allFieldsValid = validateWithAnswerVerification(surveyModel.validate.bind(surveyModel, true, true), false);
            if (!allFieldsValid) {
                toast("Please correct the invalid fields before saving.");
                return;
            }
            const errorMessage = await saveSurveyData(surveyModel, "readonly");
            if (errorMessage) {
                toast("Failed saving survey: " + errorMessage);
            } else {
                toast("Survey saved!");
                removeEventListener("beforeunload", beforeUnloadListener, { capture: true });
                removeEventListener("pagehide", pageHideListener);
            }
        },
        'startEdit': async () => {
            const response = await fetch('/api/response/lock/' + year + '/' + nren, { method: "POST" });
            const json = await response.json();
            if (!response.ok) {
                toast("Failed starting edit: " + json['message']);
                return;
            }
            addEventListener("pagehide", pageHideListener);
            addEventListener("beforeunload", beforeUnloadListener, { capture: true });
            for (const questionName in json["verification_status"]) {
                surveyModel['verificationStatus'].set(questionName, json["verification_status"][questionName]);
            }
            surveyModel.data = json['data'];
            surveyModel.clearIncorrectValues(true);
            surveyModel.mode = json['mode'];
            surveyModel['lockedBy'] = json['locked_by']
            surveyModel['lockUUID'] = json['lock_uuid'];
            surveyModel['status'] = json['status'];
            // Validate when we start editing to ensure invalid fields are corrected by the user
            const allFieldsValid = validateWithAnswerVerification(surveyModel.validate.bind(surveyModel, true, true), false);
            if (!allFieldsValid) {
                toast("Some fields are invalid, please correct them.");
                return;
            }

        },
        'releaseLock': async () => {
            const response = await fetch('/api/response/unlock/' + year + '/' + nren, { method: 'POST' });
            const json = await response.json();
            if (!response.ok) {
                toast("Failed releasing lock: " + json['message']);
                return;
            }
            surveyModel.mode = json['mode'];
            surveyModel['lockedBy'] = json['locked_by'];
            surveyModel['status'] = json['status'];
        },
        'validatePage': () => {
            const validSurvey = validateWithAnswerVerification(surveyModel.validatePage.bind(surveyModel));
            if (validSurvey) {
                toast("Page validation successful!");
            }
        }
    }

    if (!surveyModel.css.question.title.includes("sv-header-flex")) {
        surveyModel.css.question.title = "sv-title sv-question__title sv-header-flex";
        surveyModel.css.question.titleOnError = "sv-question__title--error sv-error-color-fix";
    }

    const onPageChange = (page) => {
        surveyModel.currentPageNo = page;
    }

    return (
        <>
            {isAdmin ? <SurveySidebar /> : null}
            <Container className="survey-container">
                <Toaster />
                <Prompt message="Are you sure you want to leave this page? Information you've entered may not be saved." when={() => { return surveyModel.mode == 'edit' && !!nren; }} onPageExit={onPageExitThroughRouter} />
                <SurveyNavigationComponent onPageChange={onPageChange} surveyModel={surveyModel} surveyActions={surveyActions} year={year} nren={nren}>
                    <SurveyComponent surveyModel={surveyModel} />
                </SurveyNavigationComponent>
            </Container>
        </>
    );
}

export default SurveyContainerComponent;