diff --git a/includes/class-action-scheduler.php b/includes/class-action-scheduler.php index 9d831af5a4..66390c7357 100644 --- a/includes/class-action-scheduler.php +++ b/includes/class-action-scheduler.php @@ -405,4 +405,66 @@ public static function get_hooks() { return $hooks; } + + /** + * Fetch a single ActionScheduler action by ID. + * + * Returns null when AS resolves the ID to a NullAction (action does not exist). + * + * @param int $action_id The action ID. + * + * @return \ActionScheduler_Action|null + */ + public static function get_action( $action_id ): ?\ActionScheduler_Action { + if ( ! self::is_available() ) { + return null; + } + $action = \ActionScheduler_Store::instance()->fetch_action( (int) $action_id ); + if ( ! $action || $action instanceof \ActionScheduler_NullAction ) { + return null; + } + return $action; + } + + /** + * Fetch per-action log entries for a single ActionScheduler action. + * + * Each entry is normalized to: + * [ 'date_gmt' => string, 'message' => string ] + * + * NullLogEntry sentinels are filtered out. Entries are sorted ascending + * by date. ActionScheduler_LogEntry does not expose the log-row PK, so + * consumers use the array index as a stable key. + * + * @param int $action_id The action ID. + * + * @return array + */ + public static function get_action_logs( $action_id ): array { + if ( ! self::is_available() || ! class_exists( '\ActionScheduler_Logger' ) ) { + return []; + } + $entries = \ActionScheduler_Logger::instance()->get_logs( (int) $action_id ); + if ( empty( $entries ) ) { + return []; + } + $normalized = []; + foreach ( $entries as $entry ) { + if ( $entry instanceof \ActionScheduler_NullLogEntry ) { + continue; + } + $date = $entry->get_date(); + $normalized[] = [ + 'date_gmt' => $date ? $date->format( 'Y-m-d\TH:i:s' ) : '', + 'message' => (string) $entry->get_message(), + ]; + } + usort( + $normalized, + static function ( $a, $b ) { + return strcmp( $a['date_gmt'], $b['date_gmt'] ); + } + ); + return $normalized; + } } diff --git a/includes/reader-activation/class-integrations.php b/includes/reader-activation/class-integrations.php index 60ad43d1d4..5fdac2e311 100644 --- a/includes/reader-activation/class-integrations.php +++ b/includes/reader-activation/class-integrations.php @@ -169,6 +169,30 @@ public static function get_all_action_groups() { return \Newspack\Action_Scheduler::get_groups_by_prefix( \Newspack\Action_Scheduler::GROUP_PREFIX . 'integration-' ); } + /** + * Get the AS action for the given ID if it belongs to the given integration. + * + * Combines existence + group-ownership checks so callers can do both in a + * single DB read. Returns null for missing actions and for actions in other + * groups — both cases the REST endpoints translate to a generic 404 so + * other-group actions can't be probed. + * + * @param int $action_id The AS action ID. + * @param string $integration_id The integration identifier. + * + * @return \ActionScheduler_Action|null + */ + public static function get_integration_action( $action_id, $integration_id ) { + $action = \Newspack\Action_Scheduler::get_action( (int) $action_id ); + if ( ! $action ) { + return null; + } + if ( $action->get_group() !== self::get_action_group( $integration_id ) ) { + return null; + } + return $action; + } + /** * Get ActionScheduler actions for Newspack integrations. * diff --git a/includes/wizards/audience/class-audience-integrations.php b/includes/wizards/audience/class-audience-integrations.php index 5c637ad3f6..b86026e71e 100644 --- a/includes/wizards/audience/class-audience-integrations.php +++ b/includes/wizards/audience/class-audience-integrations.php @@ -174,7 +174,47 @@ public function register_api_endpoints() { 'status' => [ 'type' => 'string', 'default' => '', - 'enum' => [ '', 'pending', 'complete', 'failed', 'canceled' ], + 'enum' => [ '', 'pending', 'in-progress', 'complete', 'failed', 'canceled' ], + ], + ], + ] + ); + + register_rest_route( + NEWSPACK_API_NAMESPACE, + '/wizard/' . $this->slug . '/settings/(?P[a-zA-Z0-9_-]+)/logs/(?P[0-9]+)', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'api_get_integration_log_detail' ], + 'permission_callback' => [ $this, 'api_permissions_check' ], + 'args' => [ + 'integration_id' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + ], + 'action_id' => [ + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + ], + ] + ); + + register_rest_route( + NEWSPACK_API_NAMESPACE, + '/wizard/' . $this->slug . '/settings/(?P[a-zA-Z0-9_-]+)/logs/(?P[0-9]+)/run', + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'api_run_integration_action' ], + 'permission_callback' => [ $this, 'api_permissions_check' ], + 'args' => [ + 'integration_id' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + ], + 'action_id' => [ + 'type' => 'integer', + 'sanitize_callback' => 'absint', ], ], ] @@ -301,13 +341,30 @@ public function api_get_integration_logs( WP_REST_Request $request ) { $total = Integrations::count_scheduled_actions( $count_args ); $hook_labels = Action_Scheduler::get_hook_labels(); + // Decode payloads once, then prime the user cache in a single query so + // the per-row email resolution below doesn't issue a query per action. + $decoded_args = []; + $user_ids = []; + foreach ( $actions as $action ) { + $args = self::decode_action_args( $action->args ?? '', $action->extended_args ?? '' ); + $decoded_args[ $action->action_id ] = $args; + $user_id = self::get_payload_user_id( $args ); + if ( $user_id ) { + $user_ids[] = $user_id; + } + } + if ( ! empty( $user_ids ) ) { + cache_users( array_values( array_unique( $user_ids ) ) ); + } + $items = array_map( - function ( $action ) use ( $hook_labels ) { + function ( $action ) use ( $hook_labels, $decoded_args ) { return [ 'id' => $action->action_id, 'timestamp' => $action->scheduled_date_gmt, 'event' => $hook_labels[ $action->hook ] ?? $action->hook, 'status' => $action->status, + 'email' => self::extract_email_from_payload( $decoded_args[ $action->action_id ] ?? null ), ]; }, $actions @@ -322,4 +379,300 @@ function ( $action ) use ( $hook_labels ) { ] ); } + + /** + * Get the full detail (payload + per-action logs) for a single scheduled action. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error + */ + public function api_get_integration_log_detail( WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $integration_id = $request->get_param( 'integration_id' ); + $action_id = (int) $request->get_param( 'action_id' ); + + $integration = Integrations::get_integration( $integration_id ); + if ( ! $integration ) { + return new WP_Error( + 'newspack_integration_not_found', + esc_html__( 'Integration not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + $action = Integrations::get_integration_action( $action_id, $integration_id ); + if ( ! $action ) { + return new WP_Error( + 'newspack_action_not_found', + esc_html__( 'Action not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + $store = \ActionScheduler_Store::instance(); + $status = $store->get_status( $action_id ); + + // The schedule's DateTime comes back in the server timezone despite the + // column being named *_gmt. Normalize to UTC so the API contract matches + // the field name and the frontend's '+00:00' parsing. + $schedule = $action->get_schedule(); + $scheduled_at = $schedule ? $schedule->get_date() : null; + if ( $scheduled_at ) { + $scheduled_at->setTimezone( new \DateTimeZone( 'UTC' ) ); + } + $scheduled_at_gmt = $scheduled_at ? $scheduled_at->format( 'Y-m-d\TH:i:s' ) : ''; + + // Resolve payload: prefer extended_args (full JSON) when present, else args. + // We read from the AS DB directly because the ActionScheduler_Action object + // only exposes the parsed args, with no signal as to whether they were + // truncated. Reading the row tells us whether extended_args carries the + // full JSON. + global $wpdb; + $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT args, extended_args, attempts, last_attempt_gmt FROM {$wpdb->prefix}actionscheduler_actions WHERE action_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $action_id + ) + ); + + $payload_raw = ''; + if ( $row ) { + $payload_raw = ! empty( $row->extended_args ) ? $row->extended_args : (string) $row->args; + } + $decoded = json_decode( $payload_raw, true ); + $args = ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) ? $payload_raw : $decoded; + + $hook_labels = Action_Scheduler::get_hook_labels(); + $hook = $action->get_hook(); + $event = $hook_labels[ $hook ] ?? $hook; + + return rest_ensure_response( + [ + 'action' => [ + 'id' => $action_id, + 'hook' => $hook, + 'event' => $event, + 'email' => self::extract_email_from_payload( $args ), + 'status' => $status, + 'scheduled_date_gmt' => $scheduled_at_gmt, + 'attempts' => $row ? (int) $row->attempts : 0, + 'last_attempt_gmt' => $row && ! empty( $row->last_attempt_gmt ) && '0000-00-00 00:00:00' !== $row->last_attempt_gmt ? gmdate( 'Y-m-d\TH:i:s', strtotime( $row->last_attempt_gmt . ' UTC' ) ) : '', + 'group' => $action->get_group(), + 'priority' => (int) $action->get_priority(), + 'args' => $args, + ], + 'logs' => Action_Scheduler::get_action_logs( $action_id ), + ] + ); + } + + /** + * Run a pending scheduled action immediately. + * + * Mirrors the WooCommerce Action Scheduler admin "Run" behavior: the action is + * processed synchronously and the post-run status is returned. Errors thrown by + * the action's callback are not surfaced as HTTP errors — AS already marks the + * action `failed` and writes a log entry, so we report `status: 'failed'` in a + * 200 response and let the UI surface the last log message. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error + */ + public function api_run_integration_action( WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $integration_id = $request->get_param( 'integration_id' ); + $action_id = (int) $request->get_param( 'action_id' ); + + $integration = Integrations::get_integration( $integration_id ); + if ( ! $integration ) { + return new WP_Error( + 'newspack_integration_not_found', + esc_html__( 'Integration not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + $action = Integrations::get_integration_action( $action_id, $integration_id ); + if ( ! $action ) { + return new WP_Error( + 'newspack_action_not_found', + esc_html__( 'Action not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + $store = \ActionScheduler_Store::instance(); + $status = $store->get_status( $action_id ); + + if ( \ActionScheduler_Store::STATUS_PENDING !== $status ) { + return new WP_Error( + 'newspack_action_not_pending', + esc_html__( 'This action is no longer pending.', 'newspack-plugin' ), + [ 'status' => 409 ] + ); + } + + // Read pre-run attempts so we can detect cases where process_action throws + // before log_execution() — e.g. another worker claimed the action between + // our pending check and process_action. AS doesn't always mark such cases + // failed, so we surface a generic retry message instead of an empty + // success-looking response. + global $wpdb; + $pre_attempts = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT attempts FROM {$wpdb->prefix}actionscheduler_actions WHERE action_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $action_id + ) + ); + + // Run synchronously like the WooCommerce AS admin "Run" button. No claim is taken, + // so two concurrent requests for the same action could in theory both execute — same + // limitation as the WC admin button, accepted at this scope. + try { + \ActionScheduler::runner()->process_action( $action_id, 'Newspack' ); + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch + // Swallow: AS marks the action failed and writes a log entry inside process_action's + // own error handler when the callback throws. We re-read state below and surface + // it to the UI. + } + + $new_status = $store->get_status( $action_id ); + $post_attempts = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + "SELECT attempts FROM {$wpdb->prefix}actionscheduler_actions WHERE action_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $action_id + ) + ); + + $ran = $new_status !== $status || $post_attempts > $pre_attempts; + + if ( $ran ) { + $logs = Action_Scheduler::get_action_logs( $action_id ); + $last_entry = end( $logs ); + $last_log = ( is_array( $last_entry ) && isset( $last_entry['message'] ) ) ? $last_entry['message'] : ''; + $response = [ + 'status' => $new_status, + 'message' => $last_log, + ]; + $audit_result = $new_status; + } else { + $response = [ + 'status' => $new_status, + 'message' => esc_html__( 'Could not run; please refresh and try again.', 'newspack-plugin' ), + ]; + $audit_result = 'no-run'; + } + + Logger::newspack_log( + 'newspack_integration_action_run', + sprintf( 'Manual run of integration action %d (%s).', $action_id, $integration_id ), + [ + 'action_id' => $action_id, + 'integration_id' => $integration_id, + 'user_id' => get_current_user_id(), + 'result' => $audit_result, + ], + 'info' + ); + + return rest_ensure_response( $response ); + } + + /** + * Decode a scheduled action's args payload into a PHP value. + * + * Prefers extended_args (full JSON) over args, mirroring the detail + * endpoint. Returns null when the payload is empty or not valid JSON. + * + * @param string $args The actionscheduler_actions.args column value. + * @param string $extended_args The actionscheduler_actions.extended_args column value. + * + * @return mixed The decoded payload, or null. + */ + private static function decode_action_args( $args, $extended_args ) { + $raw = ! empty( $extended_args ) ? $extended_args : (string) $args; + if ( '' === $raw ) { + return null; + } + $decoded = json_decode( $raw, true ); + if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) { + return null; + } + return $decoded; + } + + /** + * Best-effort resolution of the contact email for a scheduled action's payload. + * + * Integration retry actions reference the contact by WordPress user ID + * (e.g. `[ { "integration_id": "esp", "user_id": 1, ... } ]`) rather than + * carrying the email directly, so the current account email is resolved + * from that ID. An explicit `email`/`user_email` key is preferred when a + * payload does carry one, and a `previous_email` (set on email-change + * retries) is used as a last resort when the user can no longer be + * resolved. Returns '' when no email can be determined. + * + * @param mixed $payload Decoded args payload (array, scalar, or null). + * @param int $depth Current recursion depth (internal guard). + * + * @return string The resolved email, or ''. + */ + private static function extract_email_from_payload( $payload, $depth = 0 ) { + if ( $depth > 6 || ! is_array( $payload ) ) { + return ''; + } + // Prefer an explicit email carried in the payload. + foreach ( [ 'email', 'user_email' ] as $key ) { + if ( isset( $payload[ $key ] ) && is_string( $payload[ $key ] ) && is_email( $payload[ $key ] ) ) { + return sanitize_email( $payload[ $key ] ); + } + } + // Otherwise resolve the current account email from the user ID. + if ( isset( $payload['user_id'] ) && is_numeric( $payload['user_id'] ) ) { + $user = get_userdata( (int) $payload['user_id'] ); + if ( $user && is_email( $user->user_email ) ) { + return sanitize_email( $user->user_email ); + } + } + // Fall back to a previous email (email-change retries) when the user is gone. + if ( isset( $payload['previous_email'] ) && is_string( $payload['previous_email'] ) && is_email( $payload['previous_email'] ) ) { + return sanitize_email( $payload['previous_email'] ); + } + foreach ( $payload as $value ) { + if ( is_array( $value ) ) { + $found = self::extract_email_from_payload( $value, $depth + 1 ); + if ( '' !== $found ) { + return $found; + } + } + } + return ''; + } + + /** + * Find the first WordPress user ID referenced in a scheduled action's payload. + * + * Used to prime the user cache before bulk email resolution. Mirrors the + * structure walked by extract_email_from_payload(). + * + * @param mixed $payload Decoded args payload (array, scalar, or null). + * @param int $depth Current recursion depth (internal guard). + * + * @return int The first user ID found, or 0. + */ + private static function get_payload_user_id( $payload, $depth = 0 ) { + if ( $depth > 6 || ! is_array( $payload ) ) { + return 0; + } + if ( isset( $payload['user_id'] ) && is_numeric( $payload['user_id'] ) ) { + return (int) $payload['user_id']; + } + foreach ( $payload as $value ) { + if ( is_array( $value ) ) { + $user_id = self::get_payload_user_id( $value, $depth + 1 ); + if ( $user_id ) { + return $user_id; + } + } + } + return 0; + } } diff --git a/src/wizards/audience/views/integrations/constants.js b/src/wizards/audience/views/integrations/constants.js new file mode 100644 index 0000000000..fadd5f3592 --- /dev/null +++ b/src/wizards/audience/views/integrations/constants.js @@ -0,0 +1,28 @@ +/** + * Shared module-level constants and helpers for the integration activity logs + * view and its detail modal. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { dateI18n, getSettings } from '@wordpress/date'; + +export const API_BASE = '/newspack/v1/wizard/newspack-audience-integrations/settings'; + +export const STATUS_MAP = { + complete: { label: __( 'Complete', 'newspack-plugin' ), level: 'success' }, + failed: { label: __( 'Failed', 'newspack-plugin' ), level: 'error' }, + pending: { label: __( 'Pending', 'newspack-plugin' ), level: 'info' }, + 'in-progress': { label: __( 'In progress', 'newspack-plugin' ), level: 'info' }, + canceled: { label: __( 'Canceled', 'newspack-plugin' ), level: 'warning' }, +}; + +export function formatTimestamp( gmt ) { + if ( ! gmt ) { + return ''; + } + const dateFormat = getSettings().formats.datetime || 'F j, Y, g:i a'; + return dateI18n( dateFormat, `${ gmt }+00:00` ); +} diff --git a/src/wizards/audience/views/integrations/log-details-modal.js b/src/wizards/audience/views/integrations/log-details-modal.js new file mode 100644 index 0000000000..4efdc98b00 --- /dev/null +++ b/src/wizards/audience/views/integrations/log-details-modal.js @@ -0,0 +1,143 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useState, useEffect } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { Spinner, Notice } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { Badge } from '../../../../../packages/components/src'; +import { API_BASE, STATUS_MAP, formatTimestamp } from './constants'; + +function formatArgs( args ) { + if ( null === args || undefined === args ) { + return ''; + } + if ( typeof args === 'string' ) { + return args; + } + try { + return JSON.stringify( args, null, 2 ); + } catch ( e ) { + return String( args ); + } +} + +export const LogDetailsModal = ( { integrationId, actionId } ) => { + const [ data, setData ] = useState( null ); + const [ isLoading, setIsLoading ] = useState( true ); + const [ error, setError ] = useState( null ); + + useEffect( () => { + let cancelled = false; + setIsLoading( true ); + setError( null ); + + apiFetch( { path: `${ API_BASE }/${ integrationId }/logs/${ actionId }` } ) + .then( response => { + if ( ! cancelled ) { + setData( response ); + } + } ) + .catch( err => { + if ( cancelled ) { + return; + } + if ( err && err.data && err.data.status === 404 ) { + setError( __( 'This action no longer exists.', 'newspack-plugin' ) ); + } else { + setError( __( 'Failed to load action details.', 'newspack-plugin' ) ); + } + } ) + .finally( () => { + if ( ! cancelled ) { + setIsLoading( false ); + } + } ); + + return () => { + cancelled = true; + }; + }, [ integrationId, actionId ] ); + + if ( isLoading ) { + return ( +
+ +
+ ); + } + + if ( error ) { + return ( + + { error } + + ); + } + + if ( ! data || ! data.action ) { + return null; + } + + const { action, logs } = data; + const status = STATUS_MAP[ action.status ] || { label: action.status, level: 'default' }; + const formattedArgs = formatArgs( action.args ); + + return ( +
+
+

{ action.event }

+ +
+ +
+
{ __( 'Action ID', 'newspack-plugin' ) }
+
{ action.id }
+ +
{ __( 'Email', 'newspack-plugin' ) }
+
{ action.email || '—' }
+ +
{ __( 'Scheduled', 'newspack-plugin' ) }
+
{ formatTimestamp( action.scheduled_date_gmt ) }
+ +
{ __( 'Group', 'newspack-plugin' ) }
+
{ action.group }
+ +
{ __( 'Attempts', 'newspack-plugin' ) }
+
{ action.attempts }
+ +
{ __( 'Last attempt', 'newspack-plugin' ) }
+
{ action.last_attempt_gmt ? formatTimestamp( action.last_attempt_gmt ) : '—' }
+
+ +
+

{ __( 'Arguments', 'newspack-plugin' ) }

+ { formattedArgs ? ( +
{ formattedArgs }
+ ) : ( +

{ __( 'No arguments.', 'newspack-plugin' ) }

+ ) } +
+ +
+

{ __( 'Logs', 'newspack-plugin' ) }

+ { logs && logs.length > 0 ? ( +
    + { logs.map( ( log, index ) => ( +
  • + { formatTimestamp( log.date_gmt ) } + { log.message } +
  • + ) ) } +
+ ) : ( +

{ __( 'No log entries.', 'newspack-plugin' ) }

+ ) } +
+
+ ); +}; diff --git a/src/wizards/audience/views/integrations/logs-view.js b/src/wizards/audience/views/integrations/logs-view.js index 6c90a83c74..5d1ce1aefe 100644 --- a/src/wizards/audience/views/integrations/logs-view.js +++ b/src/wizards/audience/views/integrations/logs-view.js @@ -8,45 +8,30 @@ import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; import { Spinner } from '@wordpress/components'; import { DataViews as WPDataViews } from '@wordpress/dataviews'; -import { dateI18n, getSettings } from '@wordpress/date'; /** * Internal dependencies */ import { Badge, DataViews } from '../../../../../packages/components/src'; import { WIZARD_STORE_NAMESPACE } from '../../../../../packages/components/src/wizard/store'; +import { API_BASE, STATUS_MAP, formatTimestamp } from './constants'; +import { LogDetailsModal } from './log-details-modal'; import './style.scss'; -const API_BASE = '/newspack/v1/wizard/newspack-audience-integrations/settings'; - -const STATUS_MAP = { - complete: { label: __( 'Success', 'newspack-plugin' ), level: 'success' }, - failed: { label: __( 'Failed', 'newspack-plugin' ), level: 'error' }, - pending: { label: __( 'Pending', 'newspack-plugin' ), level: 'info' }, - canceled: { label: __( 'Canceled', 'newspack-plugin' ), level: 'warning' }, -}; - -function formatTimestamp( gmt ) { - if ( ! gmt ) { - return ''; - } - const dateFormat = getSettings().formats.datetime || 'F j, Y, g:i a'; - return dateI18n( dateFormat, `${ gmt }+00:00` ); -} - const DEFAULT_VIEW = { type: 'table', page: 1, perPage: 25, sort: { field: 'timestamp', direction: 'desc' }, search: '', - fields: [ 'timestamp', 'event', 'status' ], + fields: [ 'timestamp', 'email', 'event', 'status' ], filters: [], layout: { styles: { - timestamp: { width: '75%' }, - event: { width: '15%' }, - status: { width: '10%' }, + timestamp: { width: '35%' }, + email: { width: '30%' }, + event: { width: '20%' }, + status: { width: '15%' }, }, }, }; @@ -54,13 +39,14 @@ const DEFAULT_VIEW = { export const LogsView = ( { integrations, match } ) => { const integrationId = match?.params?.integrationId; const integration = integrationId ? integrations[ integrationId ] : null; - const { addNotice, setHeaderData } = useDispatch( WIZARD_STORE_NAMESPACE ); + const { addNotice, removeNotice, setHeaderData } = useDispatch( WIZARD_STORE_NAMESPACE ); const [ data, setData ] = useState( [] ); const [ total, setTotal ] = useState( 0 ); const [ isLoading, setIsLoading ] = useState( true ); const [ hasLoadedOnce, setHasLoadedOnce ] = useState( false ); const [ view, setView ] = useState( DEFAULT_VIEW ); + const [ runningActionIds, setRunningActionIds ] = useState( () => new Set() ); useEffect( () => { if ( integration ) { @@ -130,6 +116,12 @@ export const LogsView = ( { integrations, match } ) => { render: ( { item } ) => formatTimestamp( item.timestamp ), enableSorting: true, }, + { + id: 'email', + label: __( 'Email', 'newspack-plugin' ), + render: ( { item } ) => item.email || '—', + enableSorting: false, + }, { id: 'event', label: __( 'Event', 'newspack-plugin' ), @@ -145,9 +137,10 @@ export const LogsView = ( { integrations, match } ) => { }, enableSorting: true, elements: [ - { value: 'complete', label: __( 'Success', 'newspack-plugin' ) }, + { value: 'complete', label: __( 'Complete', 'newspack-plugin' ) }, { value: 'failed', label: __( 'Failed', 'newspack-plugin' ) }, { value: 'pending', label: __( 'Pending', 'newspack-plugin' ) }, + { value: 'in-progress', label: __( 'In progress', 'newspack-plugin' ) }, { value: 'canceled', label: __( 'Canceled', 'newspack-plugin' ) }, ], filterBy: { @@ -158,6 +151,81 @@ export const LogsView = ( { integrations, match } ) => { [] ); + const runAction = useCallback( + actionId => { + setRunningActionIds( prev => { + const next = new Set( prev ); + next.add( actionId ); + return next; + } ); + // Synchronous "running" notice. addNotice appends without deduping by + // id, so the final success/failure notice removes this one first + // (see removeNotice calls below) to replace it in place. + const noticeId = `integration-action-run-${ actionId }`; + addNotice( { + message: __( 'Running action…', 'newspack-plugin' ), + type: 'info', + id: noticeId, + } ); + apiFetch( { + path: `${ API_BASE }/${ integrationId }/logs/${ actionId }/run`, + method: 'POST', + } ) + .then( response => { + let message; + if ( response.status === 'complete' ) { + message = __( 'Action completed.', 'newspack-plugin' ); + } else if ( response.status === 'failed' ) { + message = response.message || __( 'Action failed.', 'newspack-plugin' ); + } else { + message = response.message || __( 'Action processed.', 'newspack-plugin' ); + } + removeNotice( noticeId ); + addNotice( { + message, + type: response.status === 'failed' ? 'error' : 'success', + id: noticeId, + } ); + } ) + .catch( err => { + const message = err && err.message ? err.message : __( 'Could not run action.', 'newspack-plugin' ); + removeNotice( noticeId ); + addNotice( { + message, + type: 'error', + id: noticeId, + } ); + } ) + .finally( () => { + setRunningActionIds( prev => { + const next = new Set( prev ); + next.delete( actionId ); + return next; + } ); + fetchLogs(); + } ); + }, + [ integrationId, addNotice, removeNotice, fetchLogs ] + ); + + const actions = useMemo( + () => [ + { + id: 'view-details', + label: __( 'View details', 'newspack-plugin' ), + modalHeader: __( 'Action details', 'newspack-plugin' ), + RenderModal: ( { items } ) => , + }, + { + id: 'run-now', + label: __( 'Run now', 'newspack-plugin' ), + isEligible: item => item.status === 'pending' && ! runningActionIds.has( item.id ), + callback: items => runAction( items[ 0 ].id ), + }, + ], + [ integrationId, runAction, runningActionIds ] + ); + const paginationInfo = useMemo( () => ( { totalItems: total, @@ -183,6 +251,7 @@ export const LogsView = ( { integrations, match } ) => { className="newspack-integration-logs" data={ data } fields={ fields } + actions={ actions } view={ view } onChangeView={ setView } paginationInfo={ paginationInfo } diff --git a/src/wizards/audience/views/integrations/style.scss b/src/wizards/audience/views/integrations/style.scss index 3bb8dea538..74417336af 100644 --- a/src/wizards/audience/views/integrations/style.scss +++ b/src/wizards/audience/views/integrations/style.scss @@ -31,4 +31,86 @@ fill: currentcolor; } } + + .dataviews-item-actions .components-button:not(:disabled) { + cursor: pointer; + } +} + +.newspack-integration-log-details { + &--loading { + display: flex; + justify-content: center; + padding: 24px 0; + } + + &__header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + + h3 { + margin: 0; + } + } + + &__meta { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 16px; + row-gap: 4px; + margin: 0 0 16px; + + dt { + font-weight: 600; + } + + dd { + margin: 0; + } + } + + &__section { + margin-top: 16px; + + h4 { + margin: 0 0 8px; + } + } + + &__args { + background: #f0f0f1; + padding: 12px; + border-radius: 4px; + max-height: 240px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: Consolas, monaco, monospace; + font-size: 12px; + } + + &__logs { + list-style: none; + margin: 0; + padding: 0; + + li { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 12px; + padding: 4px 0; + border-bottom: 1px solid #e0e0e0; + + &:last-child { + border-bottom: none; + } + } + } + + &__log-date { + color: #757575; + font-variant-numeric: tabular-nums; + } }