Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.39% covered (warning)
86.39%
127 / 147
69.23% covered (warning)
69.23%
9 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Payment_Links_List_Table
88.81% covered (warning)
88.81%
127 / 143
69.23% covered (warning)
69.23%
9 / 13
50.09
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 ajax_user_can
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_columns
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 get_sortable_columns
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepare_items
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
11
 column_name
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
6
 column_price
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 column_status
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 column_created
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 column_payment_link
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 column_default
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 no_items
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 get_payment_link
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
10
1<?php
2/**
3 * Payment Links list table for the WordPress admin dashboard.
4 *
5 * Displays all PayPal payment links in a WP_List_Table format
6 * with pagination, delete actions, and status badges.
7 *
8 * @package automattic/jetpack-paypal-payments
9 * @since 0.9.0
10 */
11
12namespace Automattic\Jetpack\PaypalPayments;
13
14if ( ! defined( 'ABSPATH' ) ) {
15    exit;
16}
17
18if ( ! class_exists( 'WP_List_Table' ) ) {
19    require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
20}
21
22/**
23 * Class PayPal_Payment_Links_List_Table
24 *
25 * WP_List_Table subclass for displaying PayPal payment links.
26 */
27class PayPal_Payment_Links_List_Table extends \WP_List_Table {
28
29    /**
30     * Items per page.
31     *
32     * @var int
33     */
34    const PER_PAGE = 20;
35
36    /**
37     * API error from the last prepare_items() call, if any.
38     *
39     * @var \WP_Error|null
40     */
41    public $api_error = null;
42
43    /**
44     * Next page token for cursor-based pagination.
45     *
46     * @var string|null
47     */
48    public $next_page_token = null;
49
50    /**
51     * Published posts embedding each link, keyed by resource id. Loaded once per table.
52     *
53     * @var array<string,int>|null
54     */
55    private $embed_counts = null;
56
57    /**
58     * Constructor.
59     */
60    public function __construct() {
61        parent::__construct(
62            array(
63                'singular' => 'payment_link',
64                'plural'   => 'payment_links',
65                'ajax'     => false,
66            )
67        );
68    }
69
70    /**
71     * Whether the current user can view the list table over AJAX.
72     *
73     * The table is registered with 'ajax' => false, so this is never reached
74     * in practice. It mirrors the capability the admin page itself requires.
75     *
76     * @return bool True if the current user may load the list.
77     */
78    public function ajax_user_can() {
79        return current_user_can( PayPal_Admin_Page::CAPABILITY );
80    }
81
82    /**
83     * Define table columns.
84     *
85     * @return array Column slug => label.
86     */
87    public function get_columns() {
88        return array(
89            'name'         => __( 'Product', 'jetpack-paypal-payments' ),
90            'price'        => __( 'Price', 'jetpack-paypal-payments' ),
91            'status'       => __( 'Status', 'jetpack-paypal-payments' ),
92            'created'      => __( 'Created', 'jetpack-paypal-payments' ),
93            'payment_link' => __( 'Payment Link', 'jetpack-paypal-payments' ),
94        );
95    }
96
97    /**
98     * Columns that should be sortable.
99     *
100     * Note: Sorting is client-side only since PayPal's API does not
101     * support server-side sort parameters.
102     *
103     * @return array
104     */
105    public function get_sortable_columns() {
106        return array();
107    }
108
109    /**
110     * Fetch payment links from the PayPal API and populate the table.
111     */
112    public function prepare_items() {
113        $this->api_error       = null;
114        $this->next_page_token = null;
115
116        $columns  = $this->get_columns();
117        $hidden   = array();
118        $sortable = $this->get_sortable_columns();
119
120        $this->_column_headers = array( $columns, $hidden, $sortable );
121
122        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only pagination parameter.
123        $page_token = isset( $_GET['page_token'] ) ? sanitize_text_field( wp_unslash( $_GET['page_token'] ) ) : '';
124
125        $result = PayPal_API_Client::list_resources_cached( self::PER_PAGE, $page_token );
126
127        if ( is_wp_error( $result ) ) {
128            $this->api_error = $result;
129            $this->items     = array();
130            return;
131        }
132
133        $this->items = $result['resources'] ?? array();
134
135        // Extract next page token from HATEOAS links if present.
136        if ( isset( $result['links'] ) && is_array( $result['links'] ) ) {
137            foreach ( $result['links'] as $link ) {
138                if ( isset( $link['rel'] ) && 'next' === $link['rel'] && isset( $link['href'] ) ) {
139                    $parsed = wp_parse_url( $link['href'] );
140                    if ( isset( $parsed['query'] ) ) {
141                        parse_str( $parsed['query'], $query_params );
142                        if ( isset( $query_params['page_token'] ) ) {
143                            $this->next_page_token = $query_params['page_token'];
144                        }
145                    }
146                    break;
147                }
148            }
149        }
150
151        // Fall back to the row count when a response has no total.
152        // total_pages is 1 because this table pages by cursor: core's numbered
153        // links navigate by `paged`, which prepare_items() ignores.
154        $this->set_pagination_args(
155            array(
156                'total_items' => absint( $result['total_items'] ?? count( $this->items ) ),
157                'total_pages' => 1,
158                'per_page'    => self::PER_PAGE,
159            )
160        );
161    }
162
163    /**
164     * Render the product name column with row actions.
165     *
166     * @param array $item The payment link data.
167     * @return string Column HTML.
168     */
169    public function column_name( $item ) {
170        $name = isset( $item['line_items'][0]['name'] ) ? esc_html( $item['line_items'][0]['name'] ) : '—';
171
172        $actions = array();
173
174        // View details link (admin detail view).
175        if ( isset( $item['id'] ) ) {
176            $detail_url = add_query_arg(
177                array(
178                    'page'        => 'paypal-payment-links',
179                    'action'      => 'view',
180                    'resource_id' => $item['id'],
181                ),
182                admin_url( 'admin.php' )
183            );
184
185            $actions['view_details'] = sprintf(
186                '<a href="%s">%s</a>',
187                esc_url( $detail_url ),
188                esc_html__( 'View Details', 'jetpack-paypal-payments' )
189            );
190        }
191
192        // View on PayPal link.
193        $payment_link = $this->get_payment_link( $item );
194        if ( $payment_link ) {
195            $actions['view_paypal'] = sprintf(
196                '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
197                esc_url( $payment_link ),
198                esc_html__( 'View on PayPal', 'jetpack-paypal-payments' )
199            );
200        }
201
202        // Delete action.
203        if ( isset( $item['id'] ) ) {
204            $delete_url = wp_nonce_url(
205                add_query_arg(
206                    array(
207                        'page'        => 'paypal-payment-links',
208                        'action'      => 'delete',
209                        'resource_id' => $item['id'],
210                    ),
211                    admin_url( 'admin.php' )
212                ),
213                'delete_payment_link_' . $item['id']
214            );
215
216            if ( null === $this->embed_counts ) {
217                $this->embed_counts = PayPal_Admin_Page::count_published_embeds();
218            }
219
220            $actions['delete'] = sprintf(
221                '<a href="%s" class="submitdelete paypal-delete-link" data-confirm="%s">%s</a>',
222                esc_url( $delete_url ),
223                esc_attr( PayPal_Admin_Page::delete_confirm_text( $this->embed_counts[ $item['id'] ] ?? 0 ) ),
224                esc_html__( 'Delete', 'jetpack-paypal-payments' )
225            );
226        }
227
228        return sprintf( '<strong>%s</strong>%s', $name, $this->row_actions( $actions ) );
229    }
230
231    /**
232     * Render the price column.
233     *
234     * @param array $item The payment link data.
235     * @return string Column HTML.
236     */
237    public function column_price( $item ) {
238        $price = PayPal_Payment_Buttons::resource_price( $item );
239
240        return '' === $price ? '—' : esc_html( $price );
241    }
242
243    /**
244     * Render the status column with a badge.
245     *
246     * @param array $item The payment link data.
247     * @return string Column HTML.
248     */
249    public function column_status( $item ) {
250        $status = isset( $item['status'] ) ? strtoupper( $item['status'] ) : 'UNKNOWN';
251
252        $class = 'ACTIVE' === $status ? 'paypal-status-active' : 'paypal-status-inactive';
253
254        // Prefix with a text icon so status is conveyed by more than color alone (A11Y-S1).
255        $label = 'ACTIVE' === $status ? "\xE2\x9C\x93 ACTIVE" : "\xE2\x80\x94 INACTIVE";
256
257        return sprintf(
258            '<span class="paypal-status-badge %s">%s</span>',
259            esc_attr( $class ),
260            esc_html( $label )
261        );
262    }
263
264    /**
265     * Render the created date column.
266     *
267     * @param array $item The payment link data.
268     * @return string Column HTML.
269     */
270    public function column_created( $item ) {
271        if ( ! isset( $item['create_time'] ) ) {
272            return '—';
273        }
274
275        $timestamp = strtotime( $item['create_time'] );
276        if ( false === $timestamp ) {
277            return '—';
278        }
279
280        $date_format = get_option( 'date_format', 'F j, Y' );
281        $time_format = get_option( 'time_format', 'g:i a' );
282
283        return esc_html( wp_date( $date_format . ' ' . $time_format, $timestamp ) );
284    }
285
286    /**
287     * Render the payment link column with a copy button.
288     *
289     * @param array $item The payment link data.
290     * @return string Column HTML.
291     */
292    public function column_payment_link( $item ) {
293        $link = $this->get_payment_link( $item );
294        if ( ! $link ) {
295            return '—';
296        }
297
298        $short = strlen( $link ) > 50 ? substr( $link, 0, 47 ) . '...' : $link;
299
300        return sprintf(
301            '<code class="paypal-payment-url" title="%s">%s</code> <button type="button" class="button button-small paypal-copy-link" data-url="%s">%s</button>',
302            esc_attr( $link ),
303            esc_html( $short ),
304            esc_attr( $link ),
305            esc_html__( 'Copy', 'jetpack-paypal-payments' )
306        );
307    }
308
309    /**
310     * Default column rendering.
311     *
312     * @param array  $item        The payment link data.
313     * @param string $column_name The column slug.
314     * @return string Column HTML.
315     */
316    public function column_default( $item, $column_name ) {
317        return isset( $item[ $column_name ] ) ? esc_html( $item[ $column_name ] ) : '—';
318    }
319
320    /**
321     * Message shown when no items are found.
322     */
323    public function no_items() {
324        if ( $this->api_error ) {
325            printf(
326                '<p>%s</p><p><a href="%s" class="button">%s</a></p>',
327                esc_html( $this->api_error->get_error_message() ),
328                esc_url( admin_url( 'admin.php?page=paypal-payment-links' ) ),
329                esc_html__( 'Try Again', 'jetpack-paypal-payments' )
330            );
331            return;
332        }
333
334        printf(
335            '<p>%s</p><p>%s</p>',
336            esc_html__( 'No payment links found.', 'jetpack-paypal-payments' ),
337            esc_html__( 'Create your first payment link by adding a PayPal Payment Buttons block in the editor.', 'jetpack-paypal-payments' )
338        );
339    }
340
341    /**
342     * Extract the payment link URL from an item.
343     *
344     * @param array $item The payment link data.
345     * @return string|null The payment link URL, or null.
346     */
347    private function get_payment_link( $item ) {
348        $link = null;
349
350        // Top-level payment_link field.
351        if ( ! empty( $item['payment_link'] ) ) {
352            $link = $item['payment_link'];
353        }
354
355        // HATEOAS links array.
356        if ( null === $link && isset( $item['links'] ) && is_array( $item['links'] ) ) {
357            foreach ( $item['links'] as $hateoas_link ) {
358                if ( isset( $hateoas_link['rel'] ) && 'payment_link' === $hateoas_link['rel'] && isset( $hateoas_link['href'] ) ) {
359                    $link = $hateoas_link['href'];
360                    break;
361                }
362            }
363        }
364
365        if ( null === $link ) {
366            return null;
367        }
368
369        // Merchants share this link straight out of the admin, so it has to
370        // carry the same attribution code as the rendered button.
371        return PayPal_Payment_Buttons::add_partner_attribution( $link );
372    }
373}