diff --git a/.github/workflows/pull-request-validation.yml b/.github/workflows/pull-request-validation.yml index 6d631f9663..7bc02a7f0a 100644 --- a/.github/workflows/pull-request-validation.yml +++ b/.github/workflows/pull-request-validation.yml @@ -281,7 +281,9 @@ jobs: with: compression-level: 0 name: test-snapshot-diff-html-${{ matrix.shard-index }} - path: ./__tests__/__image_snapshots__/*/__diff_output__/* + path: | + ./__tests__/__image_snapshots__/*/__diff_output__/* + ./__tests__/html2/**/*.snap-*-diff.png merge-test-results: if: always() diff --git a/CHANGELOG.md b/CHANGELOG.md index dbeb618c52..23b76cd819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - Added support of [contentless activity in livestream](https://github.com/microsoft/BotFramework-WebChat/blob/main/docs/LIVESTREAMING.md#scenario-3-interim-activities-with-no-content), in PR [#5430](https://github.com/microsoft/BotFramework-WebChat/pull/5430), by [@compulim](https://github.com/compulim) - Added sliding dots typing indicator in Fluent theme, in PR [#5447](https://github.com/microsoft/BotFramework-WebChat/pull/5447) and PR [#5448](https://github.com/microsoft/BotFramework-WebChat/pull/5448), by [@compulim](https://github.com/compulim) - (Experimental) Add an ability to pass `completion` prop into Fluent send box and expose the component, in PR [#5466](https://github.com/microsoft/BotFramework-WebChat/pull/5466), by [@OEvgeny](https://github.com/OEvgeny) +- Added feedback form for like/dislike button when `feedbackActionsPlacement` is `"activity-actions"`, in PR [#5460](https://github.com/microsoft/BotFramework-WebChat/pull/5460), by [@lexi-taylor](https://github.com/lexi-taylor) and [@OEvgeny](https://github.com/OEvgeny) ### Changed diff --git a/__tests__/html2/activity/feedback.form.activity.html b/__tests__/html2/activity/feedback.form.activity.html new file mode 100644 index 0000000000..b722ac3b5d --- /dev/null +++ b/__tests__/html2/activity/feedback.form.activity.html @@ -0,0 +1,133 @@ + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/activity/feedback.form.activity.html.snap-1.png b/__tests__/html2/activity/feedback.form.activity.html.snap-1.png new file mode 100644 index 0000000000..f6e7ec73b9 Binary files /dev/null and b/__tests__/html2/activity/feedback.form.activity.html.snap-1.png differ diff --git a/__tests__/html2/activity/feedback.form.activity.html.snap-2.png b/__tests__/html2/activity/feedback.form.activity.html.snap-2.png new file mode 100644 index 0000000000..d31a8f1581 Binary files /dev/null and b/__tests__/html2/activity/feedback.form.activity.html.snap-2.png differ diff --git a/__tests__/html2/activity/feedback.form.activity.html.snap-3.png b/__tests__/html2/activity/feedback.form.activity.html.snap-3.png new file mode 100644 index 0000000000..a20059bfe8 Binary files /dev/null and b/__tests__/html2/activity/feedback.form.activity.html.snap-3.png differ diff --git a/__tests__/html2/activity/feedback.form.activity.html.snap-4.png b/__tests__/html2/activity/feedback.form.activity.html.snap-4.png new file mode 100644 index 0000000000..d7bf2be2fc Binary files /dev/null and b/__tests__/html2/activity/feedback.form.activity.html.snap-4.png differ diff --git a/__tests__/html2/activity/feedback.form.activity.html.snap-5.png b/__tests__/html2/activity/feedback.form.activity.html.snap-5.png new file mode 100644 index 0000000000..a20059bfe8 Binary files /dev/null and b/__tests__/html2/activity/feedback.form.activity.html.snap-5.png differ diff --git a/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html b/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html new file mode 100644 index 0000000000..0798671f60 --- /dev/null +++ b/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html.snap-1.png b/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html.snap-1.png new file mode 100644 index 0000000000..8d9b22f8f7 Binary files /dev/null and b/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html.snap-1.png differ diff --git a/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html.snap-2.png b/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html.snap-2.png new file mode 100644 index 0000000000..1f3bd6fa46 Binary files /dev/null and b/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html.snap-2.png differ diff --git a/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html.snap-3.png b/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html.snap-3.png new file mode 100644 index 0000000000..e4ef8d8c8e Binary files /dev/null and b/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html.snap-3.png differ diff --git a/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html.snap-4.png b/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html.snap-4.png new file mode 100644 index 0000000000..4408c2006d Binary files /dev/null and b/__tests__/html2/fluentTheme/defaultFeedback.activity.dark.html.snap-4.png differ diff --git a/__tests__/html2/fluentTheme/defaultFeedback.activity.html b/__tests__/html2/fluentTheme/defaultFeedback.activity.html new file mode 100644 index 0000000000..0b60c262d4 --- /dev/null +++ b/__tests__/html2/fluentTheme/defaultFeedback.activity.html @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/fluentTheme/defaultFeedback.activity.html.snap-1.png b/__tests__/html2/fluentTheme/defaultFeedback.activity.html.snap-1.png new file mode 100644 index 0000000000..b4d90bd648 Binary files /dev/null and b/__tests__/html2/fluentTheme/defaultFeedback.activity.html.snap-1.png differ diff --git a/__tests__/html2/fluentTheme/defaultFeedback.activity.html.snap-2.png b/__tests__/html2/fluentTheme/defaultFeedback.activity.html.snap-2.png new file mode 100644 index 0000000000..53f8665347 Binary files /dev/null and b/__tests__/html2/fluentTheme/defaultFeedback.activity.html.snap-2.png differ diff --git a/__tests__/html2/fluentTheme/defaultFeedback.activity.html.snap-3.png b/__tests__/html2/fluentTheme/defaultFeedback.activity.html.snap-3.png new file mode 100644 index 0000000000..66072faa38 Binary files /dev/null and b/__tests__/html2/fluentTheme/defaultFeedback.activity.html.snap-3.png differ diff --git a/__tests__/html2/fluentTheme/defaultFeedback.activity.html.snap-4.png b/__tests__/html2/fluentTheme/defaultFeedback.activity.html.snap-4.png new file mode 100644 index 0000000000..309151bdd4 Binary files /dev/null and b/__tests__/html2/fluentTheme/defaultFeedback.activity.html.snap-4.png differ diff --git a/__tests__/html2/fluentTheme/feedback.form.multiple.html b/__tests__/html2/fluentTheme/feedback.form.multiple.html new file mode 100644 index 0000000000..9eb690a5a9 --- /dev/null +++ b/__tests__/html2/fluentTheme/feedback.form.multiple.html @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/fluentTheme/feedback.form.multiple.html.snap-1.png b/__tests__/html2/fluentTheme/feedback.form.multiple.html.snap-1.png new file mode 100644 index 0000000000..1ab0c215b2 Binary files /dev/null and b/__tests__/html2/fluentTheme/feedback.form.multiple.html.snap-1.png differ diff --git a/packages/api/src/StyleOptions.ts b/packages/api/src/StyleOptions.ts index f229984adc..b55d1f91ce 100644 --- a/packages/api/src/StyleOptions.ts +++ b/packages/api/src/StyleOptions.ts @@ -932,7 +932,7 @@ type StyleOptions = { /** * (EXPERIMENTAL) Feedback buttons placement * - * - `'activity-actions'` - place feedback buttons inside activity actions + * - `'activity-actions'` - place feedback buttons inside activity actions - will show feedback form * - `'activity-status'` - place feedback buttons inside activity status * * @default 'activity-status' diff --git a/packages/api/src/defaultStyleOptions.ts b/packages/api/src/defaultStyleOptions.ts index 8fa1ef4e6b..cda952a509 100644 --- a/packages/api/src/defaultStyleOptions.ts +++ b/packages/api/src/defaultStyleOptions.ts @@ -244,7 +244,6 @@ const DEFAULT_OPTIONS: Required = { transcriptOverlayButtonColorOnHover: undefined, // Toast UI - notificationDebounceTimeout: 400, hideToaster: false, diff --git a/packages/api/src/localization/en-US.json b/packages/api/src/localization/en-US.json index 396e9a6f3d..a675514234 100644 --- a/packages/api/src/localization/en-US.json +++ b/packages/api/src/localization/en-US.json @@ -76,6 +76,10 @@ "COPY_BUTTON_TEXT": "Copy", "COPY_BUTTON_COPIED_TEXT": "Copied", "_COPY_BUTTON_COPIED.comment": "After clicking on the copy button, this text will show briefly", + "FEEDBACK_FORM_SUBMIT_BUTTON_LABEL": "Submit", + "FEEDBACK_FORM_CANCEL_BUTTON_LABEL": "Cancel", + "FEEDBACK_FORM_PLACEHOLDER": "Give as much detail as you can, but do not include any private or sensitive information.", + "FEEDBACK_FORM_TITLE": "Tell us about your experience", "FILE_CONTENT_ALT": "'$1'", "FILE_CONTENT_DOWNLOADABLE_ALT": "Download file '$1'", "FILE_CONTENT_DOWNLOADABLE_WITH_SIZE_ALT": "Download file '$1' of size $2", diff --git a/packages/component/src/Activity/ActivityFeedback.tsx b/packages/component/src/Activity/ActivityFeedback.tsx index 2cb7ad93a0..644dddc669 100644 --- a/packages/component/src/Activity/ActivityFeedback.tsx +++ b/packages/component/src/Activity/ActivityFeedback.tsx @@ -1,10 +1,13 @@ import { hooks } from 'botframework-webchat-api'; import { getOrgSchemaMessage, OrgSchemaAction, parseAction, WebChatActivity } from 'botframework-webchat-core'; -import cx from 'classnames'; -import React, { memo, useMemo } from 'react'; - -import Feedback from './private/Feedback'; +import classNames from 'classnames'; +import React, { memo, useCallback, useMemo, useState } from 'react'; +import useStyleSet from '../hooks/useStyleSet'; import dereferenceBlankNodes from '../Utils/JSONLinkedData/dereferenceBlankNodes'; +import Feedback from './private/Feedback'; +import getDisclaimer from './private/getDisclaimer'; +import hasFeedbackLoop from './private/hasFeedbackLoop'; +import FeedbackForm from './private/FeedbackForm'; const { useStyleOptions } = hooks; @@ -12,12 +15,47 @@ type ActivityFeedbackProps = Readonly<{ activity: WebChatActivity; }>; +const parseActivity = (entities?: WebChatActivity['entities']) => { + const graph = dereferenceBlankNodes(entities || []); + const messageThing = getOrgSchemaMessage(graph); + + return { graph, messageThing }; +}; + +const defaultFeedbackEntities = { + '@context': 'https://schema.org', + '@id': '', + '@type': 'Message', + type: 'https://schema.org/Message', + + keywords: [], + potentialAction: [ + { + '@type': 'LikeAction', + actionStatus: 'PotentialActionStatus' + }, + { + '@type': 'DislikeAction', + actionStatus: 'PotentialActionStatus' + } + ] +}; + function ActivityFeedback({ activity }: ActivityFeedbackProps) { const [{ feedbackActionsPlacement }] = useStyleOptions(); + const [{ feedbackForm }] = useStyleSet(); + + const [selectedAction, setSelectedAction] = useState(); - const graph = useMemo(() => dereferenceBlankNodes(activity.entities || []), [activity.entities]); + const isFeedbackLoopSupported = hasFeedbackLoop(activity); - const messageThing = useMemo(() => getOrgSchemaMessage(graph), [graph]); + const { graph, messageThing } = useMemo(() => { + if (isFeedbackLoopSupported) { + return parseActivity([defaultFeedbackEntities]); + } + + return parseActivity(activity.entities); + }, [activity.entities, isFeedbackLoopSupported]); const feedbackActions = useMemo>(() => { try { @@ -40,14 +78,53 @@ function ActivityFeedback({ activity }: ActivityFeedbackProps) { return Object.freeze(new Set([] as OrgSchemaAction[])); }, [graph, messageThing?.potentialAction]); - return ( - + const handleFeedbackActionClick = useCallback( + (action: OrgSchemaAction) => setSelectedAction(action === selectedAction ? undefined : action), + [selectedAction, setSelectedAction] + ); + + const handleFeedbackFormReset = useCallback(() => setSelectedAction(undefined), [setSelectedAction]); + + const FeedbackComponent = useMemo( + () => ( + + ), + [feedbackActions, feedbackActionsPlacement, handleFeedbackActionClick, isFeedbackLoopSupported, selectedAction] ); + + const FeedbackFormComponent = useMemo( + () => ( + + ), + [activity, handleFeedbackFormReset, selectedAction] + ); + + if (feedbackActionsPlacement === 'activity-actions' && isFeedbackLoopSupported) { + return ( +
+
+ {FeedbackComponent} +
+ {selectedAction && selectedAction['@type'] && FeedbackFormComponent} +
+ ); + } + + // If placement is not inline with activity, we don't show the feedback form. + return FeedbackComponent; } export default memo(ActivityFeedback); diff --git a/packages/component/src/Activity/private/Feedback.tsx b/packages/component/src/Activity/private/Feedback.tsx index feb4427e5f..882e8b14b2 100644 --- a/packages/component/src/Activity/private/Feedback.tsx +++ b/packages/component/src/Activity/private/Feedback.tsx @@ -1,6 +1,6 @@ import { hooks } from 'botframework-webchat-api'; import { type OrgSchemaAction } from 'botframework-webchat-core'; -import React, { Fragment, memo, useEffect, useMemo, useState, type PropsWithChildren } from 'react'; +import React, { Fragment, memo, useEffect, useMemo, type PropsWithChildren } from 'react'; import { useRefFrom } from 'use-ref-from'; import FeedbackVoteButton from './VoteButton'; @@ -11,21 +11,23 @@ type Props = Readonly< PropsWithChildren<{ actions: ReadonlySet; className?: string | undefined; + isFeedbackFormSupported?: boolean; + onActionClick?: (action: OrgSchemaAction) => void; + selectedAction?: OrgSchemaAction | undefined; }> >; const DEBOUNCE_TIMEOUT = 500; -const Feedback = memo(({ actions, className }: Props) => { +const Feedback = memo(({ actions, className, isFeedbackFormSupported, onActionClick, selectedAction }: Props) => { const [{ clearTimeout, setTimeout }] = usePonyfill(); - const [selectedAction, setSelectedAction] = useState(); - const postActivity = usePostActivity(); const localize = useLocalizer(); + const postActivity = usePostActivity(); const postActivityRef = useRefFrom(postActivity); useEffect(() => { - if (!selectedAction) { + if (!selectedAction || isFeedbackFormSupported) { return; } @@ -41,7 +43,7 @@ const Feedback = memo(({ actions, className }: Props) => { ); return () => clearTimeout(timeout); - }, [clearTimeout, postActivityRef, selectedAction, setTimeout]); + }, [clearTimeout, isFeedbackFormSupported, postActivityRef, selectedAction, setTimeout]); const actionProps = useMemo( () => @@ -61,7 +63,7 @@ const Feedback = memo(({ actions, className }: Props) => { action={action} className={className} key={action['@id'] || index} - onClick={setSelectedAction} + onClick={onActionClick} pressed={ selectedAction === action || action.actionStatus === 'CompletedActionStatus' || diff --git a/packages/component/src/Activity/private/FeedbackForm.tsx b/packages/component/src/Activity/private/FeedbackForm.tsx new file mode 100644 index 0000000000..28a64807f8 --- /dev/null +++ b/packages/component/src/Activity/private/FeedbackForm.tsx @@ -0,0 +1,109 @@ +import { hooks } from 'botframework-webchat-api'; +import classNames from 'classnames'; +import React, { memo, useCallback, useEffect, useRef, useState, type FormEventHandler } from 'react'; +import { useRefFrom } from 'use-ref-from'; +import useStyleSet from '../../hooks/useStyleSet'; +import testIds from '../../testIds'; +import TextArea from './FeedbackTextArea'; + +const { useLocalizer, usePostActivity } = hooks; + +type FeedbackFormProps = Readonly<{ + disclaimer?: string; + feedbackType: string; + onReset: () => void; + replyToId?: string; +}>; + +function FeedbackForm({ feedbackType, disclaimer, onReset, replyToId }: FeedbackFormProps) { + const [{ feedbackForm }] = useStyleSet(); + const [hasFocus, setHasFocus] = useState(false); + const [userFeedback, setUserFeedback] = useState(''); + const feedbackTextAreaRef = useRef(null); + const localize = useLocalizer(); + const onResetRef = useRefFrom(onReset); + const postActivity = usePostActivity(); + + const handleReset = useCallback(() => { + setUserFeedback(''); + + onResetRef.current(); + + setHasFocus(false); + }, [onResetRef, setHasFocus, setUserFeedback]); + + const handleSubmit = useCallback( + event => { + event.preventDefault(); + + postActivity({ + name: 'message/submitAction', + replyToId, + type: 'invoke', + value: { + actionName: 'feedback', + actionValue: { + feedback: { feedbackText: userFeedback }, + reaction: feedbackType === 'LikeAction' ? 'like' : 'dislike' + } + } + } as any); + + handleReset(); + }, + [feedbackType, handleReset, postActivity, replyToId, userFeedback] + ); + + const handleChange: FormEventHandler = useCallback( + ({ currentTarget: { value } }) => setUserFeedback(value), + [setUserFeedback] + ); + + useEffect(() => { + // Will focus on the text area when: + // 1. The component is mounted initially, or + // 2. User clicked on the reset button + if (feedbackTextAreaRef.current && !hasFocus) { + setHasFocus(true); + + feedbackTextAreaRef.current.focus(); + } + }, [feedbackTextAreaRef, hasFocus, setHasFocus]); + + return ( +
+ + {localize('FEEDBACK_FORM_TITLE')} + +
+