QScript Functions for Filters

From Q
Jump to navigation Jump to search

This page contains functions for creating filters.

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

createFiltersForAllCategories(question)

Given a question, this function creates a new filter for each category in the corresponding question.

getMergedCategoriesFromPickOne(question)

Returns an array describing which categories in the pick one (- multi) question have been merged (excluding the NET). Each entry in the array has:

  • name: The name of the merged category
  • values: An array of the underlying values for the categories that have been merged

getMergedCategoriesFromPickAny(question)

Returns an array describing which categories in the pick any (- multi) question have been merged (excluding the NET). Each entry in the array has:

  • name: The name of the merged category
  • values: An array of the underlying values for the categories that have been merged

mergePickAnyExpression(variable_names)

Generate an JavaScript expression for merged categories in a Pick Any question.

mergePickAnyCompactExpression(variable_names, codes)

Generate an JavaScript expression for merged categories in a Pick Any - Compact question.

generateUniqueControlsName(name)

Helper function for createControlsAndFilter(). Ensures that all the created controls have unique names.

recursiveGetAllControlsNamesInGroup(group_item, objects_array)

Helper function for generateUniqueControlsName(). Recursively gets all controls in the group specified by group_item, e.g. project.report, and adds them to objects_array.

determineNonNETLabels(variable)

Uses Data Reduction to determine labels to show in the controls created by createControlsAndFilter(), doesn't match up with factor levels shown to R code exactly (most noticeably, hidden codes aren't returned by this function, but are present in the R factor representation of the variable. Used by reorderHiddenCodes.

readLevelsFromRFactor(v)

Given a variable, v, this function creates a temporary R Output to read levels of the R factor representation of the variable and returns them in an array.

reorderHiddenCodes(levels_fromR, levels_excluding_hidden, v)

Helper function for readLevelsFromRFactor(). Any hidden codes in a variable will still appear in the R variable, but unfortunately the codes/levels are in the wrong order, with the hidden codes always being the last levels of the R factor. This function attempts to correct this, reordering the labels using the underlying values. levels_fromR is an array of the the levels of the R factor representation of v, a variable and levels_excluding_hidden is the result of a call to determineNonNETLabels().

getDataFileFromDependants(deps)

Determines the DataFile given the dependants of an R Output.

createControlsAndFilter(control_type)

Main function for Combo Box (Drop-Down) Filters on an Output, Text Box Filters on an Output, and List Box Filters on an Output. Prompts the user for categorical variable sets to create a filter from, adds controls to the page to modify the created filter, creates the filter as an R variable, and applies it to any Plots, Tables, or R Outputs selected on the page by the user. Valid values for control_type are "Combobox", "Textbox", or "Listbox".

Source Code

includeWeb("QScript Utility Functions");
includeWeb("QScript Value Attributes Functions");
includeWeb("JavaScript Array Functions");
includeWeb("QScript Selection Functions");
includeWeb("QScript R Output Functions");
includeWeb("QScript Data Reduction Functions");


