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 hiive html attributes to ftc #20678

Merged
merged 38 commits into from
Oct 16, 2023
Merged

Conversation

pls78
Copy link
Member

@pls78 pls78 commented Sep 25, 2023

Context

  • We want to enable tracking of user-generated events throught Hiive and Newfold wp-module-data
  • This PR is tied to its Newfold counterpart
  • I collected all the First-time configuration-related events in this Google sheet, together with comments/additions
  • See this doc for more context about Hiive events

Summary

This PR can be summarized in the following changelog entry:

  • Adds three new hooks to allow for events tracking to the First-time configuration.
  • Adds html data attributes to track on-click events.

Relevant technical choices:

  • FirstTimeConfigurationSteps and all the buttons in packages/js/src/first-time-configuration/tailwind-components/configuration-stepper-buttons.js have been refactored to standardise the way html ids are computed.
  • In src/actions/configuration/first-time-configuration-action.php a method get_old_values has been introduced and called in set_site_representation and set_social_profiles actions to allow old options values to be passed to the events tracking code.

Test instructions

Test instructions for the acceptance test before the PR gets merged

This PR can be acceptance tested by following these steps:

  • Please note PR does not introduce new user-facing functionalities (new hooks are intended for internal use only).

Preliminary steps

  • Use the Yoast Test Helper to reset the First time configuration state
    • go to Tools -> Yoast Test
    • click the button Reset First time configuration progress in the Yoast SEO section

Verify the presence of the intended html attributes in the First-time configuration flow

  • Go to Yoast SEO -> General -> First-time configuration tab, and check for each step the presence of the following attribute/value pair:
    • SEO Data configuration step:
      • data-hiive-event-name="clicked_start_data_optimization" in the Start SEO data optimization button
      • data-hiive-event-name="clicked_continue | data optimization" in the Continue button
    • Site representation step:
      • data-hiive-event-name="clicked_select_image" in the Select image button and in the image select box
        • Once an image has been selected, the button will change to Replace image and the attribute/value pair will become data-hiive-event-name="clicked_replace_image"
      • data-hiive-event-name="clicked_remove_image" in the Remove image link
      • data-hiive-event-name="clicked_continue | site representation" in the Save and continue button
      • data-hiive-event-name="clicked_go_back | site representation" in the Go back button
    • Social profiles step (for organisations):
      • data-hiive-event-name="clicked_add_profile" in the Add another profile button
      • data-hiive-event-name="clicked_continue | social profiles" in the Save and continue button
      • data-hiive-event-name="clicked_go_back | social profiles" in the Go back button
    • Social profiles step (for people):
    • data-hiive-event-name="clicked_update_or_add_profile | social profiles" in the update or add social profiles to this user profile link
    • data-hiive-event-name="clicked_continue | social profiles" in the Save and continue button
    • data-hiive-event-name="clicked_go_back | social profiles" in the Go back button
    • Personal preferences step:
      • data-hiive-event-name="clicked_signup | personal preferences" in the Sign up! button
      • data-hiive-event-name="clicked_continue | personal preferences" in the Save and continue button
      • data-hiive-event-name="clicked_go_back | personal preferences" in the Go back button
    • Finish configuration step:
      • data-hiive-event-name="clicked_to_onboarding_page" in the Learn how to increase your rankings with Yoast SEO button
      • data-hiive-event-name="clicked_seo_dashboard" in the Or go to your SEO dashboard link

Verify the presence of the intended html attributes after the completion of the First-time configuration

  • Once you completed the First-time configuration
    • check that the Edit buttons which appear aside each step's title have the following attribute/value pair attached:
      • SEO Data configuration step: data-hiive-event-name="clicked_edit | data optimization"
      • Site representation step: data-hiive-event-name="clicked_edit | site representation"
      • Social profiles step: data-hiive-event-name="clicked_edit | social profiles"
      • Personal preferences step: data-hiive-event-name="clicked_edit | personal preferences"
    • check the Save changes button in the Site representation step has the attribute/value pair data-hiive-event-name="clicked_save changes | site representation"
    • check the Save changes button in the Social profiles step has the attribute/value pair data-hiive-event-name="clicked_save changes | social profiles"
    • check the Save changes button in the Personal preferences step has the attribute/value pair data-hiive-event-name="clicked_save changes | personal preferences

