Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b93fe05
Notes: Add search and filters to the "All notes" sidebar
adamsilverstein Oct 4, 2026
e29a9aa
Type the note test helper to satisfy typecheck
adamsilverstein Oct 4, 2026
a2ab021
Merge remote-tracking branch 'origin/trunk' into add/notes-sidebar-fi…
adamsilverstein Oct 5, 2026
8304986
Notes: Keep the author filter clearable when its author disappears
adamsilverstein Oct 5, 2026
b93d03b
Merge remote-tracking branch 'origin/trunk' into add/notes-sidebar-fi…
adamsilverstein Oct 7, 2026
0bb9cd2
Notes: Tuck the sidebar filters behind a toggle next to search
adamsilverstein Oct 7, 2026
b400b67
Merge remote-tracking branch 'origin/trunk' into add/notes-sidebar-fi…
adamsilverstein Oct 8, 2026
d7f0561
Notes: Use the brand foreground token for the filter count badge
adamsilverstein Oct 8, 2026
3510058
Notes: Expand collapsed replies while searching or filtering by author
adamsilverstein Oct 8, 2026
e353acc
Merge origin/trunk into add/notes-sidebar-filter
adamsilverstein Oct 12, 2026
e765ce7
Notes: Open the note filters in a dropdown from the filter button
adamsilverstein Oct 20, 2026
22f7f59
Notes: Show the note filters as a menu on the filter button
adamsilverstein Oct 20, 2026
6340690
Merge branch 'trunk' into add/notes-sidebar-filter
adamsilverstein Oct 20, 2026
a4a51b6
Notes: Use the new Menu and settings icon for the note filters
adamsilverstein Oct 22, 2026
bd6e65f
Notes: Replace the author filter with author name search
adamsilverstein Oct 23, 2026
8179dc2
Merge remote-tracking branch 'origin/trunk' into add/notes-sidebar-fi…
adamsilverstein Oct 23, 2026
1d9196b
Notes: Scope the Menu lint suppression to its import specifier
adamsilverstein Oct 23, 2026
6b286a0
Notes: Move filter test coverage to e2e
Mamaduka Oct 23, 2026
2b48673
Notes: Drop the filter count badge and extra props
Mamaduka Oct 23, 2026
fbe30ae
Notes: Announce filter results and keep the empty state out of the tree
Mamaduka Oct 23, 2026
74eed58
Merge branch 'trunk' into add/notes-sidebar-filter
Mamaduka Oct 23, 2026
c2223b3
Merge remote-tracking branch 'origin/trunk' into add/notes-sidebar-fi…
adamsilverstein Oct 25, 2026
3576e7b
Notes: Move focus to the next note when a filter hides the current one
adamsilverstein Oct 25, 2026
04be7df
Notes: Show when a status filter is on
adamsilverstein Oct 25, 2026
8ade62a
Merge remote-tracking branch 'origin/trunk' into add/notes-sidebar-fi…
adamsilverstein Oct 29, 2026
4289f70
Notes: Hide Resolved divider when filtering by status
Mamaduka Oct 29, 2026
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
31 changes: 29 additions & 2 deletions packages/editor/src/components/collab-sidebar/index.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import clsx from 'clsx';
import { __ } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data';
import { useRef, useState } from '@wordpress/element';
import { useMemo, useRef, useState } from '@wordpress/element';
import { useViewportMatch } from '@wordpress/compose';
import { __experimentalUseSlot as useSlot } from '@wordpress/components';
import { useShortcut } from '@wordpress/keyboard-shortcuts';
Expand All @@ -13,6 +13,13 @@ import PluginSidebar from '../plugin-sidebar';
import { ALL_NOTES_SIDEBAR } from './constants';
import { Notes } from './notes';
import { NotesDisplayModeMenu } from './notes-display-mode-menu';
import {
DEFAULT_NOTES_FILTERS,
NotesFilters,
filterNotes,
hasActiveNotesFilters,
sanitizeNotesFilters,
} from './notes-filters';
import { store as editorStore } from '../../store';
import { AddNoteMenuItem } from './add-note-menu-item';
import { NoteAvatarIndicator } from './note-indicator-toolbar';
Expand Down Expand Up @@ -64,6 +71,15 @@ function NotesSidebar( { postId, drafts } ) {
);