function createFiltersForAllCategories(question) {
    if (typeof (question) == "undefined" || question === null)
        return null;

    const q_type = question.questionType;
    let new_filter_question;
    const new_name = preventDuplicateQuestionName(question.dataFile, question.name + " - Filters");
    let merged_categories;

    // Pick Any - Grid simply gets converted to a Pick Any because there is
    // no way to treat merged categories.
    if (q_type == "Pick Any - Grid") {
        new_filter_question = question.duplicate(new_name);
        new_filter_question.questionType = "Pick Any";
    }
    else if (q_type == "Pick One - Multi") {
        // Flatten and merge using existing methods
        merged_categories = getMergedCategoriesFromPickOne(question);
        if (merged_categories == null || merged_categories.length == 0)
            return null;
        new_filter_question = pickOneMultiToPickAnyFlattenAndMergeByRows(question, merged_categories, true, false);
        new_filter_question.name = preventDuplicateQuestionName(question.dataFile, new_filter_question.name.replace("(flattened)", "- Filters"));
        new_filter_question.questionType = "Pick Any - Grid";
        if (!new_filter_question.isValid) {
            new_filter_question.questionType = "Pick Any";
        }
    }
    else {
        // For other question types we work out which categories are
        // in the data reduction (excluding the main NET).
        if (["Pick One", "Pick Any - Compact"].indexOf(q_type) > -1)
            merged_categories = getMergedCategoriesFromPickOne(question);
        else if (q_type == "Pick Any") {
            const merged_categories_with_vars = getMergedCategoriesFromPickAny(question);
            merged_categories = merged_categories_with_vars.map(cat => ({ name: cat.name, values: [], variables: cat.variables }));
        }
        else
            throw "Not applicable for " + q_type + "questions.";
        if (merged_categories == null)
            return null;

        // Create a new variable for each category in the data reduction
        const variables = question.variables;
        const data_file = question.dataFile;
        const new_vars = [];
        const num_vars = variables.length;
        let last_var = variables[num_vars - 1];
        const new_name_prefix = "FILTER" + makeid() + "_";
        merged_categories.forEach(function (obj, ind) {
            let expression = "";
            if (q_type == "Pick Any")
                expression = mergePickAnyExpression(obj.variables);
            else if (q_type == "Pick Any - Compact")
                expression = mergePickAnyCompactExpression(variables.map(function (v) { return v.name; }), obj.values);
            else if (q_type == "Pick One")
                expression = mergePickOneExpression(variables[0].name, obj.values, true);

            try {
                const new_var = question.dataFile.newJavaScriptVariable(expression, false, new_name_prefix + ind + 1, obj.name, last_var, { skipValidation: true, accelerate: true });
                new_var.variableType = "Categorical";
                last_var = new_var;
                new_vars.push(new_var);
            }
            catch (e) {
                log("Could not create filter: " + (e instanceof Error ? e.message : String(e)));
                return false;
            }
        });

        // Form the question
        if (new_vars.length > 0) {
            const new_q_name = preventDuplicateQuestionName(data_file, question.name + " - Filters");
            new_filter_question = data_file.setQuestion(new_q_name, "Pick Any", new_vars);
            setCountThisValueForVariablesInQuestion(new_filter_question, 1, true);
            new_filter_question.needsCheckValuesToCount = false;
        }
        else
            return null;
    }

    // Set the properties of the new question
    new_filter_question.isFilter = true;
    setLabelForVariablesInQuestion(new_filter_question, 0, "Not Selected");
    setLabelForVariablesInQuestion(new_filter_question, 1, "Selected");
    return new_filter_question;
}

// Returns an array describing which categories in the pick one (- multi)
// question have been merged (excluding the NET). Each entry in the array has:
// - name: The name of the merged category
// - values: An array of the underlying values for the categories that have
//           been merged
function getMergedCategoriesFromPickOne(q) {
    const value_attributes = q.valueAttributes;
    const non_missing_values = q.uniqueValues.filter(function (x) {
        return !value_attributes.getIsMissingData(x);
    }).sort();

    // Get the set of values for each code in the data reduction
    let merging_objects = getAllUnderlyingValues(q);
    if (merging_objects === null) {
        return null;
    }
    // Filter out the set of values corresoponding to the NET as
    // we don't want to keep them.
    merging_objects = merging_objects.filter(function (obj) {
        return obj.array.sort().toString() != non_missing_values.toString();
    });
    const results = merging_objects.map(function (obj) {
        return { name: obj.label, values: obj.array };
    });

    return results;
}

// Returns an array describing the data reduction of a pick any question
// excluding the NET. Each entry in the array corresponds to a code from
// the data reduction, and has:
// - name: The name of the category.
// - variables: The names of the variables in the category.
function getMergedCategoriesFromPickAny(q) {
    const NET_LABELS = ["NET"];
    const data_reduction = q.dataReduction;
    let net_labels = NET_LABELS;
    if (fileFormatVersion() > 8.41)
        net_labels = data_reduction.netRows.map(function (x) { return data_reduction.rowLabels[x]; });
    const merging_objects = [];
    data_reduction.rowLabels.forEach(function (label) {
        if (net_labels.indexOf(label) == -1)
            merging_objects.push({ name: label, variables: data_reduction.getUnderlyingVariables(label).map(function (v) { return v.name; }) });
    });
    return merging_objects;
}

// Generate an JavaScript expression for merged categories in a Pick Any question.
function mergePickAnyExpression(variable_names) {
    let nan_bit = "isNaN(" + variable_names[0] + ")";
    let main_bit = "(" + variable_names[0];
    if (variable_names.length > 1) {
        for (let j = 1; j < variable_names.length; j++) {
            nan_bit += " || isNaN(" + variable_names[j] + ")";
            main_bit += " || " + variable_names[j];
        }
    }
    return nan_bit + " ? NaN : " + main_bit + ") ? 1 : 0;";
}

// Generate an JavaScript expression for merged categories in a Pick Any - Compact question.
function mergePickAnyCompactExpression(variable_names, codes) {
    const missing_check = "(" + variable_names.map(function (name) { return "Q.IsMissing(" + name + ")"; }).join(" && ") + ") ? NaN : (";
    const term_array = [];
    variable_names.forEach(function (name) {
        codes.forEach(function (code) {
            term_array.push("Q.Source(" + name + ") == " + code);
        });
    });
    return missing_check + term_array.join(" || ") + ") ? 1 : 0;";
}

