Source: forms.js

/*
 * Checking what a stage's form submits (see App#addClientDefault).
 *
 * Fields are declared in app.fields, by the name the form uses (oTree's model fields,
 * z-Tree's input items):
 *
 *     app.fields = {
 *         'player.contribution': { type: 'int', min: 0, max: (player) => player.endowment },
 *         'player.color': { type: 'choice', choices: ['red', 'blue'] },
 *         'player.comment': { type: 'string', blank: true },
 *     };
 *
 * type is 'int', 'number', 'string', 'bool' or 'choice'. min, max, step (a number must be a
 * multiple of it) and choices can be values or functions of the player. A field is required unless blank is true. stage.formFields
 * lists the fields a stage's form must send (oTree's form_fields), and stage.validate(player,
 * values) checks them together (oTree's error_message), returning a message, an object of
 * messages by field, or nothing.
 *
 * A stage's own fields (stage.fields, declared the same way) take the place of the app's of the
 * same name on its form.
 *
 * Values for fields not declared are converted as they always have been: 'true' and
 * 'false' to booleans, and numbers to numbers.
 */

const MESSAGES = {
    required: 'Please fill in this field.',
    int: 'Please enter a whole number.',
    number: 'Please enter a number.',
    bool: 'Please choose yes or no.',
    choice: 'Please choose one of the options.',
    min: (min) => 'Please enter a value of at least ' + min + '.',
    max: (max) => 'Please enter a value of at most ' + max + '.',
    step: (step) => 'Please enter a multiple of ' + step + '.',
};

/** true or false for text that says yes or no ('true', 'True', '1', 'yes', 'on', ...); else undefined. */
function boolValue(text) {
    const t = text.toLowerCase();
    if (['true', '1', 'yes', 'on'].includes(t)) return true;
    if (['false', '0', 'no', 'off'].includes(t)) return false;
    return undefined;
}

/** value, or what it gives for player if it is a function. */
const valueFor = (value, player) => (typeof value === 'function' ? value(player) : value);

/** A value submitted for a field nobody declared, converted as jtree always has. */
function legacyValue(value) {
    if (value === 'true') return true;
    if (value === 'false') return false;
    if (typeof value === 'string' && value.trim() !== '' && !isNaN(value)) return parseFloat(value);
    return value;
}

/**
 * Converts one submitted value of a declared field; returns {value} or {error}.
 * @param {Object} field Its declaration.
 * @param {*} raw What the form sent (a string, or undefined if nothing).
 * @param {Player} player
 */
function checkField(field, raw, player) {
    const empty = raw === undefined || raw === null || (typeof raw === 'string' && raw.trim() === '');
    if (empty) {
        return field.blank ? { value: null } : { error: MESSAGES.required };
    }
    const text = String(raw).trim();
    let value;
    switch (field.type) {
        case 'int':
            if (!/^[-+]?\d+$/.test(text)) return { error: MESSAGES.int };
            value = parseInt(text, 10);
            break;
        case 'number':
            value = Number(text);
            if (!Number.isFinite(value)) return { error: MESSAGES.number };
            break;
        case 'bool':
            value = boolValue(text);
            if (value === undefined) return { error: MESSAGES.bool };
            break;
        case 'choice': {
            const choices = valueFor(field.choices, player) || [];
            const values = choices.map((c) => (Array.isArray(c) ? c[0] : c));
            // Yes/no choices: 'True' (as oTree's pages write it), 'true', '1', ...
            const match = values.find((v) => String(v) === text || (typeof v === 'boolean' && boolValue(text) === v));
            if (match === undefined) return { error: MESSAGES.choice };
            value = match;
            break;
        }
        default:
            value = typeof raw === 'string' ? raw : text;
    }
    if (field.type === 'int' || field.type === 'number') {
        const min = valueFor(field.min, player);
        const max = valueFor(field.max, player);
        if (min != null && value < min) return { error: MESSAGES.min(min) };
        if (max != null && value > max) return { error: MESSAGES.max(max) };
        const step = valueFor(field.step, player);
        if (step > 0 && Math.abs(value / step - Math.round(value / step)) > 1e-6) return { error: MESSAGES.step(step) };
    }
    return { value };
}

/**
 * Checks and converts what a stage's form submitted.
 * @param {Stage} stage
 * @param {Player} player
 * @param {Object} data The form's values by field name ('player.x', ...).
 * @return {{values: Object, errors: Object|null}} The converted values by name, and if
 * anything is wrong, the messages by field name ('' for the form as a whole).
 */
function checkForm(stage, player, data) {
    const declared = { ...((stage.app && stage.app.fields) || {}), ...(stage.fields || {}) };
    const values = {};
    const errors = {};
    const names = new Set(Object.keys(data));
    for (const name of stage.formFields || []) {
        names.add(name);
    }
    for (const name of names) {
        const field = declared[name];
        if (field == null) {
            values[name] = legacyValue(data[name]);
            continue;
        }
        const result = checkField(field, data[name], player);
        if (result.error !== undefined) {
            errors[name] = result.error;
        } else {
            values[name] = result.value;
        }
    }
    if (Object.keys(errors).length === 0 && typeof stage.validate === 'function') {
        const result = stage.validate(player, values);
        if (typeof result === 'string' && result !== '') {
            errors[''] = result;
        } else if (result != null && typeof result === 'object') {
            Object.assign(errors, result);
        }
    }
    return { values, errors: Object.keys(errors).length > 0 ? errors : null };
}

module.exports = { checkForm, checkField, MESSAGES };