Table JavaScript Functions for Selecting Matching Rows or Columns

From Q
Jump to navigation Jump to search

This page contains functions to help with writing Table JavaScript for Rules.

includeWeb('Table JavaScript Utility Functions');

// Duplicated and modified from src/Standard R/Select Rows and Columns from Table.js
// Unfortunately there is currently no way to share code with Standard R
function getInputNames(input, dim = 0) {
    const MULTI_QUESTIONTYPES = [
        'Text - Multi',
        'Pick One - Multi',
        'Pick Any - Compact',
        'Pick Any - Grid',
        'Number - Grid'
    ];
    const VECTOR_TYPES = ['numeric', 'character', 'logical', 'integer', 'factor'];

    if (input.type === 'R Output') {
        const classes = input.outputClasses;
        const input_data = input.data;
        if (!input_data || !classes) {
            return null;
        }
        const is_vector = classes.some(c => VECTOR_TYPES.includes(c));
        try { // Attempt to call getAttribute, which errors if attribute is not present
            if ((is_vector && dim === 0) || (classes.includes('data.frame') && dim === 1)) {
                return input_data.getAttribute([], 'names');
            }
            if (classes.includes('data.frame') && dim === 0) {
                var names = input_data.getAttribute([], 'row.names');
                // integer/number row names only occur if they have not been
                // changed from their defaults in R (i.e. there are no rownames),
                // return empty array so selection mode defaults to range/text box
                return !names || (names.length > 0 && typeof names[0] === 'number') ? null : names;
            }
            const dimnames = input_data.getAttribute([], 'dimnames');
            if (classes.includes('QTable') && dimnames.length === 1 && dim === 1) {
                return input_data.getAttribute([], 'statistic');
            }
            return dimnames && dim < dimnames.length && dimnames[dim] != null ? dimnames[dim] : null;
        }
        catch (e) {
            if (e.message.includes('attribute')) {
                return null;
            }
            throw e; // re-throw the error if it's not about missing attributes
        }
    }
    if (input.type === 'Table') {
        const table_output = input.calculateOutput();
        const primary = input.primary;
        const secondary = input.secondary;
        if (!table_output || !primary || !secondary) {
            return null;
        }
        const is_crosstab_or_multi_or_raw = secondary.type === 'Question'
            || MULTI_QUESTIONTYPES.includes(primary.questionType)
            || secondary === 'RAW DATA';
        if (primary.isBanner && secondary === 'SUMMARY') {
            is_crosstab_or_multi_or_raw = false;
        }
        if (dim === 0) {
            return table_output.rowLabels;
        }
        if (dim === 1 && is_crosstab_or_multi_or_raw) { // get column labels from a crosstab
            return table_output.columnLabels;
        }
        return table_output.statistics;
    }
    return null;
}

function isTable(obj) {
    if (obj.type === 'R Output') {
        const obj_classes = obj.outputClasses;
        return obj_classes.includes('matrix') || obj_classes.includes('array') || obj_classes.includes('data.frame');
    }
    else if (obj.type === 'Table') {
        return true;
    }
    else {
        return false;
    }
}

function selectUsingLabels(labels, get_original_labels, get_count, delete_method, sort_method, use_order_from_input) {
    const labels_in_lower_case = labels.map(label => normalizeLabel(label));
    const labels_set = new Set(labels_in_lower_case);
    const indices_to_remove = get_original_labels().map((label, index) => labels_set.has(normalizeLabel(label)) ? -1 : index).filter(index => index !== -1);
    if (indices_to_remove.length > 0) {
        delete_method(indices_to_remove);
    }
    if (use_order_from_input && get_count() > 0) {
        const index_infos = get_original_labels().map((label, index) => ({ originalIndex: index, indexInValues: labels_in_lower_case.indexOf(normalizeLabel(label)) }));
        // Sort by the index of label in the values
        index_infos.sort((a, b) => a.indexInValues - b.indexInValues);
        const sorted_indices = index_infos.map(info => info.originalIndex);
        sort_method(sorted_indices);
    }
}

function selectUsingIndices(indices, get_count, delete_method, sort_method, use_order_from_input, is_row) {
    // filter out NaN values
    indices = indices.filter(x => !isNaN(x));
    if (indices.length <= 0) {
        form.ruleNotApplicable('no valid values found from the selected input');
    }

    if (indices.some(x => !Number.isInteger(x))) {
        form.ruleNotApplicable('the selected input contains non-integer values');
    }

    // check if the indices are within the range
    // (indices are 1-based, so we need to subtract 1)
    if (indices.some(x => x - 1 < 0 || x - 1 >= get_count())) {
        form.ruleNotApplicable(`the selected input contains indices outside the range of the number of ${is_row ? 'rows' : 'columns'}`);
    }

    const zero_based_indices = indices.filter(x => !isNaN(x)).map(x => x - 1);
    const indices_to_remove = [];
    const indices_to_keep = [];
    for (let i = 0; i < get_count(); i++) {
        if (zero_based_indices.includes(i)) {
            indices_to_keep.push(i);
        }
        else {
            indices_to_remove.push(i);
        }
    }
    if (indices_to_remove.length > 0) {
        delete_method(indices_to_remove);
    }

    if (use_order_from_input && get_count() > 0) {
        const index_infos = indices_to_keep.map((index_before_deletion, index_after_deletion) => ({ originalIndex: index_after_deletion, indexInValues: zero_based_indices.indexOf(index_before_deletion) }));
        // Sort by the index of original index in the values
        index_infos.sort((a, b) => a.indexInValues - b.indexInValues);
        const sorted_indices = index_infos.map(info => info.originalIndex);
        sort_method(sorted_indices);
    }
}

