Repository navigation
Notes: Add search and a status filter to the All Notes sidebar #84060
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
b93fe05
e29a9aa
a2ab021
8304986
b93d03b
0bb9cd2
b400b67
d7f0561
3510058
e353acc
e765ce7
22f7f59
6340690
a4a51b6
bd6e65f
8179dc2
1d9196b
6b286a0
2b48673
fbe30ae
74eed58
c2223b3
3576e7b
04be7df
8ade62a
4289f70
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 ) && | ||
| ( 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 | ||
| ) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Noticed that here
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Following up on this one, Claude looked into how other languages handle this, findings below:
|
||
| : _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> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good call, applied in d7f0561. |
||||||
| box-sizing: border-box; | ||||||
| pointer-events: none; | ||||||
| } | ||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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..
gutenberg/packages/editor/src/components/collab-sidebar/notes.jsx
Lines 168 to 178 in 40867b6
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