const { notes, unresolvedNotes } = useNoteThreads( postId );
const [ storedFilters, setFilters ] = useState( DEFAULT_NOTES_FILTERS );
const filters = useMemo(
() => sanitizeNotesFilters( notes, storedFilters ),
[ notes, storedFilters ]
);
const filteredNotes = useMemo(
() => filterNotes( notes, filters, selectedNoteId ),
[ notes, filters, selectedNoteId ]
);
const isAllNotesSidebarOpen = useSelect(
( select ) =>
select( interfaceStore ).getActiveComplementaryArea( 'core' ) ===
Expand Down Expand Up @@ -185,8 +201,19 @@ function NotesSidebar( { postId, drafts } ) {
icon={ commentIcon }
closeLabel={ __( 'Close Notes' ) }
>
{ notes.length > 0 && (
<NotesFilters
notes={ notes }
filters={ filters }
onChange={ setFilters }
/>
) }
<NoteDraftsContext.Provider value={ drafts }>
<Notes notes={ notes } sidebarRef={ sidebarRef } />
<Notes
notes={ filteredNotes }
sidebarRef={ sidebarRef }
isFiltered={ hasActiveNotesFilters( filters ) }
/>
</NoteDraftsContext.Provider>
</PluginSidebar>
) }
Expand Down
240 changes: 240 additions & 0 deletions packages/editor/src/components/collab-sidebar/notes-filters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import {
Button,
SearchControl,
SelectControl as WCSelectControl,
} from '@wordpress/components';
import { __unstableStripHTML as stripHTML } from '@wordpress/dom';
import { useState } from '@wordpress/element';
import { __, _n, _x, sprintf } from '@wordpress/i18n';
import { funnel } from '@wordpress/icons';
import { Stack } from '@wordpress/ui';

type NoteStatusFilter = 'all' | 'hold' | 'approved';

export type NotesFilterValues = {
search: string;
status: NoteStatusFilter;
author: string;
};

type Note = {
id: number;
author: number;
author_name: string;
status: string;
content: { rendered: string };
reply?: Note[];
};

export const DEFAULT_NOTES_FILTERS: NotesFilterValues = {
search: '',
status: 'all',
author: 'all',
};

/**
* Whether any filter narrows the list.
*
* @param filters Filter values.
*/
export function hasActiveNotesFilters( filters: NotesFilterValues ) {
return (
filters.search.trim() !== '' ||
filters.status !== 'all' ||
filters.author !== 'all'
);
}

/**
* Counts the filters set in the collapsible panel. Search is left out since
* its field is always shown.
*
* @param filters Filter values.
*/
export function countPanelFilters( filters: NotesFilterValues ) {
return (
Number( filters.status !== 'all' ) + Number( filters.author !== 'all' )
);
}

/**
* Maps each note author's ID to their display name.
*
* @param threads Note threads.
*/
export function getNoteAuthors( threads: Note[] ) {
const authors = new Map< string, string >();
for ( const thread of threads ) {
for ( const note of [ thread, ...( thread.reply ?? [] ) ] ) {
authors.set( String( note.author ), note.author_name );
}
}
return authors;
}

/**
* Drops an author filter whose author no longer has notes, for example after
* their last thread is deleted, so the list can't get stuck empty.
*
* @param threads Note threads.
* @param filters Filter values.
*/
export function sanitizeNotesFilters(
threads: Note[],
filters: NotesFilterValues
): NotesFilterValues {
if (
filters.author === 'all' ||
getNoteAuthors( threads ).has( filters.author )
) {
return filters;
}
return { ...filters, author: 'all' };
}