function selectMatchingRowsOrColumns(is_row) {

    form.setHeading(`Select Matching ${is_row ? 'Rows' : 'Columns'}`);
    form.setSummary(`Select ${is_row ? 'rows' : 'columns'} matching values from another input or item`);

    const description_first_part = form.newLabel(`Select ${is_row ? 'rows' : 'columns'} using`);
    const dropbox = form.newDropBox('data_source', ['Control:combobox,listbox,textbox', 'RItem:integer,numeric,character,matrix,array,data.frame', 'Table'], false);
    dropbox.lineBreakAfter = true;

    const controls = [description_first_part, dropbox];

    const dropbox_value = dropbox.getValue();
    if (!dropbox_value) {
        form.setInputControls(controls);
        form.ruleNotApplicable('no data selected');
    }
    const referenced_obj = form.resolve(dropbox_value.guid);
    if (!referenced_obj) {
        form.setInputControls(controls);
        form.ruleNotApplicable('the selected input cannot be resolved');
    }

    let is_table = false;
    try {
        is_table = isTable(referenced_obj);
    }
    catch (err) {
        // Accessing 'outputClasses' in isTable() can throw an error when there is a circular reference
        form.setInputControls(controls);
        form.ruleNotApplicable(err.message || 'the selected input is in error');
    }

    let use_rows_from_input = null;
    if (is_table) {
        const use_label = form.newLabel('Use selected input\'s');
        const use_rows_or_columns_from_input_combobox = form.newComboBox('use_rows_or_columns_from_input', ['rows', 'columns']);
        const use_label_2 = form.newLabel('for matching');
        use_label_2.lineBreakAfter = true;
        use_rows_or_columns_from_input_combobox.setDefault('rows');
        controls.push(use_label);
        controls.push(use_rows_or_columns_from_input_combobox);
        controls.push(use_label_2);
        use_rows_from_input = use_rows_or_columns_from_input_combobox.getValue() === 'rows';
    }

    const order_label = form.newLabel(`Order ${is_row ? 'rows' : 'columns'} based on`);
    const order_combobox = form.newComboBox('match_order', ['selected input', 'original order']);
    order_combobox.setDefault('selected input');
    controls.push(order_label);
    controls.push(order_combobox);
    const use_order_from_input = order_combobox.getValue() === 'selected input';

    form.setInputControls(controls);

    const delete_method = (indices) => is_row ? table.deleteRows(indices) : table.deleteColumns(indices);
    const sort_method = (indices) => is_row ? table.sortRows(indices) : table.sortColumns(indices);
    const get_original_labels = () => is_row ? table.rowLabels : table.columnLabels;
    const get_count = () => is_row ? table.numberRows : table.numberColumns;

    if (is_table) {
        const row_labels = getInputNames(referenced_obj, 0);
        const column_labels = getInputNames(referenced_obj, 1);
        const has_row_labels = row_labels && row_labels.length > 0;
        const has_column_labels = column_labels && column_labels.length > 0;
        if (use_rows_from_input) {
            if (has_row_labels) {
                selectUsingLabels(row_labels, get_original_labels, get_count, delete_method, sort_method, use_order_from_input);
            }
            else {
                form.ruleNotApplicable('the selected input does not have row labels');
            }
        }
        else {
            if (has_column_labels) {
                selectUsingLabels(column_labels, get_original_labels, get_count, delete_method, sort_method, use_order_from_input);
            }
            else {
                form.ruleNotApplicable('the selected input does not have column labels');
            }
        }
        return;
    }

    const labels = getInputNames(referenced_obj, 0);
    if (labels && labels.length > 0) {
        selectUsingLabels(labels, get_original_labels, get_count, delete_method, sort_method, use_order_from_input);
        return;
    }

    let values = [];
    switch (referenced_obj.type) {
        case 'Control':
            values = referenced_obj.data.get([]);
            break;
        case 'R Output':
            values = referenced_obj.data.get([]);
            break;
        // Omit 'Table' case as it is handled above as is_table is true
        default:
            form.ruleNotApplicable('selected input is not supported');
    }

    values = values ? values.filter(x => x !== null && x !== undefined) : null;

    if (!values || values.length <= 0) {
        form.ruleNotApplicable('no valid values found from the selected input');
    }

    const is_string = values.every(x => typeof x === 'string');
    const is_number = values.every(x => typeof x === 'number');

    if (is_string) {
        selectUsingLabels(values, get_original_labels, get_count, delete_method, sort_method, use_order_from_input);
    }
    else if (is_number) {
        selectUsingIndices(values, get_count, delete_method, sort_method, use_order_from_input, is_row);
    }
    else {
        form.ruleNotApplicable('the selected input needs to contain either text or numeric values');
    }
}

To make these functions available when writing a QScript or Rule see JavaScript Reference.