function generateUniqueControlsName(name) {
    const controls = [];
    recursiveGetAllControlsNamesInGroup(project.report, controls);
    if (controls.indexOf(name) == -1)
        return name;
    let nonce = 1;
    while (controls.indexOf(name + "." + nonce.toString()) != -1)
        ++nonce;
    return name + "." + nonce.toString();
}

function recursiveGetAllControlsNamesInGroup(group_item, objects_array) {
    const cur_sub_items = group_item.subItems;
    for (let j = 0; j < cur_sub_items.length; j++) {
        if (cur_sub_items[j].type == 'ReportGroup') {
            recursiveGetAllControlsNamesInGroup(cur_sub_items[j], objects_array);
        }
        else if (cur_sub_items[j].type == 'Control') {
            objects_array.push(cur_sub_items[j].referenceName);
            objects_array.push(cur_sub_items[j].name);
        }
    }
}
// Uses QScript Data Reduction to determine labels
// to show in the List Boxes, doesn't match up with factor levels
// shown to R code exactly (most noticeably, hidden codes aren't
// returned by this function, but are present in the R factor
// Hence, this is only used as a fallback if simply reading
// the levels from the factor fails (it shouldn't)
function determineNonNETLabels(variable) {
    const vals = getAllUnderlyingValues(variable.question);
    if (vals === null)
        return null;

    const labels = [];
    for (let i = 0; i < vals.length; i++) {
        const val_arr = vals[i].array;
        let isNET = false;
        if (val_arr.length > 1) {
            for (let j = 0; j < vals.length; j++) {
                if (i != j && vals[j].label !== "NET")
                    isNET = val_arr.some((v) => vals[j].array.indexOf(v) >= 0);
                if (isNET)
                    break;
            }
        }
        if (!isNET)
            labels.push(vals[i].label);
    }
    return labels;
}

function readLevelsFromRFactor(v) {
    const new_qname = "questionadffewe245";
    const new_vname = "variableREWRRE12323112";
    const data_file = v.question.dataFile;
    // const expression = 'xxxxx122345xxx <- ' + stringToRName(question.name);
    const expression = 'xxxxx122345foo <- ' + generateDisambiguatedVariableName(v);
    try {
        const new_r_question = data_file.newRQuestion(expression, new_qname, new_vname, data_file.variables[data_file.variables.length - 1]);
        // const r_output = group.appendR(expression);
        //const levels = r_output.data.getAttribute([], "levels");
        const value_attributes = new_r_question.valueAttributes;
        const unique_values = new_r_question.uniqueValues;

        let labels = unique_values.filter(u => !value_attributes.getIsMissingData(u)).map(u => value_attributes.getLabel(u));

        const levels_excluding_hidden = determineNonNETLabels(v);
        if (levels_excluding_hidden && levels_excluding_hidden.length < labels.length)
            labels = reorderHiddenCodes(labels, levels_excluding_hidden, v);

        new_r_question.deleteQuestion();
        return labels;
    }
    catch (e) {
        log(e instanceof Error ? e.message : String(e));
        return determineNonNETLabels(v);
    }
}

// Hidden codes are included in the levels of the R factor representation
//   of a categorical variable, however they are positioned as the last
//  level(s) of the factor. This function reorders the R factor levels according
// to the source values in the value attributes for the variable
function reorderHiddenCodes(levels_fromR, levels_excluding_hidden, v) {
    const value_attributes = v.question.valueAttributes;
    // A NET label is only included in the R factor if all its components are hidden,
    //   in this case the hidden components are not included, so levels_fromR
    //   may contain NETs which are then included in possibly_hidden_labels below
    // getSourcecValueByLabel throws an error when its argument is a NET label
    const possibly_hidden_labels = levels_fromR.slice(levels_excluding_hidden.length, levels_fromR.length);
    let hidden_vals = possibly_hidden_labels.map(function (l) {
        try {
            return value_attributes.getValue(value_attributes.getSourceValueByLabel(l));
        }
        catch (e) {
            return "NET";
        }
    });

    const hidden_labels = possibly_hidden_labels.filter((v, i) => hidden_vals[i] !== "NET");
    hidden_vals = hidden_vals.filter(v => v !== "NET");
    if (hidden_vals.length === 0)
        return levels_fromR;

    const dr = v.question.dataReduction;
    const lvl_sourcevals = levels_fromR.map(function (l) {
        try {
            const sv = [value_attributes.getSourceValueByLabel(l)];
            return sv;
        }
        catch (e) { // merge
            const sv = dr.getUnderlyingValues(l);
            return sv;
        }
    });
    // Use mean of component values for merged categories
    const lvl_vals = lvl_sourcevals.map(function (sv) {
        return sv.reduce((v1, v2) => value_attributes.getValue(v1) + value_attributes.getValue(v2), 0) / sv.length;
    });
    const unhidden_lvl_vals = lvl_vals.slice(0, lvl_vals.length - hidden_vals.length);
    // for each hidden val, determine where to splice into label array
    let pos = 0;
    let hv;
    for (let i = 0; i < hidden_vals.length; i++) {
        hv = hidden_vals[i];
        for (let j = 0; j < unhidden_lvl_vals.length; j++) {
            const uv = unhidden_lvl_vals[j];
            if (typeof hv === 'number' && typeof uv === 'number' && hv < uv) {
                break;
            }
            else {
                pos += 1;
            }
        }
        unhidden_lvl_vals.splice(pos, 0, hv);
        levels_excluding_hidden.splice(pos, 0, hidden_labels[i]);
        pos = 0;
    }
    return levels_excluding_hidden;
}