Test the hooks get and output the correct data

  • Go to your WordPress installation root folder
  • Make sure you have the following rows in your wp-config.php file
    define( 'WP_DEBUG', true );
    define( 'WP_DEBUG_DISPLAY', false );
    define( 'WP_DEBUG_LOG', true );
    
    • in case they are missing, add them before the line /* That's all, stop editing! Happy publishing. */
  • If you already have a debug.log file in wp-content/ remove it
  • in the terminal, in the same folder where debug.log is, type tail -f debug.log: this command will output anything that will be written to the \debug.log` file, so you can see in real the events being logged here
  • In wp-content/mu-plugins folder, create two new files named listener.php and yoast-listener.php
  • Copy into listener.php the following snippet
      <?php
    
    class Listener {
    	/**
    	 * This method will print to the error_log the name of the event and the data
    	 */
    	public function push( string $event_name, array $data) : void {
    		error_log( "Event: $event_name, Data: " . json_encode( $data ) );
    	}
    }
    
  • Copy into yoast-listener.php the following snippet
       <?php
    
    /**
     * Monitors Yoast events
     */
    class Yoast extends Listener {
    	// We don't want to track these fields
    	private $site_representation_skip_fields = [ 'company_logo_id', 'person_logo_id', 'description' ];
    
    	// The names used for Hiive events tracking are different from the names used for the Yoast options
    	private $site_representation_map         = [
    		'company_or_person'         => 'site_representation',
    		'company_name'              => 'organization_name',
    		'company_logo'              => 'organization_logo',
    		'person_logo'               => 'logo',
    		'company_or_person_user_id' => 'name',
    		'website_name'              => 'website_name',
    	];
    
    	private $social_profiles_map         = [
    		'facebook_site'     => 'facebook_profile',
    		'twitter_site'      => 'twitter_profile',
    		'other_social_urls' => 'other_social_urls',
    	];
    
    	/**
    	 * Register the hooks for the listener
    	 *
    	 * @return void
    	 */
    	public function register_hooks() {
    		// First time configuration
    		add_action('wpseo_ftc_post_update_site_representation', array( $this, 'site_representation_updated' ), 10, 3 );
    		add_action('wpseo_ftc_post_update_social_profiles', array( $this, 'social_profiles_updated' ), 10, 3 );
    		add_action('wpseo_ftc_post_update_enable_tracking', array( $this, 'tracking_updated' ), 10, 3 );
    	}
    
    	/**
    	 * The user just updated their site representation
    	 *
    	 * @param array $new_values The new values for the options related to the site representation
    	 * @param array $old_values The old values for the options related to the site representation
    	 * @param array $failures   The failures that occurred during the update
    	 *
    	 * @return void
    	 */
    	public function site_representation_updated( $new_values, $old_values, $failures ) {
    		
    		// All the options are unchanged, opt out
    		if ( $new_values === $old_values ) {
    			return;
    		}
    
    		$mapped_new_values = $this->map_params_names_to_hiive_names( $new_values, $this->site_representation_map, $this->site_representation_skip_fields );
    		$mapped_old_values = $this->map_params_names_to_hiive_names( $old_values, $this->site_representation_map, $this->site_representation_skip_fields );
    		$mapped_failures   = $this->map_failures_to_hiive_names( $failures, $this->site_representation_map, $this->site_representation_skip_fields );
    
    		foreach ($mapped_new_values as $key => $value) {
    			$this->maybe_push_event( $key, $value, $mapped_old_values[ $key ], \in_array( $key, $mapped_failures ), 'ftc_site_representation' );
    		}
    	}
    
    	/**
    	 * The user just updated their personal profiles
    	 *
    	 * @param array $new_values The new values for the options related to the site representation
    	 * @param array $old_values The old values for the options related to the site representation
    	 * @param array $failures   The failures that occurred during the update
    	 *
    	 * @return void
    	 */
    	public function social_profiles_updated( $new_values, $old_values, $failures ) {
    		// Yoast stores only twitter username, and $new_values stores the pre-processed values
    		if ( strpos( $new_values[ 'twitter_site' ], 'twitter.com/' ) !== false ) {
    			$new_values[ 'twitter_site' ] = (explode( 'twitter.com/', $new_values[ 'twitter_site' ])[ 1 ] );
    		}
    
    		// All the options are unchanged, opt out
    		if ( $new_values === $old_values ) {
    			return;
    		}
    
    		$mapped_new_values = $this->map_params_names_to_hiive_names( $new_values, $this->social_profiles_map );
    		$mapped_old_values = $this->map_params_names_to_hiive_names( $old_values, $this->social_profiles_map );
    		$mapped_failures   = $this->map_failures_to_hiive_names( $failures, $this->social_profiles_map );
    
    		foreach ($mapped_new_values as $key => $value) {
    			$this->maybe_push_event( $key, $value, $mapped_old_values[ $key ], \in_array( $key, $mapped_failures ), 'ftc_personal_profiles' );
    		}
    	}
    
    	/**
    	 * The user updated their tracking preference
    	 *
    	 * @param string $new_value The new value for the option related to tracking
    	 * @param string $old_value The old value for the option related to tracking
    	 * @param bool   $failed    Whether the option update failed
    	 *
    	 * @return void
    	 */
    	public function tracking_updated( $new_value, $old_value, $failed ) {
    		// All the options are unchanged, opt out
    		if ( $new_value === $old_value ) {
    			return;
    		}
    
    		$this->maybe_push_event( 'usage_tracking', $new_value, $old_value, $failed, 'ftc_tracking' );
    	}
    
    	/**
    	 * A method used to (maybe) push an event to the queue
    	 *
    	 * @param string $key       The option key
    	 * @param string $value     The new option value
    	 * @param string $old_value The old option value
    	 * @param bool   $failure   Whether the option update failed
    	 * @param string $category  The category of the event
    	 *
    	 * @return void
    	 */
    	private function maybe_push_event( $key, $value, $old_value, $failure, $category ) {
    		// The option update failed
    		if ( $failure ) {
    			$this->push( "failed_$key", [ 'category' => $category] );
    			return;
    		}
    
    		// The option value changed
    		if ( $value !== $old_value ) {
    			// The option was set for the first time
    
    			// name is a special case, because it represents the company_or_person_user_id which is initialised to false, and the first time the user saves the site representation step
    			// is set either to 0 if the site represents an organisation, or to an integer > 0 if the site represents a person
    			if ( $key === 'name' ) {
    				if ( $old_value === false && $value === 0 ) {
    					return;
    				}
    			}
    
    			// Again, name is a special case, because if its old value was 0 and a value different that 0 is being received, it means that the user
    			// switched from organisation to person, and then the person id is being set.
    			// Once the name is assigned an integer > 0, it can never go back to 0, even if the user switches back to organisation
    			// ( it "caches" the last user id that was set)
    			if ( ( $this->is_param_empty( $old_value) ) || ( $key === 'name' && $old_value === 0 ) ){
    				$this->push( "set_$key", [ 'category' => $category] );
    				return;
    			}
    
    			// The option was updated
    			$data = array(
    				'category' => $category,
    				'data'     => array(
    					'label_key' => $key,
    					'new_value' => $value
    				),
    			);
    
    			$this->push(
    				"changed_$key",
    				$data
    			);
    		}
    	}
    
    	/**
    	 * Maps the param names to the names used for Hiive events tracking.
    	 *
    	 * @param array $params      The params to map.
    	 * @param array $map         The map to use.
    	 * @param array $skip_fields The fields to skip.
    	 *
    	 * @return array The mapped params.
    	 */
    	private function map_params_names_to_hiive_names( $params, $map, $skip_fields=[] ) {
    		$mapped_params = [];
    
    		foreach ( $params as $param_name => $param_value ) {
    			if ( in_array( $param_name, $skip_fields, true ) ) {
    				continue;
    			}
    
    			$new_name                   = $map[ $param_name ];
    			$mapped_params[ $new_name ] = $param_value;
    		}
    
    		return $mapped_params;
    	}
    
    	/**
    	 * Maps the names of the params which failed the update to the names used for Hiive events tracking.
    	 *
    	 * @param array $failures    The params names to map.
    	 * @param array $map         The map to use.
    	 * @param array $skip_fields The fields to skip.
    	 *
    	 * @return array The mapped params names.
    	 */
    	private function map_failures_to_hiive_names( $failures, $map, $skip_fields=[] ) {
    		$mapped_failures = [];
    
    		foreach ( $failures as $failed_filed_name) {
    			if ( in_array( $failed_filed_name, $skip_fields, true ) ) {
    				continue;
    			}
    
    			$mapped_failures = $map[ $failed_filed_name ];
    		}
    
    		return $mapped_failures;
    	}
    
    	/**
    	 * Checks whether a param is empty.
    	 *
    	 * @param mixed $param The param to check.
    	 *
    	 * @return bool Whether the param is empty.
    	 */
    	private function is_param_empty( $param ) {
    		if ( is_array( $param ) ) {
    			return ( count( $param ) === 0 );
    		}
    
    		return ( strlen( $param ) === 0 );
    	}
    }
    
    $yoast_listener = new Yoast();
    $yoast_listener->register_hooks();
    
    This is the same code which will run Newfold-side
  • Now reset the site options by using the Yoast test helper: this will ensure you start with an empty first time configuration
  • Go to Yoast SEO -> General and start the first time configuration
  • Reach the Site representation step, set only the Website name and click Save and continue
    • in the terminal where you're running the tail command, you should see the following line:
      Event: set_website_name, Data: {"category":"ftc_site_representation"}
  • Click Go back to return to the Site representation step
  • Fill Organization name, choose a logo, update Website name amd click Save and continue
    • In your terminal you should see the following lines:
    Event: set_organization_name, Data: {"category":"ftc_site_representation"}
    Event: set_organization_logo, Data: {"category":"ftc_site_representation"}
    Event: changed_website_name, Data: {"category":"ftc_site_representation","data":{"label_key":"website_name","new_value":"YOUR_NEW_VALUE"}}
    
  • Click Go back to return to the Site representation step
  • Change the dropdown value to represent a person and click Save and continue
    • In your terminal you should see the following line:
      Event: changed_site_representation, Data: {"category":"ftc_site_representation","data":{"label_key":"site_representation","new_value":"person"}}
  • Click Go back to return to the Site representation step
  • Select the user the site represents from the name dropdown and click Save and continue
    • In your terminal you should see the following line: Event: set_name, Data: {"category":"ftc_site_representation"}
  • Click Go back to return to the Site representation step
  • Change the user the site represents from the name dropdown and click Save and continue
    • In your terminal you should see the following line: Event: changed_name, Data: {"category":"ftc_site_representation","data":{"label_key":"name","new_value":YOUR_NEW_VALUE}}
  • Click Go back to return to the Site representation step
  • Change the dropdown value to represent n organisation and click Save and continue
    • In your terminal you should see the following line:
      Event: changed_site_representation, Data: {"category":"ftc_site_representation","data":{"label_key":"site_representation","new_value":"company"}}
  • In the Social profiles step, fill facebook, twitter with a twitter user name (i.e. without https://twitter.com), add another profile and click Save and continue
    • In your terminal you should see the following lines:
    Event: set_facebook_profile, Data: {"category":"ftc_personal_profiles"}
    Event: set_twitter_profile, Data: {"category":"ftc_personal_profiles"}
    Event: set_other_social_urls, Data: {"category":"ftc_personal_profiles"}  
    
  • Click Go back to return to the Social profiles step
  • Change facebook valu, change twitter value by pre-pending to the username https://www.twitter.com and click Save and continue
    • In your terminal you should see the following line: changed_facebook_profile, Data: {"category":"ftc_personal_profiles","data":{"label_key":"facebook_profile","new_value":"https:\/\/www.facebook.com\/YOUR_NEW_VALUE"}}
  • In the Personal preferences step, change the tracking option to Yes, you can track my site data and click Save and continue
    • In your terminal you should see the following line: Event: set_usage_tracking, Data: {"category":"ftc_tracking"}
      *Click the Edit button beside the Personal preferences step, change back the tracking option and click Save changes
    • In your terminal you should see the following line: Event: changed_usage_tracking, Data: {"category":"ftc_tracking","data":{"label_key":"usage_tracking","new_value":false}}

Relevant test scenarios

  • Changes should be tested with the browser console open
  • Changes should be tested on different posts/pages/taxonomies/custom post types/custom taxonomies
  • Changes should be tested on different editors (Block/Classic/Elementor/other)
  • Changes should be tested on different browsers
  • Changes should be tested on multisite

Test instructions for QA when the code is in the RC

  • QA should use the same steps as above.

Impact check

This PR affects the following parts of the plugin, which may require extra testing:

  • Please perform a regression test for the First time configuration, as some of the base elements have been changed.

UI changes

  • This PR changes the UI in the plugin. I have added the 'UI change' label to this PR.

Other environments

  • This PR also affects Shopify. I have added a changelog entry starting with [shopify-seo], added test instructions for Shopify and attached the Shopify label to this PR.

Documentation

  • I have written documentation for this change.

Quality assurance

  • I have tested this code to the best of my abilities.
  • During testing, I had activated all plugins that Yoast SEO provides integrations for.
  • I have added unit tests to verify the code works as intended.
  • If any part of the code is behind a feature flag, my test instructions also cover cases where the feature flag is switched off.
  • I have written this PR in accordance with my team's definition of done.
  • I have checked that the base branch is correctly set.

Innovation

  • No innovation project is applicable for this PR.
  • This PR falls under an innovation project. I have attached the innovation label.
  • I have added my hours to the WBSO document.

Fixes https://github.com/Yoast/reserved-tasks/issues/114

@pls78 pls78 added the changelog: non-user-facing Needs to be included in the 'Non-userfacing' category in the changelog label Sep 26, 2023
clicked_select_image has been added also to the image select box, and clicked_remove_image has been added to the remove image link.
Fixed also some indentation.
Copy link
Member

@enricobattocchi enricobattocchi left a comment

Choose a reason for hiding this comment

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

A few points to discuss

@pls78 pls78 marked this pull request as ready for review October 10, 2023 08:31
Copy link
Member

@igorschoester igorschoester left a comment

Choose a reason for hiding this comment

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

CR 🏗️

  • The test instructions don't seem to mention the actions at all. You could introduce those and verify the data you expect is actually there
  • While peeking at the corresponding PR there might be a case where the old vs new strict equals check results in false positives, when the order of the values changed. Not sure if that is an actual issue
  • Checking the sheet' events and your code:
    • all these changed_ and set_ are not actually there, but via the actions. Should the sheet be updated, or how does that work?
    • as noted in code comments:
      • there is no clicked_replace_image
      • typo in the clicked_save_changes (missing underscore)
    • clicked_back is noted as clicked_go_back (for all the steps) in the sheet

@@ -79,6 +80,7 @@ export default function ImageSelect( {
id={ url ? id + "__replace-image" : id + "__select-image" }
className="yst-button yst-button yst-button--secondary yst-mr-2"
onClick={ onSelectImageClick }
data-hiive-event-name="clicked_select_image"
Copy link
Member

Choose a reason for hiding this comment

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

I suppose by the difference clicked_select_image knows if it is a replace or not 🤔
But it could be a separate event too, as you could argue the same for remove.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

* @param array The old values of the options.
* @param array The options that failed to be saved.
*/
\do_action( 'wpseo_post_update_social_profiles', $params, $old_values, $failures );
Copy link
Member

Choose a reason for hiding this comment

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

  • You used in the add_action wpseo_ftc_post_update_social_profiles elsewhere!
  • Maybe signal this as @internal? Or create documentation PR?

Copy link
Member Author

@pls78 pls78 Oct 11, 2023

Choose a reason for hiding this comment

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

Done.

* @param bool Whether the option failed to be stored.
*/
// $success is negated to be aligned with the other two actions which pass $failures.
\do_action( 'wpseo_post_update_enable_tracking', $params['tracking'], $option_value, ! $success );
Copy link
Member

Choose a reason for hiding this comment

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

  • You used in the add_action wpseo_ftc_post_update_enable_tracking elsewhere!
  • Maybe signal this as @internal? Or create documentation PR?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

@@ -104,7 +123,17 @@ public function set_site_representation( $params ) {
* @return object The response object.
*/
public function set_social_profiles( $params ) {
$failures = $this->social_profiles_helper->set_organization_social_profiles( $params );
$failures = $this->social_profiles_helper->set_organization_social_profiles( $params );
$old_values = $this->get_old_values( \array_keys( $this->social_profiles_helper->get_organization_social_profile_fields() ) );
Copy link
Member

Choose a reason for hiding this comment

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

  • I'm noticing that the get_organization_social_profile_fields uses but does not check if the filtered return value. Maybe add a safety (array) there to ensure this keeps working?
  • Also, I see this is the same as for person (with get_person_social_profile_fields), but you haven't changed that code in your PR (set_person_social_profiles method). Why?

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 first point has been addressed.
About the second point, set_person_social_profiles is dead code, I'm creating a tech debt issue to address that too. 🙂

*
* @return array The old values.
*/
private function get_old_values( $fields_names ) : array {
Copy link
Member

Choose a reason for hiding this comment

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

If you're adding the return type hinting, why not also the argument?

(then I noticed the filters and missing type checks 😅 -- still, might want to make those safer instead)

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

pls78 and others added 2 commits October 10, 2023 15:16
…onfiguration-stepper-buttons.js

Co-authored-by: Igor <35524806+igorschoester@users.noreply.github.com>
…onfiguration-stepper-buttons.js

Co-authored-by: Igor <35524806+igorschoester@users.noreply.github.com>
pls78 and others added 12 commits October 10, 2023 15:20
…onfiguration-stepper-buttons.js

Co-authored-by: Igor <35524806+igorschoester@users.noreply.github.com>
…onfiguration-stepper-buttons.js

Co-authored-by: Igor <35524806+igorschoester@users.noreply.github.com>
…teps/personal-preferences/newsletter-signup.js

Co-authored-by: Igor <35524806+igorschoester@users.noreply.github.com>
It is unnecessary because the 'description' field is not used anymore, so it's never gonna be there.
The conditional about the "description" field has been removed because the field itself has been previously removed.
@pls78
Copy link
Member Author

pls78 commented Oct 13, 2023

CR 🏗️

* The test instructions don't seem to mention the actions at all. You could introduce those and verify the data you expect is actually there

* While peeking at the corresponding PR there might be a case where the old vs new strict equals check results in false positives, when the order of the values changed. Not sure if that is an actual issue

That shouldn't be a problem because, in case of a false negative, the new and old value are passed to the maybe_push_event method by fetching them from an associative array using the same key

* Checking the sheet' events and your code:
  
  * all these `changed_` and `set_` are not actually there, but via the actions. Should the sheet be updated, or how does that work?

They are there 😁

  * as noted in code comments:
    
    * there is no `clicked_replace_image`

Should be fixed.

    * typo in the `clicked_save_changes` (missing underscore)

Should be fixed.

  * `clicked_back` is noted as `clicked_go_back` (for all the steps) in the sheet

Should be fixed.

Copy link
Member

@igorschoester igorschoester left a comment

Choose a reason for hiding this comment

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

CR ✅

@vraja-pro
Copy link
Contributor

ACC ✅

@vraja-pro vraja-pro added this to the 21.5 milestone Oct 16, 2023
@vraja-pro vraja-pro merged commit 449bd2b into trunk Oct 16, 2023
33 of 34 checks passed
@vraja-pro vraja-pro deleted the add-hiive-html-attributes-to-ftc branch October 16, 2023 10:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
changelog: non-user-facing Needs to be included in the 'Non-userfacing' category in the changelog
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants