Skip to content
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

Add author and status filter to the page list #55270

Merged
merged 41 commits into from
Oct 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
eb63c42
Add skeleton for filters
oandregal Oct 11, 2023
f8ec790
List filterable fields
oandregal Oct 11, 2023
55e2dbb
Keep track of filters enabled/disabled
oandregal Oct 11, 2023
9450732
Render filter author
oandregal Oct 11, 2023
0e0328c
Filter by author
oandregal Oct 11, 2023
9fac3a5
Check author filter
oandregal Oct 11, 2023
cd42338
Add none to authors
oandregal Oct 11, 2023
fde57a7
Better description
oandregal Oct 11, 2023
8277b89
Extract field filters to own component
oandregal Oct 13, 2023
a23f699
FieldFilters: refactor to use fields instead of DataView
oandregal Oct 13, 2023
b76394b
AddFilter: refactor to use fields instead of DataView
oandregal Oct 13, 2023
22a3d87
Generate the set filter based on field props
oandregal Oct 13, 2023
32e06e4
Check for element structure: id, name
oandregal Oct 13, 2023
c783215
Bootstrap filter by column
oandregal Oct 13, 2023
6fb27a1
Show elements on the list
oandregal Oct 13, 2023
4804e94
Set filter working on columns
oandregal Oct 13, 2023
1e187f4
Remove old exploration
oandregal Oct 13, 2023
6b1b68f
Clean up
oandregal Oct 13, 2023
8a85762
Show fields filters
oandregal Oct 13, 2023
60accfd
Better style for field filter
oandregal Oct 13, 2023
ce868f6
Show field name
oandregal Oct 13, 2023
2fd1ebc
Show filter value as well
oandregal Oct 13, 2023
56309e8
Add NOT IN filter
oandregal Oct 13, 2023
05df50a
Search filter cannot be enabled as a fallback
oandregal Oct 13, 2023
b87eece
Add top-level filter for author
oandregal Oct 17, 2023
f7796e6
Remove filters from fields
oandregal Oct 17, 2023
0756dce
Rename setList to elements
oandregal Oct 17, 2023
0e3a71a
Remove column filters
oandregal Oct 17, 2023
7a8433a
Filters are destructured into the query
oandregal Oct 17, 2023
4f5b193
Make search part of filters
oandregal Oct 17, 2023
e339b28
Leftover from previous experiments
oandregal Oct 17, 2023
f39126d
Implement post status in filter
oandregal Oct 17, 2023
ba82810
Make filters take the data from the fields
oandregal Oct 17, 2023
27bcf78
Use a scalar value instead of an array
oandregal Oct 17, 2023
9ed4bdd
Nicer looking select controls
oandregal Oct 17, 2023
61c5565
Make CSS class generic
oandregal Oct 17, 2023
1fd75cd
Generate filters based on view.layout metadata
oandregal Oct 17, 2023
280dbd2
fixup rebase
oandregal Oct 17, 2023
f7b0708
Migrate view.layout.filters to view.visibleFilters and field.filters
oandregal Oct 17, 2023
14b2ab8
Add search filter
oandregal Oct 17, 2023
4bd0987
view.filters: remove undefined values
oandregal Oct 17, 2023
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
24 changes: 16 additions & 8 deletions packages/edit-site/src/components/dataviews/dataviews.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import ViewList from './view-list';
import Pagination from './pagination';
import ViewActions from './view-actions';
import TextFilter from './text-filter';
import Filters from './filters';
import { ViewGrid } from './view-grid';