function getDataFileFromDependants(deps) {
    var _a;
    let data_file;
    deps = deps.filter(removeErroneousDependant);
    let dep;
    for (let i = 0; i < deps.length; i++) {
        dep = deps[i];
        if (isQuestion(dep)) {
            data_file = dep.dataFile;
            break;
        }
        else if (isPlot(dep) || isTable(dep)) {
            data_file = (_a = dep.primary) === null || _a === void 0 ? void 0 : _a.dataFile;
            break;
        }
        else if (isVariable(dep)) {
            data_file = dep.question.dataFile;
            break;
        }
        else if (isROutput(dep)) {
            data_file = getDataFileFromDependants(dep.dependants());
            if (data_file != null) {
                break;
            }
        }
    }
    return data_file;
}

// Validates environment and user selections, returns common setup data for filter creation
function getUserInputsForFilterCreation(control_config) {
    const { is_cb, is_tb, is_date, allowed_types } = control_config;

    // On the web just take from what is selected.
    const is_displayr = inDisplayr();
    if (!is_displayr) {
        log("Sorry, this feature is only available in Displayr.");
        return null;
    }
    let group;
    let data_file;

    if (project.report.selectedRaw().length === 0) {
        log("Please select the Table(s), Plot(s), and/or R Output(s) to apply the filter to.");
        return null;
    }
    else {
        group = project.currentPage();
    }

    let questions_in_tables = getQuestionsSelectedInTables();

    // Remove empty tables
    questions_in_tables = questions_in_tables.filter(function (q) { return q.question != null; });

    // Need extra work to determine the data file if the user has no Q Plots or Tables
    //  but only R Outputs selected
    const sub_items = project.report.selectedRaw(); // group.subItems;
    const types = sub_items.map(i => i.type);
    const routput_selected = types.indexOf("R Output") > -1;
    if (questions_in_tables.length === 0) {
        if (project.dataFiles.length === 0) {
            log("You must add a Data Set to use this feature.");
            return null;
        }
        else if (project.dataFiles.length === 1) {
            data_file = project.dataFiles[0];
        }
        else {
            // Try to get dataFile from dependents in the r outputs; otherwise, prompt user
            if (routput_selected) {
                const r_outputs = sub_items.filter(i => i.type === "R Output");
                for (let i = 0; i < r_outputs.length; i++) {
                    data_file = getDataFileFromDependants(r_outputs[i].dependants());
                    if (data_file != null) {
                        break;
                    }
                }
            }
            if (!data_file) {
                data_file = selectOneDataFile('Select the data file of the variable sets you wish to use for the filter.', project.dataFiles);
            }
        }
    }
    else {
        data_file = questions_in_tables[0].question.dataFile;
        // Make sure all questions are from the same data set
        if (!questions_in_tables.map(q => q.question.dataFile.name).every(type => type == (data_file === null || data_file === void 0 ? void 0 : data_file.name))) {
            log("All of the selected outputs must be from the same data file.");
            return null;
        }
    }

    const candidate_questions = getAllQuestionsByTypes([data_file], allowed_types);
    if (candidate_questions.length == 0) {
        log("No appropriate variable sets found in the data file for the selected output.");
        return null;
    }
    let selected_questions = [];
    const prompt = "Select " + (is_date ? "Date variable" : "variable sets") + " to use for the filter:";
    while (selected_questions.length === 0) {
        selected_questions = selectManyQuestions(prompt, candidate_questions, true).questions;
        if (selected_questions.length === 0) {
            alert("Please select at least one Variable Set from the list to continue.");
        }
    }

    const multiple_selection = (is_cb ? askYesNo("Should the user be allowed to select more than one category for each variable set?") : true);
    const ignore_case = (is_tb ? askYesNo("Ignore case (e.g., match \"dog\" with \"Dog\")?") : false);

    return {
        group, data_file, selected_questions,
        multiple_selection, ignore_case, sub_items, types, allowed_types
    };
}

