File: /home/globfdxw/www/wp-content/plugins/wpforms-surveys-polls/src/Reporting/Admin.php
<?php
namespace WPFormsSurveys\Reporting;
use WPForms_Entries_List;
use WPFormsSurveys\Helpers;
use WPFormsSurveys\Reporting\Helpers as ReportingHelpers;
/**
* Survey reporting admin page.
*
* @since 1.0.0
*/
class Admin {
/**
* All the forms.
*
* @since 1.0.0
*
* @var array
*/
public $forms;
/**
* Current form ID.
*
* @since 1.0.0
*
* @var int
*/
public $form_id;
/**
* Current form data array.
*
* @since 1.0.0
*
* @var array
*/
public $form_data;
/**
* Total number of entries in the current form.
*
* @since 1.0.0
*
* @var int
*/
public $entry_count;
/**
* Field IDs for the fields with survey reporting enabled.
*
* @since 1.0.0
*
* @var array
*/
public $field_ids;
/**
* Field ID for the specific survey field the user has selected to display
* in the survey preview area.
*
* If no specific field has been defined, it will be the first survey field
* in the form.
*
* @since 1.0.0
*
* @var array
*/
public $field_id;
/**
* If we are viewing the entries list table or the survey report.
*
* @since 1.0.0
*
* @var string
*/
public $view = false;
/**
* If we are viewing the survey report printable template.
*
* @since 1.0.0
*
* @var bool
*/
public $print = false;
/**
* Abort. Bail on proceeding to process the page.
*
* @since 1.8.0
*
* @var bool
*/
private $abort = false;
/**
* Various URLs.
*
* @since 1.0.0
*
* @var array
*/
public $urls = [];
/**
* Current filters applied to the entries list.
*
* @since 1.18.0
*
* @var array
*/
private $filters;
/**
* Current preset ID.
*
* @since 1.18.0
*
* @var string
*/
private $current_preset;
/**
* Number of displayed graphs per page.
*
* @since 1.18.0
*
* @var int
*/
private const GRAPHS_PER_PAGE = 5;
/**
* Datepicker object.
*
* @since 1.18.0
*
* @var Datepicker
*/
private $datepicker;
/**
* Construct.
*
* @since 1.0.0
*/
public function __construct() {
// Fire init.
$this->init();
}
/**
* Initialize.
*
* @since 1.0.0
*/
public function init() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh, WPForms.PHP.HooksMethod.InvalidPlaceForAddingHooks
// Register AJAX handler for loading more graphs regardless of current admin page.
add_action( 'wp_ajax_wpforms_surveys_graph_load_more', [ $this, 'ajax_load_more' ] );
// Delete current cache when entry edited.
if ( wpforms()->obj( 'entries_edit' )->is_admin_entry_editing_ajax() ) {
add_action( 'wpforms_pro_admin_entries_edit_submit_completed', [ $this, 'entries_edit_submit_clear_cache' ], 10, 4 );
}
// Check page and view, determine if the user is viewing the survey reporting page.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$current_page = isset( $_GET['page'] ) ? sanitize_key( $_GET['page'] ) : '';
$current_view = isset( $_GET['view'] ) ? sanitize_key( $_GET['view'] ) : '';
$is_deleted = isset( $_GET['deleted'] ) ? sanitize_key( $_GET['deleted'] ) : '';
$is_restored = isset( $_GET['restored'] ) ? sanitize_key( $_GET['restored'] ) : '';
$is_trashed = isset( $_GET['trashed'] ) ? sanitize_key( $_GET['trashed'] ) : '';
$this->view = in_array( $current_view, [ 'survey', 'list' ], true ) ? $current_view : false;
$this->print = ! empty( $_GET['print'] );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if ( $current_page !== 'wpforms-entries' || ! $this->view ) {
return;
}
// Survey results page processing and setup.
$this->setup();
// If there is no form ID, bail.
if ( ! $this->form_id ) {
return;
}
// Clear cache when entry is deleted, trashed or restored.
if ( $is_deleted || $is_restored || $is_trashed ) {
$this->entries_delete_clear_cache();
}
/**
* Filters whether to abort the survey reporting admin page initialization.
*
* @since 1.18.0
*
* @param bool $abort Whether to abort the page initialization.
* @param array $form_data Form data and settings.
* @param Admin $this Current instance.
*
* @return bool
*/
$this->abort = (bool) apply_filters( 'wpforms_surveys_reporting_admin_abort', false, $this->form_data, $this );
// No necessity to proceed.
if ( $this->abort ) {
return;
}
// phpcs:disable WPForms.PHP.ValidateHooks.InvalidHookName
/**
* Fire when everything is ready for initializing addon page.
*
* @since 1.0.0
*
* @param Admin $instance Reporting admin page instance.
*/
do_action( 'wpforms_survey_report_init', $this );
// phpcs:enable WPForms.PHP.ValidateHooks.InvalidHookName
if ( $this->view === 'list' ) {
// Entry List survey preview area.
add_action( 'wpforms_entry_list_title', [ $this, 'entry_list_preview' ], 12, 2 );
add_filter( 'wpforms_entry_table_column_value', [ $this, 'format_likert_scale_value' ], 10, 4 );
} elseif ( $this->view === 'survey' ) {
// Remove Screen Options tab from admin area header.
add_filter( 'screen_options_show_screen', '__return_false' );
// Survey results page output.
add_action( 'wpforms_admin_page', [ $this, 'report_page' ] );
}
// Load the Underscores templates displaying question results.
add_action( 'admin_print_scripts', [ $this, 'question_template' ] );
// Enqueues.
add_action( 'admin_enqueue_scripts', [ $this, 'enqueues' ] );
// Report Print Page.
add_action( 'current_screen', [ $this, 'report_print_page' ] );
}
/**
* Format the Likert Scale entries in Entries list view
* to a more readable format.
*
* @since 1.9.0
*
* @param string $value Value.
* @param object $entry Current entry data.
* @param string $column_name Current column name.
* @param string $field_type Field type.
*
* @return string
*/
public function format_likert_scale_value( $value, $entry, $column_name, $field_type ) {
if ( $field_type !== 'likert_scale' ) {
return $value;
}
return Helpers::format_likert_scale_entry( $value, '<br />' );
}
/**
* Setup and process form data.
*
* @since 1.0.0
*/
public function setup() {
// Fetch all forms, for the form dropdown toggle nav. We only need this
// for the survey reporting page.
if ( $this->view === 'survey' ) {
$this->forms = wpforms()->obj( 'form' )->get(
'',
[
'orderby' => 'ID',
'order' => 'ASC',
]
);
}
// Get current form ID.
$this->form_id = ! empty( $_GET['form_id'] ) ? absint( $_GET['form_id'] ) : false; // phpcs:ignore
// If there is no form ID, stop.
if ( ! $this->form_id ) {
wp_safe_redirect( admin_url( 'admin.php?page=wpforms-entries' ) );
exit;
}
// Get current form details.
$this->form_data = wpforms()->obj( 'form' )->get(
$this->form_id,
[
'content_only' => true,
'cap' => 'view_entries_form_single',
]
);
// Get number of current entries.
$this->entry_count = wpforms()->obj( 'entry' )->get_entries(
[
'form_id' => $this->form_id,
],
true
);
// Various URLs needed.
$this->urls = [
'survey-report' => add_query_arg(
[
'page' => 'wpforms-entries',
'view' => 'survey',
'form_id' => $this->form_id,
],
admin_url( 'admin.php' )
),
'survey-report-print' => add_query_arg(
[
'page' => 'wpforms-entries',
'view' => 'survey',
'form_id' => $this->form_id,
'print' => '1',
],
admin_url( 'admin.php' )
),
'form-edit' => add_query_arg(
[
'page' => 'wpforms-builder',
'view' => 'fields',
'form_id' => $this->form_id,
],
admin_url( 'admin.php' )
),
'form-preview' => add_query_arg(
[
'wpforms_form_preview' => $this->form_id,
],
home_url()
),
'entries-export' => wp_nonce_url(
add_query_arg(
[
'page' => 'wpforms-tools',
'view' => 'export',
'form' => absint( $this->form_id ),
'search' => ! empty( $_GET['search'] ) ? $_GET['search'] : [], // phpcs:ignore
'date' => ! empty( $_GET['date'] ) ? $_GET['date'] : [], // phpcs:ignore
],
admin_url( 'admin.php' )
),
'wpforms_entry_list_export'
),
'entries' => add_query_arg(
[
'page' => 'wpforms-entries',
'view' => 'list',
'form_id' => $this->form_id,
],
admin_url( 'admin.php' )
),
];
// Return earlier when the form is not found or have no entries.
if (
$this->view === 'survey'
&& ( empty( $this->forms ) || ! $this->entry_count )
) {
wp_safe_redirect( $this->urls['entries'] );
exit;
}
$this->set_filters();
$this->datepicker = new Datepicker( $this->filters );
// Get details about fields with survey reporting enabled.
$this->field_ids = Fields::get_survey_fields( $this->form_data, true );
$this->field_id = [];
// For the entry list overview page, the survey preview only displays
// a report for 1 field, so reflect this in the field IDs returned.
if ( ! empty( $this->field_ids ) && ( 'list' === $this->view || ( 'survey' === $this->view && ! empty( $_GET['field_id'] ) ) ) ) { // phpcs:ignore
$specific_id = false;
if ( ! empty( $_GET['field_id'] ) ) { // phpcs:ignore
// Show specific field.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$specific_id = absint( $_GET['field_id'] );
} elseif ( ! empty( $this->form_data['meta']['survey_preview'] ) ) {
// Check the form meta and see if the user as set a specific
// field they want to use in the preview area.
$specific_id = absint( $this->form_data['meta']['survey_preview'] );
}
if ( $specific_id && ! empty( $this->form_data['fields'][ $specific_id ] ) ) {
$this->field_id = (array) $specific_id;
} else {
$this->field_id = (array) $this->field_ids[0];
}
}
// Easter egg to delete current cache.
if ( isset( $_GET['wpforms_surveys_polls_delete_cache'] ) && wpforms_current_user_can( 'view_entries_form_single', $this->form_id ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$this->entries_delete_clear_cache();
}
}
/**
* Receive and sanitize filters from the URL.
*
* @param array|string $raw_filters Raw filters from the URL.
*
* @since 1.18.0
*/
private function set_filters( $raw_filters = null ): void { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh
// All elements are sanitized in the `sanitize_filters` method.
// phpcs:disable WordPress.Security
if ( is_null( $raw_filters ) ) {
// Back-compat: when no argument is provided, read from the request.
$raw_filters = $_REQUEST['filters'] ?? null;
}
if ( is_string( $raw_filters ) && $raw_filters !== '' ) {
$decoded = json_decode( wp_unslash( $raw_filters ), true );
$filters = is_array( $decoded ) ? $decoded : [];
} elseif ( is_array( $raw_filters ) ) {
$filters = $raw_filters;
} else {
$filters = [];
}
foreach ( $filters as $key => $value ) {
$key = sanitize_text_field( $key );
$value = $this->sanitize_filters( $value );
$filters[ $key ] = $value;
if ( strpos( $key, 'field_' ) === 0 ) {
$filters[ $key ] = (array) $value;
}
}
// phpcs:enable WordPress.Security
$normalized_filters = ReportingHelpers::normalize_filters( $filters );
$presets = $this->get_presets();
foreach ( $presets as $key => $preset ) {
$preset_filters = isset( $preset['filters'] ) && is_array( $preset['filters'] ) ? ReportingHelpers::normalize_filters( $preset['filters'] ) : [];
if ( $preset_filters === $normalized_filters ) {
$this->current_preset = $key;
break;
}
}
$this->filters = $normalized_filters;
}
/**
* Get the preset list used in Filters UI.
*
* @since 1.18.0
*
* @return array[]
*/
private function get_presets(): array {
$all_presets = (array) get_option( 'wpforms_survey_filter_presets', [] );
return array_filter(
$all_presets,
function ( $preset ) {
return isset( $preset['form_id'] ) && (int) $preset['form_id'] === $this->form_id;
}
);
}
/**
* Sanitize filters.
*
* @since 1.18.0
*
* @param mixed $value Value to sanitize.
*
* @return int|mixed|string
*/
private function sanitize_filters( $value ) {
if ( is_numeric( $value ) ) {
return (int) $value;
}
if ( is_string( $value ) ) {
return sanitize_text_field( $value );
}
if ( is_array( $value ) ) {
foreach ( $value as $key => $val ) {
$value[ sanitize_key( $key ) ] = $this->sanitize_filters( $val );
}
}
return $value;
}
/**
* Output entry list reporting preview area with a link to view the full
* survey reporting.
*
* @since 1.0.0
*
* @param array $form_data Form data and settings.
* @param WPForms_Entries_List $entries_list WPForms_Entries_List object.
*
* @noinspection PhpUnusedParameterInspection
* @noinspection PhpMissingParamTypeInspection
*/
public function entry_list_preview( $form_data, $entries_list ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
// Check if the form has fields with survey reporting enabled. If not
// do not display.
if ( empty( $this->field_ids ) ) {
return;
}
?>
<div class="wpforms-survey-preview-wrapper">
<div class="wpforms-survey-preview-list"
data-all-field-choices="<?php echo esc_attr( wp_json_encode( $this->get_fields_choices() ) ); ?>">
<?php
$selected_question = $this->get_question( $this->field_id[0] );
// Enable the Filters menu whenever at least one field in the dropdown is filterable.
$any_filterable = false;
foreach ( $this->field_ids as $fid ) {
if ( ! empty( $this->form_data['fields'][ $fid ]['type'] ) && $this->field_is_filterable( $this->form_data['fields'][ $fid ]['type'] ) ) {
$any_filterable = true;
break;
}
}
$current_is_filterable = isset( $selected_question['type'] ) && $this->field_is_filterable( $selected_question['type'] );
$this->render_graph(
$selected_question,
[
'is_question_dropdown' => true,
'filter' => $any_filterable,
// Initially hide filters when the selected field is not filterable.
'filter_hidden' => $any_filterable && ! $current_is_filterable,
'legend_pagination' => true,
// On the Entries list page, include only the current field in the Questions & Answers list.
'only_current_field' => true,
// Hide "Apply To All Graphs" on the Entries overview — only one graph is visible.
'hide_apply_all' => true,
]
);
?>
</div>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo wpforms_render( WPFORMS_SURVEYS_POLLS_PATH . 'templates/entries/loader' );
?>
</div>
<?php
}
/**
* Get question data.
*
* @since 1.18.0
*
* @param int|string $field_id Field ID.
*
* @return array
*/
private function get_question( $field_id ): array {
if ( ! is_numeric( $field_id ) || empty( $this->form_data['fields'][ $field_id ] ) ) {
return [];
}
$selected_field = $this->form_data['fields'][ $field_id ];
return [
'id' => $field_id,
'type' => $selected_field['type'],
'label' => ! empty( $selected_field['label'] ) ? wp_strip_all_tags( $selected_field['label'] ) : ( '#' . $field_id ),
];
}
/**
* Get list of questions for the preview dropdown.
*
* @since 1.18.0
*
* @return array
*/
private function get_questions(): array {
$questions = [];
if ( empty( $this->field_ids ) || empty( $this->form_data['fields'] ) ) {
return $questions;
}
foreach ( (array) $this->field_ids as $fid ) {
if ( empty( $this->form_data['fields'][ $fid ]['type'] ) ) {
continue;
}
$field = $this->form_data['fields'][ $fid ];
$field_type = $field['type'];
$label = ! empty( $field['label'] ) ? $field['label'] : ( '#' . $fid );
$questions[ $fid ] = [
'type' => $field_type,
'label' => wp_strip_all_tags( $label ),
];
}
return $questions;
}
/**
* Render graph.
*
* @since 1.18.0
*
* @param array $question_data Question data.
* @param array $graph_settings Graph configuration.
*/
private function render_graph( array $question_data, array $graph_settings = [] ): void {
if ( ! isset( $question_data['id'], $question_data['type'], $question_data['label'] ) ) {
return;
}
$graph_args = [
'question_settings' => [
'id' => $question_data['id'],
'type' => $question_data['type'],
'label' => $question_data['label'],
'options' => ! empty( $graph_settings['is_question_dropdown'] ) ? $this->get_questions() : [],
// When the question dropdown is present, always render settings
// because the user can switch to a chart-capable field dynamically.
'show_settings' => ! empty( $graph_settings['is_question_dropdown'] ) || $this->field_has_chart( $question_data['type'] ),
// Initially hide settings for non-chart fields; JS will toggle on field switch.
'settings_hidden' => ! empty( $graph_settings['is_question_dropdown'] ) && ! $this->field_has_chart( $question_data['type'] ),
// Optionally hide the "Apply To All Graphs" control in Settings (e.g., single-field print view).
'hide_apply_all' => ! empty( $graph_settings['hide_apply_all'] ),
],
'filter_settings' => [
'enabled' => false,
'applied' => $this->filters,
],
'legend_settings' => [
'pagination' => ! empty( $graph_settings['legend_pagination'] ),
'is_list' => $this->view === 'survey',
'all_result_page_url' => add_query_arg(
[
'page' => 'wpforms-entries',
'view' => 'survey',
'form_id' => $this->form_id,
],
admin_url( 'admin.php' )
),
],
'show_toggle' => ! empty( $graph_settings['show_toggle'] ),
];
if ( ! empty( $graph_settings['filter'] ) ) {
// Build fields list for Questions & Answers. Optionally limit to current field (entries list page behavior).
$fields_for_filters = $this->get_fields_choices();
if ( ! empty( $graph_settings['only_current_field'] ) ) {
$current_id = $question_data['id'];
if ( is_numeric( $current_id ) && isset( $fields_for_filters[ $current_id ] ) ) {
$fields_for_filters = [ $current_id => $fields_for_filters[ $current_id ] ];
}
}
$graph_args['filter_settings'] = array_merge(
$graph_args['filter_settings'],
[
'enabled' => true,
'hidden' => ! empty( $graph_settings['filter_hidden'] ),
'survey_type_field' => [],
'fields' => $fields_for_filters,
'date_picker' => $this->get_datepicker_settings(),
'presets' => $this->get_presets(),
'current_preset' => $this->current_preset,
]
);
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo wpforms_render(
WPFORMS_SURVEYS_POLLS_PATH . 'templates/entries/graph',
$graph_args,
true
);
}
/**
* Get datepicker settings.
*
* @since 1.18.0
*
* @return array
*/
private function get_datepicker_settings(): array {
return [
'timespan' => $this->datepicker->get_active_timespan(),
'date' => $this->datepicker->get_current_date(),
'choices' => $this->datepicker->get_date_choices(),
'ranges' => $this->datepicker->get_ranges(),
];
}
/**
* Get checkboxes settings.
*
* @since 1.18.0
*
* @return array
*/
private function get_fields_choices(): array {
$fields = [];
foreach ( $this->field_ids as $field_id ) {
if ( empty( $this->form_data['fields'][ $field_id ]['type'] ) ) {
continue;
}
$type = $this->form_data['fields'][ $field_id ]['type'];
// Only include fields that support answer-choice filtering. Skip non-filterable types.
if ( ! $this->field_is_filterable( $type ) ) {
continue;
}
$fields[ $field_id ] = $this->get_field_choices( $this->form_data['fields'][ $field_id ] );
}
return $fields;
}
/**
* Get field choices to render filter checkboxes.
*
* Split into smaller helpers for readability and CS compliance.
*
* @since 1.18.0
*
* @param array $field Field data.
*
* @return array
*/
private function get_field_choices( array $field ): array {
if ( empty( $field['type'] ) || ! isset( $field['id'] ) ) {
return [];
}
[ $has_filter, $selected_values ] = $this->get_selected_values_for_field( $field );
$primary_checked = $this->is_primary_checked_for_field( $field, $selected_values, $has_filter );
$choices = [ $this->build_primary_choice( $field, $primary_checked ) ];
// Text-like fields (virtual correct/incorrect options).
if ( in_array( $field['type'], Helpers::supported_text_field_types(), true ) ) {
return $this->build_text_field_choices( $choices, $has_filter, $selected_values );
}
// Rating fields use a numeric scale instead of a choices array.
if ( $field['type'] === 'rating' ) {
return $this->build_rating_field_choices( $choices, $field, $has_filter, $selected_values );
}
// NPS fields have a fixed 0-10 scale.
if ( $field['type'] === 'net_promoter_score' ) {
return $this->build_nps_field_choices( $choices, $has_filter, $selected_values );
}
// Choice-capable fields.
return $this->build_choice_field_choices( $choices, $field, $has_filter, $selected_values );
}
/**
* Get filter presence and normalize selected values as strings.
*
* @since 1.18.0
*
* @param array $field Field data.
*
* @return array
*/
private function get_selected_values_for_field( array $field ): array {
$has_filter = isset( $this->filters[ 'field_' . $field['id'] ] );
$selected_values = $has_filter ? array_map( 'strval', (array) $this->filters[ 'field_' . $field['id'] ] ) : [];
return [ $has_filter, $selected_values ];
}
/**
* Determine if the primary ("All") checkbox should be checked.
*
* @since 1.18.0
*
* @param array $field Field data.
* @param array $selected_values Selected values as strings.
* @param bool $has_filter Whether filter is present for the field.
*
* @return bool
*/
private function is_primary_checked_for_field( array $field, array $selected_values, bool $has_filter ): bool {
// Checked when no filter is applied.
if ( ! $has_filter ) {
return true;
}
// Text-like fields: primary is checked when both virtual options are selected.
if ( in_array( $field['type'], Helpers::supported_text_field_types(), true ) ) {
return in_array( 'correct', $selected_values, true ) && in_array( 'incorrect', $selected_values, true );
}
// Rating fields: primary is checked when all scale values are selected.
if ( $field['type'] === 'rating' ) {
$scale = ! empty( $field['scale'] ) ? (int) $field['scale'] : 5;
$all_keys = array_map( 'strval', range( 1, $scale ) );
return count( array_diff( $all_keys, $selected_values ) ) === 0;
}
// NPS fields: primary is checked when all 0-10 values are selected.
if ( $field['type'] === 'net_promoter_score' ) {
$all_keys = array_map( 'strval', range( 0, 10 ) );
return count( array_diff( $all_keys, $selected_values ) ) === 0;
}
// Choice-capable: primary is checked when all options are selected.
if ( ! empty( $field['choices'] ) && is_array( $field['choices'] ) ) {
$all_keys = [];
foreach ( $field['choices'] as $idx => $choice ) {
$opt_key = isset( $choice['value'] ) && $choice['value'] !== '' ? (string) $choice['value'] : (string) $idx;
$all_keys[] = (string) $opt_key;
}
return count( array_diff( $all_keys, $selected_values ) ) === 0;
}
return false;
}
/**
* Build the primary ("All") list item.
*
* @since 1.18.0
*
* @param array $field Field data.
* @param bool $primary_checked Checked state.
*
* @return array
*/
private function build_primary_choice( array $field, bool $primary_checked ): array {
return [
'key' => '',
'label' => wp_strip_all_tags( $field['label'] ) !== '' ? wp_strip_all_tags( $field['label'] ) : ( '#' . $field['id'] ),
'is_checked' => $primary_checked,
];
}
/**
* Build choices for text-like fields (Correct/Incorrect).
*
* @since 1.18.0
*
* @param array $choices Existing choices (contains primary).
* @param bool $has_filter Whether filter is applied.
* @param array $selected_values Selected values as strings.
*
* @return array
*/
private function build_text_field_choices( array $choices, bool $has_filter, array $selected_values ): array { // phpcs:ignore Generic.Metrics.NestingLevel.TooHigh
return array_merge(
$choices,
[
[
'key' => 'correct',
'label' => esc_html__( 'Correct', 'wpforms-surveys-polls' ),
'is_checked' => ! $has_filter || in_array( 'correct', $selected_values, true ),
],
[
'key' => 'incorrect',
'label' => esc_html__( 'Incorrect', 'wpforms-surveys-polls' ),
'is_checked' => ! $has_filter || in_array( 'incorrect', $selected_values, true ),
],
]
);
}
/**
* Build choices for rating fields from the numeric scale.
*
* Rating fields do not have a choices array; instead they store a scale
* (e.g. 5) and an icon type (star, heart, etc.). Each scale value from
* 1 to N becomes a filterable option.
*
* @since 1.18.0
*
* @param array $choices Existing choices (contains primary).
* @param array $field Field data.
* @param bool $has_filter Whether filter is applied.
* @param array $selected_values Selected values as strings.
*
* @return array
*/
private function build_rating_field_choices( array $choices, array $field, bool $has_filter, array $selected_values ): array {
$scale = ! empty( $field['scale'] ) ? (int) $field['scale'] : 5;
$icon = ! empty( $field['icon'] ) ? $field['icon'] : 'star';
for ( $i = 1; $i <= $scale; $i++ ) {
$choices[] = [
'key' => (string) $i,
'label' => Fields::get_rating_label( $icon, $i, $scale ),
'is_checked' => ! $has_filter || in_array( (string) $i, $selected_values, true ),
];
}
return $choices;
}
/**
* Build choices for Net Promoter Score fields.
*
* NPS has a fixed 0-10 numeric scale. Each value becomes a
* filterable option labelled plainly with its number.
*
* @since 1.18.0
*
* @param array $choices Existing choices (contains primary).
* @param bool $has_filter Whether filter is applied.
* @param array $selected_values Selected values as strings.
*
* @return array
*/
private function build_nps_field_choices( array $choices, bool $has_filter, array $selected_values ): array {
for ( $i = 0; $i <= 10; $i++ ) {
$choices[] = [
'key' => (string) $i,
'label' => (string) $i,
'is_checked' => ! $has_filter || in_array( (string) $i, $selected_values, true ),
];
}
return $choices;
}
/**
* Build choices for choice-capable fields from field choices.
*
* @since 1.18.0
*
* @param array $choices Existing choices (contains primary).
* @param array $field Field data.
* @param bool $has_filter Whether filter is applied.
* @param array $selected_values Selected values as strings.
*
* @return array
*/
private function build_choice_field_choices( array $choices, array $field, bool $has_filter, array $selected_values ): array {
if ( empty( $field['choices'] ) || ! is_array( $field['choices'] ) ) {
return $choices;
}
foreach ( $field['choices'] as $idx => $choice ) {
$opt_key = isset( $choice['value'] ) && $choice['value'] !== '' ? (string) $choice['value'] : (string) $idx;
$opt_label = isset( $choice['label'] ) ? wp_strip_all_tags( (string) $choice['label'] ) : ( '#' . (string) $idx );
$choices[] = [
'key' => $opt_key,
'label' => $opt_label,
'is_checked' => ! $has_filter || in_array( (string) $opt_key, $selected_values, true ),
];
}
return $choices;
}
/**
* Enqueue assets.
*
* @since 1.0.0
*/
public function enqueues() {
// Check if the form has fields with survey reporting enabled. If not, do not proceed.
if ( empty( $this->field_ids ) ) {
return;
}
$min = wpforms_get_min_suffix();
/*
* JavaScript.
*/
wp_enqueue_script( 'wp-util' );
// The PDF libraries are quite large. Originally, we restricted exporting to the
// full survey report view only, but the preview (list) view also needs PDF export.
// Load PDF libraries for both 'survey' and 'list' views.
if ( in_array( $this->view, [ 'survey', 'list' ], true ) ) {
wp_enqueue_script(
'pdfmake',
wpforms_surveys_polls()->url . 'assets/js/vendor/pdfmake.min.js',
[],
'0.1.35',
true
);
wp_enqueue_script(
'pdfmake-font',
wpforms_surveys_polls()->url . 'assets/js/vendor/vfs_fonts.min.js',
[],
'0.1.35',
true
);
}
wp_enqueue_script(
'wpforms-chart',
WPFORMS_PLUGIN_URL . 'assets/lib/chart.min.js',
[],
'4.5.1',
true
);
wp_enqueue_script(
'randomColor',
wpforms_surveys_polls()->url . "assets/js/vendor/randomColor{$min}.js",
[],
'0.5.2',
false
);
wp_enqueue_script(
'stupidtable',
wpforms_surveys_polls()->url . "assets/js/vendor/stupidtable{$min}.js",
[ 'jquery' ],
'1.1.3',
false
);
wp_enqueue_script(
'jquery-minicolors',
WPFORMS_PLUGIN_URL . 'assets/lib/jquery.minicolors/jquery.minicolors.min.js',
[ 'jquery' ],
'2.3.6',
true
);
// Flatpickr for date range calendar in Filters popover.
wp_enqueue_script(
'wpforms-flatpickr',
WPFORMS_PLUGIN_URL . 'assets/lib/flatpickr/flatpickr.min.js',
[ 'jquery' ],
'4.6.9',
true
);
// Enqueue modern-screenshot.
wp_enqueue_script(
'modern-screenshot',
wpforms_surveys_polls()->url . 'assets/js/vendor/modern-screenshot.min.js',
[],
'4.6.6',
true
);
wp_enqueue_script(
'wpforms-survey-reporting',
wpforms_surveys_polls()->url . "assets/js/admin-survey-reporting{$min}.js",
[ 'jquery', 'wp-color-picker', 'wpforms-chart', 'randomColor', 'stupidtable', 'jquery-minicolors', 'modern-screenshot', 'wpforms-flatpickr' ],
WPFORMS_SURVEYS_POLLS_VERSION,
true
);
wp_enqueue_script(
'wpforms-survey-reporting-charts',
wpforms_surveys_polls()->url . "assets/js/admin-survey-reporting-charts{$min}.js",
[ 'wpforms-survey-reporting' ],
WPFORMS_SURVEYS_POLLS_VERSION,
true
);
wp_enqueue_script(
'wpforms-survey-reporting-preview',
wpforms_surveys_polls()->url . "assets/js/admin-survey-reporting-preview{$min}.js",
[ 'wpforms-survey-reporting', 'wpforms-survey-reporting-charts' ],
WPFORMS_SURVEYS_POLLS_VERSION,
true
);
wp_localize_script(
'wpforms-survey-reporting',
'wpforms_surveys',
[
'type' => $this->view,
'form_id' => $this->form_id,
'entry_count' => $this->entry_count,
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
'field_ids' => $this->view === 'survey' && empty( $_GET['field_id'] ) ? wp_json_encode( $this->field_ids ) : wp_json_encode( $this->field_id ),
'field_id' => ! empty( $this->field_id ) ? $this->field_id[0] : '',
'field_nums' => array_flip( $this->field_ids ),
'loader' => $this->display_loader( $this->view !== 'survey' ),
'print' => esc_url_raw( $this->urls['survey-report-print'] ),
'cache' => $this->get_report_cache_data(),
'view_entry' => esc_html__( 'View Entry', 'wpforms-surveys-polls' ),
'entry_url' => esc_url_raw( admin_url( 'admin.php?page=wpforms-entries&view=details&entry_id=' ) ),
'filters' => $this->filters,
'date_field' => [
'delimiter' => $this->datepicker::RANGE_DELIMITER,
'locale' => sanitize_key( wpforms_get_language_code() ),
],
'i18n' => [
'save_title' => esc_html__( 'Save your filter so you can easily reuse it in the future.', 'wpforms-surveys-polls' ),
'save_button' => esc_html__( 'Save filter', 'wpforms-surveys-polls' ),
'cancel_button' => esc_html__( 'Cancel', 'wpforms-surveys-polls' ),
'edit_title' => esc_html__( 'Save your changes to the existing filter or create a new one?', 'wpforms-surveys-polls' ),
'edit_save_button' => esc_html__( 'Save Changes', 'wpforms-surveys-polls' ),
'edit_cancel_button' => esc_html__( 'Create New', 'wpforms-surveys-polls' ),
'questions_answers' => esc_html__( 'Questions & Answers', 'wpforms-surveys-polls' ),
'delete_preset' => esc_html__( 'Delete this preset?', 'wpforms-surveys-polls' ),
'nps_detractors' => esc_html__( 'Detractors (0-6)', 'wpforms-surveys-polls' ),
'nps_passives' => esc_html__( 'Passives (7-8)', 'wpforms-surveys-polls' ),
'nps_promoters' => esc_html__( 'Promoters (9-10)', 'wpforms-surveys-polls' ),
'nps_score' => esc_html__( 'Net Promoter Score', 'wpforms-surveys-polls' ),
],
]
);
// CSS.
wp_enqueue_style( 'wp-color-picker' );
wp_enqueue_style(
'jquery-minicolors',
WPFORMS_PLUGIN_URL . 'assets/lib/jquery.minicolors/jquery.minicolors.min.css',
[],
'2.3.6'
);
// Flatpickr styles (calendar UI for date range popover).
wp_enqueue_style(
'wpforms-flatpickr',
WPFORMS_PLUGIN_URL . 'assets/lib/flatpickr/flatpickr.min.css',
[],
'4.6.9'
);
wp_enqueue_style(
'wpforms-survey-reporting',
wpforms_surveys_polls()->url . "assets/css/admin-survey-reporting{$min}.css",
[ 'wp-color-picker', 'jquery-minicolors', 'wpforms-flatpickr' ],
WPFORMS_SURVEYS_POLLS_VERSION
);
}
/**
* Output the report cache data.
*
* @since 1.15.1
*
* @return array|bool
*/
private function get_report_cache_data() {
// Output cache data if we have it, but provide a filter to disable survey report caching.
$cache = false;
/**
* Allow caching of survey report data.
*
* @since 1.0.0
*
* @param bool $is_cache_enabled Whether to cache survey report data.
* @param int $form_id Form ID.
*/
if ( (bool) apply_filters( 'wpforms_surveys_polls_report_caching', true, $this->form_id ) ) { // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
// Build a cache key that is aware of the current filters to avoid reusing unfiltered data.
$filter_hash = '';
if ( ! empty( $this->filters ) && is_array( $this->filters ) ) {
$normalized = $this->filters;
ksort( $normalized );
$filter_hash = '_' . substr( wp_hash( wp_json_encode( $normalized ) ), 0, 12 );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$base_key = $this->view === 'list' || ( $this->view === 'survey' && ! empty( $_GET['field_id'] ) )
? "wpforms_survey_report_{$this->form_id}_{$this->entry_count}_{$this->field_id[0]}"
: "wpforms_survey_report_{$this->form_id}_{$this->entry_count}";
$cache_key = $base_key . $filter_hash;
$cache = get_transient( $cache_key );
}
return $cache ? json_decode( $cache, true ) : false;
}
/**
* Display abort message if form no longer available.
*
* @since 1.8.0
* @deprecated 1.11.0
*/
public function display_abort_message() {
_deprecated_function( __METHOD__, '1.11.0 of the WPForms Surveys and Polls addon' );
?>
<div id="wpforms-entries-list" class="wrap wpforms-admin-wrap">
<h1 class="page-title">
<?php esc_html_e( 'Entries', 'wpforms-surveys-polls' ); ?>
</h1>
<div class="wpforms-admin-content">
<?php
// Output empty state screen.
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo wpforms_render(
'admin/empty-states/no-entries',
[
'message' => esc_html__( 'It looks like the form you are trying to access is no longer available.', 'wpforms-surveys-polls' ),
],
true
);
?>
</div>
</div>
<?php
}
/**
* Survey report page.
*
* @since 1.0.0
*/
public function report_page() {
?>
<div id="wpforms-entries-list" class="wrap wpforms-admin-wrap">
<h1 class="page-title">
<?php esc_html_e( 'Survey Results', 'wpforms-surveys-polls' ); ?>
<a href="<?php echo esc_url( $this->urls['entries'] ); ?>" class="page-title-action wpforms-btn wpforms-btn-orange">
<svg viewBox="0 0 16 14" class="page-title-action-icon">
<path d="M16 6v2H4l4 4-1 2-7-7 7-7 1 2-4 4h12Z"/>
</svg>
<span class="page-title-action-text"><?php esc_html_e( 'Back to All Entries', 'wpforms-surveys-polls' ); ?></span>
</a>
</h1>
<div class="wpforms-admin-content">
<div class="form-details">
<span class="form-details-sub"><?php esc_html_e( 'Select Form', 'wpforms-surveys-polls' ); ?></span>
<h3 class="form-details-title">
<?php
if ( ! empty( $this->form_data['settings']['form_title'] ) ) {
echo esc_html( sanitize_text_field( $this->form_data['settings']['form_title'] ) );
}
$this->form_selector_html();
?>
</h3>
<div class="form-details-actions">
<?php if ( wpforms_current_user_can( 'edit_form_single', $this->form_id ) ) : ?>
<a href="<?php echo esc_url( $this->urls['form-edit'] ); ?>" class="form-details-actions-edit">
<span class="dashicons dashicons-edit"></span>
<?php esc_html_e( 'Edit This Form', 'wpforms-surveys-polls' ); ?>
</a>
<?php endif; ?>
<?php if ( wpforms_current_user_can( 'view_form_single', $this->form_id ) ) : ?>
<a href="<?php echo esc_url( $this->urls['form-preview'] ); ?>" class="form-details-actions-preview" target="_blank" rel="noopener">
<span class="dashicons dashicons-visibility"></span>
<?php esc_html_e( 'Preview Form', 'wpforms-surveys-polls' ); ?>
</a>
<?php endif; ?>
<a href="<?php echo esc_url( $this->urls['entries-export'] ); ?>" class="form-details-actions-export">
<span class="dashicons dashicons-migrate"></span>
<?php esc_html_e( 'Export All', 'wpforms-surveys-polls' ); ?>
</a>
</div>
</div>
<div id="wpforms-survey-report">
<?php
$this->render_questions();
echo $this->display_loader(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
</div>
</div>
</div>
<?php
}
/**
* Display form selector HTML.
*
* @since 1.5.8
*/
protected function form_selector_html() {
if ( ! wpforms_current_user_can( 'view_forms' ) ) {
return;
}
if ( empty( $this->forms ) ) {
return;
}
?>
<div class="form-selector">
<a href="#" title="<?php esc_attr_e( 'Open form selector', 'wpforms-surveys-polls' ); ?>" class="toggle dashicons dashicons-arrow-down-alt2"></a>
<div class="form-list">
<ul>
<?php
foreach ( $this->forms as $key => $form ) {
$form_url = add_query_arg(
[
'page' => 'wpforms-entries',
'view' => 'list',
'form_id' => absint( $form->ID ),
],
admin_url( 'admin.php' )
);
echo '<li><a href="' . esc_url( $form_url ) . '">' . esc_html( $form->post_title ) . '</a></li>';
}
?>
</ul>
</div>
</div>
<?php
}
/**
* Survey report printable page.
*
* @since 1.0.0
*/
public function report_print_page() {
// Check if we should show the survey report print template.
if ( ! $this->print ) {
return;
}
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title><?php esc_html_e( 'WPForms Survey Print Preview', 'wpforms-surveys-polls' ); ?> - <?php echo esc_html( sanitize_text_field( $this->form_data['settings']['form_title'] ) ); ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow,noarchive">
<?php
// phpcs:ignore WPForms.Comments.PHPDocHooks.RequiredHookDocumentation, WPForms.PHP.ValidateHooks.InvalidHookName
do_action( 'admin_enqueue_scripts' );
// phpcs:ignore WPForms.Comments.PHPDocHooks.RequiredHookDocumentation, WPForms.PHP.ValidateHooks.InvalidHookName
do_action( 'admin_print_scripts' );
// phpcs:ignore WPForms.Comments.PHPDocHooks.RequiredHookDocumentation, WPForms.PHP.ValidateHooks.InvalidHookName
do_action( 'admin_head' );
?>
</head>
<body id="wpforms-survey-print-preview">
<h1 class="header">
<?php echo esc_html( sanitize_text_field( $this->form_data['settings']['form_title'] ) ); ?>
<div class="buttons">
<button type="button" id="wpforms-survey-print-close"><?php esc_html_e( 'Close', 'wpforms-surveys-polls' ); ?></button>
<button type="button" id="wpforms-survey-print"><?php esc_html_e( 'Print', 'wpforms-surveys-polls' ); ?></button>
</div>
</h1>
<div id="wpforms-survey-report">
<?php
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$single_field = ! empty( $_GET['field_id'] );
// Show toggle buttons only for the full report print (not single-field view).
$this->render_questions( ! $single_field );
echo $this->display_loader( $single_field ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
</div>
<?php
// phpcs:ignore WPForms.Comments.PHPDocHooks.RequiredHookDocumentation, WPForms.PHP.ValidateHooks.InvalidHookName
do_action( 'admin_print_footer_scripts' );
?>
</body>
</html>
<?php
exit();
}
/**
* Output HTML markup for our loading animation indicator.
*
* @since 1.0.0
*
* @param bool $single If we are loading a single field or all results.
*
* @return string
*/
public function display_loader( $single = false ) {
return wpforms_render(
WPFORMS_SURVEYS_POLLS_PATH . 'templates/entries/loader',
[ 'single' => $single ],
true
);
}
/**
* Render pre-built question card containers with controls.
*
* @since 1.18.0
*
* @param bool $show_toggles Whether to render print-preview toggle buttons.
*/
private function render_questions( bool $show_toggles = false ): void { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded, Generic.Metrics.NestingLevel.MaxExceeded, Generic.Metrics.CyclomaticComplexity.TooHigh
if ( empty( $this->field_ids ) || empty( $this->form_data ) ) {
return;
}
// In print view, render questions without pagination.
// When a single field is requested via field_id param, print only that field.
if ( $this->print ) {
$print_fields = ! empty( $this->field_id ) ? $this->field_id : $this->field_ids;
$question_num = 1;
$filter_rendered = false;
$only_current_field = count( (array) $print_fields ) === 1;
foreach ( $print_fields as $field_id ) {
if ( empty( $this->form_data['fields'][ $field_id ] ) ) {
continue;
}
$type = $this->form_data['fields'][ $field_id ]['type'] ?? '';
$show_filters = ! $filter_rendered; // Filters must appear on the very first question only.
// In print view for a single field, hide Filters if the current field doesn't support filtering.
if ( $only_current_field && ! $this->field_is_filterable( $type ) ) {
$show_filters = false;
}
if ( $this->field_has_chart( $type ) ) {
$this->render_graph(
$this->get_question( $field_id ),
[
'is_question_dropdown' => false,
'filter' => $show_filters,
'only_current_field' => $only_current_field,
'legend_pagination' => false,
'show_toggle' => $show_toggles,
// In print view for a single question, hide the "Apply To All Graphs" option.
'hide_apply_all' => (bool) $only_current_field,
]
);
if ( $show_filters ) {
$filter_rendered = true;
}
} else {
// Non-chart question. Still render Filters on the very first question.
$filter_settings = null;
if ( $show_filters ) {
$fields_for_filters = $this->get_fields_choices();
if ( $only_current_field ) {
if ( is_numeric( $field_id ) && isset( $fields_for_filters[ $field_id ] ) ) {
$fields_for_filters = [ $field_id => $fields_for_filters[ $field_id ] ];
} else {
$fields_for_filters = [];
}
}
$filter_settings = [
'enabled' => true,
'hidden' => false,
'survey_type_field' => [],
'fields' => $fields_for_filters,
'date_picker' => $this->get_datepicker_settings(),
'presets' => $this->get_presets(),
'current_preset' => $this->current_preset,
'applied' => $this->filters,
];
$filter_rendered = true;
}
$this->render_question_card( $field_id, $question_num, $show_toggles, false, $filter_settings );
}
++$question_num;
}
return;
}
// Render only the first page worth of graphs initially; others are loaded via AJAX.
$this->display_graph_fields( 1 );
}
/**
* Display graph fields on the Survey Results page with pagination support.
*
* @since 1.18.0
*
* @param int $page Page number to render.
*/
private function display_graph_fields( int $page ): void {
/**
* Filter the number of graphs displayed per page on the Survey Results page.
*
* @since 1.18.0
*
* @param int $per_page Number of graphs per page.
* @param int $form_id Current form ID.
* @param array $form_data Current form data.
*/
$graphs_per_page = (int) apply_filters( // phpcs:ignore WPForms.PHP.ValidateHooks.InvalidHookName
'wpforms_surveys_admin_entries_all_results_page_graphs_per_page',
self::GRAPHS_PER_PAGE,
$this->form_id,
$this->form_data
);
$has_load_more = false;
$skip = $page === 1 ? 0 : $graphs_per_page * ( $page - 1 );
$displaying = 0;
$question_num = $skip + 1;
foreach ( $this->field_ids as $field_id ) {
if ( $skip ) {
--$skip;
continue;
}
if ( $displaying >= $graphs_per_page ) {
$has_load_more = true;
break;
}
$type = $this->form_data['fields'][ $field_id ]['type'] ?? '';
$show_filters = ( $page === 1 && $displaying === 0 );
if ( $this->field_has_chart( $type ) ) {
// Show Filters menu only for the very first question on the Survey Results page.
$this->render_graph(
$this->get_question( $field_id ),
[
'is_question_dropdown' => false,
'filter' => $show_filters,
'legend_pagination' => false,
]
);
} else {
// Non-chart question; still show the Filters button if this is the very first question.
$filter_settings = null;
if ( $show_filters ) {
$filter_settings = [
'enabled' => true,
'hidden' => false,
'survey_type_field' => [],
'fields' => $this->get_fields_choices(),
'date_picker' => $this->get_datepicker_settings(),
'presets' => $this->get_presets(),
'current_preset' => $this->current_preset,
'applied' => $this->filters,
];
}
$this->render_question_card( $field_id, $question_num, false, false, $filter_settings );
}
++$displaying;
++$question_num;
}
if ( $has_load_more && ! $this->print ) {
?>
<div class="wpforms-survey-graph-preview wpforms-survey-graph-preview-load-more">
<button class="button wpforms-survey-button-load-more" data-next-page="<?php echo absint( $page + 1 ); ?>"><?php esc_html_e( 'Load More', 'wpforms-surveys-polls' ); ?></button>
</div>
<?php
}
}
/**
* AJAX: Load more survey result graphs.
*
* @since 1.18.0
*/
public function ajax_load_more(): void {
check_ajax_referer( 'wpforms-admin', 'nonce' );
$page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Missing
$form_id = isset( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
if ( ! $form_id || ! wpforms_current_user_can( 'view_entries_form_single', $form_id ) ) {
wp_send_json_error();
}
$form_data = (array) wpforms()->obj( 'form' )->get(
$form_id,
[
'content_only' => true,
'cap' => 'view_entries_form_single',
]
);
if ( empty( $form_data ) ) {
wp_send_json_error();
}
// Prepare context needed by display_graph_fields().
$this->form_id = $form_id;
$this->form_data = $form_data;
$this->field_ids = Fields::get_survey_fields( $this->form_data, true );
// Initialize runtime dependencies used by render_graph() when called via AJAX.
// Ensure filters, datepicker, and presets context exist to avoid fatal errors.
// If filters were provided via AJAX, decode and apply them so newly loaded graphs respect current selections.
$this->filters = [];
$incoming_filters = null;
if ( ! empty( $_POST['filters'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
// phpcs:disable WordPress.Security
$raw_filters = wp_unslash( $_POST['filters'] );
$decoded = json_decode( $raw_filters, true );
$incoming_filters = is_array( $decoded ) ? $decoded : null;
// phpcs:enable WordPress.Security
}
// Normalize and store filters; also determines current preset when applicable.
$this->set_filters( $incoming_filters );
$this->current_preset = $this->current_preset ?? '';
$this->datepicker = new Datepicker( $this->filters );
$this->view = 'survey';
ob_start();
$this->display_graph_fields( $page );
wp_send_json_success( ob_get_clean() );
}
/**
* Determine whether a field type supports chart rendering.
*
* @since 1.18.0
*
* @param string $type Field type.
*
* @return bool
*/
private function field_has_chart( string $type ): bool {
// Only fields with selectable choices should render charts.
// Textual inputs (single line, paragraph, etc.) and Likert Scale should be table-only.
if ( in_array( $type, Helpers::supported_text_field_types(), true ) ) {
return false;
}
if ( $type === 'likert_scale' ) {
return false;
}
return true;
}
/**
* Check whether a field type supports answer-choice filtering.
*
* Fields like text, textarea, and likert_scale do not have user-defined
* choices that can be meaningfully filtered, so they should be excluded
* from the Questions & Answers filter menu and should not be refreshed
* when filters change.
*
* @since 1.18.0
*
* @param string $type Field type.
*
* @return bool True if the field type supports filtering.
*/
private function field_is_filterable( string $type ): bool {
static $non_filterable = null;
if ( $non_filterable === null ) {
$non_filterable = array_merge(
Helpers::supported_text_field_types(),
[ 'likert_scale' ]
);
}
return ! in_array( $type, $non_filterable, true );
}
/**
* Render a question wrapper card for non-chart fields (table-only, likert, text, etc.).
*
* @since 1.18.0
*
* @param int $field_id Field ID.
* @param int $question_num Question index number.
* @param bool $show_toggles Whether to render print toggle.
* @param bool $is_hidden Whether to hide initially.
* @param array|null $filter_settings Filter settings to render the Filters menu for this question.
*/
private function render_question_card( int $field_id, int $question_num, bool $show_toggles, bool $is_hidden, ?array $filter_settings = null ): void {
if ( empty( $this->form_data['fields'][ $field_id ] ) ) {
return;
}
$field = $this->form_data['fields'][ $field_id ];
$field_type = sanitize_key( $field['type'] ?? '' );
$label = ! empty( $field['label'] ) ? wp_strip_all_tags( $field['label'] ) : ( '#' . $field_id );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo wpforms_render(
WPFORMS_SURVEYS_POLLS_PATH . 'templates/entries/question',
[
'field_id' => $field_id,
'field_type' => $field_type,
'question_num' => $question_num,
'label' => $label,
'badge' => '',
'has_chart' => false,
'is_list' => ( $this->view === 'survey' ),
'show_toggle' => $show_toggles,
'is_hidden' => $is_hidden,
'filter_settings' => $filter_settings,
],
true
);
}
/**
* Underscore template for question data content (chart, table, stats).
*
* Question card containers and controls are pre-rendered in PHP via
* render_questions(). This template only handles the dynamic data content
* populated into .wpforms-survey-question-content after AJAX fetch.
*
* @since 1.0.0
*/
public function question_template() {
// Check if the form has fields with survey reporting enabled.
// If not do not proceed.
if ( empty( $this->field_ids ) ) {
return;
}
?>
<script type="text/html" id="tmpl-wpforms-question-content">
<# var fieldData = data; #>
<# if ( _.isEmpty( fieldData.answers ) ) { #>
<div class="no-answers">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i>
<p><?php esc_html_e( 'There are no answers to this question yet.', 'wpforms-surveys-polls' ); ?></p>
</div>
<# } else { #>
<# if ( ! _.isEmpty( fieldData.chart.supports ) ) { #>
<div class="wpforms-survey-graph-content-chart {{ fieldData.type }}">
<canvas id="chart-{{ fieldData.id }}"></canvas>
</div>
<!-- High-quality canvas for exports -->
<div class="wpforms-hidden">
<canvas id="chart-{{ fieldData.id }}-hq" width="1200" height="600"></canvas>
</div>
<# if ( 'net_promoter_score' === fieldData.type ) { #>
<div class="wpforms-survey-graph-content-legend">
<div class="wpforms-survey-graph-content-legend-table-wrapper">
<table class="wpforms-survey-graph-content-legend-table">
<thead>
<tr>
<th><?php esc_html_e( 'Detractors (0-6)', 'wpforms-surveys-polls' ); ?></th>
<th><?php esc_html_e( 'Passives (7-8)', 'wpforms-surveys-polls' ); ?></th>
<th><?php esc_html_e( 'Promoters (9-10)', 'wpforms-surveys-polls' ); ?></th>
<th class="score"><?php esc_html_e( 'Net Promoter Score', 'wpforms-surveys-polls' ); ?></th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="count">{{ fieldData.nps.detractors.count }}</span><span class="percent">{{ fieldData.nps.detractors.percent }}%</span></td>
<td><span class="count">{{ fieldData.nps.passives.count }}</span><span class="percent">{{ fieldData.nps.passives.percent }}%</span></td>
<td><span class="count">{{ fieldData.nps.promoters.count }}</span><span class="percent">{{ fieldData.nps.promoters.percent }}%</span></td>
<td>{{ fieldData.nps.score }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<# } else { #>
<div class="wpforms-survey-graph-content-legend">
<div class="wpforms-survey-graph-content-legend-table-wrapper">
<table class="wpforms-survey-graph-content-legend-table wpforms-table-sorting">
<thead>
<tr>
<th><?php esc_html_e( 'Answers', 'wpforms-surveys-polls' ); ?></th>
<th data-sort="int" colspan="2"><span><?php esc_html_e( 'Responses', 'wpforms-surveys-polls' ); ?></span></th>
</tr>
</thead>
<tbody>
<# _.each( fieldData.answers, function( answer ) { #>
<tr>
<td><span>{{ answer.value }}</span></td>
<td data-sort-value="{{ answer.percent }}"><span>{{ answer.percent }}%</span></td>
<td><span class="total">{{ answer.count }}</span></td>
</tr>
<# }) #>
</tbody>
</table>
<div class="wpforms-survey-graph-content-legend-info wpforms-hidden">
<div class="wpforms-survey-graph-content-legend-info-items">
<div class="wpforms-survey-graph-content-legend-info-item wpforms-survey-graph-info-answered"><span></span> <?php esc_html_e( 'Answered', 'wpforms-surveys-polls' ); ?></div>
<div class="wpforms-survey-graph-content-legend-info-item wpforms-survey-graph-info-skipped"><span></span> <?php esc_html_e( 'Skipped', 'wpforms-surveys-polls' ); ?></div>
</div>
</div>
</div>
</div>
<# } #>
<# } else if ( 'likert_scale' === fieldData.type ) { #>
<# var rowCount = 1; #>
<div class="wpforms-survey-graph-content-legend">
<div class="wpforms-survey-graph-content-legend-table-wrapper">
<table class="wpforms-survey-graph-content-legend-table likert-results<# if ( fieldData.table.single ) { print( ' single' ); } else { print( ' wpforms-table-sorting' ); } #>">
<thead>
<tr>
<# if ( ! fieldData.table.single ) { #>
<th style="width:20%;"></th>
<# } #>
<# _.each( fieldData.table.columns, function( columnLabel, key ) { #>
<th data-sort="int" data-sort-default="desc" style="width:{{ fieldData.table.width }}%;"<# if ( ! fieldData.table.single ) { print( ' class="sortable"' ); } #>><span>{{ columnLabel }}</span></th>
<# }) #>
</tr>
</thead>
<tbody>
<# _.each( fieldData.table.rows, function( rowLabel, rowKey ) { #>
<# if ( ! fieldData.table.single || ( fieldData.table.single && rowCount === 1 ) ) { #>
<tr>
<# if ( ! fieldData.table.single ) { #>
<td class="th">{{ rowLabel }}</td>
<# } #>
<# _.each( fieldData.table.columns, function( columnLabel, columnKey ) { #>
<td data-sort-value="{{ fieldData.answers[rowKey + '_' + columnKey].count }}"<# if ( fieldData.answers[rowKey + '_' + columnKey].highest ) { print( ' class="highest"' ); } #>>
<# if ( fieldData.answers[rowKey + '_' + columnKey].count > 0 ) { #>
<span class="count">{{ fieldData.answers[rowKey + '_' + columnKey].count }}</span>
<span class="percent">{{ fieldData.answers[rowKey + '_' + columnKey].percent }}%</span>
<# } #>
</td>
<# }) #>
</tr>
<# } #>
<# rowCount++ #>
<# }) #>
</tbody>
</table>
<div class="wpforms-survey-graph-content-legend-info">
<div class="wpforms-survey-graph-content-legend-info-items">
<div class="wpforms-survey-graph-content-legend-info-item wpforms-survey-graph-info-answered"><span>{{ fieldData.answered }}</span> <?php esc_html_e( 'Answered', 'wpforms-surveys-polls' ); ?></div>
<div class="wpforms-survey-graph-content-legend-info-item wpforms-survey-graph-info-skipped"><span>{{ fieldData.skipped }}</span> <?php esc_html_e( 'Skipped', 'wpforms-surveys-polls' ); ?></div>
</div>
</div>
</div>
</div>
<# } else { #>
<div class="wpforms-survey-graph-content-legend">
<div class="wpforms-survey-graph-content-legend-table-wrapper">
<table class="wpforms-survey-graph-content-legend-table wpforms-table-sorting text-results">
<thead>
<tr>
<th><?php esc_html_e( 'Answers', 'wpforms-surveys-polls' ); ?></th>
<th data-sort="int" data-sort-default="desc" data-sort-onload="yes" class="date"><span><?php esc_html_e( 'Date', 'wpforms-surveys-polls' ); ?></span></th>
</tr>
</thead>
<tbody>
<# _.each( fieldData.answers, function( answer, key ) { #>
<tr>
<td><span>{{ answer.value }}</span></td>
<td class="date" data-sort-value="{{ answer.date_unix }}">
{{ answer.date }}
<a href="<?php echo esc_url( admin_url( 'admin.php?page=wpforms-entries&view=details&entry_id=' ) ); ?>{{ answer.entry_id }}" title="<?php esc_html_e( "View respondent's answers", 'wpforms-surveys-polls' ); ?>" target="_blank" rel="noopener noreferrer" class="view-entry"><i class="fa fa-external-link" aria-hidden="true"></i></a>
</td>
</tr>
<# }) #>
</tbody>
</table>
<div class="wpforms-survey-graph-content-legend-info">
<div class="wpforms-survey-graph-content-legend-info-items">
<div class="wpforms-survey-graph-content-legend-info-item wpforms-survey-graph-info-answered"><span>{{ fieldData.answered }}</span> <?php esc_html_e( 'Answered', 'wpforms-surveys-polls' ); ?></div>
<div class="wpforms-survey-graph-content-legend-info-item wpforms-survey-graph-info-skipped"><span>{{ fieldData.skipped }}</span> <?php esc_html_e( 'Skipped', 'wpforms-surveys-polls' ); ?></div>
</div>
</div>
</div>
</div>
<# } #>
<# if ( ! _.isEmpty( fieldData.chart.supports ) ) { #>
<div class="stats <# if ( fieldData.average ) { print( 'has-average' ); } #>">
<div class="answered">
<strong>{{ fieldData.answered }}</strong>
<?php esc_html_e( 'Answered', 'wpforms-surveys-polls' ); ?>
</div>
<# if ( fieldData.average ) { #>
<div class="average">
<strong>{{ fieldData.average }}</strong>
<?php esc_html_e( 'Average', 'wpforms-surveys-polls' ); ?>
</div>
<# } #>
<div class="skipped">
<strong>{{ fieldData.skipped }}</strong>
<?php esc_html_e( 'Skipped', 'wpforms-surveys-polls' ); ?>
</div>
</div>
<# } #>
<# } #>
</script>
<?php
}
/**
* Delete current cache when entry edited.
*
* @since 1.6.3
*
* @param array $form_data Form data.
* @param mixed $response Entries edit process response.
* @param array $updated_fields Updated fields data.
* @param object $entry Existing entry data.
*/
public function entries_edit_submit_clear_cache( $form_data, $response, $updated_fields, $entry ) {
if ( empty( $response['modified'] ) || empty( $updated_fields ) ) {
return;
}
$entry = (array) $entry;
if ( ! wpforms_current_user_can( 'edit_entry_single', $entry['entry_id'] ) ) {
return;
}
$fields = ! empty( $form_data['fields'] ) ? $form_data['fields'] : [];
$entry_count = wpforms()->obj( 'entry' )->get_entries( [ 'form_id' => $form_data['id'] ], true );
$deleted = false;
foreach ( $fields as $field ) {
if ( ! Fields::field_has_survey( $field, $form_data ) ) {
continue;
}
if ( ! isset( $field['id'] ) || ! array_key_exists( $field['id'], $updated_fields ) ) {
continue;
}
delete_transient( "wpforms_survey_report_{$form_data['id']}_{$entry_count}_{$field['id']}" );
$deleted = true;
}
if ( $deleted ) {
delete_transient( "wpforms_survey_report_{$form_data['id']}_{$entry_count}" );
}
}
/**
* Drop cache for current count and all previous.
*
* @since 1.11.0
*/
private function entries_delete_clear_cache() {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$option_names = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s",
'_transient_%' . $wpdb->esc_like( "wpforms_survey_report_{$this->form_id}_" ) . '%'
)
);
if ( empty( $option_names ) ) {
return;
}
$option_names = array_map(
static function ( $option_name ) {
return str_replace( [ '_transient_timeout_', '_transient_' ], '', $option_name );
},
$option_names
);
$option_names = array_unique( $option_names );
foreach ( $option_names as $option_name ) {
delete_transient( $option_name );
}
}
}