Admin tables: scroll containers and phone cards; Alerts list flags missing media
Test and Release / test (push) Successful in 1m26s
Test and Release / release (push) Successful in 5s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-15 13:59:20 -08:00
1 parent afa768fa42
commit fb200594ce
8 files changed
+190 -54

No files matched your search

+31 -1
View File
@@ -46,7 +46,6 @@
@media (max-width: 782px) { .sg-admin .sg-copy { flex-direction: column; align-items: stretch; } }
/* Phones: wide tables scroll inside their own box instead of pushing the page; controls never exceed the card. */
@media (max-width: 782px) {
.sg-admin table.sg-table { display: block; max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.sg-admin .sg-table th, .sg-admin .sg-table td { min-width: 3em; padding: 8px 6px; font-size: 13px; }
.sg-admin .sg-table code { overflow-wrap: anywhere; word-break: break-all; font-size: 12px; }
.sg-admin .sg-table td:last-child .button { margin: 0 4px 4px 0; }
@@ -56,3 +55,34 @@
.sg-admin select, .sg-admin input[type="text"], .sg-admin input[type="url"], .sg-admin input[type="number"], .sg-admin textarea { max-width: 100%; box-sizing: border-box; }
.sg-admin .sg-card { padding-left: 14px; padding-right: 14px; }
}
/* Table wrapper: scrolls sideways inside itself, never the page. Desktop look is unchanged. */
.sg-admin .sg-scroll { max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.sg-admin .sg-scroll:focus-visible { outline: 2px solid #2271b1; outline-offset: 1px; }
.sg-admin .sg-swipe-hint { display: none; }
.sg-admin .sg-table code { overflow-wrap: anywhere; }
@media (max-width: 782px) {
.sg-admin .sg-table code { word-break: normal; overflow-wrap: anywhere; white-space: normal; }
/* Scroll tables: edge fades (scroll shadows) plus a one-line hint. */
.sg-admin .sg-swipe-hint { display: block; margin: 4px 0; font-size: 12px; color: #50575e; }
.sg-admin .sg-scroll:not(.sg-scroll-cards) {
background: linear-gradient(to right, #fff 30%, rgba(255,255,255,0)) 0 0 / 40px 100% no-repeat local,
linear-gradient(to left, #fff 30%, rgba(255,255,255,0)) 100% 0 / 40px 100% no-repeat local,
linear-gradient(to right, rgba(0,0,0,.22), rgba(0,0,0,0)) 0 0 / 14px 100% no-repeat scroll,
linear-gradient(to left, rgba(0,0,0,.22), rgba(0,0,0,0)) 100% 0 / 14px 100% no-repeat scroll;
}
.sg-admin .sg-scroll:not(.sg-scroll-cards) .sg-table { min-width: 560px; }
/* Card tables: each row becomes a labelled card. */
.sg-admin .sg-scroll-cards { overflow-x: visible; }
.sg-admin table.sg-cards, .sg-admin table.sg-cards tbody, .sg-admin table.sg-cards tr, .sg-admin table.sg-cards td { display: block; width: auto; box-sizing: border-box; }
.sg-admin table.sg-cards { border: 0; background: none; box-shadow: none; }
.sg-admin table.sg-cards thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.sg-admin table.sg-cards tr { margin: 0 0 12px; padding: 4px 0; border: 1px solid #c3c4c7; border-radius: 4px; background: #fff; }
.sg-admin table.sg-cards td { display: flex; gap: 10px; justify-content: space-between; align-items: baseline; padding: 6px 12px; font-size: 14px; border: 0; overflow-wrap: anywhere; text-align: right; }
.sg-admin table.sg-cards td::before { content: attr(data-label); flex: 0 0 34%; font-weight: 600; color: #50575e; text-align: left; }
.sg-admin table.sg-cards td:first-child { display: block; font-size: 15px; text-align: left; border-bottom: 1px solid #f0f0f1; padding-bottom: 8px; }
.sg-admin table.sg-cards td:first-child::before, .sg-admin table.sg-cards td[data-label=""]::before, .sg-admin table.sg-cards td.sg-actions::before { display: none; }
.sg-admin table.sg-cards td.sg-actions, .sg-admin table.sg-cards td[data-label=""] { display: flex; flex-direction: column; align-items: stretch; gap: 8px; padding: 10px 12px 8px; text-align: center; }
.sg-admin table.sg-cards td.sg-actions .button, .sg-admin table.sg-cards td[data-label=""] .button { display: block; width: 100%; min-height: 44px; line-height: 42px; margin: 0; padding: 0 12px; box-sizing: border-box; text-align: center; }
.sg-admin table.sg-cards td.sg-actions form, .sg-admin table.sg-cards td[data-label=""] form { display: block; margin: 0; }
}
+3
View File
@@ -31,3 +31,6 @@
.sga-howto{margin-top:8px}
.sga-howto pre{overflow-x:auto;max-width:100%;padding:8px;background:#f0f0f1}
.sga-bg-pick{margin-left:12px}
/* Alerts list: media that was deleted from the Media Library */
.sga-missing{color:#8a2424;background:#fcf0f0;border:1px solid #d63638;border-radius:3px;padding:0 6px;font-weight:600;display:inline-block}
+14 -14
View File
@@ -55,17 +55,17 @@ final class ActionsPage {
if ( ! $queued ) {
echo '<p>Nothing in the queue. When a donation triggers a reward, it shows up here.</p>';
} else {
echo '<table class="widefat striped sg-table"><thead><tr><th>Donor</th><th>Amount</th><th>Reward</th><th>Time</th><th>Message</th><th></th></tr></thead><tbody>';
echo Admin::tableOpen( array( 'Donor', 'Amount', 'Reward', 'Time', 'Message', '' ), 'Reward queue' );
foreach ( $queued as $e ) {
echo '<tr><td>' . esc_html( Format::donorName( $e ) ) . '</td>';
echo '<td>' . esc_html( Format::money( (int) ( $e['base_minor'] ?? 0 ), Format::currency( $e['currency'] ?? $cur ) ) ) . '</td>';
echo '<td><strong>' . esc_html( (string) $e['title'] ) . '</strong> &times; ' . (int) $e['times'] . '</td>';
echo '<td>' . esc_html( self::when( (string) $e['created_at'] ) ) . '</td>';
echo '<td>' . esc_html( (string) ( $e['message'] ?? '' ) ) . '</td><td>';
echo '<tr>' . Admin::td( 'Donor' ) . esc_html( Format::donorName( $e ) ) . '</td>';
echo Admin::td( 'Amount' ) . esc_html( Format::money( (int) ( $e['base_minor'] ?? 0 ), Format::currency( $e['currency'] ?? $cur ) ) ) . '</td>';
echo Admin::td( 'Reward' ) . '<strong>' . esc_html( (string) $e['title'] ) . '</strong> &times; ' . (int) $e['times'] . '</td>';
echo Admin::td( 'Time' ) . esc_html( self::when( (string) $e['created_at'] ) ) . '</td>';
echo Admin::td( 'Message' ) . esc_html( (string) ( $e['message'] ?? '' ) ) . '</td>' . Admin::td( '', 'sg-actions' );
Admin::form( 'sg_event_done', array( 'event_id' => (int) $e['id'] ), 'sg-inline' );
echo '<button class="button button-primary">Mark done</button></form></td></tr>';
}
echo '</tbody></table>';
echo Admin::tableClose();
Admin::form( 'sg_event_done_all' );
echo '<p><button class="button">Mark all done</button></p></form>';
}
@@ -104,17 +104,17 @@ final class ActionsPage {
echo '<p>No rewards yet.</p></div>';
return;
}
echo '<table class="widefat striped sg-table"><thead><tr><th>Reward</th><th>Amount</th><th>Triggers</th><th>Applies to</th><th>Order</th><th>Actions</th></tr></thead><tbody>';
echo Admin::tableOpen( array( 'Reward', 'Amount', 'Triggers', 'Applies to', 'Order', 'Actions' ), 'Reward menu' );
foreach ( $actions as $a ) {
$id = (int) $a['id'];
echo '<tr' . ( $a['enabled'] ? '' : ' class="sg-off"' ) . '><td><strong>' . esc_html( $a['title'] ) . '</strong>';
echo '<tr' . ( $a['enabled'] ? '' : ' class="sg-off"' ) . '>' . Admin::td( 'Reward' ) . '<strong>' . esc_html( $a['title'] ) . '</strong>';
if ( '' !== $a['description'] ) {
echo '<br><span class="description">' . esc_html( $a['description'] ) . '</span>';
}
echo '</td><td>' . esc_html( Format::money( $a['amount_minor'], $cur ) ) . '</td>';
echo '<td>' . esc_html( Matcher::EVERY === $a['mode'] ? 'Every time it adds up' : 'Exactly this amount' ) . '</td>';
echo '<td>' . esc_html( 0 === $a['goal_id'] ? 'All goals' : ( $goals[ $a['goal_id'] ] ?? 'Goal #' . $a['goal_id'] ) ) . '</td>';
echo '<td>' . (int) $a['sort'] . '</td><td class="sg-actions">';
echo '</td>' . Admin::td( 'Amount' ) . esc_html( Format::money( $a['amount_minor'], $cur ) ) . '</td>';
echo Admin::td( 'Triggers' ) . esc_html( Matcher::EVERY === $a['mode'] ? 'Every time it adds up' : 'Exactly this amount' ) . '</td>';
echo Admin::td( 'Applies to' ) . esc_html( 0 === $a['goal_id'] ? 'All goals' : ( $goals[ $a['goal_id'] ] ?? 'Goal #' . $a['goal_id'] ) ) . '</td>';
echo Admin::td( 'Order' ) . (int) $a['sort'] . '</td>' . Admin::td( 'Actions', 'sg-actions' );
echo '<a class="button" href="' . esc_url( self::url( array( 'edit' => $id ) ) ) . '">Edit</a> ';
Admin::form( 'sg_action_toggle', array( 'action_id' => $id, 'enabled' => $a['enabled'] ? 0 : 1 ), 'sg-inline' );
echo '<button class="button">' . ( $a['enabled'] ? 'Turn off' : 'Turn on' ) . '</button></form> ';
@@ -122,7 +122,7 @@ final class ActionsPage {
echo '<button class="button button-link-delete" data-confirm="Delete this reward? Rewards already waiting in the queue stay there.">Delete</button></form>';
echo '</td></tr>';
}
echo '</tbody></table></div>';
echo Admin::tableClose() . '</div>';
}
private static function renderForm( int $id ): void {
+27
View File
@@ -175,6 +175,33 @@ final class Admin {
}
}
/**
* Open a table inside a keyboard-focusable scroll region. $headers is a list of column labels (an empty
* label is a visually hidden column). $cards=true turns rows into labelled cards on phones (cells need
* td() labels); false keeps sideways scrolling and shows a swipe hint on phones.
*
* @param string[] $headers
*/
public static function tableOpen( array $headers, string $label, bool $cards = true ): string {
$h = static fn( string $s ): string => htmlspecialchars( $s, ENT_QUOTES, 'UTF-8' );
$out = $cards ? '' : '<p class="sg-swipe-hint">Swipe sideways to see more</p>';
$out .= '<div class="sg-scroll' . ( $cards ? ' sg-scroll-cards' : '' ) . '" tabindex="0" role="region" aria-label="' . $h( $label ) . '">';
$out .= '<table class="widefat striped sg-table' . ( $cards ? ' sg-cards' : '' ) . '"><thead><tr>';
foreach ( $headers as $t ) {
$out .= '' === $t ? '<th><span class="screen-reader-text">Actions</span></th>' : '<th>' . $h( $t ) . '</th>';
}
return $out . '</tr></thead><tbody>';
}
public static function tableClose(): string {
return '</tbody></table></div>';
}
/** Opening <td> carrying the column label that phone cards print before the value. */
public static function td( string $label, string $class = '' ): string {
return '<td' . ( '' !== $class ? ' class="' . htmlspecialchars( $class, ENT_QUOTES, 'UTF-8' ) . '"' : '' ) . ' data-label="' . htmlspecialchars( $label, ENT_QUOTES, 'UTF-8' ) . '">';
}
public static function close(): void {
echo '</div>';
}
+48 -12
View File
@@ -48,6 +48,44 @@ final class AlertsPage {
);
}
/**
* Media column parts for one alert. $existing maps attachment id => true for ids still in the Media Library.
*
* @param array<string,mixed> $d
* @param array<int,bool> $existing
* @return array<int,array{0:string,1:bool}> list of [label, missing]
*/
public static function mediaParts( array $d, array $existing ): array {
$parts = array();
$vid = (int) ( $d['visual_id'] ?? 0 );
if ( $vid > 0 ) {
$video = 'video' === ( $d['visual_type'] ?? '' );
$parts[] = isset( $existing[ $vid ] ) ? array( $video ? 'Video' : 'Picture/GIF', false ) : array( $video ? 'Video missing' : 'Picture missing', true );
}
$sid = (int) ( $d['sound_id'] ?? 0 );
if ( $sid > 0 ) {
$parts[] = isset( $existing[ $sid ] ) ? array( 'Sound', false ) : array( 'Sound missing', true );
}
return $parts;
}
/** One query: which of the alerts' attachments still exist. @return array<int,bool> */
private static function existingAttachments( array $defs ): array {
$ids = array();
foreach ( $defs as $d ) {
foreach ( array( 'visual_id', 'sound_id' ) as $k ) {
if ( (int) ( $d[ $k ] ?? 0 ) > 0 ) {
$ids[ (int) $d[ $k ] ] = true;
}
}
}
if ( ! $ids ) {
return array();
}
$found = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'any', 'post__in' => array_keys( $ids ), 'posts_per_page' => count( $ids ), 'fields' => 'ids', 'orderby' => 'none', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false ) );
return array_fill_keys( array_map( 'intval', $found ), true );
}
/* ---------------------------------------------------------------- render */
public static function render(): void {
@@ -89,24 +127,22 @@ final class AlertsPage {
echo '<p>No custom alerts yet.</p></div>';
return;
}
echo '<table class="widefat striped sg-table"><thead><tr><th>Name</th><th>Plays when</th><th>Applies to</th><th>Media</th><th>Order</th><th>Actions</th></tr></thead><tbody>';
echo Admin::tableOpen( array( 'Name', 'Plays when', 'Applies to', 'Media', 'Order', 'Actions' ), 'Your alerts' );
$existing = self::existingAttachments( $defs );
foreach ( $defs as $d ) {
$id = (int) $d['id'];
$when = 'reward' === $d['trigger_type']
? 'Reward: ' . ( $rewards[ $d['action_id'] ] ?? 'a reward that was deleted' )
: 'Donation of at least ' . Format::money( (int) $d['min_minor'], $cur );
$media = array();
if ( $d['visual_id'] ) {
$media[] = 'video' === $d['visual_type'] ? 'Video' : 'Picture/GIF';
foreach ( self::mediaParts( $d, $existing ) as [ $mLabel, $mMissing ] ) {
$media[] = $mMissing ? '<span class="sga-missing">' . esc_html( $mLabel ) . '</span>' : esc_html( $mLabel );
}
if ( $d['sound_id'] ) {
$media[] = 'Sound';
}
echo '<tr' . ( $d['enabled'] ? '' : ' class="sg-off"' ) . '><td><strong>' . esc_html( $d['title'] ) . '</strong></td>';
echo '<td>' . esc_html( $when ) . '</td>';
echo '<td>' . esc_html( 0 === $d['goal_id'] ? 'All goals' : ( $goals[ $d['goal_id'] ] ?? 'Goal #' . $d['goal_id'] ) ) . '</td>';
echo '<td>' . esc_html( $media ? implode( ', ', $media ) : 'Text only' ) . '</td>';
echo '<td>' . (int) $d['sort'] . '</td><td class="sg-actions">';
echo '<tr' . ( $d['enabled'] ? '' : ' class="sg-off"' ) . '>' . Admin::td( 'Name' ) . '<strong>' . esc_html( $d['title'] ) . '</strong></td>';
echo Admin::td( 'Plays when' ) . esc_html( $when ) . '</td>';
echo Admin::td( 'Applies to' ) . esc_html( 0 === $d['goal_id'] ? 'All goals' : ( $goals[ $d['goal_id'] ] ?? 'Goal #' . $d['goal_id'] ) ) . '</td>';
echo Admin::td( 'Media' ) . ( $media ? implode( ', ', $media ) : 'Text only' ) . '</td>';
echo Admin::td( 'Order' ) . (int) $d['sort'] . '</td>' . Admin::td( 'Actions', 'sg-actions' );
echo '<a class="button" href="' . esc_url( self::url( array( 'edit' => $id ) ) ) . '">Edit</a> ';
Admin::form( 'sg_alert_toggle', array( 'alert_id' => $id, 'enabled' => $d['enabled'] ? 0 : 1 ), 'sg-inline' );
echo '<button class="button">' . ( $d['enabled'] ? 'Turn off' : 'Turn on' ) . '</button></form> ';
@@ -114,7 +150,7 @@ final class AlertsPage {
echo '<button class="button button-link-delete" data-confirm="Delete this alert? Your media files stay in the Media Library.">Delete</button></form>';
echo '</td></tr>';
}
echo '</tbody></table></div>';
echo Admin::tableClose() . '</div>';
}
private static function mediaField( string $name, string $label, string $kind, int $id, string $help ): void {
+12 -12
View File
@@ -44,18 +44,18 @@ final class DonationsPage {
foreach ( Store::goals()->all() as $g ) {
$goals[ (int) $g['id'] ] = (string) $g['title'];
}
echo '<table class="widefat striped sg-table"><thead><tr><th>Date</th><th>Donor</th><th>Amount</th><th>Fee</th><th>Total paid</th><th>Goal</th><th>Status</th><th>Message</th></tr></thead><tbody>';
echo Admin::tableOpen( array( 'Date', 'Donor', 'Amount', 'Fee', 'Total paid', 'Goal', 'Status', 'Message' ), 'Donations', false );
foreach ( $res['rows'] as $r ) {
$cur = Format::currency( $r['currency'] );
$test = Format::isTest( $r );
echo '<tr><td>' . esc_html( self::fmtDate( (string) $r['created_at'] ) ) . '</td>';
echo '<td>' . esc_html( Format::donorName( $r ) ) . ( $test ? ' <span class="sg-badge sg-test">TEST</span>' : '' ) . '</td>';
echo '<td>' . esc_html( Format::money( (int) $r['base_minor'], $cur ) ) . '</td><td>' . esc_html( Format::money( (int) $r['fee_minor'], $cur ) ) . '</td><td>' . esc_html( Format::money( (int) $r['total_minor'], $cur ) ) . '</td>';
echo '<td>' . esc_html( $test ? '(test)' : ( $goals[ (int) $r['goal_id'] ] ?? '#' . (int) $r['goal_id'] ) ) . '</td>';
echo '<td>' . esc_html( ucfirst( (string) $r['status'] ) ) . ( 'approved' === $r['moderation'] ? '' : ' <span class="sg-badge sg-' . esc_attr( (string) $r['moderation'] ) . '">message ' . esc_html( (string) $r['moderation'] ) . '</span>' ) . '</td>';
echo '<td>' . esc_html( (string) $r['message'] ) . '</td></tr>';
echo '<tr>' . Admin::td( 'Date' ) . esc_html( self::fmtDate( (string) $r['created_at'] ) ) . '</td>';
echo Admin::td( 'Donor' ) . esc_html( Format::donorName( $r ) ) . ( $test ? ' <span class="sg-badge sg-test">TEST</span>' : '' ) . '</td>';
echo Admin::td( 'Amount' ) . esc_html( Format::money( (int) $r['base_minor'], $cur ) ) . '</td>' . Admin::td( 'Fee' ) . esc_html( Format::money( (int) $r['fee_minor'], $cur ) ) . '</td>' . Admin::td( 'Total paid' ) . esc_html( Format::money( (int) $r['total_minor'], $cur ) ) . '</td>';
echo Admin::td( 'Goal' ) . esc_html( $test ? '(test)' : ( $goals[ (int) $r['goal_id'] ] ?? '#' . (int) $r['goal_id'] ) ) . '</td>';
echo Admin::td( 'Status' ) . esc_html( ucfirst( (string) $r['status'] ) ) . ( 'approved' === $r['moderation'] ? '' : ' <span class="sg-badge sg-' . esc_attr( (string) $r['moderation'] ) . '">message ' . esc_html( (string) $r['moderation'] ) . '</span>' ) . '</td>';
echo Admin::td( 'Message' ) . esc_html( (string) $r['message'] ) . '</td></tr>';
}
echo '</tbody></table>';
echo Admin::tableClose();
$pages = (int) ceil( $res['total'] / self::PER_PAGE );
if ( $pages > 1 ) {
echo '<div class="tablenav"><div class="tablenav-pages">' . wp_kses_post( (string) paginate_links( array(
@@ -83,18 +83,18 @@ final class DonationsPage {
Admin::close();
return;
}
echo '<table class="widefat striped sg-table"><thead><tr><th>Date</th><th>Donor</th><th>Amount</th><th>Message</th><th>Decision</th></tr></thead><tbody>';
echo Admin::tableOpen( array( 'Date', 'Donor', 'Amount', 'Message', 'Decision' ), 'Messages waiting for review' );
foreach ( $res['rows'] as $r ) {
$cur = Format::currency( $r['currency'] );
echo '<tr><td>' . esc_html( self::fmtDate( (string) $r['created_at'] ) ) . '</td><td>' . esc_html( Format::donorName( $r ) ) . '</td>';
echo '<td>' . esc_html( Format::money( (int) $r['base_minor'], $cur ) ) . '</td><td>' . esc_html( (string) $r['message'] ) . '</td><td class="sg-actions">';
echo '<tr>' . Admin::td( 'Date' ) . esc_html( self::fmtDate( (string) $r['created_at'] ) ) . '</td>' . Admin::td( 'Donor' ) . esc_html( Format::donorName( $r ) ) . '</td>';
echo Admin::td( 'Amount' ) . esc_html( Format::money( (int) $r['base_minor'], $cur ) ) . '</td>' . Admin::td( 'Message' ) . esc_html( (string) $r['message'] ) . '</td>' . Admin::td( 'Decision', 'sg-actions' );
foreach ( array( 'approved' => 'Approve', 'rejected' => 'Reject' ) as $to => $lbl ) {
Admin::form( 'sg_moderate', array( 'donation_id' => (int) $r['id'], 'decision' => $to ), 'sg-inline' );
echo '<button class="button' . ( 'approved' === $to ? ' button-primary' : '' ) . '">' . esc_html( $lbl ) . '</button></form> ';
}
echo '</td></tr>';
}
echo '</tbody></table>';
echo Admin::tableClose();
Admin::close();
}
+15 -15
View File
@@ -40,17 +40,17 @@ final class GoalsPage {
Admin::close();
return;
}
echo '<table class="widefat striped sg-table"><thead><tr><th>Goal</th><th>Status</th><th>Raised</th><th>Dates</th><th>Actions</th></tr></thead><tbody>';
echo Admin::tableOpen( array( 'Goal', 'Status', 'Raised', 'Dates', 'Actions' ), 'Goals' );
foreach ( $goals as $g ) {
$gid = (int) $g['id'];
$cur = Format::currency( $g['currency'] );
$raised = Store::goals()->raised_minor( $gid );
$target = (int) $g['target_minor'];
$pct = $target > 0 ? min( 100, intdiv( $raised * 100, $target ) ) : 0;
echo '<tr><td><strong>' . esc_html( (string) $g['title'] ) . '</strong><br><span class="description">ID ' . $gid . ' &middot; <code>[streamgoals_donate goal="' . $gid . '"]</code></span></td>';
echo '<td><span class="sg-badge sg-' . esc_attr( (string) $g['status'] ) . '">' . esc_html( self::STATUSES[ $g['status'] ] ?? (string) $g['status'] ) . '</span></td>';
echo '<td>' . esc_html( Format::money( $raised, $cur ) . ' of ' . Format::money( $target, $cur ) ) . ' (' . (int) $pct . '%)<div class="sg-bar"><span style="width:' . (int) $pct . '%"></span></div>' . ( ! empty( $g['close_at_target'] ) ? '<span class="description">Closes at target</span>' : '' ) . '</td>';
echo '<td>' . esc_html( self::dateLabel( $g['starts_at'] ?? null, 'Starts' ) ) . '<br>' . esc_html( self::dateLabel( $g['ends_at'] ?? null, 'Ends' ) ) . '</td><td class="sg-actions">';
echo '<tr>' . Admin::td( 'Goal' ) . '<strong>' . esc_html( (string) $g['title'] ) . '</strong><br><span class="description">ID ' . $gid . ' &middot; <code>[streamgoals_donate goal="' . $gid . '"]</code></span></td>';
echo Admin::td( 'Status' ) . '<span class="sg-badge sg-' . esc_attr( (string) $g['status'] ) . '">' . esc_html( self::STATUSES[ $g['status'] ] ?? (string) $g['status'] ) . '</span></td>';
echo Admin::td( 'Raised' ) . esc_html( Format::money( $raised, $cur ) . ' of ' . Format::money( $target, $cur ) ) . ' (' . (int) $pct . '%)<div class="sg-bar"><span style="width:' . (int) $pct . '%"></span></div>' . ( ! empty( $g['close_at_target'] ) ? '<span class="description">Closes at target</span>' : '' ) . '</td>';
echo Admin::td( 'Dates' ) . esc_html( self::dateLabel( $g['starts_at'] ?? null, 'Starts' ) ) . '<br>' . esc_html( self::dateLabel( $g['ends_at'] ?? null, 'Ends' ) ) . '</td>' . Admin::td( 'Actions', 'sg-actions' );
echo '<a class="button" href="' . esc_url( Admin::url( Admin::HOME, array( 'view' => 'edit', 'goal' => $gid ) ) ) . '">Edit</a> ';
echo '<a class="button" href="' . esc_url( Admin::url( Admin::HOME, array( 'view' => 'rules', 'goal' => $gid ) ) ) . '">Rules</a> ';
$nt = $tierCounts[ $gid ] ?? 0;
@@ -69,7 +69,7 @@ final class GoalsPage {
echo '<button class="button button-link-delete" data-confirm="Delete this goal and its rules? This cannot be undone.">Delete</button></form>';
echo '</td></tr>';
}
echo '</tbody></table>';
echo Admin::tableClose();
Admin::close();
}
@@ -216,22 +216,22 @@ final class GoalsPage {
if ( ! $tiers ) {
echo '<p>No tiers yet. This goal works as a single target until you add one.</p>';
} else {
echo '<table class="widefat striped sg-table"><thead><tr><th>#</th><th>Unlock</th><th>Target</th><th>Status</th><th>Actions</th></tr></thead><tbody>';
echo Admin::tableOpen( array( '#', 'Unlock', 'Target', 'Status', 'Actions' ), 'Tiers' );
foreach ( TierPolicy::sorted( $tiers ) as $i => $t ) {
$tid = (int) $t['id'];
$status = TierPolicy::status( $tiers, $t );
$label = 'reached' === $status ? 'Reached on ' . get_date_from_gmt( (string) $t['reached_at'], 'Y-m-d H:i' ) : ( 'current' === $status ? 'Current' : 'Upcoming' );
echo '<tr><td>' . (int) ( $i + 1 ) . '</td><td><strong>' . esc_html( $t['title'] ) . '</strong>';
echo '<tr>' . Admin::td( '#' ) . (int) ( $i + 1 ) . '</td>' . Admin::td( 'Unlock' ) . '<strong>' . esc_html( $t['title'] ) . '</strong>';
if ( '' !== $t['description'] ) {
echo '<br><span class="description">' . esc_html( $t['description'] ) . '</span>';
}
echo '</td><td>' . esc_html( Format::money( (int) $t['target_minor'], $cur ) ) . '</td>';
echo '<td><span class="sg-badge sg-' . ( 'reached' === $status ? 'complete' : ( 'current' === $status ? 'active' : 'paused' ) ) . '">' . esc_html( $label ) . '</span></td><td class="sg-actions">';
echo '</td>' . Admin::td( 'Target' ) . esc_html( Format::money( (int) $t['target_minor'], $cur ) ) . '</td>';
echo Admin::td( 'Status' ) . '<span class="sg-badge sg-' . ( 'reached' === $status ? 'complete' : ( 'current' === $status ? 'active' : 'paused' ) ) . '">' . esc_html( $label ) . '</span></td>' . Admin::td( 'Actions', 'sg-actions' );
echo '<a class="button" href="' . esc_url( self::tiersUrl( $goalId, array( 'tier' => $tid ) ) ) . '#sg-tier-form">Edit</a> ';
Admin::form( 'sg_tier_delete', array( 'goal_id' => $goalId, 'tier_id' => $tid ), 'sg-inline sg-confirm' );
echo '<button class="button button-link-delete" data-confirm="Delete this tier?">Delete</button></form></td></tr>';
}
echo '</tbody></table>';
echo Admin::tableClose();
}
echo '</div>';
@@ -331,24 +331,24 @@ final class GoalsPage {
if ( ! $rules ) {
echo '<p>No rules yet. The goal bar works without any rules.</p>';
} else {
echo '<table class="widefat striped sg-table"><thead><tr><th>Rule</th><th>What it does</th><th>Actions</th></tr></thead><tbody>';
echo Admin::tableOpen( array( 'Rule', 'What it does', 'Actions' ), 'Rules' );
foreach ( $rules as $r ) {
$rid = (int) $r['id'];
echo '<tr class="' . ( $r['enabled'] ? '' : 'sg-off' ) . '"><td>' . esc_html( RuleConfig::TYPES[ $r['type'] ] ?? (string) $r['type'] ) . ( $r['enabled'] ? '' : ' <em>(off)</em>' ) . '</td>';
echo '<tr class="' . ( $r['enabled'] ? '' : 'sg-off' ) . '">' . Admin::td( 'Rule' ) . esc_html( RuleConfig::TYPES[ $r['type'] ] ?? (string) $r['type'] ) . ( $r['enabled'] ? '' : ' <em>(off)</em>' ) . '</td>';
try {
$summary = RuleConfig::summary( (string) $r['type'], $r['config'], $cur );
} catch ( \Throwable $e ) {
// One unreadable rule must not take down the whole screen; say what is wrong so it can be fixed or deleted.
$summary = 'Could not read this rule (' . $e->getMessage() . '). You can delete it and add it again.';
}
echo '<td>' . esc_html( $summary ) . '</td><td class="sg-actions">';
echo Admin::td( 'What it does' ) . esc_html( $summary ) . '</td>' . Admin::td( 'Actions', 'sg-actions' );
echo '<a class="button" href="' . esc_url( Admin::url( Admin::HOME, array( 'view' => 'rules', 'goal' => $goalId, 'rule' => $rid ) ) ) . '#sg-rule-form">Edit</a> ';
Admin::form( 'sg_rule_toggle', array( 'goal_id' => $goalId, 'rule_id' => $rid, 'enabled' => $r['enabled'] ? 0 : 1 ), 'sg-inline' );
echo '<button class="button">' . ( $r['enabled'] ? 'Turn off' : 'Turn on' ) . '</button></form> ';
Admin::form( 'sg_rule_delete', array( 'goal_id' => $goalId, 'rule_id' => $rid ), 'sg-inline sg-confirm' );
echo '<button class="button button-link-delete" data-confirm="Delete this rule?">Delete</button></form></td></tr>';
}
echo '</tbody></table>';
echo Admin::tableClose();
}
echo '</div>';
+40
View File
@@ -0,0 +1,40 @@
<?php
declare( strict_types=1 );
namespace StreamGoals\Tests\Unit;
use PHPUnit\Framework\TestCase;
use StreamGoals\Admin\Admin;
use StreamGoals\Admin\AlertsPage;
final class AdminTableAlertMediaTest extends TestCase {
public function test_media_parts_present_and_missing(): void {
$d = array( 'visual_id' => 5, 'visual_type' => 'image', 'sound_id' => 6 );
$this->assertSame( array( array( 'Picture/GIF', false ), array( 'Sound', false ) ), AlertsPage::mediaParts( $d, array( 5 => true, 6 => true ) ) );
$this->assertSame( array( array( 'Picture missing', true ), array( 'Sound', false ) ), AlertsPage::mediaParts( $d, array( 6 => true ) ) );
$this->assertSame( array( array( 'Picture missing', true ), array( 'Sound missing', true ) ), AlertsPage::mediaParts( $d, array() ) );
}
public function test_media_parts_video_and_text_only(): void {
$this->assertSame( array( array( 'Video missing', true ) ), AlertsPage::mediaParts( array( 'visual_id' => 9, 'visual_type' => 'video', 'sound_id' => 0 ), array() ) );
$this->assertSame( array( array( 'Video', false ) ), AlertsPage::mediaParts( array( 'visual_id' => 9, 'visual_type' => 'video', 'sound_id' => 0 ), array( 9 => true ) ) );
$this->assertSame( array(), AlertsPage::mediaParts( array( 'visual_id' => 0, 'sound_id' => 0 ), array() ) );
}
public function test_table_open_is_focusable_region_with_hint_only_when_scrolling(): void {
$cards = Admin::tableOpen( array( 'Goal', '' ), 'Goals "x"' );
$this->assertStringContainsString( 'tabindex="0" role="region" aria-label="Goals &quot;x&quot;"', $cards );
$this->assertStringContainsString( 'sg-cards', $cards );
$this->assertStringNotContainsString( 'Swipe sideways', $cards );
$this->assertStringContainsString( 'screen-reader-text', $cards );
$scroll = Admin::tableOpen( array( 'A' ), 'Donations', false );
$this->assertStringContainsString( 'Swipe sideways to see more', $scroll );
$this->assertStringNotContainsString( 'sg-cards', $scroll );
$this->assertSame( '</tbody></table></div>', Admin::tableClose() );
}
public function test_td_escapes_label(): void {
$this->assertSame( '<td class="sg-actions" data-label="A&amp;&quot;">', Admin::td( 'A&"', 'sg-actions' ) );
}
}