Skip to content
4 changes: 4 additions & 0 deletions projects/packages/forms/changelog/update-store-rating-type
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: added

Forms: Improve how we store and display ratings field values
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,26 @@ function ( $option ) {
/* translators: %s is the name of a form field */
$this->add_error( sprintf( __( '%s requires a time', 'jetpack-forms' ), $field_label ) );
}
break;
case 'rating':
$max_rating = $this->get_attribute( 'max' ) ? (int) $this->get_attribute( 'max' ) : 5;
if ( str_ends_with( $field_value, '/' . $max_rating ) ) {
$field_value = explode( '/', $field_value )[0];
}

if ( ! is_numeric( $field_value ) ) {
/* translators: %s is the name of a form field - For example "Rate our website rating must be a number." where "Rate our website" is the name. */
$this->add_error( sprintf( __( '%s rating must be a number.', 'jetpack-forms' ), $field_label ) );
break;
}

$min_value = $this->get_attribute( 'required' ) ? 1 : 0;

if ( $max_rating < $field_value || $field_value < $min_value ) {
/* translators: %s is the name of a form field - For example "Rate our website rating must be between 1 and 5." where "Rate our website" is the name. */
$this->add_error( sprintf( __( '%1$s rating must be between %2$d and %3$d.', 'jetpack-forms' ), $field_label, $min_value, $max_rating ) );
}

break;
case 'file':
// Make sure the file field is not empty
Expand Down Expand Up @@ -2700,7 +2720,7 @@ private function render_rating_field( $id, $label, $value, $class, $required, $r
id="%1$s"
type="radio"
name="%2$s"
value="%3$s/%4$s"
value="%3$s"
data-wp-on--change="actions.onFieldChange"
class="jetpack-field-rating__input visually-hidden"
%5$s
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -990,7 +990,7 @@ private static function render_ajax_success_wrapper( $form, $submission_success
$html .= '<template data-wp-each--submission="context.formattedSubmissionData">
<div>
<div class="field-name" data-wp-text="context.submission.label" data-wp-bind--hidden="!context.submission.label"></div>
<div class="field-value" data-wp-text="context.submission.value"></div>
<div class="field-value" data-wp-watch="callbacks.renderContent"></div>
<div class="field-images" data-wp-bind--hidden="!context.submission.images">
<template data-wp-each--image="context.submission.images">
<img class="field-image" data-wp-bind--src="context.image" data-wp-bind--hidden="!context.image"/>
Expand Down
43 changes: 43 additions & 0 deletions projects/packages/forms/src/contact-form/class-feedback-field.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,15 @@ function ( $choice ) {
);
}

if ( $this->is_of_type( 'rating' ) ) {
if ( isset( $this->meta['icon'] ) ) { // Check if the icon is set. This means that we are using the new format.
$max = is_numeric( $this->meta['max'] ) && (int) $this->meta['max'] > 0 ? (int) $this->meta['max'] : 5;
return sprintf( '%d/%d', $this->value, $max );
}

return $this->value;
}

return $this->get_render_default_value();
}

Expand Down Expand Up @@ -247,6 +256,36 @@ private function get_render_default_value() {
return $this->value;
}

if ( $this->is_of_type( 'rating' ) ) {
if ( isset( $this->meta['icon'] ) ) { // Check if the icon is set. This means that we are using the new format.
$max = is_numeric( $this->meta['max'] ) && (int) $this->meta['max'] > 0 ? (int) $this->meta['max'] : 5;
$value = is_numeric( $this->value ) && (int) $this->value > 0 ? (int) $this->value : 0;

if ( $value > $max ) {
$value = $max;
}

/* translators: %1$d is the current rating, %2$d is the maximum rating, %3$s is the type of rating (stars or hearts). */
$accessibility_label = sprintf( __( '%1$d out of %2$d stars', 'jetpack-forms' ), $value, $max );
Copy link
Member

Choose a reason for hiding this comment

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

We'll need _n() instead of __() even if we never use the singular; in some languages (like Russian) singular/plural rules are more complex than in English.

So 1$d out of %2$d star and 1$d out of %2$d stars

$icon_path = '<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.62L12 2 9.19 8.62 2 9.24l5.46 4.73L5.82 21z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path>';
if ( $this->meta['icon'] === 'hearts' ) {
$icon_path = '<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path>';

/* translators: %1$d is the current rating, %2$d is the maximum rating). */
$accessibility_label = sprintf( __( '%1$d out of %2$d hearts', 'jetpack-forms' ), $value, $max );
}

$icon_svg = '<svg class="jetpack-field-rating__icon is-filled" viewBox="0 0 24 24" aria-hidden="true">' . $icon_path . '</svg>';
$empty_icon = '<svg class="jetpack-field-rating__icon is-empty" viewBox="0 0 24 24" aria-hidden="true">' . $icon_path . '</svg>';
$icons = array_fill( 0, $value, $icon_svg );
$icons = array_merge( $icons, array_fill( 0, $max - $value, $empty_icon ) );

return '<span aria-label="' . esc_attr( $accessibility_label ) . '">' . implode( '', $icons ) . '</span>';
}
// Return the array as is.
return $this->value;
}

