Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions projects/packages/forms/changelog/add-forms-unread-filtering
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Forms: Add unread/read filter to the dashboard.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Automattic\Jetpack\Status\Host;
use Jetpack_AI_Helper;
use WP_Error;
use WP_Query;
use WP_REST_Request;
use WP_REST_Response;

Expand Down Expand Up @@ -301,32 +302,38 @@ public function register_routes() {
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'callback' => array( $this, 'get_status_counts' ),
'args' => array(
'search' => array(
'search' => array(
'description' => 'Limit results to those matching a string.',
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
),
'parent' => array(
'parent' => array(
'description' => 'Limit results to those of a specific parent ID.',
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
),
'before' => array(
'before' => array(
'description' => 'Limit results to feedback published before a given ISO8601 compliant date.',
'type' => 'string',
'format' => 'date-time',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
),
'after' => array(
'after' => array(
'description' => 'Limit results to feedback published after a given ISO8601 compliant date.',
'type' => 'string',
'format' => 'date-time',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
),
'is_unread' => array(
'description' => 'Limit results to read or unread feedback items.',
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'validate_callback' => 'rest_validate_request_arg',
),
),
)
);
Expand Down Expand Up @@ -387,10 +394,11 @@ static function ( $post_id ) {
public function get_status_counts( $request ) {
global $wpdb;

$search = $request->get_param( 'search' );
$parent = $request->get_param( 'parent' );
$before = $request->get_param( 'before' );
$after = $request->get_param( 'after' );
$search = $request->get_param( 'search' );
$parent = $request->get_param( 'parent' );
$before = $request->get_param( 'before' );
$after = $request->get_param( 'after' );
$is_unread = $request->get_param( 'is_unread' );

$where_conditions = array( $wpdb->prepare( 'post_type = %s', 'feedback' ) );

Expand All @@ -411,6 +419,11 @@ public function get_status_counts( $request ) {
$where_conditions[] = $wpdb->prepare( 'post_date >= %s', $after );
}

if ( null !== $is_unread ) {
$comment_status = $is_unread ? Feedback::STATUS_UNREAD : Feedback::STATUS_READ;
$where_conditions[] = $wpdb->prepare( 'comment_status = %s', $comment_status );
}

$where_clause = implode( ' AND ', $where_conditions );

// Execute single query with CASE statements for all status counts.
Expand Down Expand Up @@ -803,6 +816,81 @@ public function prepare_item_for_response( $item, $request ) {
return rest_ensure_response( $response );
}

/**
* Retrieves a collection of feedback items.
* Overrides parent to support invalid_ids with OR logic.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
$invalid_ids = $request->get_param( 'invalid_ids' );

// If we have invalid_ids, we need to modify the query with a WHERE clause
if ( ! empty( $invalid_ids ) ) {
add_filter( 'posts_where', array( $this, 'modify_query_for_invalid_ids' ), 10, 2 );
// Store invalid_ids temporarily so the filter can access them
$this->temp_invalid_ids = $invalid_ids;
}

$response = parent::get_items( $request );

// Clean up
if ( ! empty( $invalid_ids ) ) {
remove_filter( 'posts_where', array( $this, 'modify_query_for_invalid_ids' ), 10 );
unset( $this->temp_invalid_ids );
}

return $response;
}

/**
* Modify the WHERE clause to include invalid_ids with OR logic.
*
* @param string $where The WHERE clause.
* @param WP_Query $query The WP_Query instance.
* @return string Modified WHERE clause.
*/
public function modify_query_for_invalid_ids( $where, $query ) {
global $wpdb;

// Only modify our feedback queries
if ( ! isset( $this->temp_invalid_ids ) || empty( $this->temp_invalid_ids ) ) {
return $where;
}

// Only modify if this is a feedback query
$post_type = $query->get( 'post_type' );
if ( $post_type !== 'feedback' ) {
return $where;
}

$invalid_ids_sql = implode( ',', array_map( 'absint', $this->temp_invalid_ids ) );

// Add OR condition for invalid_ids at the end of the WHERE clause
// Keep the AND at the beginning since WordPress WHERE clauses start with "AND"
$where .= " OR {$wpdb->posts}.ID IN ({$invalid_ids_sql})";

return $where;
}

/**
* Filters the query arguments for the feedback collection.
*
* @param array $args Key value array of query var to query value.
* @param WP_REST_Request $request The request used.
* @return array Modified query arguments.
*/
protected function prepare_items_query( $args = array(), $request = null ) {
$args = parent::prepare_items_query( $args, $request );

if ( isset( $request['is_unread'] ) ) {
$args['comment_status'] = $request['is_unread'] ? Feedback::STATUS_UNREAD : Feedback::STATUS_READ;
}

return $args;
}

/**
* Retrieves the query params for the feedback collection.
*
Expand All @@ -829,6 +917,24 @@ public function get_collection_params() {
),
'default' => array(),
);
$query_params['is_unread'] = array(
'description' => __( 'Limit result set to read or unread feedback items.', 'jetpack-forms' ),
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'validate_callback' => 'rest_validate_request_arg',
);
$query_params['invalid_ids'] = array(
'description' => __( 'List of item IDs to include in results regardless of filters.', 'jetpack-forms' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => function ( $param ) {
return array_map( 'absint', (array) $param );
},
'validate_callback' => 'rest_validate_request_arg',
);
return $query_params;
}

Expand Down
4 changes: 2 additions & 2 deletions projects/packages/forms/src/contact-form/class-feedback.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ class Feedback {
*
* @var string
*/
private const STATUS_UNREAD = 'open';
public const STATUS_UNREAD = 'open';

/**
* Comment status for read feedback.
*
* @var string
*/
private const STATUS_READ = 'closed';
public const STATUS_READ = 'closed';

/**
* The form field values.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ import clsx from 'clsx';
import useConfigValue from '../../../hooks/use-config-value';
import CopyClipboardButton from '../../components/copy-clipboard-button';
import Gravatar from '../../components/gravatar';
import useInboxData from '../../hooks/use-inbox-data';
import { useMarkAsSpam } from '../../hooks/use-mark-as-spam';
import { getPath, updateMenuCounter, updateMenuCounterOptimistically } from '../../inbox/utils';
import { store as dashboardStore } from '../../store';
import type { FormResponse } from '../../../types';

const getDisplayName = response => {
Expand Down Expand Up @@ -196,6 +198,7 @@ const ResponseViewBody = ( {
isLoading,
onModalStateChange,
}: ResponseViewBodyProps ): import('react').JSX.Element => {
const { currentQuery } = useInboxData();
const [ isPreviewModalOpen, setIsPreviewModalOpen ] = useState( false );
const [ previewFile, setPreviewFile ] = useState< null | object >( null );
const [ isImageLoading, setIsImageLoading ] = useState( true );
Expand All @@ -210,6 +213,8 @@ const ResponseViewBody = ( {
response as FormResponse
);

const { invalidateCounts, markRecordsAsInvalid } = useDispatch( dashboardStore );

const ref = useRef( undefined );

const openFilePreview = useCallback(
Expand Down Expand Up @@ -362,6 +367,10 @@ const ResponseViewBody = ( {
.then( ( { count } ) => {
// Update menu counter with accurate count from server
updateMenuCounter( count );
// Mark record as invalid instead of removing from view
markRecordsAsInvalid( [ response.id ] );
// invalidate counts to refresh the counts across all status tabs
invalidateCounts();
} )
.catch( () => {
// Revert the change in the store
Expand All @@ -374,7 +383,14 @@ const ResponseViewBody = ( {
updateMenuCounterOptimistically( 1 );
}
} );
}, [ response, editEntityRecord, hasMarkedSelfAsRead ] );
}, [
response,
editEntityRecord,
hasMarkedSelfAsRead,
invalidateCounts,
markRecordsAsInvalid,
currentQuery,
] );

const handelImageLoaded = useCallback( () => {
return setIsImageLoading( false );
Expand Down
87 changes: 61 additions & 26 deletions projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import { useEntityRecords, store as coreDataStore } from '@wordpress/core-data';
import { useDispatch, useSelect } from '@wordpress/data';
import { useMemo } from '@wordpress/element';
import { useMemo, useRef, useEffect, useState } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
import { isEmpty } from 'lodash';
import { useSearchParams } from 'react-router';
Expand Down Expand Up @@ -78,35 +78,58 @@ export default function useInboxData(): UseInboxDataReturn {
const urlStatus = searchParams.get( 'status' );
const statusFilter = getStatusFilter( urlStatus );

const {
selectedResponsesCount,
currentStatus,
currentQuery,
filterOptions,
totalItemsInbox,
totalItemsSpam,
totalItemsTrash,
} = useSelect(
select => ( {
selectedResponsesCount: select( dashboardStore ).getSelectedResponsesCount(),
currentStatus: select( dashboardStore ).getCurrentStatus(),
currentQuery: select( dashboardStore ).getCurrentQuery(),
filterOptions: select( dashboardStore ).getFilters(),
totalItemsInbox: select( dashboardStore ).getInboxCount(),
totalItemsSpam: select( dashboardStore ).getSpamCount(),
totalItemsTrash: select( dashboardStore ).getTrashCount(),
} ),
[]
);
const { selectedResponsesCount, currentStatus, currentQuery, filterOptions, invalidRecords } =
useSelect(
select => ( {
selectedResponsesCount: select( dashboardStore ).getSelectedResponsesCount(),
currentStatus: select( dashboardStore ).getCurrentStatus(),
currentQuery: select( dashboardStore ).getCurrentQuery(),
filterOptions: select( dashboardStore ).getFilters(),
invalidRecords: select( dashboardStore ).getInvalidRecords(),
} ),
[]
);

// Track the frozen invalid_ids for the current page
// This prevents re-fetching when new items are marked as invalid
const [ frozenInvalidIds, setFrozenInvalidIds ] = useState< number[] >( [] );
const currentPageRef = useRef< number >( currentQuery?.page || 1 );

// When page changes, freeze the current invalid records for this page
useEffect( () => {
const newPage = currentQuery?.page || 1;
const hasUnreadFilter = currentQuery?.is_unread === true;

// If we're navigating to a new page
if ( newPage !== currentPageRef.current ) {
currentPageRef.current = newPage;

// Freeze invalid IDs when navigating to page 2+
if ( hasUnreadFilter ) {
setFrozenInvalidIds( Array.from( invalidRecords || new Set() ) );
} else {
// Clear frozen IDs on page 1 or when unread filter is off
setFrozenInvalidIds( [] );
}
}
}, [ currentQuery?.page, currentQuery?.is_unread, invalidRecords ] );

// Use frozen invalid_ids for the query
const queryWithInvalidIds = useMemo( () => {
if ( frozenInvalidIds.length > 0 ) {
return {
...currentQuery,
invalid_ids: frozenInvalidIds,
};
}
return currentQuery;
}, [ currentQuery, frozenInvalidIds ] );
const {
records: rawRecords,
hasResolved,
totalItems,
totalPages,
} = useEntityRecords( 'postType', 'feedback', {
...currentQuery,
} );
} = useEntityRecords( 'postType', 'feedback', queryWithInvalidIds );

const records = useSelect(
select => {
Expand Down Expand Up @@ -153,14 +176,26 @@ export default function useInboxData(): UseInboxDataReturn {
if ( currentQuery?.after ) {
params.after = currentQuery.after;
}
if ( currentQuery?.is_unread !== undefined ) {
params.is_unread = currentQuery.is_unread;
}

return params;
}, [ currentQuery?.search, currentQuery?.parent, currentQuery?.before, currentQuery?.after ] );
}, [ currentQuery ] );

// Use the getCounts selector with resolver - this will automatically fetch and cache counts
// The resolver ensures counts are only fetched once for the same query params across all hook instances
useSelect(
const { totalItemsInbox, totalItemsSpam, totalItemsTrash } = useSelect(
select => {
// This will trigger the resolver if the counts for these queryParams aren't already cached
select( dashboardStore ).getCounts( countsQueryParams );

// Return the counts for the current query
return {
totalItemsInbox: select( dashboardStore ).getInboxCount( countsQueryParams ),
totalItemsSpam: select( dashboardStore ).getSpamCount( countsQueryParams ),
totalItemsTrash: select( dashboardStore ).getTrashCount( countsQueryParams ),
};
},
[ countsQueryParams ]
);
Expand Down
Loading
Loading