Entries. With no form_id it's a global overview (every form's
* entry count, plus the most recent submissions across all forms). With a
* form_id it drills into that form's own entries table. Plain PHP admin
* tables — no JS bundle needed here, keeps this screen usable even before
* the builder JS has loaded.
*/
class Entries_Page {
public function __construct() {
add_action( 'admin_menu', [ $this, 'register' ] );
add_action( 'admin_post_chargeforms_delete_entry', [ $this, 'handle_delete' ] );
add_action( 'admin_post_chargeforms_bulk_entry_action', [ $this, 'handle_bulk_entry_action' ] );
add_action( 'admin_post_chargeforms_export_entries', [ $this, 'handle_export' ] );
add_action( 'admin_post_chargeforms_toggle_entry_status', [ $this, 'handle_toggle_status' ] );
add_action( 'admin_post_chargeforms_print_entry', [ $this, 'handle_print' ] );
add_action( 'admin_post_chargeforms_import_entries', [ $this, 'handle_import' ] );
add_action( 'admin_post_chargeforms_update_entry', [ $this, 'handle_update_entry' ] );
add_action( 'admin_post_chargeforms_toggle_star', [ $this, 'handle_toggle_star' ] );
add_action( 'admin_post_chargeforms_add_entry_note', [ $this, 'handle_add_note' ] );
add_action( 'admin_post_chargeforms_resend_notification', [ $this, 'handle_resend_notification' ] );
}
/** Admin screens (table columns, detail view, export) show a field's admin-only label when set, since it's meant to be clearer for staff than the public-facing label. */
/** Public (roadmap issue #31: "Admin Tables Mix Concerns in Large PHP Page Classes") so the extracted Entry_Exporter can reuse the exact same column-labeling this page's own table/detail/print views use, rather than a second copy of the same one-line rule. */
public static function column_label( array $field ) {
return ! empty( $field['adminLabel'] ) ? $field['adminLabel'] : $field['label'];
}
/**
* A stored field value is usually a plain string, but multi-value
* fields (checkbox groups, Address's street/city/... sub-fields) are
* stored as arrays — see SubmissionHandler::handle(), which only
* scalar-sanitizes non-array values and array_maps the rest. Casting
* an array straight to (string) produces the literal text "Array"
* plus a PHP warning; on a host with display_errors on, that warning
* prints inline mid-table and visibly wrecks the row's column
* alignment. Every place a value reaches the page (table cell, detail
* view, print view, CSV export) must go through this instead of a
* raw (string) cast.
*/
/** Public — see column_label()'s identical note; Entry_Exporter's CSV/JSON export needs the same array-safe display rule every other view of an entry value uses. */
public static function display_value( $value ) {
if ( ! is_array( $value ) ) {
return (string) $value;
}
// A flat array (checkboxes/multi-select: every element already a
// scalar) keeps its original ", "-joined behavior. A Repeater's
// value is an array of ROWS, each itself an array of that row's
// own sub-field values — array_map( 'strval', ... ) on THOSE rows
// would trigger PHP's "Array to string conversion" and silently
// render every row as the literal string "Array" instead of its
// real values (found via live testing against a real submission,
// not something a fixture built from already-flat sample data
// would ever exercise). Detected once up front rather than
// per-element so the common flat case pays zero extra cost.
$is_nested = false;
foreach ( $value as $item ) {
if ( is_array( $item ) ) {
$is_nested = true;
break;
}
}
if ( ! $is_nested ) {
return implode( ', ', array_filter( array_map( 'strval', $value ), fn( $v ) => '' !== trim( $v ) ) );
}
$rows = array_map( function ( $row ) {
if ( ! is_array( $row ) ) {
return (string) $row;
}
return implode( ', ', array_filter( array_map( 'strval', $row ), fn( $v ) => '' !== trim( $v ) ) );
}, $value );
return implode( '; ', array_filter( $rows, fn( $r ) => '' !== trim( $r ) ) );
}
/**
* Global Settings > Miscellaneous > "Date & Time Format" — relative
* ("2 hours ago") or WordPress's own configured date/time format,
* overriding the older free-form entries_date_format setting when set
* to 'relative'.
*
* `$mysql_datetime` is stored in GMT (roadmap issue #27 —
* Entry::create()/add_meta() both now store explicit GMT rather than
* relying on the table's own timezone-ambiguous DEFAULT
* CURRENT_TIMESTAMP). The relative branch compares against GMT "now"
* to match; the absolute branches convert back to the site's
* configured timezone via get_date_from_gmt() before formatting, so
* an admin sees their OWN local time, not raw UTC.
*/
private static function format_entry_date( $mysql_datetime, $fallback_format ) {
if ( 'relative' === Settings::get( 'date_time_format', 'relative' ) ) {
/* translators: %s: human-readable time difference, e.g. "2 hours" */
return sprintf( __( '%s ago', 'chargeforms' ), human_time_diff( strtotime( $mysql_datetime ), current_time( 'timestamp', true ) ) );
}
$local_datetime = get_date_from_gmt( $mysql_datetime, 'Y-m-d H:i:s' );
if ( 'wp_default' === Settings::get( 'date_time_format' ) ) {
return mysql2date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $local_datetime );
}
return mysql2date( $fallback_format, $local_datetime );
}
/**
* Turns a preset key (or an explicit custom range) into
* [ date_from, date_to ] — both '' when no filter applies.
*
* `$today` must be GMT (roadmap issue #27), not site-local, since the
* range this resolves into is compared against `created_at` via
* gmdate()-built boundaries elsewhere (Entry::for_form()/query()) —
* mixing a site-local "today" with GMT comparison boundaries silently
* shifts which entries land in "today"/"this week"/etc. by the site's
* UTC offset.
*/
private function resolve_date_preset( $preset, $custom_from, $custom_to ) {
$today = current_time( 'Y-m-d', true );
switch ( $preset ) {
case 'today':
return [ $today, $today ];
case 'yesterday':
$y = gmdate( 'Y-m-d', strtotime( $today . ' -1 day' ) );
return [ $y, $y ];
case 'last_week':
return [ gmdate( 'Y-m-d', strtotime( $today . ' -7 days' ) ), $today ];
case 'last_month':
return [ gmdate( 'Y-m-d', strtotime( $today . ' -30 days' ) ), $today ];
case 'custom':
return [ $custom_from, $custom_to ];
default:
return [ '', '' ];
}
}
public function register() {
add_submenu_page(
'chargeforms',
__( 'Entries', 'chargeforms' ),
__( 'Entries', 'chargeforms' ) . self::unread_badge_html(),
'chargeforms_manage_forms',
'chargeforms-entries',
[ $this, 'render' ]
);
}
/** The blue unread-count bubble next to "Entries" in the WP sidebar submenu and the ChargeForms admin-bar node — WP core menu titles intentionally allow HTML here (see its own Plugins-update bubble), so this isn't escaped. */
public static function unread_badge_html() {
$count = Entry::count_unread();
if ( $count < 1 ) {
return '';
}
return ' ';
}
public function render() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page load, not a state-changing request; both values are (int)-cast immediately.
$form_id = isset( $_GET['form_id'] ) ? (int) $_GET['form_id'] : 0;
$entry_id = isset( $_GET['entry_id'] ) ? (int) $_GET['entry_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( $entry_id ) {
$this->render_single( $entry_id );
return;
}
if ( ! $form_id ) {
$this->render_overview();
return;
}
$form = Form::get( $form_id );
if ( ! $form ) {
echo '
' . esc_html__( 'Form not found.', 'chargeforms' ) . '
';
return;
}
$this->render_form_entries( $form );
}
/** Field types the Advanced Filter offers as a value instead of a free-text input — anything with a fixed option list. */
const CHOICE_FIELD_TYPES = [ 'select', 'radio', 'checkbox', 'multi_select', 'multiple_choice' ];
/**
* Field types that never hold a meaningful, displayable per-entry value
* — layout markers (section/page breaks, custom HTML), a Hidden field's
* technical default, and the three CAPTCHA widgets (their "value" is a
* verification token, not something a site owner wants to review).
* Excluded from every entries table/export column list; previously only
* section_break/page_break/html were excluded, which left Hidden and
* CAPTCHA fields rendering as a real column with an effectively-always-
* empty cell — visually indistinguishable from a layout bug (an
* unexplained gap before the next real column).
*/
const NON_DATA_FIELD_TYPES = [ 'section_break', 'page_break', 'html', 'hidden', 'recaptcha', 'hcaptcha', 'turnstile' ];
/**
* The per-form Entries screen — dynamic columns from the form's own
* fields (matching the reference product's own per-form Entries screen, not a
* fixed generic column set), a real Advanced Filter (AND within a
* group, OR between groups — same convention the reference product states in
* its own UI), a client-side column-visibility/pin picker, and proper
* pagination (the previous version loaded every entry unbounded).
*/
private function render_form_entries( array $form ) {
$form_id = $form['id'];
$date_format = Settings::get( 'entries_date_format', 'M j, Y g:ia' );
$table_fields = array_values( array_filter( $form['fields'], function ( $field ) {
return ! in_array( $field['type'], self::NON_DATA_FIELD_TYPES, true );
} ) );
$all_forms = Form::all();
$groups = [];
if ( isset( $_GET['filter_groups'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended -- the decoded structure is untrusted, but Entry::build_condition() below whitelists every field name against a fixed column map and binds every value through $wpdb->prepare() placeholders; sanitize_text_field() here would corrupt legitimate operator values (e.g. ">=") without adding any real safety. Read-only list filter, not a state-changing request.
$decoded = json_decode( wp_unslash( $_GET['filter_groups'] ), true );
if ( is_array( $decoded ) ) {
$groups = $decoded;
}
}
$search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$date_preset = isset( $_GET['date_preset'] ) ? sanitize_key( wp_unslash( $_GET['date_preset'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
list( $date_from, $date_to ) = $this->resolve_date_preset(
$date_preset,
isset( $_GET['date_from'] ) ? sanitize_text_field( wp_unslash( $_GET['date_from'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
isset( $_GET['date_to'] ) ? sanitize_text_field( wp_unslash( $_GET['date_to'] ) ) : '' // phpcs:ignore WordPress.Security.NonceVerification.Recommended
);
$sort_by = isset( $_GET['sort_by'] ) ? sanitize_key( wp_unslash( $_GET['sort_by'] ) ) : 'created_at'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$sort_dir = isset( $_GET['sort_dir'] ) && 'asc' === $_GET['sort_dir'] ? 'asc' : 'desc'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$page = isset( $_GET['paged'] ) ? max( 1, (int) $_GET['paged'] ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$per_page = isset( $_GET['per_page'] ) ? min( 200, max( 1, (int) $_GET['per_page'] ) ) : 10; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$status = isset( $_GET['status'] ) && 'trashed' === $_GET['status'] ? 'trashed' : 'all'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$result = Entry::advanced_query( $form_id, $groups, [
'page' => $page,
'per_page' => $per_page,
'search' => $search,
'date_from' => $date_from,
'date_to' => $date_to,
'sort_by' => $sort_by,
'sort_dir' => $sort_dir,
'status' => $status,
] );
$trashed_count = Entry::count_trashed_for_form( $form_id );
$filter_fields = [
'inputs' => array_map( function ( $field ) {
return [
'id' => 'input:' . $field['id'],
'label' => self::column_label( $field ),
'choices' => in_array( $field['type'], self::CHOICE_FIELD_TYPES, true ) ? array_values( $field['options'] ?? [] ) : null,
];
}, $table_fields ),
'attrs' => [
[ 'id' => 'attr:id', 'label' => __( 'Submission ID', 'chargeforms' ), 'choices' => null ],
[ 'id' => 'attr:status', 'label' => __( 'Status', 'chargeforms' ), 'choices' => [ 'unread', 'read' ] ],
// Stored as meta '1'/'0' (Entry::set_meta( $id, 'starred', ... )
// — same flag the Favorite star row-action toggles), so this
// needs labeled {value, label} choices rather than the raw
// stored value, unlike attr:status above where 'unread'/'read'
// already read fine on screen as-is.
[ 'id' => 'attr:starred', 'label' => __( 'Favorite', 'chargeforms' ), 'choices' => [
[ 'value' => '1', 'label' => __( 'Favorited', 'chargeforms' ) ],
[ 'value' => '0', 'label' => __( 'Not Favorited', 'chargeforms' ) ],
] ],
[ 'id' => 'attr:created_at', 'label' => __( 'Submission Date', 'chargeforms' ), 'choices' => null ],
[ 'id' => 'attr:ip_address', 'label' => __( 'IP Address', 'chargeforms' ), 'choices' => null ],
[ 'id' => 'attr:source_url', 'label' => __( 'Source URL', 'chargeforms' ), 'choices' => null ],
],
'user' => [
[ 'id' => 'user:id', 'label' => __( 'User ID', 'chargeforms' ), 'choices' => null ],
[ 'id' => 'user:user_login', 'label' => __( 'Username', 'chargeforms' ), 'choices' => null ],
[ 'id' => 'user:display_name', 'label' => __( 'Display Name', 'chargeforms' ), 'choices' => null ],
[ 'id' => 'user:user_email', 'label' => __( 'Email', 'chargeforms' ), 'choices' => null ],
],
];
$today = current_time( 'Y-m-d', true );
$today_count = Entry::count_by_form( [ $form_id ], $today, $today )[ $form_id ] ?? 0;
$unread_count = Entry::count_unread_by_form( [ $form_id ] )[ $form_id ] ?? 0;
$starred_count = Entry::count_starred_for_form( $form_id );
// Stat-card row (design_handoff_chargeforms's Entries screen: a
// 4-tile KPI row under the header) — Total/Unread/Starred/Today are
// all real counts already available or one cheap query away; no
// fabricated percent-change delta, since there's no honest "vs
// yesterday" comparison the design mockup actually specifies.
$entry_stats = [
[ 'label' => __( 'Total Entries', 'chargeforms' ), 'value' => number_format_i18n( $result['total'] ), 'delta' => __( 'All time', 'chargeforms' ) ],
[ 'label' => __( 'Unread', 'chargeforms' ), 'value' => number_format_i18n( $unread_count ), 'delta' => $unread_count > 0 ? __( 'Needs review', 'chargeforms' ) : __( 'All caught up', 'chargeforms' ) ],
[ 'label' => __( 'Favorited', 'chargeforms' ), 'value' => number_format_i18n( $starred_count ), 'delta' => __( 'Starred entries', 'chargeforms' ) ],
[ 'label' => __( 'Today', 'chargeforms' ), 'value' => number_format_i18n( $today_count ), 'delta' => __( 'Since midnight UTC', 'chargeforms' ) ],
];
?>
$filter_fields,
'operators' => [
[ 'value' => 'equal', 'label' => __( 'Equal', 'chargeforms' ) ],
[ 'value' => 'not_equal', 'label' => __( 'Not Equal', 'chargeforms' ) ],
[ 'value' => 'equal_in', 'label' => __( 'Equal In', 'chargeforms' ) ],
[ 'value' => 'not_equal_in', 'label' => __( 'Not Equal In', 'chargeforms' ) ],
[ 'value' => 'contains', 'label' => __( 'Contains', 'chargeforms' ) ],
[ 'value' => 'not_contains', 'label' => __( 'Does Not Contain', 'chargeforms' ) ],
[ 'value' => 'starts_with', 'label' => __( 'Starts With', 'chargeforms' ) ],
[ 'value' => 'ends_with', 'label' => __( 'Ends With', 'chargeforms' ) ],
[ 'value' => 'greater_than', 'label' => __( 'Greater Than', 'chargeforms' ) ],
[ 'value' => 'less_than', 'label' => __( 'Less Than', 'chargeforms' ) ],
[ 'value' => 'is_empty', 'label' => __( 'Is Empty', 'chargeforms' ) ],
[ 'value' => 'is_not_empty', 'label' => __( 'Is Not Empty', 'chargeforms' ) ],
],
'groups' => $groups,
'formId' => $form_id,
'baseUrl' => admin_url( 'admin.php?page=chargeforms-entries&form_id=' . $form_id ),
'storageKey' => 'chargeforms_entries_columns_' . $form_id,
] );
?>
$key, 'sort_dir' => $next_dir, 'paged' => false ] );
?>
' . esc_html__( 'Entry not found.', 'chargeforms' ) . '
';
return;
}
$form = Form::get( $entry['form_id'] );
if ( ! $form ) {
echo '' . esc_html__( 'Form not found.', 'chargeforms' ) . '
';
return;
}
$date_format = Settings::get( 'entries_date_format', 'M j, Y g:ia' );
$table_fields = array_values( array_filter( $form['fields'], function ( $field ) {
return ! in_array( $field['type'], self::NON_DATA_FIELD_TYPES, true );
} ) );
$siblings = Entry::for_form( $form['id'] );
$ids = array_map( fn( $e ) => (int) $e['id'], $siblings );
$pos = array_search( (int) $entry_id, $ids, true );
$prev_id = false !== $pos && $pos < count( $ids ) - 1 ? $ids[ $pos + 1 ] : null;
$next_id = false !== $pos && $pos > 0 ? $ids[ $pos - 1 ] : null;
$nav_url = fn( $id ) => admin_url( 'admin.php?page=chargeforms-entries&entry_id=' . $id . '&form_id=' . $form['id'] );
$view_url = admin_url( 'admin.php?page=chargeforms-entries&entry_id=' . $entry_id . '&form_id=' . $form['id'] );
$editing = isset( $_GET['edit'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$starred = '1' === (string) Entry::get_meta( $entry_id, 'starred' );
$source_url = Entry::get_meta( $entry_id, 'source_url' );
$browser = Entry::classify_browser( $entry['user_agent'] );
$device = $entry['user_agent'] ? Entry::classify_device( $entry['user_agent'] ) : '';
$submitter_id = (int) Entry::get_meta( $entry_id, 'submitted_by_user_id' );
$submitter = $submitter_id ? get_userdata( $submitter_id ) : false;
$notes = Entry::get_meta_all( $entry_id, 'note' );
$logs = Entry::get_meta_all( $entry_id, 'activity_log' );
$status_options = [
'unread' => __( 'Unread', 'chargeforms' ),
'read' => __( 'Read', 'chargeforms' ),
];
?>
%s', esc_attr( $name ), esc_textarea( $value ) );
break;
case 'select':
printf( '', esc_attr( $name ) );
foreach ( $field['options'] ?? [] as $option ) {
printf( '%1$s ', esc_attr( $option ), selected( $value, $option, false ) );
}
echo ' ';
break;
case 'radio':
foreach ( $field['options'] ?? [] as $option ) {
printf(
' %s ',
esc_attr( $name ),
esc_attr( $option ),
checked( $value, $option, false ),
esc_html( $option )
);
}
break;
case 'checkbox':
printf( ' ', esc_attr( $name ), checked( $value, '1', false ) );
break;
case 'number':
printf( ' ', esc_attr( $name ), esc_attr( $value ) );
break;
case 'email':
printf( ' ', esc_attr( $name ), esc_attr( $value ) );
break;
case 'date':
printf( ' ', esc_attr( $name ), esc_attr( $value ) );
break;
case 'url':
printf( ' ', esc_attr( $name ), esc_attr( $value ) );
break;
default:
printf( ' ', esc_attr( $name ), esc_attr( $value ) );
}
}
public function handle_update_entry() {
$entry_id = isset( $_POST['entry_id'] ) ? (int) $_POST['entry_id'] : 0;
if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! check_admin_referer( 'chargeforms_update_entry_' . $entry_id ) ) {
wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 );
}
$entry = Entry::get( $entry_id );
if ( ! $entry ) {
wp_die( esc_html__( 'Entry not found.', 'chargeforms' ), 404 );
}
$form = Form::get( $entry['form_id'] );
$raw = isset( $_POST['chargeforms_data'] ) ? wp_unslash( $_POST['chargeforms_data'] ) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
// Merge onto the existing values rather than replacing wholesale — the
// edit form deliberately omits file fields (no re-upload support yet),
// so a field absent from $_POST must keep its current stored value.
$values = $entry['values'];
foreach ( $form['fields'] ?? [] as $field ) {
if ( ! array_key_exists( $field['id'], $raw ) ) {
continue;
}
$val = $raw[ $field['id'] ];
$values[ $field['id'] ] = is_array( $val ) ? array_map( 'sanitize_text_field', $val ) : sanitize_textarea_field( $val );
}
Entry::update( $entry_id, $values );
Entry::log_activity( $entry_id, __( 'Entry data edited', 'chargeforms' ) );
wp_safe_redirect( admin_url( 'admin.php?page=chargeforms-entries&entry_id=' . $entry_id . '&form_id=' . $entry['form_id'] . '&chargeforms_notice=updated' ) );
exit;
}
/** Toggles the "starred" flag (Entry::set_meta upserts, so repeated clicks don't accumulate rows). */
public function handle_toggle_star() {
$entry_id = isset( $_GET['entry_id'] ) ? (int) $_GET['entry_id'] : 0;
if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_toggle_star_' . $entry_id ) ) {
wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 );
}
$starred = '1' === (string) Entry::get_meta( $entry_id, 'starred' );
Entry::set_meta( $entry_id, 'starred', $starred ? '0' : '1' );
wp_safe_redirect( wp_get_referer() ?: admin_url( 'admin.php?page=chargeforms-entries' ) );
exit;
}
/** Adds one Submission Notes entry — stored the same way as the activity log, under its own meta_key so the two lists never mix. */
public function handle_add_note() {
$entry_id = isset( $_POST['entry_id'] ) ? (int) $_POST['entry_id'] : 0;
if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! check_admin_referer( 'chargeforms_add_entry_note_' . $entry_id ) ) {
wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 );
}
$entry = Entry::get( $entry_id );
if ( ! $entry ) {
wp_die( esc_html__( 'Entry not found.', 'chargeforms' ), 404 );
}
$note = isset( $_POST['note'] ) ? sanitize_textarea_field( wp_unslash( $_POST['note'] ) ) : '';
if ( '' !== $note ) {
$user = wp_get_current_user();
Entry::add_meta( $entry_id, 'note', wp_json_encode( [
'text' => $note,
'author' => $user->display_name,
] ) );
}
wp_safe_redirect( admin_url( 'admin.php?page=chargeforms-entries&entry_id=' . $entry_id . '&form_id=' . $entry['form_id'] ) );
exit;
}
/**
* Re-sends the form's owner-notification email for an existing entry —
* reuses SubmissionHandler::send_entry_notification() so the resend uses
* the exact same body-building logic as the original send, not a second
* copy that could drift out of sync. Custom Recipient is validated with
* is_email() before use — never trust an admin-post field as a mail
* header input without checking it's actually a well-formed address.
*/
public function handle_resend_notification() {
$entry_id = isset( $_POST['entry_id'] ) ? (int) $_POST['entry_id'] : 0;
if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! check_admin_referer( 'chargeforms_resend_notification_' . $entry_id ) ) {
wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 );
}
$entry = Entry::get( $entry_id );
if ( ! $entry ) {
wp_die( esc_html__( 'Entry not found.', 'chargeforms' ), 404 );
}
$form = Form::get( $entry['form_id'] );
if ( ! $form ) {
wp_die( esc_html__( 'Form not found.', 'chargeforms' ), 404 );
}
$recipient_mode = isset( $_POST['recipient_mode'] ) ? sanitize_key( wp_unslash( $_POST['recipient_mode'] ) ) : 'default';
$custom_email = isset( $_POST['custom_email'] ) ? sanitize_email( wp_unslash( $_POST['custom_email'] ) ) : '';
$to_override = ( 'custom' === $recipient_mode && is_email( $custom_email ) ) ? $custom_email : '';
SubmissionHandler::send_entry_notification( $form, $entry['values'], $entry_id, $to_override );
Entry::log_activity( $entry_id, $to_override
/* translators: %s: recipient email address */
? sprintf( __( 'Notification resent to %s', 'chargeforms' ), $to_override )
: __( 'Notification resent to default recipient', 'chargeforms' )
);
wp_safe_redirect( admin_url( 'admin.php?page=chargeforms-entries&entry_id=' . $entry_id . '&form_id=' . $entry['form_id'] . '&chargeforms_notice=notification_sent' ) );
exit;
}
private function render_overview() {
$forms = Form::all();
if ( empty( $forms ) ) {
?>
resolve_date_preset(
$date_preset,
isset( $_GET['date_from'] ) ? sanitize_text_field( wp_unslash( $_GET['date_from'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
isset( $_GET['date_to'] ) ? sanitize_text_field( wp_unslash( $_GET['date_to'] ) ) : '' // phpcs:ignore WordPress.Security.NonceVerification.Recommended
);
$query_args = [
'form_id' => isset( $_GET['filter_form_id'] ) ? (int) $_GET['filter_form_id'] : 0, // phpcs:ignore WordPress.Security.NonceVerification.Recommended
'status' => isset( $_GET['status'] ) ? sanitize_key( wp_unslash( $_GET['status'] ) ) : 'all', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
'search' => isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
'page' => isset( $_GET['paged'] ) ? max( 1, (int) $_GET['paged'] ) : 1, // phpcs:ignore WordPress.Security.NonceVerification.Recommended
'per_page' => isset( $_GET['per_page'] ) ? min( 200, max( 1, (int) $_GET['per_page'] ) ) : 10, // phpcs:ignore WordPress.Security.NonceVerification.Recommended
'date_from' => $date_from,
'date_to' => $date_to,
];
$result = Entry::query( $query_args );
$date_format = Settings::get( 'entries_date_format', 'M j, Y g:ia' );
$daily = Entry::counts_by_day( 14 );
$max_daily = max( 1, ...array_values( $daily ) );
$trashed_all = Entry::count_trashed();
?>
'chargeforms-entries',
'filter_form_id' => $query_args['form_id'] ?: null,
'status' => 'all' === $query_args['status'] ? null : $query_args['status'],
's' => '' !== $query_args['search'] ? $query_args['search'] : null,
'per_page' => $result['per_page'],
] );
\ChargeForms\Includes\Pagination_Helper::render( $result, $base_args, [ 'jumper_id' => 'chargeforms-entries-jumper' ] );
}
public function handle_toggle_status() {
$entry_id = isset( $_GET['entry_id'] ) ? (int) $_GET['entry_id'] : 0;
$to = isset( $_GET['to'] ) ? sanitize_key( wp_unslash( $_GET['to'] ) ) : 'read';
if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_toggle_status_' . $entry_id ) ) {
wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 );
}
// 'trashed' is a real, allowed transition here (Trash/Restore reuse
// this same status-change machinery rather than a separate
// handler), but it's never offered as a choice in the "Change
// status to" dropdown (Admin\Entries_Page::render_single()'s
// $status_options only lists read/unread) — Trash/Restore have
// their own dedicated icon actions instead, matching how this
// codebase already keeps the Favorite star toggle separate from
// that same dropdown.
$new_status = in_array( $to, [ 'read', 'unread', 'trashed' ], true ) ? $to : 'read';
Entry::set_status( $entry_id, $new_status );
$log_message = 'trashed' === $new_status
? __( 'Moved to Trash', 'chargeforms' )
/* translators: %s: new status (Read/Unread) */
: sprintf( __( 'Status changed to %s', 'chargeforms' ), ucfirst( $new_status ) );
Entry::log_activity( $entry_id, $log_message );
wp_safe_redirect( wp_get_referer() ?: admin_url( 'admin.php?page=chargeforms-entries' ) );
exit;
}
public function handle_delete() {
$entry_id = isset( $_GET['entry_id'] ) ? (int) $_GET['entry_id'] : 0;
$form_id = isset( $_GET['form_id'] ) ? (int) $_GET['form_id'] : 0;
if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_delete_entry_' . $entry_id ) ) {
wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 );
}
Entry::delete( $entry_id );
wp_safe_redirect( admin_url( 'admin.php?page=chargeforms-entries&form_id=' . $form_id . '&chargeforms_notice=entry_deleted' ) );
exit;
}
/**
* The row-checkbox column's actual action (roadmap gap analysis: checkboxes
* previously led nowhere) — applies one action to every checked entry in a
* single request rather than one-at-a-time. Started as delete-only;
* expanded to match the reference product's own bulk-actions bar (mark read/unread,
* favorite/unfavorite, delete) since those reuse the exact same
* single-entry logic (Entry::set_status()/set_meta()) this page's row
* actions already call — just looped, not new functionality invented for
* the bulk case.
*/
public function handle_bulk_entry_action() {
$form_id = isset( $_POST['form_id'] ) ? (int) $_POST['form_id'] : 0;
if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'chargeforms_bulk_entry_action_' . $form_id ) ) {
wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 );
}
$entry_ids = isset( $_POST['entry_ids'] ) ? array_map( 'intval', (array) $_POST['entry_ids'] ) : [];
$bulk_action = isset( $_POST['bulk_action'] ) ? sanitize_key( wp_unslash( $_POST['bulk_action'] ) ) : '';
$entry_ids = array_values( array_filter( $entry_ids, fn( $id ) => $id > 0 ) );
$notice = '';
switch ( $bulk_action ) {
case 'delete':
// Permanent — only ever offered as a bulk option from within
// the Trashed view (see the toolbar's contextual bulk_action
// options below); 'trash' below is the soft-delete every
// other view's "Delete Selected" now means.
foreach ( $entry_ids as $entry_id ) {
Entry::delete( $entry_id );
}
$notice = 'entries_deleted';
break;
case 'trash':
case 'restore':
$status = 'trash' === $bulk_action ? 'trashed' : 'read';
foreach ( $entry_ids as $entry_id ) {
Entry::set_status( $entry_id, $status );
Entry::log_activity( $entry_id, 'trash' === $bulk_action ? __( 'Moved to Trash', 'chargeforms' ) : __( 'Restored from Trash', 'chargeforms' ) );
}
$notice = 'trash' === $bulk_action ? 'entries_trashed' : 'entries_restored';
break;
case 'mark_read':
case 'mark_unread':
$status = 'mark_read' === $bulk_action ? 'read' : 'unread';
foreach ( $entry_ids as $entry_id ) {
Entry::set_status( $entry_id, $status );
/* translators: %s: new status (Read/Unread) */
Entry::log_activity( $entry_id, sprintf( __( 'Status changed to %s', 'chargeforms' ), ucfirst( $status ) ) );
}
$notice = 'entries_updated';
break;
case 'mark_favorite':
case 'unmark_favorite':
$flag = 'mark_favorite' === $bulk_action ? '1' : '0';
foreach ( $entry_ids as $entry_id ) {
Entry::set_meta( $entry_id, 'starred', $flag );
}
$notice = 'entries_updated';
break;
default:
// Empty/unrecognized bulk_action — the Apply button is a no-op
// client-side too (admin.js) until something is selected; a
// direct/malformed POST just redirects back with no changes,
// same as picking nothing and clicking Apply in the UI.
break;
}
$redirect = admin_url( 'admin.php?page=chargeforms-entries&form_id=' . $form_id );
// Restoring/deleting-permanently out of the Trashed view lands back
// on the normal view (the acted-on rows are no longer trashed, so
// staying on ?status=trashed would just show them vanish); trashing
// from the normal view has nowhere else to go either. The one case
// worth preserving is staying on ?status=trashed after a delete, so
// a site owner permanently-deleting several trashed rows one batch
// at a time doesn't get bounced out after each batch.
if ( 'delete' === $bulk_action && isset( $_POST['status'] ) ) {
$redirect = add_query_arg( 'status', sanitize_key( wp_unslash( $_POST['status'] ) ), $redirect );
}
if ( $notice ) {
$redirect = add_query_arg( 'chargeforms_notice', $notice, $redirect );
}
wp_safe_redirect( $redirect );
exit;
}
/** Standalone print-friendly view of one entry — opened in a new tab, calls window.print() on load. */
public function handle_print() {
$entry_id = isset( $_GET['entry_id'] ) ? (int) $_GET['entry_id'] : 0;
if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_print_entry_' . $entry_id ) ) {
wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 );
}
$entry = Entry::get( $entry_id );
if ( ! $entry ) {
wp_die( esc_html__( 'Entry not found.', 'chargeforms' ), 404 );
}
$form = Form::get( $entry['form_id'] );
$date_format = Settings::get( 'entries_date_format', 'M j, Y g:ia' );
// Dedicated no-src handles so this admin-post standalone print page
// (outside the normal wp_enqueue_scripts/wp_enqueue_styles lifecycle)
// can carry its own inline CSS/JS through wp_add_inline_style()/
// wp_add_inline_script() instead of raw