Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Dashboard: ask whether the new Traffic tab is ready to replace the old one, instead of how it compares with the old one.
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import { RadioControl } from '@wordpress/components';
import { useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';

/** How ready the reader thinks the new tab is. The value is what reaches Tracks. */
export type StatsFeedbackReadiness = 'ready' | 'almost' | 'not_yet';

// Tracks drops an event whose properties are oversized, so a pasted essay would
// cost us the rating too.
// cost us the answer too.
const COMMENT_MAX_LENGTH = 1000;

/**
Expand All @@ -26,13 +29,51 @@ function ratingOptions() {
];
}

/**
* The readiness answers, readiest first. `value` is what reaches Tracks.
*
* @return The options, in the order they are offered.
*/
function readinessOptions(): { value: StatsFeedbackReadiness; label: string }[] {
return [
{
value: 'ready',
label: __( "Yes, I'd be happy to switch now", 'jetpack-premium-analytics-pkg' ),
},
{
value: 'almost',
label: __( 'Almost — there are a few things missing', 'jetpack-premium-analytics-pkg' ),
},
{ value: 'not_yet', label: __( 'Not yet', 'jetpack-premium-analytics-pkg' ) },
];
}

/**
* The reader's readiness answer as Happiness reads it in the ticket body.
*
* Untranslated on purpose: this is triage copy, like the product name, not reader copy.
*
* Bracketed, not newline separated: the endpoint runs the message through
* `sanitize_text_field`, which collapses every run of whitespace to one space.
*
* @param readiness - The answer the reader picked.
* @return The question and its answer as one line.
*/
export function readinessSummary( readiness: StatsFeedbackReadiness ) {
const answers: Record< StatsFeedbackReadiness, string > = {
ready: 'Yes, ready to switch now',
almost: 'Almost, a few things missing',
not_yet: 'Not yet',
};

return `[Ready to replace the old Traffic tab? ${ answers[ readiness ] }]`;
}

/**
* The question above a control whose own label is hidden.
*
* The control keeps the question as its accessible name, so this copy is for the
* eye only and stays out of the accessibility tree rather than being read twice.
* The label slot itself is an 11px uppercase caption, which a sentence-long
* question reads badly as.
* Kept out of the accessibility tree so the control's accessible name is not read twice; the
* label slot itself is an 11px uppercase caption, which a sentence-long question reads badly as.
*
* @param {object} props - Component props.
* @param {string} props.children - The question.
Expand All @@ -42,7 +83,37 @@ function Question( { children }: { children: string } ) {
return <Text aria-hidden="true">{ children }</Text>;
}

type FeedbackFieldsProps = {
type CommentFieldProps = {
question: string;
comment: string;
onCommentChange: ( comment: string ) => void;
};

/**
* The open question every feedback surface ends on.
*
* @param {CommentFieldProps} props - Component props.
* @param {string} props.question - The question above the box.
* @param {string} props.comment - The comment as typed.
* @param {Function} props.onCommentChange - Called with the comment as typed.
* @return The field.
*/
function CommentField( { question, comment, onCommentChange }: CommentFieldProps ) {
return (
<Stack direction="column" gap="sm">
<Question>{ question }</Question>
<TextareaControl
hideLabelFromVision
label={ question }
value={ comment }
maxLength={ COMMENT_MAX_LENGTH }
onValueChange={ onCommentChange }
/>
</Stack>
);
}

type ComparisonFieldsProps = {
rating: StatsFeedbackRating | undefined;
onRatingChange: ( rating: StatsFeedbackRating ) => void;
comment: string;
Expand All @@ -51,24 +122,23 @@ type FeedbackFieldsProps = {
};

/**
* The comparison scale and the comment box every feedback surface shares; each
* surface asks its own open question under the scale.
* The comparison scale, above an open question the surface chooses.
*
* @param {FeedbackFieldsProps} props - Component props.
* @param {number|undefined} props.rating - The score picked, if any.
* @param {Function} props.onRatingChange - Called with the score picked.
* @param {string} props.comment - The comment as typed.
* @param {Function} props.onCommentChange - Called with the comment as typed.
* @param {string} props.commentQuestion - The question above the comment box.
* @param {ComparisonFieldsProps} props - Component props.
* @param {number|undefined} props.rating - The score picked, if any.
* @param {Function} props.onRatingChange - Called with the score picked.
* @param {string} props.comment - The comment as typed.
* @param {Function} props.onCommentChange - Called with the comment as typed.
* @param {string} props.commentQuestion - The question above the comment box.
* @return The two fields.
*/
export function FeedbackFields( {
export function ComparisonFields( {
rating,
onRatingChange,
comment,
onCommentChange,
commentQuestion,
}: FeedbackFieldsProps ) {
}: ComparisonFieldsProps ) {
const comparisonQuestion = __(
'Compared with the existing Traffic tab in Stats, the new Traffic tab is:',
'jetpack-premium-analytics-pkg'
Expand All @@ -92,16 +162,72 @@ export function FeedbackFields( {
/>
</Stack>

<CommentField
question={ commentQuestion }
comment={ comment }
onCommentChange={ onCommentChange }
/>
</>
);
}

type ReadinessFieldsProps = {
readiness: StatsFeedbackReadiness | undefined;
onReadinessChange: ( readiness: StatsFeedbackReadiness ) => void;
comment: string;
onCommentChange: ( comment: string ) => void;
};

/**
* The readiness question and the open question that follows it.
*
* @param {ReadinessFieldsProps} props - Component props.
* @param {string|undefined} props.readiness - The answer picked, if any.
* @param {Function} props.onReadinessChange - Called with the answer picked.
* @param {string} props.comment - The comment as typed.
* @param {Function} props.onCommentChange - Called with the comment as typed.
* @return The two fields.
*/
export function ReadinessFields( {
readiness,
onReadinessChange,
comment,
onCommentChange,
}: ReadinessFieldsProps ) {
const readinessQuestion = __(
'Is the new Traffic tab ready to replace the old one?',
'jetpack-premium-analytics-pkg'
);

// "What's missing?" reads oddly after "Yes", where nothing is missing by definition.
const commentQuestion =
readiness === 'ready'
? __( "Any other feedback you'd like to share?", 'jetpack-premium-analytics-pkg' )
: __( "What's missing?", 'jetpack-premium-analytics-pkg' );

const selectReadiness = useCallback(
( value: string ) => onReadinessChange( value as StatsFeedbackReadiness ),
[ onReadinessChange ]
);

return (
<>
<Stack direction="column" gap="sm">
<Question>{ commentQuestion }</Question>
<TextareaControl
<Question>{ readinessQuestion }</Question>
<RadioControl
hideLabelFromVision
label={ commentQuestion }
value={ comment }
maxLength={ COMMENT_MAX_LENGTH }
onValueChange={ onCommentChange }
label={ readinessQuestion }
options={ readinessOptions() }
selected={ readiness }
onChange={ selectReadiness }
/>
</Stack>

<CommentField
question={ commentQuestion }
comment={ comment }
onCommentChange={ onCommentChange }
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
/**
* WordPress dependencies
*/
import { submitStatsUserFeedback, type StatsFeedbackRating } from '@jetpack-premium-analytics/data';
import { submitStatsUserFeedback } from '@jetpack-premium-analytics/data';
import { Button, Dialog, Notice, Stack } from '@jetpack-premium-analytics/externals';
import { useCallback, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { useTrackEvent } from '../../hooks/use-track-event';
import { FeedbackFields } from './feedback-fields';
import { ReadinessFields, readinessSummary, type StatsFeedbackReadiness } from './feedback-fields';

// Reaches Happiness as the subject line of the feedback email ("Feedback received
// from …"), so it has to name the surface without any further context.
Expand All @@ -20,7 +20,7 @@ type FeedbackModalProps = {
};

/**
* Comparison rating with an optional comment. Both reach Tracks as one event; a non-empty
* Readiness answer with an optional comment. Both reach Tracks as one event; a non-empty
* comment also goes to the Stats feedback endpoint.
*
* @param {FeedbackModalProps} props - Component props.
Expand All @@ -29,15 +29,10 @@ type FeedbackModalProps = {
*/
export function FeedbackModal( { onClose }: FeedbackModalProps ) {
const trackEvent = useTrackEvent();
const [ rating, setRating ] = useState< StatsFeedbackRating >();
const [ readiness, setReadiness ] = useState< StatsFeedbackReadiness >();
const [ comment, setComment ] = useState( '' );
const [ hasSubmitted, setHasSubmitted ] = useState( false );

const blockerQuestion = __(
"What's the one thing we'd need to fix before this replaces the old Stats?",
'jetpack-premium-analytics-pkg'
);

const handleOpenChange = useCallback(
( isOpen: boolean ) => {
if ( ! isOpen ) {
Expand All @@ -48,28 +43,30 @@ export function FeedbackModal( { onClose }: FeedbackModalProps ) {
);

const submit = useCallback( () => {
if ( rating === undefined ) {
if ( readiness === undefined ) {
return;
}

const message = comment.trim();

trackEvent( 'jetpack_premium_analytics_feedback_submit', { rating, comment: message } );
trackEvent( 'jetpack_premium_analytics_feedback_submit', { readiness, comment: message } );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The tracking section of the description points at an "Open questions" section for the event-name decision, but there is no such section, so the question is only implied. Keeping jetpack_premium_analytics_feedback_submit means rows carry rating up to this deploy and readiness after it, and a query on rating goes quiet without an error. The event is six days old (#51870), so a rename is the cheapest it will ever be. Is keeping the name the intended choice?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Keeping the name is intentional: the event still means "the reader sent feedback", so submission counts stay continuous across the cutover. I dropped the dangling "Open questions" sentence and noted the property swap and its date on STATS-460, where the feedback analysis lives.


// Second channel, deliberately not awaited: Tracks is a pixel and ad blockers drop it
// silently, so the message also goes to Happiness where delivery is not the reader's
// browser's decision. A rating alone would only open an empty ticket.
// browser's decision. An answer alone would only open an empty ticket.
if ( message ) {
submitStatsUserFeedback( { rating, comment: message, productName: PRODUCT_NAME } ).catch(
() => {
// The reader has already been thanked and Tracks may well have the submission;
// a second, contradictory message would cost more than the lost email.
}
);
// The endpoint has no readiness field, so the answer rides along in the message.
submitStatsUserFeedback( {
comment: `${ readinessSummary( readiness ) } ${ message }`,
productName: PRODUCT_NAME,
} ).catch( () => {
// The reader has already been thanked and Tracks may well have the submission;
// a second, contradictory message would cost more than the lost email.
} );
}

setHasSubmitted( true );
}, [ comment, rating, trackEvent ] );
}, [ comment, readiness, trackEvent ] );

return (
<Dialog.Root open onOpenChange={ handleOpenChange }>
Expand Down Expand Up @@ -110,12 +107,11 @@ export function FeedbackModal( { onClose }: FeedbackModalProps ) {
<>
<Dialog.Content>
<Stack direction="column" gap="xl">
<FeedbackFields
rating={ rating }
onRatingChange={ setRating }
<ReadinessFields
readiness={ readiness }
onReadinessChange={ setReadiness }
comment={ comment }
onCommentChange={ setComment }
commentQuestion={ blockerQuestion }
/>
</Stack>
</Dialog.Content>
Expand All @@ -127,7 +123,7 @@ export function FeedbackModal( { onClose }: FeedbackModalProps ) {
<Button
variant="solid"
size="compact"
disabled={ rating === undefined }
disabled={ readiness === undefined }
onClick={ submit }
>
{ __( 'Send feedback', 'jetpack-premium-analytics-pkg' ) }
Expand Down
Loading
Loading