Forms — the "All Forms" landing screen. Overwrites the
* top-level menu's default submenu entry (same 'chargeforms' slug) so it reads
* "Forms" instead of duplicating "ChargeForms". Registered after Menu so the
* relabel trick applies — see boot order in chargeforms.php.
*/
class Forms_List_Page {
public function __construct() {
// Real bug found live: the "relabel the phantom first row as Forms"
// trick below only works when THIS registration is the very FIRST
// add_submenu_page() call WordPress sees for the 'chargeforms'
// parent — it only suppresses its own auto-generated duplicate
// entry (labeled with the top-level menu's own title) when the
// matching (parent===menu_slug) submenu is registered before any
// OTHER submenu under that parent. Dashboard_Page registers at
// priority 8; this was still at 10, so Dashboard's registration
// (a different slug) ran first and the phantom "ChargeForms" row
// appeared anyway, sitting above Dashboard/Forms as a genuine
// duplicate of Forms' own link. Moved to priority 6 — right after
// Menu::register_menu()'s own priority 5, before anything else.
add_action( 'admin_menu', [ $this, 'register' ], 6 );
add_action( 'admin_post_chargeforms_duplicate_form', [ $this, 'handle_duplicate' ] );
add_action( 'admin_post_chargeforms_delete_form', [ $this, 'handle_delete' ] );
add_action( 'admin_post_chargeforms_toggle_form_status', [ $this, 'handle_toggle_status' ] );
add_action( 'admin_post_chargeforms_bulk_forms', [ $this, 'handle_bulk_forms' ] );
add_action( 'admin_post_chargeforms_preview_form', [ $this, 'handle_preview' ] );
add_action( 'admin_post_chargeforms_export_single_form', [ $this, 'handle_export_single' ] );
add_action( 'wp_ajax_chargeforms_find_form_usage', [ $this, 'ajax_find_usage' ] );
// Body class hook: forms-list-refine.css scopes its gray-canvas
// rules to body.chargeforms-screen-forms-list so they never leak
// onto Entries, Reports, or Global Settings (all under the same
// ChargeForms menu).
add_filter( 'admin_body_class', [ $this, 'admin_body_class' ] );
}
public function register() {
add_submenu_page(
'chargeforms',
__( 'Forms', 'chargeforms' ),
__( 'Forms', 'chargeforms' ),
'chargeforms_manage_forms',
'chargeforms',
[ $this, 'render' ]
);
}
/**
* Screen-scoped body class for the All Forms screen: the reference product skin paints
* the gray #f2f2f2 page canvas only on page=chargeforms (forms-list-refine.css),
* never on the sibling screens that share this admin body.
*
* @param string $classes Space-separated admin body classes.
* @return string
*/
public function admin_body_class( $classes ) {
if ( isset( $_GET['page'] ) && 'chargeforms' === sanitize_key( wp_unslash( $_GET['page'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$classes .= ' chargeforms-screen-forms-list';
}
return $classes;
}
public function render() {
$query_args = $this->parse_query_args();
$result = Form::query( $query_args );
// One GROUP BY query for the whole page of forms instead of one
// Entry::count_for_form() call per row (roadmap issue #36) — this
// page is paginated, so the win is smaller than the Reports page's
// identical fix (all-forms, not one-page), but still a real,
// free reduction for every page load regardless.
$entry_counts = Entry::count_by_form( array_column( $result['items'], 'id' ) );
// Fields/Views/Conversion — real gap found live (user report): these
// three columns exist in design_handoff_chargeforms's own Forms List
// spec but were never actually implemented anywhere (neither this
// page nor the now-retired Vue rewrite had them). One GROUP BY query
// for the whole page's view counts, same reasoning as $entry_counts
// above — not one Form_Start::count_for_form() call per row.
$view_counts = Form_Start::count_by_form( array_column( $result['items'], 'id' ), 'view' );
?>
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
);
return [
'status' => isset( $_GET['category'] ) && in_array( $_GET['category'], [ 'published', 'draft' ], true ) ? sanitize_key( wp_unslash( $_GET['category'] ) ) : 'all', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
'category' => isset( $_GET['category'] ) && 'multistep' === $_GET['category'] ? 'multistep' : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
'search' => isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
'sort' => isset( $_GET['sort'] ) ? sanitize_key( wp_unslash( $_GET['sort'] ) ) : 'newest', // 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,
];
}
/**
* Turns a preset key (or an explicit custom range) into
* [ date_from, date_to ] — both '' when no filter applies. Mirrors
* Entries_Page's identical helper, including the GMT-not-site-local
* `$today` fix (roadmap issue #27 — see that copy's doc comment for
* why: this range gets compared against `created_at` via gmdate()-
* built boundaries elsewhere, so `$today` needs the same reference
* frame).
*/
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 [ '', '' ];
}
}
/** The single "Category" select — All / Active / Inactive / Multi-step Form. Payment Form and Conversational Form aren't real ChargeForms categories yet, so they're intentionally not offered here rather than faked. */
private function current_category( array $query_args ) {
if ( 'multistep' === $query_args['category'] ) {
return 'multistep';
}
if ( 'published' === $query_args['status'] ) {
return 'published';
}
if ( 'draft' === $query_args['status'] ) {
return 'draft';
}
return 'all';
}
private function render_toolbar( array $query_args, $total = 0 ) {
$category = $this->current_category( $query_args );
$date_preset = isset( $_GET['date_preset'] ) ? sanitize_key( wp_unslash( $_GET['date_preset'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$clear_url = remove_query_arg( [ 'date_preset', 'date_from', 'date_to' ] );
?>
current_category( $query_args );
$base_args = array_filter( [
'page' => 'chargeforms',
'category' => 'all' !== $category ? $category : null,
's' => '' !== $query_args['search'] ? $query_args['search'] : null,
'sort' => 'newest' === $query_args['sort'] ? null : $query_args['sort'],
'date_from' => $query_args['date_from'] ?: null,
'date_to' => $query_args['date_to'] ?: null,
'per_page' => $result['per_page'],
] );
$first = ( $result['page'] - 1 ) * $result['per_page'] + 1;
$last = min( $result['page'] * $result['per_page'], $result['total'] );
?>
$id > 0 ) );
foreach ( $form_ids as $form_id ) {
if ( 'duplicate' === $bulk_action ) {
Form::duplicate( $form_id );
} elseif ( 'delete' === $bulk_action ) {
Form::delete( $form_id );
}
}
wp_safe_redirect( admin_url( 'admin.php?page=chargeforms&chargeforms_notice=1' ) );
exit;
}
public function handle_duplicate() {
$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_duplicate_' . $form_id ) ) {
wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 );
}
Form::duplicate( $form_id );
wp_safe_redirect( admin_url( 'admin.php?page=chargeforms&chargeforms_notice=1' ) );
exit;
}
public function handle_delete() {
$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_form_' . $form_id ) ) {
wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 );
}
Form::delete( $form_id );
wp_safe_redirect( admin_url( 'admin.php?page=chargeforms&chargeforms_notice=1' ) );
exit;
}
public function handle_toggle_status() {
$form_id = isset( $_GET['form_id'] ) ? (int) $_GET['form_id'] : 0;
$to = isset( $_GET['to'] ) ? sanitize_key( $_GET['to'] ) : 'published';
if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'chargeforms_toggle_status_' . $form_id ) ) {
wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 );
}
Form::update( $form_id, [ 'status' => in_array( $to, [ 'published', 'draft' ], true ) ? $to : 'published' ] );
wp_safe_redirect( wp_get_referer() ?: admin_url( 'admin.php?page=chargeforms' ) );
exit;
}
/**
* The "Find" row action — answers "where is this form actually used on
* the site?" by scanning post_content for the form's shortcode, rather
* than making the site owner guess or check every page by hand. Casts a
* broad net at the database level (any post containing the literal
* "[chargeforms" substring, which is cheap to index-scan even without a
* dedicated index since it's a bounded LIMIT) and narrows to the exact
* form id in PHP via a word-boundary-aware regex — a plain `LIKE
* '%id="5"%'` would also match id="50", id="51", etc., which is a real
* false-positive risk once a site has more than a handful of forms.
*/
public function ajax_find_usage() {
if ( ! current_user_can( 'chargeforms_manage_forms' ) || ! check_ajax_referer( 'chargeforms_find_usage', 'nonce', false ) ) {
wp_send_json_error( [ 'message' => __( 'Not allowed.', 'chargeforms' ) ], 403 );
}
$form_id = isset( $_POST['form_id'] ) ? (int) $_POST['form_id'] : 0;
if ( ! $form_id ) {
wp_send_json_error( [ 'message' => __( 'Invalid form.', 'chargeforms' ) ], 400 );
}
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- no Form/Entry model wraps a cross-post-table content scan; bounded by LIMIT below.
$candidates = $wpdb->get_results(
"SELECT ID, post_title, post_type, post_status, post_content FROM {$wpdb->posts}
WHERE post_status NOT IN ( 'trash', 'auto-draft' )
AND post_type NOT IN ( 'revision', 'attachment' )
AND post_content LIKE '%[chargeforms%'
ORDER BY post_modified DESC
LIMIT 500"
);
$pattern = '/\[chargeforms\b[^\]]*\bid=[\'"]?' . preg_quote( (string) $form_id, '/' ) . '[\'"]?(?=[\s\]])[^\]]*\]/';
$items = [];
foreach ( $candidates as $row ) {
if ( ! preg_match( $pattern, $row->post_content ) ) {
continue;
}
$post_type_obj = get_post_type_object( $row->post_type );
$items[] = [
'title' => $row->post_title ? $row->post_title : __( '(no title)', 'chargeforms' ),
'edit_url' => get_edit_post_link( $row->ID, 'raw' ),
'view_url' => 'publish' === $row->post_status ? get_permalink( $row->ID ) : '',
'type' => $post_type_obj ? $post_type_obj->labels->singular_name : $row->post_type,
'status' => get_post_status_object( $row->post_status ) ? get_post_status_object( $row->post_status )->label : $row->post_status,
];
if ( count( $items ) >= 50 ) {
break;
}
}
wp_send_json_success( [ 'items' => $items, 'scanned_limit' => 500 === count( $candidates ) ] );
}
/** Renders the form standalone in a new tab, bypassing the published-only check the real shortcode enforces. */
/**
* The real "Preview & Design" screen — opens in its own tab, a browser-
* chrome mockup with Desktop/Tablet/Mobile width toggles around the
* actual rendered form, plus a mini top-nav back into the builder/
* settings/entries. Deliberately does NOT include a marketing panel
* listing paid-tier features ChargeForms doesn't have (the real product's
* equivalent screen does) — that would misrepresent what upgrading
* (there is no paid tier yet) would actually unlock.
*/
public function handle_preview() {
$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_preview_' . $form_id ) ) {
wp_die( esc_html__( 'Not allowed.', 'chargeforms' ), 403 );
}
$form = Form::get( $form_id );
if ( ! $form ) {
wp_die( esc_html__( 'Form not found.', 'chargeforms' ), 404 );
}
/*
* register_assets() normally runs on 'wp_enqueue_scripts', which this
* admin-post.php request never fires — register and enqueue the
* style directly, and give add-ons the same chance via an action
* hook. A field type an add-on contributes (ChargeForms Bookings'
* Booking Calendar, e.g.) enqueues its OWN script/style from inside
* its render callback below, same as every field render always has
* — but that call is a silent no-op unless the handle was already
* registered, which normally happens on 'wp_enqueue_scripts' too.
* Real bug found live: without this, Booking Calendar previewed as
* an empty shell (no calendar grid, no console error either) since
* its picker script/style never got registered at all on this one
* request type.
*/
wp_register_style( 'chargeforms-frontend', CHARGEFORMS_URL . 'assets/css/frontend.css', [], CHARGEFORMS_VERSION );
wp_enqueue_style( 'chargeforms-frontend' );
wp_add_inline_style( 'chargeforms-frontend', ' * { box-sizing: border-box; }
body {
margin: 0;
background: #f5f7fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #16181d;
}
.fcp-topbar {
display: flex;
align-items: center;
gap: 28px;
background: #fff;
border-bottom: 1px solid rgba(15,23,42,.08);
padding: 16px 28px;
}
.fcp-topbar h1 {
font-size: 16px;
margin: 0;
font-weight: 700;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
}
.fcp-topbar nav { display: flex; gap: 24px; flex: 1; }
.fcp-topbar nav a { color: #606266; text-decoration: none; font-size: 13px; font-weight: 600; transition: color .12s ease; }
.fcp-topbar nav a:hover { color: #16181d; }
.fcp-topbar-right { display: flex; align-items: center; gap: 12px; }
.fcp-preview-only { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #606266; }
.fcp-shortcode {
display: inline-flex;
align-items: center;
gap: 6px;
background: #f5f7fa;
border: 1px solid rgba(15,23,42,.08);
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
font-family: monospace;
color: #606266;
}
.fcp-body { display: flex; align-items: flex-start; gap: 28px; padding: 32px; }
.fcp-frame-col { flex: 1; min-width: 0; }
.fcp-chrome {
background: #fff;
border: 1px solid rgba(15,23,42,.08);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 20px -6px rgba(15,23,42,.10);
overflow: hidden;
}
.fcp-chrome-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: #fafbfc;
border-bottom: 1px solid rgba(15,23,42,.08);
}
.fcp-dots { display: flex; gap: 7px; }
.fcp-dots span { width: 10px; height: 10px; border-radius: 50%; }
.fcp-dots span:nth-child(1) { background: #e5484d; }
.fcp-dots span:nth-child(2) { background: #f0a020; }
.fcp-dots span:nth-child(3) { background: #16a672; }
.fcp-devices { display: flex; gap: 4px; }
.fcp-devices button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
border: 1px solid transparent;
background: none;
border-radius: 8px;
cursor: pointer;
color: #606266;
transition: background .12s ease, color .12s ease;
}
.fcp-devices button.is-active { background: #f2effe; color: #6c4ef5; border-color: #e1d8fd; }
.fcp-devices button svg { display: block; }
.fcp-canvas {
padding: 56px 24px;
display: flex;
justify-content: center;
background: #f5f7fa;
transition: none;
}
.fcp-viewport {
width: 100%;
max-width: 680px;
transition: max-width 0.25s ease;
background: #fff;
padding: 20px;
border-radius: 6px;
}
.fcp-viewport.is-tablet { max-width: 480px; }
.fcp-viewport.is-mobile { max-width: 320px; }
.fcp-sidebar {
width: 280px;
flex-shrink: 0;
background: #fff;
border: 1px solid rgba(15,23,42,.08);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15,23,42,.04), 0 8px 20px -6px rgba(15,23,42,.10);
padding: 24px;
}
.fcp-sidebar h2 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 16px; }
.fcp-sidebar select {
width: 100%;
padding: 10px 12px;
border: 1px solid rgba(15,23,42,.08);
border-radius: 8px;
background: #f5f7fa;
font-size: 13px;
margin-bottom: 20px;
}
.fcp-roadmap { border-top: 1px solid rgba(15,23,42,.08); padding-top: 18px; margin-top: 4px; }
.fcp-roadmap h3 { font-size: 11.5px; text-transform: uppercase; letter-spacing: .06em; font-weight: 700; color: #909399; margin: 0 0 12px; }
.fcp-roadmap ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.fcp-roadmap li { font-size: 12.5px; color: #606266; display: flex; gap: 6px; }
@media (max-width: 900px) {
.fcp-body { flex-direction: column; }
.fcp-sidebar { width: 100%; }
}'
);
// Dedicated no-src handle so this admin-post standalone preview page
// (outside the normal wp_enqueue_scripts lifecycle) can carry its own
// inline JS through wp_add_inline_script() instead of a raw