Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.18% covered (warning)
87.18%
136 / 156
69.23% covered (warning)
69.23%
9 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Payment_Links_List_Table
89.47% covered (warning)
89.47%
136 / 152
69.23% covered (warning)
69.23%
9 / 13
51.80
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%
34 / 34
100.00% covered (success)
100.00%
1 / 1
13
 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%
6 / 6
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        // Cache list API responses for 60 seconds to reduce redundant API calls.
126        $cache_key = 'paypal_list_cache_' . md5( $page_token );
127        $result    = get_transient( $cache_key );
128
129        if ( false === $result ) {
130            $result = PayPal_API_Client::list_resources( self::PER_PAGE, $page_token );
131
132            if ( ! is_wp_error( $result ) ) {
133                set_transient( $cache_key, $result, 60 );
134            }
135        }
136
137        if ( is_wp_error( $result ) ) {
138            $this->api_error = $result;
139            $this->items     = array();
140            return;
141        }
142
143        $this->items = $result['resources'] ?? array();
144
145        // Extract next page token from HATEOAS links if present.
146        if ( isset( $result['links'] ) && is_array( $result['links'] ) ) {
147            foreach ( $result['links'] as $link ) {
148                if ( isset( $link['rel'] ) && 'next' === $link['rel'] && isset( $link['href'] ) ) {
149                    $parsed = wp_parse_url( $link['href'] );
150                    if ( isset( $parsed['query'] ) ) {
151                        parse_str( $parsed['query'], $query_params );
152                        if ( isset( $query_params['page_token'] ) ) {
153                            $this->next_page_token = $query_params['page_token'];
154                        }
155                    }
156                    break;
157                }
158            }
159        }
160
161        // Fall back to the row count when a response has no total.
162        // total_pages is 1 because this table pages by cursor: core's numbered
163        // links navigate by `paged`, which prepare_items() ignores.
164        $this->set_pagination_args(
165            array(
166                'total_items' => absint( $result['total_items'] ?? count( $this->items ) ),
167                'total_pages' => 1,
168                'per_page'    => self::PER_PAGE,
169            )
170        );
171    }
172
173    /**
174     * Render the product name column with row actions.
175     *
176     * @param array $item The payment link data.
177     * @return string Column HTML.
178     */
179    public function column_name( $item ) {
180        $name = isset( $item['line_items'][0]['name'] ) ? esc_html( $item['line_items'][0]['name'] ) : '—';
181
182        $actions = array();
183
184        // View details link (admin detail view).
185        if ( isset( $item['id'] ) ) {
186            $detail_url = add_query_arg(
187                array(
188                    'page'        => 'paypal-payment-links',
189                    'action'      => 'view',
190                    'resource_id' => $item['id'],
191                ),
192                admin_url( 'admin.php' )
193            );
194
195            $actions['view_details'] = sprintf(
196                '<a href="%s">%s</a>',
197                esc_url( $detail_url ),
198                esc_html__( 'View Details', 'jetpack-paypal-payments' )
199            );
200        }
201
202        // View on PayPal link.
203        $payment_link = $this->get_payment_link( $item );
204        if ( $payment_link ) {
205            $actions['view_paypal'] = sprintf(
206                '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
207                esc_url( $payment_link ),
208                esc_html__( 'View on PayPal', 'jetpack-paypal-payments' )
209            );
210        }
211
212        // Delete action.
213        if ( isset( $item['id'] ) ) {
214            $delete_url = wp_nonce_url(
215                add_query_arg(
216                    array(
217                        'page'        => 'paypal-payment-links',
218                        'action'      => 'delete',
219                        'resource_id' => $item['id'],
220                    ),
221                    admin_url( 'admin.php' )
222                ),
223                'delete_payment_link_' . $item['id']
224            );
225
226            if ( null === $this->embed_counts ) {
227                $this->embed_counts = PayPal_Admin_Page::count_published_embeds();
228            }
229
230            $actions['delete'] = sprintf(
231                '<a href="%s" class="submitdelete paypal-delete-link" data-confirm="%s">%s</a>',
232                esc_url( $delete_url ),
233                esc_attr( PayPal_Admin_Page::delete_confirm_text( $this->embed_counts[ $item['id'] ] ?? 0 ) ),
234                esc_html__( 'Delete', 'jetpack-paypal-payments' )
235            );
236        }
237
238        return sprintf( '<strong>%s</strong>%s', $name, $this->row_actions( $actions ) );
239    }
240
241    /**
242     * Render the price column.
243     *
244     * @param array $item The payment link data.
245     * @return string Column HTML.
246     */
247    public function column_price( $item ) {
248        if ( ! isset( $item['line_items'][0]['unit_amount'] ) ) {
249            return '—';
250        }
251
252        $amount   = $item['line_items'][0]['unit_amount'];
253        $value    = $amount['value'] ?? '0.00';
254        $currency = $amount['currency_code'] ?? 'USD';
255
256        return esc_html( PayPal_Payment_Buttons::format_price( $value, $currency ) );
257    }
258
259    /**
260     * Render the status column with a badge.
261     *
262     * @param array $item The payment link data.
263     * @return string Column HTML.
264     */
265    public function column_status( $item ) {
266        $status = isset( $item['status'] ) ? strtoupper( $item['status'] ) : 'UNKNOWN';
267
268        $class = 'ACTIVE' === $status ? 'paypal-status-active' : 'paypal-status-inactive';
269
270        // Prefix with a text icon so status is conveyed by more than color alone (A11Y-S1).
271        $label = 'ACTIVE' === $status ? "\xE2\x9C\x93 ACTIVE" : "\xE2\x80\x94 INACTIVE";
272
273        return sprintf(
274            '<span class="paypal-status-badge %s">%s</span>',
275            esc_attr( $class ),
276            esc_html( $label )
277        );
278    }
279
280    /**
281     * Render the created date column.
282     *
283     * @param array $item The payment link data.
284     * @return string Column HTML.
285     */
286    public function column_created( $item ) {
287        if ( ! isset( $item['create_time'] ) ) {
288            return '—';
289        }
290
291        $timestamp = strtotime( $item['create_time'] );
292        if ( false === $timestamp ) {
293            return '—';
294        }
295
296        $date_format = get_option( 'date_format', 'F j, Y' );
297        $time_format = get_option( 'time_format', 'g:i a' );
298
299        return esc_html( wp_date( $date_format . ' ' . $time_format, $timestamp ) );
300    }
301
302    /**
303     * Render the payment link column with a copy button.
304     *
305     * @param array $item The payment link data.
306     * @return string Column HTML.
307     */
308    public function column_payment_link( $item ) {
309        $link = $this->get_payment_link( $item );
310        if ( ! $link ) {
311            return '—';
312        }
313
314        $short = strlen( $link ) > 50 ? substr( $link, 0, 47 ) . '...' : $link;
315
316        return sprintf(
317            '<code class="paypal-payment-url" title="%s">%s</code> <button type="button" class="button button-small paypal-copy-link" data-url="%s">%s</button>',
318            esc_attr( $link ),
319            esc_html( $short ),
320            esc_attr( $link ),
321            esc_html__( 'Copy', 'jetpack-paypal-payments' )
322        );
323    }
324
325    /**
326     * Default column rendering.
327     *
328     * @param array  $item        The payment link data.
329     * @param string $column_name The column slug.
330     * @return string Column HTML.
331     */
332    public function column_default( $item, $column_name ) {
333        return isset( $item[ $column_name ] ) ? esc_html( $item[ $column_name ] ) : '—';
334    }
335
336    /**
337     * Message shown when no items are found.
338     */
339    public function no_items() {
340        if ( $this->api_error ) {
341            printf(
342                '<p>%s</p><p><a href="%s" class="button">%s</a></p>',
343                esc_html( $this->api_error->get_error_message() ),
344                esc_url( admin_url( 'admin.php?page=paypal-payment-links' ) ),
345                esc_html__( 'Try Again', 'jetpack-paypal-payments' )
346            );
347            return;
348        }
349
350        printf(
351            '<p>%s</p><p>%s</p>',
352            esc_html__( 'No payment links found.', 'jetpack-paypal-payments' ),
353            esc_html__( 'Create your first payment link by adding a PayPal Payment Buttons block in the editor.', 'jetpack-paypal-payments' )
354        );
355    }
356
357    /**
358     * Extract the payment link URL from an item.
359     *
360     * @param array $item The payment link data.
361     * @return string|null The payment link URL, or null.
362     */
363    private function get_payment_link( $item ) {
364        $link = null;
365
366        // Top-level payment_link field.
367        if ( ! empty( $item['payment_link'] ) ) {
368            $link = $item['payment_link'];
369        }
370
371        // HATEOAS links array.
372        if ( null === $link && isset( $item['links'] ) && is_array( $item['links'] ) ) {
373            foreach ( $item['links'] as $hateoas_link ) {
374                if ( isset( $hateoas_link['rel'] ) && 'payment_link' === $hateoas_link['rel'] && isset( $hateoas_link['href'] ) ) {
375                    $link = $hateoas_link['href'];
376                    break;
377                }
378            }
379        }
380
381        if ( null === $link ) {
382            return null;
383        }
384
385        // Merchants share this link straight out of the admin, so it has to
386        // carry the same attribution code as the rendered button.
387        return PayPal_Payment_Buttons::add_partner_attribution( $link );
388    }
389}