Skip to content

Commit bf1d20a

Browse files
enejblezama
andauthored
Forms: Add unread/read filtering (#45514)
* Update the endpoint to allow filtering by unread * Update the counts endpoint to to return read and unread state * Update the front end to show the filter and update the count. * Update the count to always include a the current Query as key so that the filters always return the expected results. * changelog * Update the empty state to account for the filter. * Invalidet the count store when the user marks things as unread so that the count is more accurate * normalize values in cache key * Invaliade even if only one of the requests succeeded * Fix wording. Co-authored-by: Miguel Lezama <[email protected]> * don't double sanitize * Fix: Keep track of the fields that are marked as read and need to be refetched. * Make the js tests pass * Fix pagination bug. Currently when you are using the unread/read filter and you mark things as read the pagination breaks. Since the state on the client and the backend is not in sync any more. We fix this by passing in an array of invalid ids. This way the state between the browser and the backend matched and the pagination works as expected. * Fix phan * fix js tests --------- Co-authored-by: Miguel Lezama <[email protected]>
1 parent 6d9e34e commit bf1d20a

File tree

18 files changed

+508
-67
lines changed

18 files changed

+508
-67
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Forms: Add unread/read filter to the dashboard.

projects/packages/forms/src/contact-form/class-contact-form-endpoint.php

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Automattic\Jetpack\Status;
1818
use Automattic\Jetpack\Status\Host;
1919
use WP_Error;
20+
use WP_Query;
2021
use WP_REST_Request;
2122
use WP_REST_Response;
2223

@@ -290,32 +291,38 @@ public function register_routes() {
290291
'permission_callback' => array( $this, 'get_items_permissions_check' ),
291292
'callback' => array( $this, 'get_status_counts' ),
292293
'args' => array(
293-
'search' => array(
294+
'search' => array(
294295
'description' => 'Limit results to those matching a string.',
295296
'type' => 'string',
296297
'sanitize_callback' => 'sanitize_text_field',
297298
'validate_callback' => 'rest_validate_request_arg',
298299
),
299-
'parent' => array(
300+
'parent' => array(
300301
'description' => 'Limit results to those of a specific parent ID.',
301302
'type' => 'integer',
302303
'sanitize_callback' => 'absint',
303304
'validate_callback' => 'rest_validate_request_arg',
304305
),
305-
'before' => array(
306+
'before' => array(
306307
'description' => 'Limit results to feedback published before a given ISO8601 compliant date.',
307308
'type' => 'string',
308309
'format' => 'date-time',
309310
'sanitize_callback' => 'sanitize_text_field',
310311
'validate_callback' => 'rest_validate_request_arg',
311312
),
312-
'after' => array(
313+
'after' => array(
313314
'description' => 'Limit results to feedback published after a given ISO8601 compliant date.',
314315
'type' => 'string',
315316
'format' => 'date-time',
316317
'sanitize_callback' => 'sanitize_text_field',
317318
'validate_callback' => 'rest_validate_request_arg',
318319
),
320+
'is_unread' => array(
321+
'description' => 'Limit results to read or unread feedback items.',
322+
'type' => 'boolean',
323+
'sanitize_callback' => 'rest_sanitize_boolean',
324+
'validate_callback' => 'rest_validate_request_arg',
325+
),
319326
),
320327
)
321328
);
@@ -376,10 +383,11 @@ static function ( $post_id ) {
376383
public function get_status_counts( $request ) {
377384
global $wpdb;
378385

379-
$search = $request->get_param( 'search' );
380-
$parent = $request->get_param( 'parent' );
381-
$before = $request->get_param( 'before' );
382-
$after = $request->get_param( 'after' );
386+
$search = $request->get_param( 'search' );
387+
$parent = $request->get_param( 'parent' );
388+
$before = $request->get_param( 'before' );
389+
$after = $request->get_param( 'after' );
390+
$is_unread = $request->get_param( 'is_unread' );
383391

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

@@ -400,6 +408,11 @@ public function get_status_counts( $request ) {
400408
$where_conditions[] = $wpdb->prepare( 'post_date >= %s', $after );
401409
}
402410

411+
if ( null !== $is_unread ) {
412+
$comment_status = $is_unread ? Feedback::STATUS_UNREAD : Feedback::STATUS_READ;
413+
$where_conditions[] = $wpdb->prepare( 'comment_status = %s', $comment_status );
414+
}
415+
403416
$where_clause = implode( ' AND ', $where_conditions );
404417

405418
// Execute single query with CASE statements for all status counts.
@@ -792,6 +805,81 @@ public function prepare_item_for_response( $item, $request ) {
792805
return rest_ensure_response( $response );
793806
}
794807

808+
/**
809+
* Retrieves a collection of feedback items.
810+
* Overrides parent to support invalid_ids with OR logic.
811+
*
812+
* @param WP_REST_Request $request Full details about the request.
813+
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
814+
*/
815+
public function get_items( $request ) {
816+
$invalid_ids = $request->get_param( 'invalid_ids' );
817+
818+
// If we have invalid_ids, we need to modify the query with a WHERE clause
819+
if ( ! empty( $invalid_ids ) ) {
820+
add_filter( 'posts_where', array( $this, 'modify_query_for_invalid_ids' ), 10, 2 );
821+
// Store invalid_ids temporarily so the filter can access them
822+
$this->temp_invalid_ids = $invalid_ids;
823+
}
824+
825+
$response = parent::get_items( $request );
826+
827+
// Clean up
828+
if ( ! empty( $invalid_ids ) ) {
829+
remove_filter( 'posts_where', array( $this, 'modify_query_for_invalid_ids' ), 10 );
830+
unset( $this->temp_invalid_ids );
831+
}
832+
833+
return $response;
834+
}
835+
836+
/**
837+
* Modify the WHERE clause to include invalid_ids with OR logic.
838+
*
839+
* @param string $where The WHERE clause.
840+
* @param WP_Query $query The WP_Query instance.
841+
* @return string Modified WHERE clause.
842+
*/
843+
public function modify_query_for_invalid_ids( $where, $query ) {
844+
global $wpdb;
845+
846+
// Only modify our feedback queries
847+
if ( ! isset( $this->temp_invalid_ids ) || empty( $this->temp_invalid_ids ) ) {
848+
return $where;
849+
}
850+
851+
// Only modify if this is a feedback query
852+
$post_type = $query->get( 'post_type' );
853+
if ( $post_type !== 'feedback' ) {
854+
return $where;
855+
}
856+
857+
$invalid_ids_sql = implode( ',', array_map( 'absint', $this->temp_invalid_ids ) );
858+
859+
// Add OR condition for invalid_ids at the end of the WHERE clause
860+
// Keep the AND at the beginning since WordPress WHERE clauses start with "AND"
861+
$where .= " OR {$wpdb->posts}.ID IN ({$invalid_ids_sql})";
862+
863+
return $where;
864+
}
865+
866+
/**
867+
* Filters the query arguments for the feedback collection.
868+
*
869+
* @param array $args Key value array of query var to query value.
870+
* @param WP_REST_Request $request The request used.
871+
* @return array Modified query arguments.
872+
*/
873+
protected function prepare_items_query( $args = array(), $request = null ) {
874+
$args = parent::prepare_items_query( $args, $request );
875+
876+
if ( isset( $request['is_unread'] ) ) {
877+
$args['comment_status'] = $request['is_unread'] ? Feedback::STATUS_UNREAD : Feedback::STATUS_READ;
878+
}
879+
880+
return $args;
881+
}
882+
795883
/**
796884
* Retrieves the query params for the feedback collection.
797885
*
@@ -818,6 +906,24 @@ public function get_collection_params() {
818906
),
819907
'default' => array(),
820908
);
909+
$query_params['is_unread'] = array(
910+
'description' => __( 'Limit result set to read or unread feedback items.', 'jetpack-forms' ),
911+
'type' => 'boolean',
912+
'sanitize_callback' => 'rest_sanitize_boolean',
913+
'validate_callback' => 'rest_validate_request_arg',
914+
);
915+
$query_params['invalid_ids'] = array(
916+
'description' => __( 'List of item IDs to include in results regardless of filters.', 'jetpack-forms' ),
917+
'type' => 'array',
918+
'items' => array(
919+
'type' => 'integer',
920+
),
921+
'default' => array(),
922+
'sanitize_callback' => function ( $param ) {
923+
return array_map( 'absint', (array) $param );
924+
},
925+
'validate_callback' => 'rest_validate_request_arg',
926+
);
821927
return $query_params;
822928
}
823929

projects/packages/forms/src/contact-form/class-feedback.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ class Feedback {
2222
*
2323
* @var string
2424
*/
25-
private const STATUS_UNREAD = 'open';
25+
public const STATUS_UNREAD = 'open';
2626

2727
/**
2828
* Comment status for read feedback.
2929
*
3030
* @var string
3131
*/
32-
private const STATUS_READ = 'closed';
32+
public const STATUS_READ = 'closed';
3333

3434
/**
3535
* The form field values.

projects/packages/forms/src/dashboard/components/response-view/body.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ import clsx from 'clsx';
2727
import useConfigValue from '../../../hooks/use-config-value';
2828
import CopyClipboardButton from '../../components/copy-clipboard-button';
2929
import Gravatar from '../../components/gravatar';
30+
import useInboxData from '../../hooks/use-inbox-data';
3031
import { useMarkAsSpam } from '../../hooks/use-mark-as-spam';
3132
import { getPath, updateMenuCounter, updateMenuCounterOptimistically } from '../../inbox/utils';
33+
import { store as dashboardStore } from '../../store';
3234
import type { FormResponse } from '../../../types';
3335

3436
const getDisplayName = response => {
@@ -196,6 +198,7 @@ const ResponseViewBody = ( {
196198
isLoading,
197199
onModalStateChange,
198200
}: ResponseViewBodyProps ): import('react').JSX.Element => {
201+
const { currentQuery } = useInboxData();
199202
const [ isPreviewModalOpen, setIsPreviewModalOpen ] = useState( false );
200203
const [ previewFile, setPreviewFile ] = useState< null | object >( null );
201204
const [ isImageLoading, setIsImageLoading ] = useState( true );
@@ -210,6 +213,8 @@ const ResponseViewBody = ( {
210213
response as FormResponse
211214
);
212215

216+
const { invalidateCounts, markRecordsAsInvalid } = useDispatch( dashboardStore );
217+
213218
const ref = useRef( undefined );
214219

215220
const openFilePreview = useCallback(
@@ -362,6 +367,10 @@ const ResponseViewBody = ( {
362367
.then( ( { count } ) => {
363368
// Update menu counter with accurate count from server
364369
updateMenuCounter( count );
370+
// Mark record as invalid instead of removing from view
371+
markRecordsAsInvalid( [ response.id ] );
372+
// invalidate counts to refresh the counts across all status tabs
373+
invalidateCounts();
365374
} )
366375
.catch( () => {
367376
// Revert the change in the store
@@ -374,7 +383,14 @@ const ResponseViewBody = ( {
374383
updateMenuCounterOptimistically( 1 );
375384
}
376385
} );
377-
}, [ response, editEntityRecord, hasMarkedSelfAsRead ] );
386+
}, [
387+
response,
388+
editEntityRecord,
389+
hasMarkedSelfAsRead,
390+
invalidateCounts,
391+
markRecordsAsInvalid,
392+
currentQuery,
393+
] );
378394

379395
const handelImageLoaded = useCallback( () => {
380396
return setIsImageLoading( false );

projects/packages/forms/src/dashboard/hooks/use-inbox-data.ts

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import { useEntityRecords, store as coreDataStore } from '@wordpress/core-data';
55
import { useDispatch, useSelect } from '@wordpress/data';
6-
import { useMemo } from '@wordpress/element';
6+
import { useMemo, useRef, useEffect, useState } from '@wordpress/element';
77
import { decodeEntities } from '@wordpress/html-entities';
88
import { isEmpty } from 'lodash';
99
import { useSearchParams } from 'react-router';
@@ -78,35 +78,58 @@ export default function useInboxData(): UseInboxDataReturn {
7878
const urlStatus = searchParams.get( 'status' );
7979
const statusFilter = getStatusFilter( urlStatus );
8080

81-
const {
82-
selectedResponsesCount,
83-
currentStatus,
84-
currentQuery,
85-
filterOptions,
86-
totalItemsInbox,
87-
totalItemsSpam,
88-
totalItemsTrash,
89-
} = useSelect(
90-
select => ( {
91-
selectedResponsesCount: select( dashboardStore ).getSelectedResponsesCount(),
92-
currentStatus: select( dashboardStore ).getCurrentStatus(),
93-
currentQuery: select( dashboardStore ).getCurrentQuery(),
94-
filterOptions: select( dashboardStore ).getFilters(),
95-
totalItemsInbox: select( dashboardStore ).getInboxCount(),
96-
totalItemsSpam: select( dashboardStore ).getSpamCount(),
97-
totalItemsTrash: select( dashboardStore ).getTrashCount(),
98-
} ),
99-
[]
100-
);
81+
const { selectedResponsesCount, currentStatus, currentQuery, filterOptions, invalidRecords } =
82+
useSelect(
83+
select => ( {
84+
selectedResponsesCount: select( dashboardStore ).getSelectedResponsesCount(),
85+
currentStatus: select( dashboardStore ).getCurrentStatus(),
86+
currentQuery: select( dashboardStore ).getCurrentQuery(),
87+
filterOptions: select( dashboardStore ).getFilters(),
88+
invalidRecords: select( dashboardStore ).getInvalidRecords(),
89+
} ),
90+
[]
91+
);
10192

93+
// Track the frozen invalid_ids for the current page
94+
// This prevents re-fetching when new items are marked as invalid
95+
const [ frozenInvalidIds, setFrozenInvalidIds ] = useState< number[] >( [] );
96+
const currentPageRef = useRef< number >( currentQuery?.page || 1 );
97+
98+
// When page changes, freeze the current invalid records for this page
99+
useEffect( () => {
100+
const newPage = currentQuery?.page || 1;
101+
const hasUnreadFilter = currentQuery?.is_unread === true;
102+
103+
// If we're navigating to a new page
104+
if ( newPage !== currentPageRef.current ) {
105+
currentPageRef.current = newPage;
106+
107+
// Freeze invalid IDs when navigating to page 2+
108+
if ( hasUnreadFilter ) {
109+
setFrozenInvalidIds( Array.from( invalidRecords || new Set() ) );
110+
} else {
111+
// Clear frozen IDs on page 1 or when unread filter is off
112+
setFrozenInvalidIds( [] );
113+
}
114+
}
115+
}, [ currentQuery?.page, currentQuery?.is_unread, invalidRecords ] );
116+
117+
// Use frozen invalid_ids for the query
118+
const queryWithInvalidIds = useMemo( () => {
119+
if ( frozenInvalidIds.length > 0 ) {
120+
return {
121+
...currentQuery,
122+
invalid_ids: frozenInvalidIds,
123+
};
124+
}
125+
return currentQuery;
126+
}, [ currentQuery, frozenInvalidIds ] );
102127
const {
103128
records: rawRecords,
104129
hasResolved,
105130
totalItems,
106131
totalPages,
107-
} = useEntityRecords( 'postType', 'feedback', {
108-
...currentQuery,
109-
} );
132+
} = useEntityRecords( 'postType', 'feedback', queryWithInvalidIds );
110133

111134
const records = useSelect(
112135
select => {
@@ -153,14 +176,26 @@ export default function useInboxData(): UseInboxDataReturn {
153176
if ( currentQuery?.after ) {
154177
params.after = currentQuery.after;
155178
}
179+
if ( currentQuery?.is_unread !== undefined ) {
180+
params.is_unread = currentQuery.is_unread;
181+
}
182+
156183
return params;
157-
}, [ currentQuery?.search, currentQuery?.parent, currentQuery?.before, currentQuery?.after ] );
184+
}, [ currentQuery ] );
158185

159186
// Use the getCounts selector with resolver - this will automatically fetch and cache counts
160187
// The resolver ensures counts are only fetched once for the same query params across all hook instances
161-
useSelect(
188+
const { totalItemsInbox, totalItemsSpam, totalItemsTrash } = useSelect(
162189
select => {
190+
// This will trigger the resolver if the counts for these queryParams aren't already cached
163191
select( dashboardStore ).getCounts( countsQueryParams );
192+
193+
// Return the counts for the current query
194+
return {
195+
totalItemsInbox: select( dashboardStore ).getInboxCount( countsQueryParams ),
196+
totalItemsSpam: select( dashboardStore ).getSpamCount( countsQueryParams ),
197+
totalItemsTrash: select( dashboardStore ).getTrashCount( countsQueryParams ),
198+
};
164199
},
165200
[ countsQueryParams ]
166201
);

0 commit comments

Comments
 (0)