/**
* Returns the threads that match the filters. A thread matches the search
* and author filters when its first note or any reply does.
*
* @param threads Note threads.
* @param filters Filter values.
* @param selectedNoteId Selected thread, kept visible so it doesn't vanish.
*/
export function filterNotes< T extends Note >(
threads: T[],
filters: NotesFilterValues,
selectedNoteId?: number | string
): T[] {
if ( ! hasActiveNotesFilters( filters ) ) {
return threads;
}
const search = filters.search.trim().toLowerCase();
return threads.filter( ( thread ) => {
if ( thread.id === selectedNoteId ) {
return true;
}
const threadNotes = [ thread, ...( thread.reply ?? [] ) ];
return (
( filters.status === 'all' || thread.status === filters.status ) &&

@yashjawale yashjawale Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolved divider logic also checks for attached block's existence, counting the ones with deleted blocks as open. Perhaps we'll need to mimic that logic for the filter too..

// In the "All notes" view, find where the resolved notes begin so a
// "Resolved" divider can be rendered above them. Resolved notes (status
// 'approved' with a still-present block) always sort after the active
// ones, so the first match marks the boundary. The floating view only
// lists unresolved notes, so it needs no divider.
const firstResolvedIndex = isFloating
? -1
: threads.findIndex(
( thread ) =>
thread.status === 'approved' && !! thread.blockClientId
);

Else we get the resolved notes of deleted blocks on top of the divider in resolved view. Which might be confusing for users.

Screen.Recording.2026-10-29.at.1.04.20.PM.mov

( filters.author === 'all' ||
threadNotes.some(
( note ) => String( note.author ) === filters.author
) ) &&
( ! search ||
threadNotes.some( ( note ) =>
stripHTML( note.content.rendered )
.toLowerCase()
.includes( search )
) )
);
} );
}

type NotesFiltersProps = {
notes: Note[];
filters: NotesFilterValues;
onChange: ( filters: NotesFilterValues ) => void;
};

