` also * speaks wire vocabulary; {@see SyncJobsListQueryParser} performs * the inverse `queued` → `pending` translation when constructing the * query VO. * * Escaping invariants: * * - Every visible text emission flows through `esc_html()`. * - Every attribute value flows through `esc_attr()`. * - Every URL flows through `esc_url()`. * * @package Affilun\Presentation\Admin\Page */ declare(strict_types=1); // phpcs:disable Generic.Files.LineLength.TooLong -- Table-cell render expressions are clearer kept as one escaped echo per column. namespace Affilun\Presentation\Admin\Page; use Affilun\Application\SyncJobs\UseCase\ListSyncJobsUseCase; use Affilun\Infrastructure\Persistence\SyncJobListQuery; use Affilun\Presentation\Admin\AdminTimeFormatter; /** * Renders the §6 Sync Jobs admin page to stdout. * * See the file-level docblock for the page layout (filter form, * results table, pagination), the wire ⇔ DB status posture, and the * escaping invariants. */ final class SyncJobsPageRenderer { /** * Admin menu slug the filter form / pagination links point at. */ private const MENU_SLUG = 'affilun-sync-jobs'; /** * Page-size choices exposed on admin tables. * * @var list */ private const PER_PAGE_OPTIONS = array( 10, 20, 50 ); /** * Wire-vocabulary statuses surfaced in the filter `'; if ( $query->perPage() !== SyncJobListQuery::DEFAULT_PER_PAGE ) { echo ''; } echo ''; echo ''; } /** * Render the §6 results table ``. */ private function renderTableHead(): void { echo ''; foreach ( array( 'ID', 'ジョブ種別', 'ステータス', '優先度', '試行回数', '作成日時', '実行可能時刻', '完了時刻', '最終エラー', ) as $heading ) { echo '' . esc_html( $heading ) . ''; } echo ''; } /** * Render a single row. * * @param array $row Wire-shape row from the use case. */ private function renderRow( array $row ): void { $id = $row['id'] ?? null; $jobType = $row['job_type'] ?? ''; $status = $row['status'] ?? ''; $priority = $row['priority'] ?? null; $attempts = $row['attempts'] ?? 0; $createdAt = $row['created_at'] ?? null; $availableAt = $row['available_at'] ?? null; $finishedAt = $row['finished_at'] ?? null; $lastError = $row['last_error'] ?? null; echo ''; echo '' . esc_html( $id === null ? '' : (string) $id ) . ''; echo '' . esc_html( (string) $jobType ) . ''; echo '' . esc_html( (string) $status ) . ''; echo '' . esc_html( $priority === null ? '' : (string) (int) $priority ) . ''; echo '' . esc_html( (string) (int) $attempts ) . ''; echo '' . esc_html( AdminTimeFormatter::format( $createdAt === null ? null : (string) $createdAt ) ) . ''; echo '' . esc_html( AdminTimeFormatter::format( $availableAt === null ? null : (string) $availableAt ) ) . ''; echo '' . esc_html( AdminTimeFormatter::format( $finishedAt === null ? null : (string) $finishedAt ) ) . ''; echo '' . esc_html( $lastError === null ? '' : (string) $lastError ) . ''; echo ''; } /** * Render the textual `Page X of Y` pagination nav with prev / * next anchors that preserve the current filter state. * * @param SyncJobListQuery $query Originating query. * @param int $totalPages Total page count. * @param int $totalItems Total item count. * @param string $menuSlug Admin page slug for generated links. * @param string $anchor Optional URL fragment to preserve tab focus. */ private function renderPagination( SyncJobListQuery $query, int $totalPages, int $totalItems, string $menuSlug = self::MENU_SLUG, string $anchor = '' ): void { $current = $query->page(); echo '
'; echo ''; echo esc_html( sprintf( '%d件', $totalItems ) ); echo ''; $this->renderPerPageLinks( $query, $menuSlug, $anchor ); if ( $totalPages > 1 ) { echo ' '; if ( $current > 1 ) { $prevUrl = self::buildPageUrl( $current - 1, $query, $menuSlug, $anchor ); echo '' . esc_html( '« 前へ' ) . ' '; } echo '' . esc_html( sprintf( '%d / %d', $current, max( 1, $totalPages ) ) ) . ' '; if ( $current < $totalPages ) { $nextUrl = self::buildPageUrl( $current + 1, $query, $menuSlug, $anchor ); echo '' . esc_html( '次へ »' ) . ''; } echo ''; } else { echo ' ' . esc_html( sprintf( '%d / %d', $current, max( 1, $totalPages ) ) ) . ''; } echo '
'; } /** * Render 10/20/50 page-size links. * * @param SyncJobListQuery $query Current query. * @param string $menuSlug Admin page slug for generated links. * @param string $anchor Optional URL fragment to preserve tab focus. */ private function renderPerPageLinks( SyncJobListQuery $query, string $menuSlug, string $anchor ): void { echo ' '; echo esc_html( '表示件数: ' ); $first = true; foreach ( self::PER_PAGE_OPTIONS as $option ) { if ( ! $first ) { echo ' / '; } if ( $option === $query->perPage() ) { echo '' . esc_html( sprintf( '%d件', $option ) ) . ''; } else { echo '' . esc_html( sprintf( '%d件', $option ) ) . ''; } $first = false; } echo ''; } /** * Build the per-page URL preserving the current filter state. * * The `status` axis is rewritten back to wire vocabulary * (`pending` → `queued`) before being emitted into the URL so * the round-trip reaches {@see SyncJobsListQueryParser} in the * same wire shape the original filter form submitted. * * Prefers `add_query_arg()` (the WordPress helper handles * existing query-string fragments correctly) and falls back to * manual `http_build_query()` concatenation in pure-unit * harnesses where WordPress is not loaded. * * @param int $page Target page (>= 1). * @param SyncJobListQuery $query Current query state. * @param string $menuSlug Admin page slug for generated links. * @param string $anchor Optional URL fragment to preserve tab focus. */ private static function buildPageUrl( int $page, SyncJobListQuery $query, string $menuSlug = self::MENU_SLUG, string $anchor = '' ): string { $args = array( 'page' => $menuSlug, SyncJobsListQueryParser::KEY_PAGE => $page, ); $status = $query->status(); if ( $status !== null ) { if ( $status === 'pending' ) { $status = 'queued'; } $args[ SyncJobsListQueryParser::KEY_STATUS ] = $status; } if ( $query->jobType() !== null ) { $args[ SyncJobsListQueryParser::KEY_JOB_TYPE ] = $query->jobType(); } if ( $query->perPage() !== SyncJobListQuery::DEFAULT_PER_PAGE ) { $args[ SyncJobsListQueryParser::KEY_PER_PAGE ] = $query->perPage(); } $base = self::resolveAdminUrl( 'admin.php' ); if ( function_exists( 'add_query_arg' ) ) { return (string) add_query_arg( $args, $base ) . $anchor; } $separator = str_contains( $base, '?' ) ? '&' : '?'; return $base . $separator . http_build_query( $args ) . $anchor; } /** * Resolve a form action URL for embedded filter forms. * * @param string $anchor Optional URL fragment to preserve tab focus. */ private static function formActionUrl( string $anchor ): string { return $anchor === '' ? '' : self::resolveAdminUrl( 'admin.php' ) . $anchor; } /** * Resolve an admin URL via `admin_url()` when available, * falling back to a synthetic absolute URL in pure-unit * harnesses where WordPress is not loaded so `esc_url()` does * not strip the value. * * @param string $path Admin path (e.g. `'admin.php'`). */ private static function resolveAdminUrl( string $path ): string { if ( function_exists( 'admin_url' ) ) { return (string) admin_url( $path ); } return 'http://example.test/wp-admin/' . ltrim( $path, '/' ); } }