*/ private const PER_PAGE_OPTIONS = array( 10, 20, 50 ); /** * §12.4 list use case. */ private ListAuditEventsUseCase $listUseCase; /** * §12.5 summary use case. */ private GetAuditEventsSummaryUseCase $summaryUseCase; /** * Construct the renderer with explicit collaborator injection. * * @param ListAuditEventsUseCase $listUseCase §12.4 list use case. * @param GetAuditEventsSummaryUseCase $summaryUseCase §12.5 summary use case. */ public function __construct( ListAuditEventsUseCase $listUseCase, GetAuditEventsSummaryUseCase $summaryUseCase ) { $this->listUseCase = $listUseCase; $this->summaryUseCase = $summaryUseCase; } /** * Echo the §10 Audit Events page HTML to stdout. * * Drives the §12.5 summary use case for the headline card, * then the §12.4 list use case for the paginated results * table. A summary failure is swallowed so the list half * still renders; a list failure (typically a driver / schema * problem) bubbles out to the caller because there is no * meaningful page to draw without it. * * @param AuditEventListQuery $query Already-validated list query. */ public function render( AuditEventListQuery $query ): void { echo '
'; echo '

' . esc_html( '監査イベント' ) . '

'; $this->renderEmbedded( $query, self::MENU_SLUG, '' ); echo '
'; } /** * Echo audit-event content without the outer admin-page wrapper. * * @param AuditEventListQuery $query Current query. * @param string $menuSlug Admin page slug for embedded forms. * @param string $anchor Optional URL fragment to preserve tab focus. */ public function renderEmbedded( AuditEventListQuery $query, string $menuSlug = self::MENU_SLUG, string $anchor = '' ): void { $this->renderSummary(); $this->renderFilterForm( $query, $menuSlug, $anchor ); $result = $this->listUseCase->execute( $query ); if ( $result->items() === array() ) { echo '

' . esc_html( '条件に一致する監査イベントはありません。' ) . '

'; return; } echo ''; $this->renderTableHead(); echo ''; foreach ( $result->items() as $row ) { $this->renderRow( $row ); } echo ''; echo '
'; $this->renderPagination( $query, $result->pageCount(), $result->total(), $menuSlug, $anchor ); } /** * Render the §10 summary card: a small `` listing each * §10.2 audit-kind → row count plus a headline total row. * * Failures from the summary use case are caught silently so * the filter / table half of the page still renders. The list * use case is the canonical failure surface for the §10 page. */ private function renderSummary(): void { echo '

' . esc_html( '概要' ) . '

'; try { $summary = $this->summaryUseCase->execute(); } catch ( \Throwable $e ) { unset( $e ); echo '

' . esc_html( '概要を一時的に取得できません。' ) . '

'; return; } $byKind = $summary->byKind(); $total = $summary->total(); if ( $total === 0 ) { echo '

' . esc_html( 'まだ監査イベントは記録されていません。' ) . '

'; return; } echo '
'; echo ''; foreach ( array( '監査種別', '件数' ) as $heading ) { echo ''; } echo ''; echo ''; foreach ( $byKind as $kind => $count ) { echo ''; echo ''; echo ''; echo ''; } echo ''; echo ''; echo ''; echo ''; echo '
' . esc_html( $heading ) . '
' . esc_html( (string) $kind ) . '' . esc_html( (string) (int) $count ) . '
' . esc_html( '合計' ) . '' . esc_html( (string) $total ) . '
'; } /** * Render the §10 filter form. * * The form submits via GET back to the same admin page so the * parsed query state survives a page reload; the implicit * `page=affilun-audit` hidden field keeps the WordPress admin * router pointing at this page across the round-trip. * * Seven filter axes are exposed: the four first-class column * filters (`audit_kind` / `actor_id` / `target_type` / * `target_id`) plus the three Phase 2 KI-006 follow-up * context filters (`request_id` / `correlation_id` / * `job_id`). The persistence layer honors all seven * uniformly through {@see AuditEventListQuery}. * * @param AuditEventListQuery $query Current query (drives selected option state). * @param string $menuSlug Admin page slug for the form round-trip. * @param string $anchor Optional URL fragment to preserve tab focus. */ private function renderFilterForm( AuditEventListQuery $query, string $menuSlug = self::MENU_SLUG, string $anchor = '' ): void { echo '
'; echo ''; if ( $query->perPage() !== AuditEventListQuery::DEFAULT_PER_PAGE ) { echo ''; } echo ''; echo '
'; } /** * Render the §10 results table ``. */ private function renderTableHead(): void { echo ''; foreach ( array( 'ID', '作成日時', '監査種別', 'イベント種別', 'アクション', '結果', 'アクター', '対象', '概要', 'リクエスト ID', '相関 ID', 'ジョブ ID', ) as $heading ) { echo '' . esc_html( $heading ) . ''; } echo ''; } /** * Render a single projected row. * * The row shape matches the §12.4 envelope row produced by * {@see \Affilun\Application\Audit\UseCase\ListAuditEventsUseCase::projectRow()}: * `actor` and `target` are `{ type, id }` sub-objects (or * null), and the three context-lifted ids are top-level * string-or-null slots. * * @param array $row Projected row from the use case. */ private function renderRow( array $row ): void { $id = $row['id'] ?? null; $createdAt = $row['created_at'] ?? ''; $auditKind = $row['audit_kind'] ?? ''; $eventType = $row['audit_event_type'] ?? ''; $action = $row['action'] ?? ''; $result = $row['result'] ?? ''; $actor = $row['actor'] ?? null; $target = $row['target'] ?? null; $safeSummary = $row['safe_summary'] ?? ''; $requestId = $row['request_id'] ?? null; $correlationId = $row['correlation_id'] ?? null; $jobId = $row['job_id'] ?? null; echo ''; echo '' . esc_html( $id === null ? '' : (string) $id ) . ''; echo '' . esc_html( AdminTimeFormatter::format( (string) $createdAt ) ) . ''; echo '' . esc_html( (string) $auditKind ) . ''; echo '' . esc_html( (string) $eventType ) . ''; echo '' . esc_html( (string) $action ) . ''; echo '' . esc_html( (string) $result ) . ''; echo '' . esc_html( self::formatActorOrTarget( $actor ) ) . ''; echo '' . esc_html( self::formatActorOrTarget( $target ) ) . ''; echo '' . esc_html( (string) $safeSummary ) . ''; echo '' . esc_html( $requestId === null ? '' : (string) $requestId ) . ''; echo '' . esc_html( $correlationId === null ? '' : (string) $correlationId ) . ''; echo '' . esc_html( $jobId === null ? '' : (string) $jobId ) . ''; echo ''; } /** * Format an `actor` / `target` `{ type, id }` sub-object (or * null) into a single human-readable cell value. * * Layout: * * - `null` → empty string. * - `{ type: 'user', id: '42' }` → `"user:42"`. * - `{ type: 'system', id: null }` → `"system"`. * - `{ type: null, id: '42' }` → `"42"` (defensive, should * not happen because the §12.4 projection collapses both- * null to null in the first place). * * @param mixed $value Raw `actor` / `target` slot from the * projected row. */ private static function formatActorOrTarget( $value ): string { if ( ! is_array( $value ) ) { return ''; } $type = isset( $value['type'] ) && is_string( $value['type'] ) ? $value['type'] : null; $id = isset( $value['id'] ) && is_string( $value['id'] ) ? $value['id'] : null; if ( $type === null && $id === null ) { return ''; } if ( $type === null ) { return (string) $id; } if ( $id === null ) { return $type; } return $type . ':' . $id; } /** * Render the textual `Page X of Y` pagination nav with prev / * next anchors that preserve the current filter state. * * @param AuditEventListQuery $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( AuditEventListQuery $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 for audit events. * * @param AuditEventListQuery $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( AuditEventListQuery $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. * * 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. * * Every non-default filter axis is round-tripped through the * URL so the prev / next links observe the same filter set * the operator just chose; omitting any axis would silently * widen the result set across a page transition. * * @param int $page Target page (>= 1). * @param AuditEventListQuery $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, AuditEventListQuery $query, string $menuSlug = self::MENU_SLUG, string $anchor = '' ): string { $args = array( 'page' => $menuSlug, AuditListQueryParser::KEY_PAGE => $page, ); if ( $query->auditKind() !== null ) { $args[ AuditListQueryParser::KEY_AUDIT_KIND ] = $query->auditKind(); } if ( $query->actorId() !== null ) { $args[ AuditListQueryParser::KEY_ACTOR_ID ] = $query->actorId(); } if ( $query->targetType() !== null ) { $args[ AuditListQueryParser::KEY_TARGET_TYPE ] = $query->targetType(); } if ( $query->targetId() !== null ) { $args[ AuditListQueryParser::KEY_TARGET_ID ] = $query->targetId(); } if ( $query->requestId() !== null ) { $args[ AuditListQueryParser::KEY_REQUEST_ID ] = $query->requestId(); } if ( $query->correlationId() !== null ) { $args[ AuditListQueryParser::KEY_CORRELATION_ID ] = $query->correlationId(); } if ( $query->jobId() !== null ) { $args[ AuditListQueryParser::KEY_JOB_ID ] = $query->jobId(); } if ( $query->perPage() !== AuditEventListQuery::DEFAULT_PER_PAGE ) { $args[ AuditListQueryParser::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, '/' ); } }