├── .gitignore ├── src ├── taxonomy │ ├── style-index.css │ ├── index.js │ ├── view.js │ ├── block.json │ ├── render.php │ └── edit.js └── post-type │ ├── index.js │ ├── block.json │ ├── render.php │ └── edit.js ├── composer.json ├── package.json ├── query-filter.php ├── CONTRIBUTING.md ├── README.md └── inc └── namespace.php /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /vendor/ 4 | -------------------------------------------------------------------------------- /src/taxonomy/style-index.css: -------------------------------------------------------------------------------- 1 | @view-transition { 2 | navigation: auto; 3 | } 4 | 5 | .wp-block-query-filter { 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: stretch; 9 | } 10 | -------------------------------------------------------------------------------- /src/post-type/index.js: -------------------------------------------------------------------------------- 1 | import { registerBlockType } from '@wordpress/blocks'; 2 | import Edit from './edit'; 3 | import metadata from './block.json'; 4 | 5 | registerBlockType( metadata.name, { 6 | /** 7 | * @see ./edit.js 8 | */ 9 | edit: Edit, 10 | } ); 11 | -------------------------------------------------------------------------------- /src/taxonomy/index.js: -------------------------------------------------------------------------------- 1 | import { registerBlockType } from '@wordpress/blocks'; 2 | import Edit from './edit'; 3 | import metadata from './block.json'; 4 | import './style-index.css'; 5 | 6 | registerBlockType( metadata.name, { 7 | /** 8 | * @see ./edit.js 9 | */ 10 | edit: Edit, 11 | } ); 12 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "humanmade/query-filter", 3 | "description": "Query Loop Block filters", 4 | "type": "wordpress-plugin", 5 | "require": { 6 | "composer/installers": "^1 || ^2" 7 | }, 8 | "license": "GPL-2.0-or-later", 9 | "authors": [ 10 | { 11 | "name": "Human Made Limited", 12 | "email": "hello@humanmade.com" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "query-loop-filter", 3 | "private": true, 4 | "description": "Filter blocks for query loops", 5 | "scripts": { 6 | "build": "wp-scripts build --experimental-modules", 7 | "start": "wp-scripts start --experimental-modules", 8 | "format": "wp-scripts format", 9 | "lint:css": "wp-scripts lint-style --fix", 10 | "lint:js": "wp-scripts lint-js", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "author": "Human Made Limited", 14 | "license": "GPL-2.0-or-later", 15 | "dependencies": { 16 | "@wordpress/scripts": "^28.6.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /query-filter.php: -------------------------------------------------------------------------------- 1 | { 4 | const url = new URL( action ); 5 | if ( value || name === 's' ) { 6 | url.searchParams.set( name, value ); 7 | } else { 8 | url.searchParams.delete( name ); 9 | } 10 | const { actions } = await import( '@wordpress/interactivity-router' ); 11 | await actions.navigate( url.toString() ); 12 | }; 13 | 14 | const { state } = store( 'query-filter', { 15 | actions: { 16 | *navigate( e ) { 17 | e.preventDefault(); 18 | const { actions } = yield import( 19 | '@wordpress/interactivity-router' 20 | ); 21 | yield actions.navigate( e.target.value ); 22 | }, 23 | *search( e ) { 24 | e.preventDefault(); 25 | const { ref } = getElement(); 26 | let action, name, value; 27 | if ( ref.tagName === 'FORM' ) { 28 | const input = ref.querySelector( 'input[type="search"]' ); 29 | action = ref.action; 30 | name = input.name; 31 | value = input.value; 32 | } else { 33 | action = ref.closest( 'form' ).action; 34 | name = ref.name; 35 | value = ref.value; 36 | } 37 | 38 | // Don't navigate if the search didn't really change. 39 | if ( value === state.searchValue ) return; 40 | 41 | state.searchValue = value; 42 | 43 | yield updateURL( action, value, name ); 44 | }, 45 | }, 46 | } ); 47 | -------------------------------------------------------------------------------- /src/post-type/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemas.wp.org/trunk/block.json", 3 | "apiVersion": 2, 4 | "name": "query-filter/post-type", 5 | "version": "0.1.0", 6 | "title": "Post Type Filter", 7 | "category": "theme", 8 | "icon": "filter", 9 | "description": "Allows users to filter by post type when placed wihin a query loop block", 10 | "ancestor": [ "core/query" ], 11 | "usesContext": [ "queryId", "query" ], 12 | "supports": { 13 | "html": false, 14 | "className": true, 15 | "customClassName": true, 16 | "color": { 17 | "background": true, 18 | "text": true 19 | }, 20 | "typography": { 21 | "fontSize": true, 22 | "textAlign": true, 23 | "lineHeight": true, 24 | "__experimentalFontFamily": true, 25 | "__experimentalFontWeight": true, 26 | "__experimentalFontStyle": true, 27 | "__experimentalTextTransform": true, 28 | "__experimentalTextDecoration": true, 29 | "__experimentalLetterSpacing": true, 30 | "__experimentalDefaultControls": { 31 | "fontSize": true 32 | } 33 | }, 34 | "spacing": { 35 | "margin": true, 36 | "padding": true, 37 | "blockGap": true 38 | }, 39 | "interactivity": { 40 | "clientNavigation": true 41 | } 42 | }, 43 | "attributes": { 44 | "emptyLabel": { 45 | "type": "string", 46 | "default": "" 47 | }, 48 | "label": { 49 | "type": "string" 50 | }, 51 | "showLabel": { 52 | "type": "boolean", 53 | "default": true 54 | } 55 | }, 56 | "textdomain": "query-filter", 57 | "editorScript": "file:./index.js", 58 | "viewScriptModule": "query-filter-taxonomy-view-script-module", 59 | "style": "query-filter-view", 60 | "render": "file:./render.php" 61 | } 62 | -------------------------------------------------------------------------------- /src/taxonomy/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemas.wp.org/trunk/block.json", 3 | "apiVersion": 3, 4 | "name": "query-filter/taxonomy", 5 | "version": "0.1.0", 6 | "title": "Taxonomy Filter", 7 | "category": "theme", 8 | "icon": "filter", 9 | "description": "Allows users to filter by taxonomy terms when placed wihin a query loop block", 10 | "ancestor": [ "core/query" ], 11 | "usesContext": [ "queryId", "query" ], 12 | "supports": { 13 | "html": false, 14 | "className": true, 15 | "customClassName": true, 16 | "color": { 17 | "background": true, 18 | "text": true 19 | }, 20 | "typography": { 21 | "fontSize": true, 22 | "textAlign": true, 23 | "lineHeight": true, 24 | "__experimentalFontFamily": true, 25 | "__experimentalFontWeight": true, 26 | "__experimentalFontStyle": true, 27 | "__experimentalTextTransform": true, 28 | "__experimentalTextDecoration": true, 29 | "__experimentalLetterSpacing": true, 30 | "__experimentalDefaultControls": { 31 | "fontSize": true 32 | } 33 | }, 34 | "spacing": { 35 | "margin": true, 36 | "padding": true, 37 | "blockGap": true 38 | }, 39 | "interactivity": { 40 | "clientNavigation": true 41 | } 42 | }, 43 | "attributes": { 44 | "taxonomy": { 45 | "type": "string" 46 | }, 47 | "emptyLabel": { 48 | "type": "string", 49 | "default": "" 50 | }, 51 | "label": { 52 | "type": "string" 53 | }, 54 | "showLabel": { 55 | "type": "boolean", 56 | "default": true 57 | } 58 | }, 59 | "textdomain": "query-filter", 60 | "editorScript": "file:./index.js", 61 | "style": "query-filter-view", 62 | "viewScriptModule": "file:./view.js", 63 | "render": "file:./render.php" 64 | } 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Query Loop Filters 2 | 3 |  4 | 5 | This plugin allows you to easily add filters to any query loop block. 6 | 7 | Provides 2 new blocks that can be added within a query loop block to allow filtering by either post type or a taxonomy. Also supports using the core search block to allow you to search. 8 | 9 | Compatible with both the core query loop block and the [Advanced query loop plugin](https://wordpress.org/plugins/advanced-query-loop/) (In fact, in order to use post type filters, use of the Advanced Query Loop plugin is required). 10 | 11 | Easy to use and lightweight, built using the WordPress Interactivity API. 12 | 13 | ## Usage 14 | 15 | * Add a query block. This can anyhere that the query block is supported e.g. page, template, or pattern. 16 | * Add one of the filter blocks and configure as required: 17 | * Taxonomy filter. Select which taxonomy to to use, customise the label (and whether it's shown), and customise the text used when none is selected. 18 | * Post type filter. Customise the label (and whether it's shown), as well as the text used when no filter is applied. 19 | * Search block. No extra options. 20 | 21 |  22 | 23 | ## Installation 24 | 25 | ### Using Composer 26 | 27 | This plugin is available on packagist. 28 | 29 | `composer require humanmade/query-filter` 30 | 31 | ### Manually from Github. 32 | 33 | 1. Download the plugin from the [GitHub repository](https://github.com/humanmade/query-filter). 34 | 2. Upload the plugin to your site's `wp-content/plugins` directory. 35 | 3. Activate the plugin from the WordPress admin. 36 | -------------------------------------------------------------------------------- /src/taxonomy/render.php: -------------------------------------------------------------------------------- 1 | context['query']['inherit'] ) ) { 11 | $query_id = $block->context['queryId'] ?? 0; 12 | $query_var = sprintf( 'query-%d-%s', $query_id, $attributes['taxonomy'] ); 13 | $page_var = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; 14 | $base_url = remove_query_arg( [ $query_var, $page_var ] ); 15 | } else { 16 | $query_var = sprintf( 'query-%s', $attributes['taxonomy'] ); 17 | $page_var = 'page'; 18 | $base_url = str_replace( '/page/' . get_query_var( 'paged' ), '', remove_query_arg( [ $query_var, $page_var ] ) ); 19 | } 20 | 21 | $terms = get_terms( [ 22 | 'hide_empty' => true, 23 | 'taxonomy' => $attributes['taxonomy'], 24 | 'number' => 100, 25 | ] ); 26 | 27 | if ( is_wp_error( $terms ) || empty( $terms ) ) { 28 | return; 29 | } 30 | ?> 31 | 32 |
WP_Query as parsed by the block context.
64 | */
65 | function filter_query_loop_block_query_vars( array $query, \WP_Block $block, int $page ) : array {
66 | if ( isset( $block->context['queryId'] ) ) {
67 | $query['query_id'] = $block->context['queryId'];
68 | }
69 |
70 | return $query;
71 | }
72 |
73 | /**
74 | * Fires after the query variable object is created, but before the actual query is run.
75 | *
76 | * @param WP_Query $query The WP_Query instance (passed by reference).
77 | */
78 | function pre_get_posts_transpose_query_vars( WP_Query $query ) : void {
79 | $query_id = $query->get( 'query_id', null );
80 |
81 | if ( ! $query->is_main_query() && is_null( $query_id ) ) {
82 | return;
83 | }
84 |
85 | $prefix = $query->is_main_query() ? 'query-' : "query-{$query_id}-";
86 | $tax_query = [];
87 | $valid_keys = [
88 | 'post_type' => $query->is_search() ? 'any' : 'post',
89 | 's' => '',
90 | ];
91 |
92 | // Preserve valid params for later retrieval.
93 | foreach ( $valid_keys as $key => $default ) {
94 | $query->set(
95 | "query-filter-$key",
96 | $query->get( $key, $default )
97 | );
98 | }
99 |
100 | // Map get params to this query.
101 | foreach ( $_GET as $key => $value ) {
102 | if ( strpos( $key, $prefix ) === 0 ) {
103 | $key = str_replace( $prefix, '', $key );
104 | $value = sanitize_text_field( urldecode( wp_unslash( $value ) ) );
105 |
106 | // Handle taxonomies specifically.
107 | if ( get_taxonomy( $key ) ) {
108 | $tax_query['relation'] = 'AND';
109 | $tax_query[] = [
110 | 'taxonomy' => $key,
111 | 'terms' => [ $value ],
112 | 'field' => 'slug',
113 | ];
114 | } else {
115 | // Other options should map directly to query vars.
116 | $key = sanitize_key( $key );
117 |
118 | if ( ! in_array( $key, array_keys( $valid_keys ), true ) ) {
119 | continue;
120 | }
121 |
122 | $query->set(
123 | $key,
124 | $value
125 | );
126 | }
127 | }
128 | }
129 |
130 | if ( ! empty( $tax_query ) ) {
131 | $existing_query = $query->get( 'tax_query', [] );
132 |
133 | if ( ! empty( $existing_query ) ) {
134 | $tax_query = [
135 | 'relation' => 'AND',
136 | [ $existing_query ],
137 | $tax_query,
138 | ];
139 | }
140 |
141 | $query->set( 'tax_query', $tax_query );
142 | }
143 | }
144 |
145 | /**
146 | * Filters the settings determined from the block type metadata.
147 | *
148 | * @param array $metadata Metadata provided for registering a block type.
149 | * @return array Array of metadata for registering a block type.
150 | */
151 | function filter_block_type_metadata( array $metadata ) : array {
152 | // Add query context to search block.
153 | if ( $metadata['name'] === 'core/search' ) {
154 | $metadata['usesContext'] = array_merge( $metadata['usesContext'] ?? [], [ 'queryId', 'query' ] );
155 | }
156 |
157 | return $metadata;
158 | }
159 |
160 | /**
161 | * Filters the content of a single block.
162 | *
163 | * @param string $block_content The block content.
164 | * @param array $block The full block, including name and attributes.
165 | * @param \WP_Block $instance The block instance.
166 | * @return string The block content.
167 | */
168 | function render_block_search( string $block_content, array $block, \WP_Block $instance ) : string {
169 | if ( empty( $instance->context['query'] ) ) {
170 | return $block_content;
171 | }
172 |
173 | wp_enqueue_script_module( 'query-filter-taxonomy-view-script-module' );
174 |
175 | $query_var = empty( $instance->context['query']['inherit'] )
176 | ? sprintf( 'query-%d-s', $instance->context['queryId'] ?? 0 )
177 | : 's';
178 |
179 | $action = str_replace( '/page/'. get_query_var( 'paged', 1 ), '', add_query_arg( [ $query_var => '' ] ) );
180 |
181 | // Note sanitize_text_field trims whitespace from start/end of string causing unexpected behaviour.
182 | $value = wp_unslash( $_GET[ $query_var ] ?? '' );
183 | $value = urldecode( $value );
184 | $value = wp_check_invalid_utf8( $value );
185 | $value = wp_pre_kses_less_than( $value );
186 | $value = strip_tags( $value );
187 |
188 | wp_interactivity_state( 'query-filter', [
189 | 'searchValue' => $value,
190 | ] );
191 |
192 | $block_content = new WP_HTML_Tag_Processor( $block_content );
193 | $block_content->next_tag( [ 'tag_name' => 'form' ] );
194 | $block_content->set_attribute( 'action', $action );
195 | $block_content->set_attribute( 'data-wp-interactive', 'query-filter' );
196 | $block_content->set_attribute( 'data-wp-on--submit', 'actions.search' );
197 | $block_content->set_attribute( 'data-wp-context', '{searchValue:""}' );
198 | $block_content->next_tag( [ 'tag_name' => 'input', 'class_name' => 'wp-block-search__input' ] );
199 | $block_content->set_attribute( 'name', $query_var );
200 | $block_content->set_attribute( 'inputmode', 'search' );
201 | $block_content->set_attribute( 'value', $value );
202 | $block_content->set_attribute( 'data-wp-bind--value', 'state.searchValue' );
203 | $block_content->set_attribute( 'data-wp-on--input', 'actions.search' );
204 |
205 | return (string) $block_content;
206 | }
207 |
208 | /**
209 | * Add data attributes to the query block to describe the block query.
210 | *
211 | * @param string $block_content Default query content.
212 | * @param array $block Parsed block.
213 | * @return string
214 | */
215 | function render_block_query( $block_content, $block ) {
216 | $block_content = new WP_HTML_Tag_Processor( $block_content );
217 | $block_content->next_tag();
218 |
219 | // Always allow region updates on interactivity, use standard core region naming.
220 | $block_content->set_attribute( 'data-wp-interactive', 'query-filter' );
221 | $block_content->set_attribute( 'data-wp-router-region', 'query-' . ( $block['attrs']['queryId'] ?? 0 ) );
222 |
223 | return (string) $block_content;
224 | }
225 |
--------------------------------------------------------------------------------