export default function DataViews( {
Expand All @@ -28,13 +28,21 @@ export default function DataViews( {
return (
<div className="dataviews-wrapper">
<VStack spacing={ 4 }>
<HStack justify="space-between">
<TextFilter view={ view } onChangeView={ onChangeView } />
<ViewActions
fields={ fields }
view={ view }
onChangeView={ onChangeView }
/>
<HStack>
<HStack justify="start">
<Filters
fields={ fields }
view={ view }
onChangeView={ onChangeView }
/>
</HStack>
<HStack justify="end">
<ViewActions
fields={ fields }
view={ view }
onChangeView={ onChangeView }
/>
</HStack>
</HStack>
<ViewComponent
fields={ fields }
Expand Down
57 changes: 57 additions & 0 deletions packages/edit-site/src/components/dataviews/filters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import TextFilter from './text-filter';
import InFilter from './in-filter';

export default function Filters( { fields, view, onChangeView } ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I like this new component

const filters = {};
fields.forEach( ( field ) => {
if ( ! field.filters ) {
return;
}

field.filters.forEach( ( f ) => {
filters[ f.id ] = { type: f.type };
} );
} );

return (
view.visibleFilters?.map( ( filterName ) => {
const filter = filters[ filterName ];

if ( ! filter ) {
return null;
}

if ( filter.type === 'search' ) {
return (
<TextFilter
key={ filterName }
id={ filterName }
view={ view }
onChangeView={ onChangeView }
/>
);
}
if ( filter.type === 'enumeration' ) {
return (
<InFilter
key={ filterName }
id={ filterName }
fields={ fields }
view={ view }
onChangeView={ onChangeView }
/>
);
}

return null;
} ) || __( 'No filters available' )
);
}
47 changes: 47 additions & 0 deletions packages/edit-site/src/components/dataviews/in-filter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* WordPress dependencies
*/
import {
__experimentalInputControlPrefixWrapper as InputControlPrefixWrapper,
SelectControl,
} from '@wordpress/components';
import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';

/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';

const { cleanEmptyObject } = unlock( blockEditorPrivateApis );

export default ( { id, fields, view, onChangeView } ) => {
const field = fields.find( ( f ) => f.id === id );

return (
<SelectControl
value={ view.filters[ id ] }
prefix={
<InputControlPrefixWrapper
as="span"
className="dataviews__select-control-prefix"
>
{ field.header + ':' }
</InputControlPrefixWrapper>
}
options={ field?.elements || [] }
onChange={ ( value ) => {
if ( value === '' ) {
value = undefined;
}

onChangeView( ( currentView ) => ( {
...currentView,
filters: cleanEmptyObject( {
...currentView.filters,
[ id ]: value,
} ),
} ) );
} }
/>
);
};
2 changes: 1 addition & 1 deletion packages/edit-site/src/components/dataviews/pagination.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function PageSizeControl( { view, onChangeView } ) {
prefix={
<InputControlPrefixWrapper
as="span"
className="dataviews__per-page-control-prefix"
className="dataviews__select-control-prefix"
>
{ label }
</InputControlPrefixWrapper>
Expand Down
2 changes: 1 addition & 1 deletion packages/edit-site/src/components/dataviews/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
}
}

.dataviews__per-page-control-prefix {
.dataviews__select-control-prefix {
color: $gray-700;
text-wrap: nowrap;
}
Expand Down
9 changes: 6 additions & 3 deletions packages/edit-site/src/components/dataviews/text-filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import { SearchControl } from '@wordpress/components';
*/
import useDebouncedInput from '../../utils/use-debounced-input';

export default function TextFilter( { view, onChangeView } ) {
export default function TextFilter( { id, view, onChangeView } ) {
oandregal marked this conversation as resolved.
Show resolved Hide resolved
const [ search, setSearch, debouncedSearch ] = useDebouncedInput(
view.search
view.filters[ id ]
);
const onChangeViewRef = useRef( onChangeView );
useEffect( () => {
Expand All @@ -21,8 +21,11 @@ export default function TextFilter( { view, onChangeView } ) {
useEffect( () => {
onChangeViewRef.current( ( currentView ) => ( {
...currentView,
search: debouncedSearch,
page: 1,
filters: {
...currentView.filters,
[ id ]: debouncedSearch,
},
} ) );
}, [ debouncedSearch ] );
const searchLabel = __( 'Filter list' );
Expand Down
41 changes: 37 additions & 4 deletions packages/edit-site/src/components/page-pages/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,17 @@ const defaultConfigPerViewType = {
export default function PagePages() {
const [ view, setView ] = useState( {
type: 'list',
search: '',
filters: {
search: '',
status: 'publish, draft',
Copy link
Member

Choose a reason for hiding this comment

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

I think for enumerations we are going to support "is" filters and "is not" filters. Should our structure take that into account and use something like:

status: { is: 'publish, draft' }

or

status: 'is: publish, draft' 

Copy link
Member Author

@oandregal oandregal Oct 17, 2023

Choose a reason for hiding this comment

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

I'd like to implement a NOT IN filter in a follow-up to showcase how it can work. In a previous exploration (filters in columns), I did it by declaring this metadata in the field:

filters: [
  { id: 'author', type: 'in' },
  { id: 'author_exclude', type: 'notIN' },
]

I think something like that is doable for top-level filters as well.

And the view would hold the data as it's passed to the endpoint:

{
  filters: {
    search: '...',
    author_exclude: '...',
    author: '...'
  }
}

Copy link
Member

Choose a reason for hiding this comment

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

I think at the UI level a single component will render the in and not in filter. That seems to be what happens in current applications.
So I don't think we should have this shape:

filters: [
  { id: 'author', type: 'in' },
  { id: 'author_exclude', type: 'notIN' },
]

Because in and notIn are a single type of filter that renders a UI that will allow to pick an author part of the set or exclude an author from the set. Rendering two components separately will easily pollute the UI.

The same for text filters, if a field is of type text its filter will allow in a single component things like contains, starts with etc.

Copy link
Member Author

Choose a reason for hiding this comment

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

I went with your suggestion type: enumeration. I think that could be enough as well, though, there's a subtle difference between IN and NOT IN filters: the label for the reset state. For IN filters we want All and for NOT IN filters we want None. We could also find a string that works for both.

#55440 changes a bit how the "reset option" works: having one by default but allowing filters to change it, should they need. This would help to provide a different string for NOT IN filters, for example, while we maintain the filter type as enumeration.

Copy link
Member Author

Choose a reason for hiding this comment

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

#55508 tries to bring back the column filters exploration, so revisits the idea of more granularity for filter types (see comment about enumeration vs enumeration_in/enumeration_not_in).

},
page: 1,
perPage: 5,
sort: {
field: 'date',
direction: 'desc',
},
visibleFilters: [ 'search', 'author', 'status' ],
// All fields are visible by default, so it's
// better to keep track of the hidden ones.
hiddenFields: [ 'date', 'featured-image' ],
Expand All @@ -63,8 +67,7 @@ export default function PagePages() {
_embed: 'author',
order: view.sort?.direction,
orderby: view.sort?.field,
search: view.search,
status: [ 'publish', 'draft' ],
...view.filters,
} ),
[ view ]
);
Expand All @@ -75,6 +78,10 @@ export default function PagePages() {
totalPages,
} = useEntityRecords( 'postType', 'page', queryArgs );

const { records: authors } = useEntityRecords( 'root', 'user', {
Copy link
Member Author

@oandregal oandregal Oct 13, 2023

Choose a reason for hiding this comment

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

We don't need to have this data up-front, only when the user goes to filter (or edit cells, in the future). This data could be lazy loaded upon events (opening the filter menu, etc.). Leave the comment here as food for thought to be addressed in follow-ups.

Copy link
Contributor

Choose a reason for hiding this comment

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

In general this is a filter that we shouldn't pre load all records, not only for performance, but also about the REST API limits to 100.

Copy link
Member Author

Choose a reason for hiding this comment

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

My comment is a bit stale, since the approach has changed: the filter is now always present in the UI (before it was only visible as part of the columns' actions). Does that change your thinking? What should we do here instead?

who: 'authors',
} );

const paginationInfo = useMemo(
() => ( {
totalItems,
Expand Down Expand Up @@ -126,6 +133,7 @@ export default function PagePages() {
</VStack>
);
},
filters: [ { id: 'search', type: 'search' } ],
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think the "search" is the same thing as "title". One is a "global search" that searches in multiple fields and not just the "title".

Copy link
Member Author

Choose a reason for hiding this comment

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

I agree, search is different from the other filters.

I shared some thoughts in this thread. The way I see it, search is a multi field filter that searches across post_title, post_excerpt, and post_content while the other filters use a single field. Note that search can also be single-field if search_columns is set to post_title. It's a bit weird. Conceptually, all of them are filters and share the same characteristics: endpoints may or may not allow a dataset to be filtered by this type of filter, the endpoint query parameter can be different, the user should be able to hide/show the filter, etc.

We may need a special treatment for filters like this one. I know tanstack differentiates between global and column filters, but I'm not sure if that's enough for us. We may need more granularity, allowing filters to declare in which context they should be rendered: top-level, table view (columns), grid view, kanban, etc.

I'd like to stretch it a bit and see how far it gets us. I'll prepare a follow-up to explore rendering the filters in the columns, and that should inform the needs for this API.

Copy link
Member Author

Choose a reason for hiding this comment

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

Extracting search out of fields at #55475

maxWidth: 400,
sortingFn: 'alphanumeric',
enableHiding: false,
Expand All @@ -142,12 +150,37 @@ export default function PagePages() {
</a>
);
},
filters: [ { id: 'author', type: 'enumeration' } ],
elements: [
Copy link
Member Author

Choose a reason for hiding this comment

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

The elements defined by a field could be used in different scenarios: filters, when editing the value of the cell, etc.

{
value: '',
label: __( 'All' ),
},
...( authors?.map( ( { id, name } ) => ( {
value: id,
label: name,
} ) ) || [] ),
],
},
{
header: __( 'Status' ),
id: 'status',
accessorFn: ( page ) =>
postStatuses[ page.status ] ?? page.status,
filters: [ { type: 'enumeration', id: 'status' } ],
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the "id" here is a bit weird, it seems to just duplicate the field id for me.

Copy link
Member Author

Choose a reason for hiding this comment

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

#55440 addresses this.

elements: [
{ label: __( 'All' ), value: 'publish,draft' },
Copy link
Contributor

Choose a reason for hiding this comment

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

Why are we limiting to "publish" and "draft"? There might be more statuses like "schedule"... Why not use the REST API for this. Also, why "all" has a value and not just "unset" the filter.

I also believe the "all" could be added automatically and not provided in this elements config.

Copy link
Member Author

Choose a reason for hiding this comment

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

Why are we limiting to "publish" and "draft"? There might be more statuses like "schedule"... Why not use the REST API for this. Also, why "all" has a value and not just "unset" the filter.

I don't know. This was in the original query. If all statuses are used, we don't need to provide the "unset" value for the endpoint. I'll look into this in a follow-up.

I also believe the "all" could be added automatically and not provided in this elements config.

This is addressed at #55440

Copy link
Member Author

Choose a reason for hiding this comment

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

Listing pages with any status at #55476

...( ( postStatuses &&
Object.entries( postStatuses )
.filter( ( [ slug ] ) =>
[ 'publish', 'draft' ].includes( slug )
)
.map( ( [ slug, name ] ) => ( {
value: slug,
label: name,
} ) ) ) ||
[] ),
],
enableSorting: false,
},
{
Expand All @@ -163,7 +196,7 @@ export default function PagePages() {
enableSorting: false,
},
],
[ postStatuses ]
[ postStatuses, authors ]
);

const trashPostAction = useTrashPostAction();
Expand Down
Loading