if ( is_array( $this->value ) ) {
return implode( ', ', $this->value );
}
Expand Down Expand Up @@ -283,6 +322,10 @@ private function get_render_api_value() {
return $this->value;
}

if ( $this->is_of_type( 'rating' ) ) {
return $this->get_render_default_value();
}

if ( is_array( $this->value ) ) {
// If the value is an array, we can return it as a JSON string.
return implode( ', ', $this->value );
Expand Down
29 changes: 28 additions & 1 deletion projects/packages/forms/src/contact-form/class-feedback.php
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@ private function get_field_value( $key, $post_data, $type = null ) {
return array( 'choices' => array() );
}

if ( $type === 'rating' ) {
if ( isset( $post_data[ $key ] ) ) {
if ( str_contains( $post_data[ $key ], '/' ) ) {
$parts = explode( '/', $post_data[ $key ] );
return (string) absint( $parts[0] );
}
return $post_data[ $key ];
}
}

if ( isset( $post_data[ $key ] ) ) {
if ( is_array( $post_data[ $key ] ) ) {
return array_map( 'sanitize_textarea_field', wp_unslash( $post_data[ $key ] ) );
Expand Down Expand Up @@ -1358,7 +1368,7 @@ private function get_computed_fields( $post_data, $form ) {
$label = wp_strip_all_tags( $field->get_attribute( 'label' ) );
$key = $i . '_' . $label;

$meta = array();
$meta = $this->get_field_meta( $field );
$fields[ $key ] = new Feedback_Field( $key, $label, $value, $type, $meta, $field_id );
if ( ! $this->has_file && $fields[ $key ]->has_file() ) {
$this->has_file = true;
Expand All @@ -1368,6 +1378,23 @@ private function get_computed_fields( $post_data, $form ) {

return $fields;
}
/**
* Get additional metadata for specific field types.
*
* @param Contact_Form_Field $field The field object.
* @return array An array of metadata.
*/
private function get_field_meta( $field ) {
$meta = array();

// For file fields, we need to store the upload ID and name.
if ( $field->get_attribute( 'type' ) === 'rating' ) {
$meta['max'] = $field->get_attribute( 'max' );
$meta['icon'] = $field->get_attribute( 'iconstyle' );
}

return $meta;
}

/**
* Gets the computed subject.
Expand Down
9 changes: 9 additions & 0 deletions projects/packages/forms/src/contact-form/css/grunion.scss
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,15 @@ that needs to mimic the input element styles */
white-space: pre-wrap;
}

.contact-form-submission .field-value svg {
width: 1em;
}

.contact-form-submission .field-value svg.is-filled,
.contact-form-submission .field-value svg.is-filled path {
fill: var(--jetpack--contact-form--rating-star-color, currentColor);
}

.form-errors .form-error-message {
color: var(--jetpack--contact-form--error-color);
}
Expand Down
2 changes: 1 addition & 1 deletion projects/packages/forms/src/dashboard/inbox/response.js
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@
);
}

return value;
return <div dangerouslySetInnerHTML={ { __html: value } } />;

Check failure on line 293 in projects/packages/forms/src/dashboard/inbox/response.js

View workflow job for this annotation

GitHub Actions / ESLint (non-excluded files only)

Dangerous property 'dangerouslySetInnerHTML' found
};

useEffect( () => {
Expand Down
10 changes: 10 additions & 0 deletions projects/packages/forms/src/dashboard/inbox/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,16 @@
align-items: center;
}

.jetpack-field-rating__icon {
width: 16px;
height: 16px;
}

.jetpack-field-rating__icon.is-filled,
.jetpack-field-rating__icon.is-filled path {
fill: var(--wp-admin-theme-color, #2271b1);
}

.jp-forms__inbox-response-data-value .file-field {
margin-top: 4px;

Expand Down
8 changes: 7 additions & 1 deletion projects/packages/forms/src/modules/form/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import {
getContext,
getElement,
store,
getConfig,
withSyncEvent as originalWithSyncEvent,
Expand Down Expand Up @@ -46,7 +47,6 @@ const setSubmissionData = ( data = [] ) => {
const context = getContext();

context.submissionData = data;

// This cannot be a derived state because it needs to be defined on the backend for first render to avoid hydration errors.
context.formattedSubmissionData = data.map( item => ( {
label: maybeAddColonToLabel( item.label ),
Expand Down Expand Up @@ -564,6 +564,12 @@ const { state, actions } = store( NAMESPACE, {
registerField( fieldId, fieldType, fieldLabel, fieldValue, fieldIsRequired, fieldExtra );
},

renderContent() {
const context = getContext();
const element = getElement();
element.ref.innerHTML = context.submission.value;
},

scrollToWrapper() {
const context = getContext();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3463,4 +3463,65 @@ public function test_validate_radio_form() {

Contact_Form::reset_errors();
}

public function test_rating_form_submission() {
$form_id = Utility::get_form_id();

$_POST = Utility::get_post_request(
array(
'heart' => '10', // Invalid, max is 5
'star' => 'abc',
'negative' => '-1', // Invalid, min is 0.
'zero' => '0', // Invalid, min is 1.
'zeroint' => '0', // Valid, min is 0.
'other' => '5/10', // Valid this is the previous format, and should be accepted.
),
'g' . $form_id
);

$form = new Contact_Form(
array(
'title' => 'Test Form',
'description' => 'This is a test form.',
),
"[contact-field label='Heart' type='rating' required='1' iconstyle='hearts' /]"
. "[contact-field label='Star' type='rating' required='1' iconstyle='stars' max='10' /]"
. "[contact-field label='Negative' type='rating' iconstyle='stars' max='10' /]"
. "[contact-field label='Zero' type='rating' required='1' iconstyle='stars' max='10' /]"
. "[contact-field label='Zeroint' type='rating' iconstyle='stars' max='10' /]"
. "[contact-field label='Other' type='rating' required='1' iconstyle='stars' max='10' /]"
);

$form->validate();
unset( $_POST ); // Clean up the global $_POST variable after the test.

$this->assertTrue( $form->has_errors(), 'Form should have errors after validation.' );
$this->assertEquals( array( 'Heart rating must be between 1 and 5.', 'Star rating must be a number.', 'Negative rating must be between 0 and 10.', 'Zero rating must be between 1 and 10.' ), $form->get_error_messages(), 'Form should have errors after validation.' );
}

public function test_rating_form_submission_legacy() {
$form_id = Utility::get_form_id();

$_POST = Utility::get_post_request(
array(
'heart' => '3/5',
),
'g' . $form_id
);

$form = new Contact_Form(
array(
'title' => 'Test Form',
'description' => 'This is a test form.',
),
"[contact-field label='Heart' type='rating' required='1' iconstyle='hearts' /]"
);

$form->validate();
$result = $form->process_submission();
unset( $_POST ); // Clean up the global $_POST variable after the test.

$this->assertFalse( $form->has_errors(), 'Form should have errors after validation.' );
$this->assertEquals( '<p><div class="field-name">Heart:</div> <div class="field-value">♥♥♥♡♡</div></p>', $result );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,56 @@ public function test_get_render_api_value() {
remove_filter( 'jetpack_unauth_file_download_url', array( $this, 'return_url' ) );
}

public function test_feedback_field_rating_legacy_value() {

$legacy_field = new Feedback_Field(
'test_key',
'test_label',
'3/4',
'rating'
);

$this->assertEquals( '3/4', $legacy_field->get_render_value() );
$this->assertEquals( '3/4', $legacy_field->get_render_value( 'api' ) );
$this->assertEquals( '3/4', $legacy_field->get_render_value( 'csv' ) );
}

public function test_feedback_field_star_rating_values() {

$field = new Feedback_Field(
'test_key',
'test_label',
'3',
'rating',
array(
'icon' => 'stars',
'max' => '',
)
);

$this->assertEquals( '★★★☆☆', $field->get_render_value() );
$this->assertEquals( '★★★☆☆', $field->get_render_value( 'api' ) );
$this->assertEquals( '3/5', $field->get_render_value( 'csv' ) );
}

public function test_feedback_field_heart_rating_values() {

$field = new Feedback_Field(
'test_key',
'test_label',
'2',
'rating',
array(
'icon' => 'hearts',
'max' => '8',
)
);

$this->assertEquals( '♥♥♡♡♡♡♡♡', $field->get_render_value() );
$this->assertEquals( '♥♥♡♡♡♡♡♡', $field->get_render_value( 'api' ) );
$this->assertEquals( '2/8', $field->get_render_value( 'csv' ) );
}

public function test_render_label_in_different_contexts() {
$field = new Feedback_Field( 'test_key', 'test_label', 'test_value' );
$this->assertEquals( 'test_label', $field->get_label() );
Expand Down
Loading
Loading