Skip to content
1 change: 1 addition & 0 deletions packages/dataviews/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

### Features

- Introduce locked filters, as filters that cannot be edited by the user. [#71075](https://github.com/WordPress/gutenberg/pull/71075)
- Add "groupBy" support to the table layout. [#71055](https://github.com/WordPress/gutenberg/pull/71055)
- Elements in the Field API can now provide an empty value that will be used instead of the default. [#70894](https://github.com/WordPress/gutenberg/pull/70894)
- Support Ctrl + Click / Cmd + Click for multiselecting rows in the Table layout ([#70891](https://github.com/WordPress/gutenberg/pull/70891)).
Expand Down
1 change: 1 addition & 0 deletions packages/dataviews/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ Properties:
- `field`: which field this filter is bound to.
- `operator`: which type of filter it is. See "Operator types".
- `value`: the actual value selected by the user.
- `isLocked`: whether the filter is locked (cannot be edited by the user).
- `perPage`: number of records to show per page.
- `page`: the page that is visible.
- `sort`:
Expand Down
20 changes: 15 additions & 5 deletions packages/dataviews/src/components/dataviews-filters/filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,8 +497,9 @@ export default function Filter( {
}

const isPrimary = filter.isPrimary;
const hasValues = filterInView?.value !== undefined;
const canResetOrRemove = ! isPrimary || hasValues;
const isLocked = filterInView?.isLocked;
const hasValues = ! isLocked && filterInView?.value !== undefined;
const canResetOrRemove = ! isLocked && ( ! isPrimary || hasValues );
return (
<Dropdown
defaultOpen={ openedFilter === filter.field }
Expand All @@ -523,17 +524,26 @@ export default function Filter( {
{
'has-reset': canResetOrRemove,
'has-values': hasValues,
'is-not-clickable': isLocked,
}
) }
role="button"
tabIndex={ 0 }
onClick={ onToggle }
tabIndex={ isLocked ? -1 : 0 }
onClick={ () => {
if ( ! isLocked ) {
onToggle();
}
} }
Copy link
Member

Choose a reason for hiding this comment

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

When hovering over the locked status chip, the cursor still appears as a pointer. I would expect a different cursor and for the hover effect to be disabled altogether. Does that make sense?

Screenshot 2025-08-06 at 18 00 36

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 updated this at 166ba06 and also prevented locked filters from participating in the tab sequence (makes sense for primary but not for filters you cannot actually interact with).

onKeyDown={ ( event ) => {
if ( [ ENTER, SPACE ].includes( event.key ) ) {
if (
! isLocked &&
[ ENTER, SPACE ].includes( event.key )
) {
onToggle();
event.preventDefault();
}
} }
aria-disabled={ isLocked }
aria-pressed={ isOpen }
aria-expanded={ isOpen }
ref={ toggleRef }
Expand Down
19 changes: 17 additions & 2 deletions packages/dataviews/src/components/dataviews-filters/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export function useFilters( fields: NormalizedField< any >[], view: View ) {

const operators = field.filterBy.operators;
const isPrimary = !! field.filterBy?.isPrimary;
const isLocked =
view.filters?.some(
( f ) => f.field === field.id && !! f.isLocked
) ?? false;
filters.push( {
field: field.id,
name: field.label,
Expand All @@ -45,18 +49,29 @@ export function useFilters( fields: NormalizedField< any >[], view: View ) {
),
operators,
isVisible:
isLocked ||
isPrimary ||
!! view.filters?.some(
( f ) =>
f.field === field.id &&
ALL_OPERATORS.includes( f.operator )
),
isPrimary,
isLocked,
} );
Copy link
Member

Choose a reason for hiding this comment

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

Should we consider sorting locked filters prominently like primary filters below for a better user experience?

Copy link
Member Author

Choose a reason for hiding this comment

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

Great thinking, thanks. I've made locked filters first, then primary, then the rest at 71931f0 I haven't pushed any other UI changes.

} );
// Sort filters by primary property. We need the primary filters to be first.
// Then we sort by name.

// Sort filters by:
// - locked filters go first
// - primary filters go next
// - then, sort by name
filters.sort( ( a, b ) => {
if ( a.isLocked && ! b.isLocked ) {
return -1;
}
if ( ! a.isLocked && b.isLocked ) {
return 1;
}
if ( a.isPrimary && ! b.isPrimary ) {
return -1;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export default function ResetFilter( {
! view.search &&
! view.filters?.some(
( _filter ) =>
_filter.value !== undefined || ! isPrimary( _filter.field )
! _filter.isLocked &&
( _filter.value !== undefined || ! isPrimary( _filter.field ) )
);
return (
<Button
Expand All @@ -42,7 +43,8 @@ export default function ResetFilter( {
...view,
page: 1,
search: '',
filters: [],
filters:
view.filters?.filter( ( f ) => !! f.isLocked ) || [],
} );
} }
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,15 @@
align-items: center;
box-sizing: border-box;

&.is-not-clickable {
cursor: default;
}

&.has-reset {
padding-inline-end: $button-size-small + $grid-unit-05;
}

&:hover,
&:hover:not(&.is-not-clickable),
&:focus-visible,
&[aria-expanded="true"] {
background: $gray-200;
Expand Down
25 changes: 22 additions & 3 deletions packages/dataviews/src/components/dataviews/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import type { ReactNode, ComponentProps, ReactElement } from 'react';
* WordPress dependencies
*/
import { __experimentalHStack as HStack } from '@wordpress/components';
import { useContext, useMemo, useRef, useState } from '@wordpress/element';
import {
useContext,
useMemo,
useRef,
useState,
useEffect,
} from '@wordpress/element';
import { useResizeObserver } from '@wordpress/compose';

/**
Expand Down Expand Up @@ -170,10 +176,23 @@ function DataViews< Item >( {
}, [ selection, data, getItemId ] );

const filters = useFilters( _fields, view );
const [ isShowingFilter, setIsShowingFilter ] = useState< boolean >( () =>
( filters || [] ).some( ( filter ) => filter.isPrimary )
const hasPrimaryOrLockedFilters = useMemo(
() =>
( filters || [] ).some(
( filter ) => filter.isPrimary || filter.isLocked
),
[ filters ]
);
const [ isShowingFilter, setIsShowingFilter ] = useState< boolean >(
hasPrimaryOrLockedFilters
);

useEffect( () => {
if ( hasPrimaryOrLockedFilters && ! isShowingFilter ) {
setIsShowingFilter( true );
Copy link
Member

Choose a reason for hiding this comment

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

The effect sets isShowingFilter to true, but it never resets it to false. Could this lead to any potential edge cases? Also, I wonder if it's possible to derive the value directly using useMemo instead, so we don't need to have useEffect and useState?

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 state is previous to this PR, and this change fixes a bug: the current code doesn't consider that fields can be updated (e.g., they are empty when dataviews is mounted and updated later). The bug can be reproduced by having a primary filter in this screen (expected: is visible, actual: is not).

I've refactored a bit at b69b582

}
}, [ hasPrimaryOrLockedFilters, isShowingFilter ] );

return (
<DataViewsContext.Provider
value={ {
Expand Down
10 changes: 10 additions & 0 deletions packages/dataviews/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ export interface Filter {
* The value to filter by.
*/
value: any;

/**
* Whether the filter can be edited by the user.
*/
isLocked?: boolean;
}

export interface NormalizedFilter {
Expand Down Expand Up @@ -346,6 +351,11 @@ export interface NormalizedFilter {
* Whether it is a primary filter.
*/
isPrimary: boolean;

/**
* Whether the filter can be edited by the user.
*/
isLocked: boolean;
}

interface ViewBase {
Expand Down
43 changes: 1 addition & 42 deletions packages/edit-site/src/components/post-list/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,26 +223,10 @@ export default function PostList( { postType } ) {
},
[ location.path, location.query.isCustom, history ]
);
const getActiveViewFilters = ( views, match ) => {
const found = views.find( ( { slug } ) => slug === match );
return found?.filters ?? [];
};

const { isLoading: isLoadingFields, fields: _fields } = usePostFields( {
const { isLoading: isLoadingFields, fields: fields } = usePostFields( {
postType,
} );
const fields = useMemo( () => {
const activeViewFilters = getActiveViewFilters(
defaultViews,
activeView
).map( ( { field } ) => field );
return _fields.map( ( field ) => ( {
...field,
...( activeViewFilters.includes( field.id )
? { filterBy: false }
: {} ),
} ) );
}, [ _fields, defaultViews, activeView ] );

const queryArgs = useMemo( () => {
const filters = {};
Expand All @@ -266,31 +250,6 @@ export default function PostList( { postType } ) {
}
} );

// The bundled views want data filtered without displaying the filter.
const activeViewFilters = getActiveViewFilters(
defaultViews,
activeView
);
activeViewFilters.forEach( ( filter ) => {
if (
filter.field === 'status' &&
filter.operator === OPERATOR_IS_ANY
) {
filters.status = filter.value;
}
if (
filter.field === 'author' &&
filter.operator === OPERATOR_IS_ANY
) {
filters.author = filter.value;
} else if (
filter.field === 'author' &&
filter.operator === OPERATOR_IS_NONE
) {
filters.author_exclude = filter.value;
}
} );

// We want to provide a different default item for the status filter
// than the REST API provides.
if ( ! filters.status || filters.status === '' ) {
Expand Down
Loading
Loading