// Creates standardized control names for UI labels and internal references
function generateControlTypeNames(control_type, is_date) {
    if (is_date) {
        return {
            label: "Date Control",
            reference: "DateControl"
        };
    }
    else {
        const base_name = control_type.substring(0, control_type.length - 3);
        return {
            label: base_name + " Box",
            reference: base_name + "Box"
        };
    }
}

// Generates complete control configuration object for createControlsForFilter
function generateControlConfig(control_type) {
    const is_cb = control_type === "Combobox";
    const is_tb = control_type === "Textbox";
    const is_date = control_type === "Date";
    const control_type_names = generateControlTypeNames(control_type, is_date);
    const allowed_types = is_tb ? ["Text", "Text - Multi"] :
        (is_date ? ["Date"] : ["Pick One", "Pick Any", "Pick One - Multi"]);

    return {
        is_cb,
        is_tb,
        is_date,
        control_type: control_type,
        control_type_names,
        allowed_types
    };
}

// Converts selected questions into filter objects with single/multi type classification
function createFilterObjects(selected_questions, supported_types) {
    const filter_objects = [];

    selected_questions.forEach(function (q) {
        if (["Pick Any", "Date"].includes(q.questionType) && supported_types.includes(q.questionType)) {
            filter_objects.push({ data: q, type: "multi", question: q });
        }
        else if (supported_types.includes(q.questionType)) {
            const q_vars = q.variables;
            q_vars.forEach(function (v) {
                filter_objects.push({ data: v, type: "single", question: q });
            });
        }
    });

    return filter_objects;
}

