Skip to content

Commit 3f5a704

Browse files
authored
feat(collections): add hierarchical fields (#4051)
1 parent d445444 commit 3f5a704

9 files changed

Lines changed: 298 additions & 25 deletions

File tree

includes/collections/README.md

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,31 +44,47 @@ The Collections system is built around a custom post type (`newspack_collection`
4444

4545
## Backend components
4646

47-
### 1. Anatomy of a collection
47+
### Anatomy of a collection
4848

4949
A collection is a post of the `newspack_collection` CPT and a term of the `newspack_collection_taxonomy` at the same time. Both entities are linked together and share the same name.
5050

5151
A post is assigned to a collection via the `newspack_collection_taxonomy` taxonomy. Posts can also be organized in the `newspack_collection_section` taxonomy. When visiting the Collections page (single template for the collection CPT), posts will be listed and organized by the Sections taxonomy.
5252

5353
The collections themselves can also be categorized using the `newspack_collection_category` taxonomy. This will allow us to not only have one archive to list all the collections, but additional archives for each Collection category.
5454

55-
### 2. Collection custom post type ([`class-post-type.php`](class-post-type.php))
55+
### Global settings ([`class-settings.php`](class-settings.php))
56+
57+
Collections can be configured globally via the Newspack settings.
58+
The following nested options are available as properties of the `newspack_collections_settings` option array:
59+
60+
| Setting | Type | Description | Format |
61+
|---------|------|-------------|--------|
62+
| `custom_naming_enabled` | Boolean | Enable custom naming for collections | Boolean |
63+
| `custom_name` | String | Custom plural name for collections. Only affects the reader-facing nomenclature | Text (e.g., "Issues") |
64+
| `custom_singular_name` | String | Custom singular name for collections. Only affects the reader-facing nomenclature | Text (e.g., "Issue") |
65+
| `custom_slug` | String | Custom URL slug for collections. | Text (e.g., "issue") |
66+
| `subscribe_link` | String | Global subscription URL displayed on collection pages | Valid URL |
67+
| `order_link` | String | Global order URL for purchasing physical copies | Valid URL |
68+
69+
The `subscribe_link` and `order_link` settings provide defaults that can be overridden at the collection category level (via term meta) or collection level (via post meta).
70+
71+
### Collection custom post type ([`class-post-type.php`](class-post-type.php))
5672
- Defined as a `newspack_collection` CPT.
5773
- Supports: `title`, `editor`, `thumbnail`, `custom-fields`, and `page-attributes`.
5874
- Includes custom ordering functionality via `menu_order`.
5975
- Provides admin interface customizations.
6076

61-
### 3. Collection post meta fields ([`class-collection-meta.php`](class-collection-meta.php))
77+
### Collection post meta fields ([`class-collection-meta.php`](class-collection-meta.php))
6278

6379
The following table details all available meta fields for collections:
6480

6581
| Meta Field | Type | Description | Format |
66-
|------------|------|-------------|---------|
82+
|------------|------|-------------|--------|
6783
| `newspack_collection_volume` | String | Collection volume information | Text (e.g., "IV") |
6884
| `newspack_collection_number` | String | Collection number | Text (e.g., "#22") |
6985
| `newspack_collection_period` | String | Collection period | Text (e.g., "Spring 2025") |
70-
| `newspack_collection_subscribe_link` | String | A link to subscribe that will be displayed as a button on the collection page | Valid URL |
71-
| `newspack_collection_order_link` | String | A link to order the physical version of that collection | Valid URL |
86+
| `newspack_collection_subscribe_link` | String | Override global/category subscription link for this specific collection | Valid URL |
87+
| `newspack_collection_order_link` | String | Override global/category order link for this specific collection | Valid URL |
7288
| `newspack_collection_ctas` | Array | An array of CTAs (Call-to-Action buttons) | Array of objects with `label`, `type`, `id` and `url` properties |
7389

7490
Sample `newspack_collection_ctas` post meta structure:
@@ -89,36 +105,50 @@ Sample `newspack_collection_ctas` post meta structure:
89105

90106
Where `type` can either be `link` or `attachment`, and `id` is the attachment ID.
91107

92-
### 4. Data management ([`class-enqueuer.php`](class-enqueuer.php))
108+
### Data management ([`class-enqueuer.php`](class-enqueuer.php))
93109
- Manages JavaScript data localization.
94110
- Provides a common interface for adding/retrieving collection data dynamically.
95111
- Dynamically handles styles and scripts enqueuing in a single place if data is localized and passed to the frontend.
96112

97-
### 5. Taxonomies
113+
### Taxonomies
98114
The system includes multiple taxonomy classes for organizing collections:
99115

100116
#### Collection category taxonomy ([`class-collection-category-taxonomy.php`](class-collection-category-taxonomy.php))
101117
- Taxonomy name: `newspack_collection_category`.
102118
- Non-hierarchical taxonomy for categorizing collections.
103119
- Similar to WordPress post categories.
120+
- Term meta fields:
121+
122+
| Meta Field | Type | Description | Format |
123+
|------------|------|-------------|--------|
124+
| `newspack_collection_subscribe_link` | String | Override global subscription link for the current collection category | Valid URL |
125+
| `newspack_collection_order_link` | String | Override global order link for the current collection category | Valid URL |
104126

105127
#### Collection section taxonomy ([`class-collection-section-taxonomy.php`](class-collection-section-taxonomy.php))
106128
- Taxonomy name: `newspack_collection_section`.
107129
- Non-hierarchical taxonomy for categorizing posts into sections.
108130
- Similar to WordPress tags.
109131
- Adds a new "Section" column to the post list.
110-
- Order is stored in the term meta `newspack_collection_section_order`.
132+
- Term meta fields:
133+
134+
| Meta Field | Type | Description | Format |
135+
|------------|------|-------------|--------|
136+
| `newspack_collection_section_order` | Integer | Order of the section | Integer |
111137

112138
#### Collection taxonomy ([`class-collection-taxonomy.php`](class-collection-taxonomy.php))
113139
- Taxonomy name: `newspack_collection_taxonomy`.
114-
- Special internal taxonomy (`newspack_collection_taxonomy`) for associating collections with posts.
140+
- Special internal taxonomy for associating collections with posts.
115141
- It's a copy of the collection post title and slug to allow regular posts to be tagged with collections.
116142
- Not publicly queryable.
117143
- Hidden from the admin UI.
118144
- Adds a new "Collection" column to the post list.
119-
- Terms can be deactivated via an internal `_newspack_collection_inactive` term meta. Used when trashing posts, as terms don't manage status.
145+
- Term meta fields:
146+
147+
| Meta Field | Type | Description | Format |
148+
|------------|------|-------------|--------|
149+
| `_newspack_collection_inactive` | Boolean | Internal. Whether the section is inactive. Used when trashing posts, as terms don't manage status. | Boolean |
120150

121-
### 6. Synchronization ([`class-sync.php`](class-sync.php))
151+
### Synchronization ([`class-sync.php`](class-sync.php))
122152
- Handles synchronization of collection posts and collection terms.
123153
- Ensures data consistency across objects using a two-way meta relationship:
124154
- For posts, link via `_newspack_collection_term_id` internal post meta.
@@ -135,9 +165,11 @@ Term edited -> Post edited (copy title and slug)
135165
Term deleted -> Post trashed (to prevent data loss)
136166
```
137167

138-
### 7. Post meta fields ([`class-post-meta.php`](class-post-meta.php))
139-
- Meta key: `newspack_order_in_collection`.
140-
- Used for storing the post order in collection.
168+
### Post meta fields ([`class-post-meta.php`](class-post-meta.php))
169+
170+
| Meta Field | Type | Description | Format |
171+
|------------|------|-------------|--------|
172+
| `newspack_order_in_collection` | Integer | Order of the post in a collection | Integer |
141173

142174
## Frontend components
143175

includes/collections/class-collection-category-taxonomy.php

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,78 @@
1717
class Collection_Category_Taxonomy {
1818

1919
/**
20-
* Taxonomy for Collection Categories.
20+
* Taxonomy and term meta prefix.
2121
*
2222
* @var string
2323
*/
24-
private const TAXONOMY = 'newspack_collection_category';
24+
public const PREFIX = 'newspack_collection_';
2525

2626
/**
2727
* Get the taxonomy for Collection Categories.
2828
*
2929
* @return string The taxonomy name.
3030
*/
3131
public static function get_taxonomy() {
32-
return self::TAXONOMY;
32+
return self::PREFIX . 'category';
33+
}
34+
35+
/**
36+
* Get meta keys.
37+
*
38+
* @return array {
39+
* Array of term meta definitions.
40+
*
41+
* @type string $type The type of data associated with this meta key.
42+
* @type string $label A human-readable label of the data attached to this meta key.
43+
* @type string $description A description of the data attached to this meta key.
44+
* @type bool $single Whether the meta key has one value per object, or an array of values per object.
45+
* @type string $sanitize_callback A function or method to call when sanitizing `$meta_key` data.
46+
* @type array $show_in_rest Show in REST configuration.
47+
* }
48+
*/
49+
public static function get_metas() {
50+
return [
51+
'subscribe_link' => [
52+
'type' => 'string',
53+
'label' => __( 'Subscription URL', 'newspack-plugin' ),
54+
'description' => __( 'Override the global subscription link for this category.', 'newspack-plugin' ),
55+
'single' => true,
56+
'sanitize_callback' => 'esc_url_raw',
57+
'show_in_rest' => [
58+
'schema' => [
59+
'format' => 'uri',
60+
],
61+
],
62+
],
63+
'order_link' => [
64+
'type' => 'string',
65+
'label' => __( 'Order URL', 'newspack-plugin' ),
66+
'description' => __( 'Override the global order link for this category.', 'newspack-plugin' ),
67+
'single' => true,
68+
'sanitize_callback' => 'esc_url_raw',
69+
'show_in_rest' => [
70+
'schema' => [
71+
'format' => 'uri',
72+
],
73+
],
74+
],
75+
];
3376
}
3477

3578
/**
3679
* Initialize the taxonomy handler.
3780
*/
3881
public static function init() {
3982
add_action( 'init', [ __CLASS__, 'register_taxonomy' ] );
83+
add_action( 'init', [ __CLASS__, 'register_term_meta' ] );
4084
add_action( 'newspack_collections_before_flush_rewrites', [ __CLASS__, 'register_taxonomy' ] );
4185
add_action( 'manage_' . Post_Type::get_post_type() . '_posts_columns', [ __CLASS__, 'set_taxonomy_column_name' ] );
86+
87+
// Term meta field handling.
88+
add_action( self::get_taxonomy() . '_add_form_fields', [ __CLASS__, 'add_term_meta_fields' ] );
89+
add_action( self::get_taxonomy() . '_edit_form_fields', [ __CLASS__, 'edit_term_meta_fields' ] );
90+
add_action( 'created_' . self::get_taxonomy(), [ __CLASS__, 'save_term_meta' ] );
91+
add_action( 'edited_' . self::get_taxonomy(), [ __CLASS__, 'save_term_meta' ] );
4292
}
4393

4494
/**
@@ -89,4 +139,104 @@ public static function set_taxonomy_column_name( $posts_columns ) {
89139

90140
return $posts_columns;
91141
}
142+
143+
/**
144+
* Add meta columns to the taxonomy edit screen.
145+
*
146+
* @param array $columns An associative array of column headings.
147+
* @return array The modified columns array.
148+
*/
149+
public static function add_meta_columns( $columns ) {
150+
foreach ( self::get_metas() as $key => $meta ) {
151+
$columns[ self::PREFIX . $key ] = $meta['label'];
152+
}
153+
return $columns;
154+
}
155+
156+
/**
157+
* Register meta fields for the collection category taxonomy.
158+
*/
159+
public static function register_term_meta() {
160+
foreach ( self::get_metas() as $key => $meta ) {
161+
register_term_meta(
162+
self::get_taxonomy(),
163+
self::PREFIX . $key,
164+
array_merge(
165+
$meta,
166+
[
167+
'auth_callback' => [ __CLASS__, 'auth_callback' ],
168+
]
169+
)
170+
);
171+
}
172+
}
173+
174+
/**
175+
* Add term meta fields to the add term form.
176+
*/
177+
public static function add_term_meta_fields() {
178+
foreach ( self::get_metas() as $key => $meta ) {
179+
$meta_key = self::PREFIX . $key;
180+
?>
181+
<div class="form-field">
182+
<label for="<?php echo esc_attr( $meta_key ); ?>"><?php echo esc_html( $meta['label'] ); ?></label>
183+
<input type="url" name="<?php echo esc_attr( $meta_key ); ?>" id="<?php echo esc_attr( $meta_key ); ?>" value="" />
184+
<p class="description"><?php echo esc_html( $meta['description'] ); ?></p>
185+
</div>
186+
<?php
187+
}
188+
}
189+
190+
/**
191+
* Add term meta fields to the edit term form.
192+
*
193+
* @param WP_Term $term Current taxonomy term object.
194+
*/
195+
public static function edit_term_meta_fields( $term ) {
196+
foreach ( self::get_metas() as $key => $meta ) {
197+
$meta_key = self::PREFIX . $key;
198+
$value = get_term_meta( $term->term_id, $meta_key, true );
199+
?>
200+
<tr class="form-field">
201+
<th scope="row">
202+
<label for="<?php echo esc_attr( $meta_key ); ?>"><?php echo esc_html( $meta['label'] ); ?></label>
203+
</th>
204+
<td>
205+
<input type="url" name="<?php echo esc_attr( $meta_key ); ?>" id="<?php echo esc_attr( $meta_key ); ?>" value="<?php echo esc_attr( $value ); ?>" />
206+
<p class="description"><?php echo esc_html( $meta['description'] ); ?></p>
207+
</td>
208+
</tr>
209+
<?php
210+
}
211+
}
212+
213+
/**
214+
* Save the term meta when term is created or updated.
215+
*
216+
* @param int $term_id Term ID.
217+
*/
218+
public static function save_term_meta( $term_id ) {
219+
// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
220+
foreach ( self::get_metas() as $key => $meta ) {
221+
$meta_key = self::PREFIX . $key;
222+
if ( isset( $_POST[ $meta_key ] ) ) {
223+
$value = $meta['sanitize_callback']( $_POST[ $meta_key ] );
224+
if ( $value ) {
225+
update_term_meta( $term_id, $meta_key, $value );
226+
} else {
227+
delete_term_meta( $term_id, $meta_key );
228+
}
229+
}
230+
}
231+
// phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
232+
}
233+
234+
/**
235+
* Auth callback for term meta fields.
236+
*
237+
* @return bool Whether the user can manage categories.
238+
*/
239+
public static function auth_callback() {
240+
return current_user_can( 'manage_categories' );
241+
}
92242
}

includes/collections/class-settings.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class Settings {
2828
'custom_singular_name' => '',
2929
'custom_slug' => '',
3030
'subscribe_link' => '',
31+
'order_link' => '',
3132
];
3233

3334
/**
@@ -110,6 +111,10 @@ public static function get_rest_args() {
110111
'required' => false,
111112
'sanitize_callback' => 'esc_url_raw',
112113
],
114+
'order_link' => [
115+
'required' => false,
116+
'sanitize_callback' => 'esc_url_raw',
117+
],
113118
];
114119
}
115120

src/wizards/newspack/views/settings/collections/custom-naming-card.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const CustomNamingCard: React.FC< CustomNamingCardProps > = ( { settings, isSavi
2020
hasGreyHeader={ !! settings.custom_naming_enabled }
2121
>
2222
{ settings.custom_naming_enabled && (
23-
<Grid columns={ 1 } gutter={ 24 }>
23+
<Grid columns={ 2 } gutter={ 24 }>
2424
<TextControl
2525
label={ __( 'Name', 'newspack-plugin' ) }
2626
help={ __( 'Name to be used instead of "Collections" (e.g., "Issues", "Magazines")', 'newspack-plugin' ) }

src/wizards/newspack/views/settings/collections/index.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const DEFAULT_COLLECTIONS_SETTINGS: CollectionsSettingsData = {
2323
custom_singular_name: '',
2424
custom_slug: '',
2525
subscribe_link: '',
26+
order_link: '',
2627
};
2728

2829
// Helper function to extract collection settings from API data with defaults.
@@ -91,14 +92,27 @@ function Collections() {
9192
<>
9293
<CustomNamingCard settings={ settings } isSaving={ isSavingSettings } onChange={ updateSetting } />
9394

94-
<Grid columns={ 1 } gutter={ 32 }>
95+
<Grid columns={ 2 } gutter={ 32 }>
9596
<TextControl
9697
label={ __( 'Subscription URL', 'newspack-plugin' ) }
97-
help={ __( 'Global URL where readers can subscribe to collections.', 'newspack-plugin' ) }
98+
help={ __(
99+
'URL for the "Subscribe" button that will be displayed in the Collections archive pages when no subscription URL is set for the Collection or its parent category.',
100+
'newspack-plugin'
101+
) }
98102
value={ settings.subscribe_link }
99103
onChange={ ( value: string ) => updateSetting( 'subscribe_link', value ) }
100104
placeholder={ `e.g., https://${ window.location.hostname }/subscribe` }
101105
/>
106+
<TextControl
107+
label={ __( 'Order URL', 'newspack-plugin' ) }
108+
help={ __(
109+
'URL for the "Order" button that will be displayed in the Collections archive pages when no order URL is set for the Collection or its parent category.',
110+
'newspack-plugin'
111+
) }
112+
value={ settings.order_link }
113+
onChange={ ( value: string ) => updateSetting( 'order_link', value ) }
114+
placeholder={ `e.g., https://${ window.location.hostname }/order` }
115+
/>
102116
</Grid>
103117

104118
<div className="newspack-buttons-card">

src/wizards/newspack/views/settings/collections/types.d.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ type CollectionsSettingsData = {
66
custom_singular_name: string;
77
custom_slug: string;
88
subscribe_link: string;
9+
order_link: string;
910
};
1011

11-
type FieldChangeHandler< T > = < K extends keyof T >(
12-
key: K,
13-
value: T[ K ]
14-
) => void;
12+
type FieldChangeHandler< T > = < K extends keyof T >( key: K, value: T[ K ] ) => void;

0 commit comments

Comments
 (0)