entry_id() ? __( 'Entries', 'devplugins-custom-form-builder' ) : __( 'Entry details', 'devplugins-custom-form-builder' ); } /** * Returns the menu label. */ public function menu_title(): string { return __( 'Entries', 'devplugins-custom-form-builder' ); } /** * Returns the required capability. */ public function capability(): string { return Capabilities::get( Capabilities::MANAGE_ENTRIES ); } /** * Handles the actions, then prepares the screen. */ public function load(): void { if ( ! current_user_can( $this->capability() ) ) { return; } $this->handle_filters(); $this->handle_actions(); if ( $this->entry_id() > 0 ) { $this->load_entry(); return; } add_screen_option( 'per_page', array( 'label' => __( 'Entries per page', 'devplugins-custom-form-builder' ), 'default' => EntriesListTable::PER_PAGE_DEFAULT, 'option' => EntriesListTable::PER_PAGE_OPTION, ) ); $this->list_table = new EntriesListTable( $this->submissions, $this->forms, $this->metadata, $this->exports ); $this->list_table->prepare_items(); } /** * Returns the view path. */ protected function view(): string { return $this->entry instanceof Submission ? 'admin/entries/single' : 'admin/entries/list'; } /** * Returns the data passed to the view. * * @return array Variables exposed to the template. */ protected function data(): array { if ( $this->entry instanceof Submission ) { return $this->entry_data( $this->entry ); } return array( 'page_title' => $this->page_title(), 'list_table' => $this->list_table, 'notice' => $this->notice(), 'page_slug' => self::SLUG, 'exports' => $this->export_links(), /* * Asked of the rows already on screen. This used to run the whole * filtered count a second time, on every page load, to decide whether * to print a link. */ 'has_export' => $this->list_table instanceof EntriesListTable && $this->list_table->has_items(), 'saved_filters' => $this->saved_filter_links(), 'filter_form' => $this->filter_form(), ); } /** * Builds the data of the detail screen. * * @param Submission $entry Entry being viewed. * * @return array Variables exposed to the template. */ private function entry_data( Submission $entry ): array { $form = $this->forms->find( $entry->form_id() ); $labels = $this->metadata->field_labels( $entry->form_id() ); $values = array(); foreach ( $this->submissions->meta( $entry->id() ) as $key => $raw ) { $values[] = array( 'key' => $key, 'label' => $labels[ $key ] ?? $key, 'value' => $this->formatter->decode( $raw ), ); } return array( 'page_title' => $this->page_title(), 'entry' => $entry, 'form' => $form, 'values' => $values, 'formatter' => $this->formatter, 'list_url' => $this->url(), 'delete_url' => wp_nonce_url( $this->url( array( 'action' => 'delete', 'entry' => $entry->id(), ) ), self::row_nonce_action( 'delete', $entry->id() ) ), ); } /** * Applies, stores or removes a saved filter. * * Applying one redirects to the URL it stands for rather than filtering the * list from the saved values directly. That keeps the query string the single * description of what is on screen, so the exports, the pagination, the status * links and a copied URL all keep working without knowing filters can be * saved at all. */ private function handle_filters(): void { $key = Request::key( 'formpilot_filter' ); if ( '' !== $key ) { $saved = $this->filters->find( $key ); if ( null === $saved ) { $this->redirect( 'filter_missing', 0 ); } wp_safe_redirect( $this->url( array_merge( array( 'formpilot_applied' => $key ), $saved['args'] ) ) ); exit; } $action = Request::key( 'formpilot_filter_action' ); if ( '' === $action ) { return; } SecurityManager::verify_admin_request( self::FILTER_NONCE, Capabilities::MANAGE_ENTRIES ); if ( 'delete' === $action ) { $removed = $this->filters->delete( Request::key( 'filter' ) ); $this->redirect( $removed ? 'filter_deleted' : 'error', 0 ); } if ( 'save' !== $action ) { return; } $saved = $this->filters->save( Request::text( 'filter_name' ), $this->filter_url_args() ); $this->redirect( '' !== $saved ? 'filter_saved' : 'filter_refused', 0 ); } /** * Dispatches the requested action. */ private function handle_actions(): void { $action = $this->requested_action(); if ( '' === $action ) { return; } if ( 'export' === $action ) { $this->export(); return; } $selection = Request::ids( 'entries' ); if ( array() !== $selection ) { // Nonce emitted by WP_List_Table::display() for the "entries" plural. SecurityManager::verify_admin_request( self::BULK_NONCE, Capabilities::MANAGE_ENTRIES ); if ( str_starts_with( $action, 'export_' ) ) { $this->export( substr( $action, 7 ), $selection ); return; } if ( 'mark_read' === $action || 'mark_unread' === $action ) { $changed = $this->submissions->mark_status( $selection, 'mark_read' === $action ? Submission::STATUS_READ : Submission::STATUS_UNREAD ); $this->redirect( 'mark_read' === $action ? 'read' : 'unread', $changed ); } if ( 'delete' !== $action ) { return; } $deleted = $this->submissions->delete_many( $selection ); $this->redirect( $deleted > 0 ? 'deleted' : 'error', $deleted ); } $entry_id = Request::int( 'entry' ); if ( 'delete' !== $action || 0 === $entry_id ) { return; } SecurityManager::verify_admin_request( self::row_nonce_action( 'delete', $entry_id ), Capabilities::MANAGE_ENTRIES ); $deleted = $this->submissions->delete( $entry_id ); $this->redirect( $deleted ? 'deleted' : 'error', $deleted ? 1 : 0 ); } /** * Loads the entry being viewed and marks it as read. */ private function load_entry(): void { $entry = $this->submissions->find( $this->entry_id() ); if ( ! $entry instanceof Submission ) { wp_safe_redirect( $this->url( array( FormsPage::MESSAGE_ARG => 'missing' ) ) ); exit; } /* * Marked read on a POST, not on the GET that displays it. * * Changing state on a safe method meant `` in a comment * or an email marked an entry read — small on its own, but it also lets * somebody quietly hide that an entry has not been looked at. The screen * posts the acknowledgement back with a nonce instead, so opening the page * reads and nothing more. */ if ( ! $entry->is_read() && $this->is_read_request( $entry->id() ) ) { $this->submissions->mark_read( $entry->id() ); $entry = $this->submissions->find( $entry->id() ) ?? $entry; } $this->entry = $entry; } /** * Determines whether the current request asked to mark an entry read. * * @param int $entry_id Entry being viewed. */ private function is_read_request( int $entry_id ): bool { if ( ! Request::has( self::READ_FIELD, 'post' ) ) { return false; } check_admin_referer( Nonce::action( 'entry', 'read', $entry_id ) ); Capabilities::require( Capabilities::MANAGE_ENTRIES ); return true; } /** * Streams the requested entries as a download. * * Called either from the export links or from a bulk action. Both verify a * nonce here rather than leaving one branch relying on the caller having done * it: the bulk branch used to check the capability alone and take the CSRF * protection on trust from `handle_bulk_action()`, which is a convention a * second caller would silently break. * * @param string $format Optional. Format slug. Default read from the request. * @param array $selection Optional. Restrict the export to these entries. Default empty. */ private function export( string $format = '', array $selection = array() ): void { if ( '' === $format ) { SecurityManager::verify_admin_request( self::EXPORT_NONCE, Capabilities::MANAGE_ENTRIES ); $format = Request::key( 'format' ); } else { check_admin_referer( self::BULK_NONCE ); Capabilities::require( Capabilities::MANAGE_ENTRIES ); } if ( null === $this->exports->exporter( $format ) ) { $format = 'csv'; } $this->exports->export( $format, $this->filter_args(), $selection ); /* * Every writer ends in `exit`, so reaching this line means the download was * refused — output had already started, so the headers could not be sent. * Returning quietly re-rendered the list as though the link had not been * clicked, which is indistinguishable from a broken link. `SettingsTransfer` * already says so out loud; this now matches it. */ wp_die( esc_html__( 'The export could not be sent because output had already started. Set WP_DEBUG_DISPLAY to false, or deactivate the plugin printing early, and try again.', 'devplugins-custom-form-builder' ), esc_html__( 'Export failed', 'devplugins-custom-form-builder' ), array( 'response' => 500 ) ); } /** * Returns the filters currently applied to the list. * * @return array Query arguments. */ private function filter_args(): array { return EntriesListTable::filters(); } /** * Returns the current filters as query arguments. * * The list table reads the status under `entry_status`, because `status` is * already taken by core on some screens. * * @return array Query arguments. */ private function filter_url_args(): array { return EntriesListTable::url_filters(); } /** * Builds the export links, one per format, carrying the current filters. * * @return array The links. */ private function export_links(): array { $links = array(); foreach ( $this->exports->exporters() as $format => $exporter ) { $args = array_merge( array( 'action' => 'export', 'format' => $format, ), $this->filter_url_args() ); $links[] = array( 'label' => $exporter->label(), 'url' => wp_nonce_url( $this->url( $args ), self::EXPORT_NONCE ), ); } return $links; } /** * Builds the saved filter links. * * @return array The links. */ private function saved_filter_links(): array { $applied = Request::key( 'formpilot_applied' ); $links = array(); foreach ( $this->filters->all() as $key => $filter ) { $links[] = array( 'key' => $key, 'name' => $filter['name'], 'url' => $this->url( array( 'formpilot_filter' => $key ) ), 'delete_url' => wp_nonce_url( $this->url( array( 'formpilot_filter_action' => 'delete', 'filter' => $key, ) ), self::FILTER_NONCE ), 'current' => $applied === $key, ); } return $links; } /** * Builds what the "save this filter" form needs. * * @return array{url: string, nonce: string, active: bool, fields: array} The form data. */ private function filter_form(): array { $fields = $this->filter_url_args(); return array( 'url' => $this->url(), 'nonce' => self::FILTER_NONCE, // Nothing to save when the list is showing everything. 'active' => array() !== $fields, 'fields' => $fields, ); } /** * Returns the entry requested for the detail screen. * * @return int The entry ID, or 0 when the list is shown. */ private function entry_id(): int { if ( 'delete' === $this->requested_action() ) { return 0; } return Request::int( 'entry' ); } /** * Sends the browser back to a clean list URL. * * @param string $message Message key describing the outcome. * @param int $count Number of affected rows. */ private function redirect( string $message, int $count ): void { $args = array_merge( array( FormsPage::MESSAGE_ARG => $message, FormsPage::COUNT_ARG => $count, ), $this->filter_url_args() ); wp_safe_redirect( $this->url( $args ) ); exit; } /** * Builds the notice shown after an action. * * @return array{type: string, text: string}|null The notice, or null when there is nothing to report. */ private function notice(): ?array { $message = Request::key( FormsPage::MESSAGE_ARG ); if ( '' === $message ) { return null; } $count = max( 0, Request::int( FormsPage::COUNT_ARG ) ); return match ( $message ) { 'deleted' => array( 'type' => 'success', 'text' => sprintf( /* translators: %s: Number of deleted entries. */ _n( '%s entry deleted.', '%s entries deleted.', $count, 'devplugins-custom-form-builder' ), number_format_i18n( $count ) ), ), 'read' => array( 'type' => 'success', 'text' => sprintf( /* translators: %s: Number of entries. */ _n( '%s entry marked as read.', '%s entries marked as read.', $count, 'devplugins-custom-form-builder' ), number_format_i18n( $count ) ), ), 'unread' => array( 'type' => 'success', 'text' => sprintf( /* translators: %s: Number of entries. */ _n( '%s entry marked as unread.', '%s entries marked as unread.', $count, 'devplugins-custom-form-builder' ), number_format_i18n( $count ) ), ), 'filter_saved' => array( 'type' => 'success', 'text' => __( 'Filter saved.', 'devplugins-custom-form-builder' ), ), 'filter_deleted' => array( 'type' => 'success', 'text' => __( 'Filter removed.', 'devplugins-custom-form-builder' ), ), 'filter_missing' => array( 'type' => 'error', 'text' => __( 'That saved filter no longer exists.', 'devplugins-custom-form-builder' ), ), 'filter_refused' => array( 'type' => 'error', 'text' => sprintf( /* translators: %s: Largest number of saved filters. */ __( 'The filter was not saved. Give it a name, narrow the list first, and keep to %s saved filters.', 'devplugins-custom-form-builder' ), number_format_i18n( SavedFilters::LIMIT ) ), ), 'missing' => array( 'type' => 'error', 'text' => __( 'That entry no longer exists.', 'devplugins-custom-form-builder' ), ), default => array( 'type' => 'error', 'text' => __( 'The action could not be completed.', 'devplugins-custom-form-builder' ), ), }; } }