// Creates UI controls for filtering based on question types and requirements.
// Inputs:
//   filter_objects: Array from createFilterObjects(), describes variables/questions to filter.
//   control_config: Object from generateControlConfig(), contains the configuration for the control.
//   group: UI group/container for controls.
//   multiple_selection: Boolean indicating if multiple selections are allowed.
function createControlsForFilter(filter_objects, control_config, group, multiple_selection) {
    const { is_cb, is_tb, is_date, control_type_names } = control_config;

    const controls = [];
    let above;
    const row_pad = 10;
    const height = is_cb || is_tb || is_date ? 25 : 100;
    const control_names = [];
    const end_date_names = [];

    // DS-3217: Control names must be unique, but some users move controls
    // to a page master slide. These cannot be found via QScript/recursively
    // searching project.report). Hence, resort to try-catch/while loop until
    // a unique name is generated
    function _setUniqueControlName(ctrl, ref_name) {
        let unique_name_found = false;
        let duplicate_count = 0;
        while (!unique_name_found) {
            const ref_name_unique = ref_name + (duplicate_count > 0 ? ("." + duplicate_count.toString()) : "");
            try {
                ctrl.referenceName = ref_name_unique;
                unique_name_found = true;
            }
            catch (e) {
                duplicate_count++;
            }
        }
        ctrl.name = ctrl.referenceName;
        return;
    }

    filter_objects.forEach(function (obj, ind) {

        const v = obj.data;
        let dates;
        const ctrl = group.appendControl(control_config.control_type);
        if (is_tb) {
            ctrl.text = "";
        }
        else if (is_date) {
            dates = v.uniqueValues.filter(x => !isNaN(x));
            const min_date = arrayMin(dates);
            ctrl.date = min_date;
        }
        else {
            if (obj.type == "multi") {
                const data_reduction = v.dataReduction;
                let row_labels = data_reduction.rowLabels;
                const net_rows = data_reduction.netRows;
                if (row_labels) {
                    row_labels = row_labels.filter(function (label, index) {
                        return net_rows.indexOf(index) == -1;
                    });
                    requireControlLabelsAreUnique(row_labels, obj.question, ctrl);
                    const list_ctrl = ctrl;
                    list_ctrl.itemList = row_labels;
                    list_ctrl.selectedItems = row_labels;
                    list_ctrl.selectionMode = multiple_selection ? "MultipleSelection" : "SingleSelection";
                }
            }
            else {
                const value_attributes = v.valueAttributes;
                const has_missing_values = v.uniqueValues.some(x => value_attributes.getIsMissingData(x));
                let labels = readLevelsFromRFactor(v);
                if (labels === null) {
                    log("Variable " + v.name + " contains no categories");
                    return false;
                }
                labels = labels.map(lbl => lbl.trim());
                if (has_missing_values) {
                    const MISSING_DATA_LABEL = "Missing data";
                    labels.push(MISSING_DATA_LABEL);
                }
                requireControlLabelsAreUnique(labels, obj.question, ctrl);
                const list_ctrl = ctrl;
                list_ctrl.itemList = labels;
                list_ctrl.selectedItems = labels;
                list_ctrl.selectionMode = multiple_selection ? "MultipleSelection" : "SingleSelection";
            }
        }
        let ref_name;
        if (is_date)
            ref_name = generateUniqueControlsName(cleanVariableName("Start" +
                control_type_names.reference + v.variables[0].label, "StartControl"));
        else
            ref_name = generateUniqueControlsName(cleanVariableName(control_type_names.reference +
                (obj.type == "multi" ? v.name : v.label), "Control"));
        _setUniqueControlName(ctrl, ref_name);

        control_names.push(ctrl.name);
        controls.push(ctrl);
        ctrl.top = ind == 0 ? 0 : above.top + height + row_pad;
        ctrl.height = height;
        ctrl.left = 0;
        above = ctrl;
        if (is_date) { // Add end date control and control titles for date filters
            let ctrl_title = group.appendText();
            let html = Q.htmlBuilder();
            html.appendParagraph("From");
            ctrl_title.content = html;
            ctrl_title.height = height;
            ctrl_title.left = 0;
            ctrl_title.top = ctrl.top;
            ctrl_title.width = ctrl.width;
            ctrl.top = ctrl.top + height;
            const ctrl2 = group.appendControl("Date");
            const max_date = arrayMax(dates);
            ctrl2.date = max_date;
            ref_name = generateUniqueControlsName(cleanVariableName("EndDateControl" +
                v.variables[0].label, "EndDateControl"));
            _setUniqueControlName(ctrl2, ref_name);
            end_date_names.push(ctrl2.name);
            ctrl_title = group.appendText();
            html = Q.htmlBuilder();
            html.appendParagraph("To");
            ctrl_title.content = html;
            ctrl_title.height = height;
            ctrl_title.left = 0;
            ctrl_title.top = above.top + height + row_pad;
            ctrl_title.width = ctrl.width;
            ctrl2.top = ctrl_title.top + height;
            ctrl2.height = height;
            ctrl2.left = 0;
            above = ctrl2;
        }
    });

    return {
        controls, control_names, end_date_names
    };
}

// Applies the created filter to selected outputs (tables, plots, R outputs).
// Inputs:
//   filterQuestion: The filter question object containing the filter variables to apply.
//   outputItems: An array of selected output items (e.g., tables, plots, R outputs) to which the filter will be applied.
//   outputTypes: An array of strings indicating the type of each item in outputItems (e.g., "Table", "Plot", "R Output").
function applyFilterToOutputs(filterQuestion, outputItems, outputTypes) {
    const allowed_output_types = ["Plot", "R Output", "Table"];
    if (outputTypes.filter(function (type) { return allowed_output_types.indexOf(type) === -1; }).length > 0) {
        log("Some selections were not a Chart, Table or R Output and the created filter has not been applied to them.");
    }
    for (let i = 0; i < outputItems.length; i++) {
        if (allowed_output_types.indexOf(outputTypes[i]) > -1) {
            const has_nested_table = outputItems[i].type == "R Output" && outputItems[i].subItems.length > 0 && outputItems[i].subItems[0].type == "Table";
            const filter_item = has_nested_table ? outputItems[i].subItems[0] : outputItems[i];
            if (filter_item.filters !== null) {
                filter_item.filters = filter_item.filters.concat(filterQuestion.variables);
            }
        }
    }
}