/**
* Search and filter controls for the "All notes" sidebar.
*
* @param props Component props.
* @param props.notes Note threads, used to list the authors.
* @param props.filters Filter values.
* @param props.onChange Called with the new filter values.
*/
export function NotesFilters( {
notes,
filters,
onChange,
}: NotesFiltersProps ) {
const authors = getNoteAuthors( notes );
const panelFilterCount = countPanelFilters( filters );
// Start open when filters are already set, e.g. after reopening the sidebar.
const [ isPanelOpen, setIsPanelOpen ] = useState( panelFilterCount > 0 );

return (
<Stack
className="editor-collab-sidebar-filters"
direction="column"
gap="sm"
>
<Stack direction="row" gap="sm" align="center">
<SearchControl
className="editor-collab-sidebar-filters__search"
label={ __( 'Search notes' ) }
value={ filters.search }
onChange={ ( search ) =>
onChange( { ...filters, search } )
}
/>
<div className="editor-collab-sidebar-filters__toggle">
<Button
__next40pxDefaultSize
icon={ funnel }
label={
panelFilterCount
? sprintf(
/* translators: %d: Number of filters applied. */
_n(
'Filter (%d applied)',
'Filter (%d applied)',
panelFilterCount
),
panelFilterCount
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noticed that here _n() returns same strings for both singular/plural cases.
Different strings may be used in context of other languages so perhaps _n is right for this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, interesting! I'm guessing in some languages the %d applied part could be different, but I'm not suret about that. I'll ask Claude to research it further. Thanks for raising.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on this one, _n() is the correct choice here.

Claude looked into how other languages handle this, findings below:

_n() exists so each locale can supply its own plural forms. The English strings only seed the source, so identical singular and plural text is fine. Many languages have more than two forms, and the participle agrees with the count:

  • Polish has three forms (1, 2-4, 5+), eg. "zastosowany", "zastosowane", "zastosowanych".
  • Russian and Ukrainian also have three; Arabic has six.
  • With a plain __(), translators get a single string and can't match the number.

Core does the same thing. The plugin list table status links pass identical strings, eg. _n( 'Active <span class="count">(%s)</span>', 'Active <span class="count">(%s)</span>', $count ): https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-admin/includes/class-wp-plugins-list-table.php

The translator comment (%d: Number of filters applied.) already gives the needed context, so no change here.

: _x( 'Filter', 'verb' )
}
aria-expanded={ isPanelOpen }
isPressed={ isPanelOpen }
onClick={ () => setIsPanelOpen( ! isPanelOpen ) }
/>
{ panelFilterCount > 0 && (
<span
className="editor-collab-sidebar-filters__count"
aria-hidden="true"
>
{ panelFilterCount }
</span>
) }
</div>
</Stack>
{ isPanelOpen && (
<Stack direction="row" gap="sm" align="flex-start">
<WCSelectControl
label={ __( 'Status' ) }
value={ filters.status }
options={ [
{ label: __( 'All' ), value: 'all' },
{ label: __( 'Open' ), value: 'hold' },
{ label: __( 'Resolved' ), value: 'approved' },
] }
onChange={ ( status ) =>
onChange( { ...filters, status } )
}
/>
{ ( authors.size > 1 || filters.author !== 'all' ) && (
<WCSelectControl
label={ __( 'Author' ) }
value={ filters.author }
options={ [
{ label: __( 'All' ), value: 'all' },
...Array.from(
authors,
( [ value, label ] ) => ( {
label,
value,
} )
),
] }
onChange={ ( author ) =>
onChange( { ...filters, author } )
}
/>
) }
</Stack>
) }
</Stack>
);
}
13 changes: 12 additions & 1 deletion packages/editor/src/components/collab-sidebar/notes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import { store as editorStore } from '../../store';

const { useBlockElement } = unlock( blockEditorPrivateApis );

export function Notes( { notes, sidebarRef, isFloating = false } ) {
export function Notes( {
notes,
sidebarRef,
isFloating = false,
isFiltered = false,
} ) {
const {
onCreate: onAddReply,
onEdit: onEditNote,
Expand Down Expand Up @@ -187,6 +192,9 @@ export function Notes( { notes, sidebarRef, isFloating = false } ) {
const isAddingNote =
! isFloating && selectedNote === 'new' && !! selectedBlockClientId;

// When filters hide every thread, say so instead of an empty list.
const showNoResults = isFiltered && ! isAddingNote && ! threads.length;

return (
<Stack
className="editor-collab-sidebar-panel"
Expand All @@ -205,6 +213,9 @@ export function Notes( { notes, sidebarRef, isFloating = false } ) {
isFloating ? __( 'Unresolved notes' ) : __( 'All notes' )
}
>
{ showNoResults && (
<Text render={ <p /> }>{ __( 'No notes found.' ) }</Text>
) }
{ isAddingNote && (
<AddNote
key={ selectedBlockClientId }
Expand Down
33 changes: 33 additions & 0 deletions packages/editor/src/components/collab-sidebar/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -341,3 +341,36 @@ mark.wp-note {
.editor-collab-sidebar-panel__mention-suggestion .editor-autocompleters__user-avatar {
border-radius: $radius-round;
}

.editor-collab-sidebar-filters {
padding: $grid-unit-20 $grid-unit-20 0;
}

.editor-collab-sidebar-filters__search {
flex: 1;
min-width: 0;
}

.editor-collab-sidebar-filters__toggle {
position: relative;
flex-shrink: 0;
}

.editor-collab-sidebar-filters__count {
position: absolute;
top: 0;
right: 0;
transform: translate(50%, -50%);
background: var(--wpds-color-background-interactive-brand-strong);
height: var(--wpds-dimension-size-2xs);
min-width: var(--wpds-dimension-size-2xs);
line-height: var(--wpds-typography-line-height-xs);
padding: 0 var(--wpds-dimension-padding-xs);
text-align: center;
border-radius: var(--wpds-border-radius-lg);
font-size: 11px;
outline: var(--wpds-border-width-focus) solid var(--wpds-color-background-surface-neutral-strong);
color: var(--wpds-color-foreground-interactive-neutral-strong);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the brand variation of color token instead of neutral will match better with the brand token being used for the background.

Suggested change
color: var(--wpds-color-foreground-interactive-neutral-strong);
color: var(--wpds-color-foreground-interactive-brand-strong);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, applied in d7f0561.

box-sizing: border-box;
pointer-events: none;
}
Loading
Loading