function createControlsAndFilter(control_type) {
    /////////////////////////////////////////////////////////////////////////////////////
    // Generate control configuration
    const control_config = generateControlConfig(control_type);
    const { is_cb, is_tb, is_date } = control_config;

    /////////////////////////////////////////////////////////////////////////////////////
    // Get user inputs and setup for filter creation
    const filter_creation_options = getUserInputsForFilterCreation(control_config);
    if (!filter_creation_options)
        return false;

    const { group, data_file, selected_questions, multiple_selection, ignore_case, sub_items, types, allowed_types } = filter_creation_options;

    /////////////////////////////////////////////////////////////////////////////////////
    // Add Controls to page
    const filter_objects = createFilterObjects(selected_questions, allowed_types);
    const control_setup = createControlsForFilter(filter_objects, control_config, group, multiple_selection);
    if (!control_setup)
        return false;

    const { controls, control_names, end_date_names } = control_setup;

    //////////////////////////////////////////////////////////////////////////////
    // Create filter as a new R Question
    const label = control_config.control_type_names.label + " Filter " + selected_questions.map(function (q) {
        return q.questionType == "Pick Any" ? q.name : q.variables.map(function (v) { return v.label; }).join(" + ");
    }).join(" + ");
    const new_question_name = preventDuplicateQuestionName(data_file, label);
    const vname = control_config.control_type_names.reference.toLowerCase() + "_filter_" +
        selected_questions.map(function (q) {
            return q.name.replace(/[^a-zA-Z0-9_@\#\$\\]/g, '_').toLowerCase();
        }).join("_");
    const new_var_name = preventDuplicateVariableName(data_file, vname);
    let r_expr = "";
    let new_r_question;
    const multiple_boxes = controls.length > 1;
    if (multiple_boxes)
        r_expr += "(";
    if (is_tb) {
        r_expr = ("IGNORE_CASE = " + (ignore_case ? "TRUE\n" : "FALSE\n"));
        r_expr += "caseFun <- if (IGNORE_CASE) tolower else I\n";
    }

    for (let i = 0; i < controls.length; i++) {
        const is_multi = filter_objects[i].type == "multi";
        const data = filter_objects[i].data;
        const name_in_code = is_multi ? generateDisambiguatedQuestionName(data) : generateDisambiguatedVariableName(data);
        if (is_tb)
            r_expr += `grepl(caseFun(\`${control_names[i]}\`), caseFun(${name_in_code}), fixed = TRUE)`;
        else if (is_date)
            r_expr += `${name_in_code} >= \`${stringToRName(control_names[i])}\` & ` +
                `${name_in_code} <= \`${stringToRName(end_date_names[i])}\``;
        else if (is_multi)
            r_expr += `rowSums(${name_in_code}[ , trimws(${stringToRName(control_names[i])},
    whitespace = "[\\\\h\\\\v]"), drop = FALSE]) > 0`;
        else
            r_expr += RCodeForNominal(control_names[i], name_in_code);
        if (i < controls.length - 1)
            r_expr += multiple_boxes ? ") & \n (" : " & ";
    }
    if (multiple_boxes)
        r_expr += ")";
    try {
        new_r_question = data_file.newRQuestion(r_expr, new_question_name, new_var_name, null);
    }
    catch (e) {
        const errorFun = (e) => {
            log("The filter could not be created for the selected variable sets: " + e.message);
            return false;
        };
        if (/(R code is ambiguous)|(There are [0-9]* cases in this result)/.test(e.message)) {
            r_expr = "";
            if (is_tb) {
                r_expr = ("IGNORE_CASE = " + (ignore_case ? "TRUE\n" : "FALSE\n"));
                r_expr += "caseFun <- if (IGNORE_CASE) tolower else I\n";
            }
            if (multiple_boxes)
                r_expr += "(";
            for (let i = 0; i < controls.length; i++) {
                const data = filter_objects[i].data;
                (r_expr += is_tb ?
                    ("grepl(caseFun(`" + control_names[i] + "`), caseFun(" + generateDisambiguatedVariableName(data) + "), fixed = TRUE)") :
                    (RCodeForNominal("`" + control_names[i] + "`", generateDisambiguatedVariableName(data))));
                if (i < controls.length - 1)
                    r_expr += multiple_boxes ? ") & \n (" : " & ";
            }
            if (multiple_boxes)
                r_expr += ")";
            try {
                new_r_question = data_file.newRQuestion(r_expr, new_question_name, new_var_name, null);
            }
            catch (e) {
                return errorFun(e);
            }
        }
        else {
            return errorFun(e);
        }
    }
    // New properties added for https://numbers.atlassian.net/browse/FS1-741 are not available in versions older than 25.24
    if (fileFormatVersion() < 25.24) {
        new_r_question.isHidden = true;
    }
    else {
        new_r_question.isHiddenInView = true;
        new_r_question.isHiddenInExplore = true;
    }
    new_r_question.isFilter = true;
    new_r_question.questionType = "Pick One";
    insertAtHoverButtonIfShown(new_r_question);

    ///////////////////////////////////////////////////////////////////////////////////
    // Apply filter to the selected outputs
    applyFilterToOutputs(new_r_question, sub_items, types);
    return true;
}

// Validates control labels are unique, deletes control if duplicates found
function requireControlLabelsAreUnique(labels, question, control) {
    const duplicates = findDuplicateLabels(labels);
    if (duplicates.length == 0)
        return true;
    control.deleteItem();
    const unique_duplicates = uniqueElementsInArray(duplicates).join(", ");
    const msg = `The selected data, ${question.name}, cannot be used to create a filter as it contains multiple categories that have the same label but different data: ${unique_duplicates}`;
    throw new UserError(msg);
}

function findDuplicateLabels(labels) {
    const seen = new Set();
    return labels.filter(item => {
        if (seen.has(item))
            return true;
        seen.add(item);
        return false;
    });
}

// Generates R code for nominal variable filtering with missing data handling
function RCodeForNominal(control_name, name_in_code) {
    const MISSING_DATA_LABEL = "Missing data";
    const WHITESPACE_REGEX = "[\\\\h\\\\v]";
    return `("${MISSING_DATA_LABEL}" %in% ${stringToRName(control_name)})*is.na(${name_in_code}) |
    trimws(${name_in_code}, whitespace = "${WHITESPACE_REGEX}") %in% ${stringToRName(control_name)}`;
}



// Creates controls and filter question using FilterTerms API for categorical variables
function createControlsAndFilterQuestion(control_type) {
    const LISTBOX_CONTROL = "Listbox";
    const COMBOBOX_CONTROL = "Combobox";
    const ANY_OF_OPERATOR = "Any of";

    // Only support Listbox and Combobox for categorical variable selection
    if (control_type !== LISTBOX_CONTROL && control_type !== COMBOBOX_CONTROL) {
        log("Filter questions only support Listbox and Combobox controls for categorical variable selection.");
        return false;
    }

    /////////////////////////////////////////////////////////////////////////////////////
    // Generate control configuration
    const control_config = generateControlConfig(control_type);

    const filter_creation_options = getUserInputsForFilterCreation(control_config);
    if (!filter_creation_options)
        return false;

    const { group, data_file, selected_questions, multiple_selection, ignore_case, sub_items, types, allowed_types } = filter_creation_options;

    /////////////////////////////////////////////////////////////////////////////////////
    // Add Controls to page
    const filter_objects = createFilterObjects(selected_questions, allowed_types);
    const control_setup = createControlsForFilter(filter_objects, control_config, group, multiple_selection);
    if (!control_setup)
        return false;

    const { controls } = control_setup;

    //////////////////////////////////////////////////////////////////////////////
    // Create filter question with Control-based FilterTerms
    const label = control_config.control_type_names.label + " Filter " + selected_questions.map(function (q) {
        return q.questionType == "Pick Any" ? q.name : q.variables.map(function (v) {
            return v.label;
        }).join(" + ");
    }).join(" + ");
    const new_question_name = preventDuplicateQuestionName(data_file, label);
    const vname = control_config.control_type_names.reference.toLowerCase() + "_filter_" +
        selected_questions.map(function (q) {
            return q.name.replace(/[^a-zA-Z0-9_@\#\$\\]/g, '_').toLowerCase();
        }).join("_");
    const new_var_name = preventDuplicateVariableName(data_file, vname);

    // Create QScriptFilterTerm objects directly for the new API
    const filter_terms = [];

    for (let i = 0; i < controls.length; i++) {
        const is_multi = filter_objects[i].type == "multi";
        const data = filter_objects[i].data;

        // Use "Any of" operator for intuitive multi-category selection
        const operator = ANY_OF_OPERATOR;

        try {
            const filter_term = data_file.createFilterTerm(data, operator, controls[i], undefined, false);
            filter_terms.push(filter_term);
        }
        catch (e) {
            log("Could not create FilterTerm for " + (is_multi ? data.name : data.label) + ": " + (e instanceof Error ? e.message : String(e)));
            return false;
        }
    }

    // Validate that we have at least one filter term before creating the binary variable
    if (filter_terms.length === 0) {
        log("No valid filter terms could be created from the selected variable sets. Please ensure the selected questions have the appropriate question types and contain variables.");
        return false;
    }

    let new_filter_question;
    try {
        new_filter_question = data_file.newFilterQuestion(filter_terms, new_question_name, new_var_name, label, null);
        insertAtHoverButtonIfShown(new_filter_question);
    }
    catch (e) {
        log("The filter question could not be created for the selected variable sets: " + (e instanceof Error ? e.message : String(e)));
        return false;
    }

    ///////////////////////////////////////////////////////////////////////////////////
    // Apply filter to the selected outputs
    applyFilterToOutputs(new_filter_question, sub_items, types);
    return true;
}

See also