diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f0db9993d..21612b3dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -148,6 +148,10 @@ Breaking changes in this release: - `import { hooks } from 'botframework-webchat'` should be replaced by `import * as hooks from 'botframework-webchat/hook'` - Added target to Chrome 100 and re-enable Lightning CSS for ESM builds, by [@compulim](https://github.com/compulim) in PR [#5602](https://github.com/microsoft/BotFramework-WebChat/pull/5602) - Relaxed `role` prop to allow any string instead of ARIA landmark roles, in PR [#5561](https://github.com/microsoft/BotFramework-WebChat/pull/5561), by [@compulim](https://github.com/compulim) +- Added `renderFeedbackFormOverrideComponent` style option to allow host applications to provide custom feedback form components, in PR [#5818](https://github.com/microsoft/BotFramework-WebChat/pull/5818) + - Enables hosts to replace the native feedback form with their own UI via `styleOptions.renderFeedbackFormOverrideComponent` + - Feedback buttons remain controlled by Web Chat while form rendering is delegated to the host application + - Added sample and integration tests demonstrating custom feedback form implementation - Cleaned up `` and various CSS related code, in PR [#5611](https://github.com/microsoft/BotFramework-WebChat/pull/5611), by [@compulim](https://github.com/compulim) - (Experimental) Reworked the copilot variant to align with the modern Copilot UX, in PR [#5630](https://github.com/microsoft/BotFramework-WebChat/pull/5630), by [@OEvgeny](https://github.com/OEvgeny), in PR [#5634](https://github.com/microsoft/BotFramework-WebChat/pull/5634), by [@OEvgeny](https://github.com/OEvgeny), in PR [#5656](https://github.com/microsoft/BotFramework-WebChat/pull/5656), by [@OEvgeny](https://github.com/OEvgeny) - Added loading animation for `copilot`, and `fluent` variants diff --git a/__tests__/html2/feedbackForm/feedback.form.middleware.inline.changeMind.html b/__tests__/html2/feedbackForm/feedback.form.middleware.inline.changeMind.html new file mode 100644 index 0000000000..bb224cda78 --- /dev/null +++ b/__tests__/html2/feedbackForm/feedback.form.middleware.inline.changeMind.html @@ -0,0 +1,222 @@ + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/__tests__/html2/feedbackForm/feedback.form.middleware.inline.html b/__tests__/html2/feedbackForm/feedback.form.middleware.inline.html new file mode 100644 index 0000000000..6246e47f7f --- /dev/null +++ b/__tests__/html2/feedbackForm/feedback.form.middleware.inline.html @@ -0,0 +1,209 @@ + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/packages/api/src/StyleOptions.ts b/packages/api/src/StyleOptions.ts index 2664030055..27533bd86b 100644 --- a/packages/api/src/StyleOptions.ts +++ b/packages/api/src/StyleOptions.ts @@ -1,4 +1,19 @@ -import type { WebChatActivity } from 'botframework-webchat-core'; +import type { ReactNode } from 'react'; +import type { OrgSchemaAction, WebChatActivity } from 'botframework-webchat-core'; + +/** + * Context passed to a host-provided feedback form override component. + * + * @see StyleOptions.renderFeedbackFormOverrideComponent + */ +export type RenderFeedbackFormOverrideComponentContext = { + /** The currently selected vote action (e.g., LikeAction or DislikeAction). */ + selectedAction: OrgSchemaAction; + /** Call to submit the currently selected vote. */ + onSubmit: () => void; + /** Call to dismiss/cancel the feedback form. Resets the vote selection. */ + onDismiss: () => void; +}; type StyleOptions = { /** @@ -956,6 +971,49 @@ type StyleOptions = { */ feedbackActionsPlacement?: 'activity-actions' | 'activity-status'; + /** + * (EXPERIMENTAL) Host-provided feedback form override component. + * + * When set, replaces the native feedback text form with a host-provided React component. + * The native vote buttons (thumbs up/down) still render and manage selection state. + * Only the feedback form (text area + submit/cancel) is replaced. + * + * Useful when the host wants to render an external/imperative feedback surface + * (e.g., a portal-mounted dialog or modal owned by the host application). In that + * case the host can perform side effects in a hook and return `null` to render + * nothing inline within Web Chat. + * + * The renderer receives an object with the current feedback state and callbacks: + * - `selectedAction` — the currently selected vote action + * - `onSubmit` — call to submit the currently selected vote. Web Chat will post + * the invoke activity to the bot and mark feedback as submitted. + * - `onDismiss` — call to cancel/dismiss the feedback form. Web Chat will clear + * the vote selection so the user can vote again. + * + * Hosts SHOULD call exactly one of `onSubmit` or `onDismiss` when their external + * surface is closed. If neither is called, the bot will not receive the vote and + * the thumb will remain selected, preventing the user from re-voting. + * + * Return a React node to render in place of the native form, or `null` to render nothing. + * + * @example + * // Side-effect-only renderer: launch external dialog, wire its callbacks. + * renderFeedbackFormOverrideComponent: ({ selectedAction, onSubmit, onDismiss }) => { + * useEffect(() => { + * openExternalFeedbackDialog({ + * onSuccess: () => onSubmit(), + * onCancel: () => onDismiss(), // clear thumb selection + * }); + * }, []); + * return null; + * } + * + * @default undefined (uses native feedback form) + * + * New in 4.19.0. + */ + renderFeedbackFormOverrideComponent?: (context: RenderFeedbackFormOverrideComponentContext) => ReactNode | null; + /** * Use continuous mode for speech recognition. Default to `false`. * diff --git a/packages/api/src/defaultStyleOptions.ts b/packages/api/src/defaultStyleOptions.ts index e9ea89c7f9..7a8094ae3f 100644 --- a/packages/api/src/defaultStyleOptions.ts +++ b/packages/api/src/defaultStyleOptions.ts @@ -309,6 +309,7 @@ const DEFAULT_OPTIONS: Required = { codeBlockTheme: 'github-light-default' as const, feedbackActionsPlacement: 'activity-status' as const, + renderFeedbackFormOverrideComponent: undefined, // Speech recognition speechRecognitionContinuous: false, diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 2b2cff9478..649ac7d943 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -31,7 +31,11 @@ export { type SendBoxToolbarMiddlewareRequest } from './middleware/SendBoxToolbarMiddleware'; export { default as normalizeStyleOptions } from './normalizeStyleOptions'; -export { type StrictStyleOptions, type default as StyleOptions } from './StyleOptions'; +export { + type RenderFeedbackFormOverrideComponentContext, + type StrictStyleOptions, + type default as StyleOptions +} from './StyleOptions'; export { type ActivityStatusMiddleware, type RenderActivityStatus } from './types/ActivityStatusMiddleware'; export { type AttachmentForScreenReaderComponentFactory, diff --git a/packages/component/src/ActivityFeedback/ActivityFeedback.tsx b/packages/component/src/ActivityFeedback/ActivityFeedback.tsx index 4e9e2d0db0..30bb9a5686 100644 --- a/packages/component/src/ActivityFeedback/ActivityFeedback.tsx +++ b/packages/component/src/ActivityFeedback/ActivityFeedback.tsx @@ -1,5 +1,15 @@ +import { hooks } from 'botframework-webchat-api'; +import type { OrgSchemaAction } from 'botframework-webchat-core'; import { useStyles } from '@msinternal/botframework-webchat-styles/react'; -import React, { memo, useCallback, useMemo, type FormEventHandler, type KeyboardEventHandler } from 'react'; +import React, { + memo, + useCallback, + useMemo, + type Dispatch, + type FormEventHandler, + type KeyboardEventHandler, + type SetStateAction +} from 'react'; import { Extract, wrapWith } from 'react-wrap-with'; import { useRefFrom } from 'use-ref-from'; @@ -11,24 +21,41 @@ import useActivityFeedbackHooks from './providers/useActivityFeedbackHooks'; import styles from './private/FeedbackForm.module.css'; +const { useStyleOptions } = hooks; + +type FeedbackFormOverrideRenderer = (context: { + selectedAction: OrgSchemaAction; + onSubmit: () => void; + onDismiss: () => void; +}) => React.ReactNode | null; + +type FeedbackTextState = readonly [string | undefined, Dispatch>]; +type SelectedActionState = readonly [OrgSchemaAction | undefined, (action: OrgSchemaAction | undefined) => void]; +type StyleOptionsWithFeedbackFormOverrideComponent = Readonly<{ + renderFeedbackFormOverrideComponent?: FeedbackFormOverrideRenderer | undefined; +}>; + function InternalActivityFeedback() { const classNames = useStyles(styles); const { useActions, useFeedbackText, useFocusFeedbackButton, useHasSubmitted, useSelectedAction, useSubmit } = useActivityFeedbackHooks(); - const [_, setFeedbackText] = useFeedbackText(); + const [_, setFeedbackText] = useFeedbackText() as FeedbackTextState; const [actions] = useActions(); const [hasSubmitted] = useHasSubmitted(); - const [selectedAction, setSelectedAction] = useSelectedAction(); + const [selectedAction, setSelectedAction] = useSelectedAction() as SelectedActionState; const focusFeedbackButton = useFocusFeedbackButton(); const submit = useSubmit(); + const [{ renderFeedbackFormOverrideComponent }] = useStyleOptions() as readonly [ + StyleOptionsWithFeedbackFormOverrideComponent + ]; const firstActionRequireReview = useMemo(() => actions.find(isActionRequireReview), [actions]); const selectedActionRef = useRefFrom(selectedAction); const handleReset = useCallback>(() => { - focusFeedbackButton(selectedActionRef.current); + selectedActionRef.current && focusFeedbackButton(selectedActionRef.current); setFeedbackText(undefined); setSelectedAction(undefined); @@ -38,7 +65,7 @@ function InternalActivityFeedback() { event => { event.preventDefault(); - submit(selectedActionRef.current); + selectedActionRef.current && submit(selectedActionRef.current); }, [selectedActionRef, submit] ); @@ -61,22 +88,50 @@ function InternalActivityFeedback() { [hasSubmitted, selectedAction] ); + // Callbacks for custom feedback form renderer. + const handleCustomSubmit = useCallback(() => { + selectedActionRef.current && submit(selectedActionRef.current); + }, [selectedActionRef, submit]); + + const handleCustomDismiss = useCallback(() => { + selectedActionRef.current && focusFeedbackButton(selectedActionRef.current); + + setFeedbackText(undefined); + setSelectedAction(undefined); + }, [focusFeedbackButton, selectedActionRef, setFeedbackText, setSelectedAction]); + + const customFormElement = useMemo(() => { + if (!renderFeedbackFormOverrideComponent || !isExpanded || !selectedAction) { + return null; + } + + return renderFeedbackFormOverrideComponent({ + selectedAction, + onSubmit: handleCustomSubmit, + onDismiss: handleCustomDismiss + }); + }, [renderFeedbackFormOverrideComponent, isExpanded, selectedAction, handleCustomSubmit, handleCustomDismiss]); + + if (!actions.length) { + return null; + } + return ( - !!actions.length && ( -
- - {/* We put the form outside of the container to let it wrap to next line instead of keeping it the same line as the like/dislike buttons. */} - {isExpanded && } - - ) +
+ + {/* We put the form outside of the container to let it wrap to next line instead of keeping it the same line as the like/dislike buttons. */} + {isExpanded && !renderFeedbackFormOverrideComponent && } + {/* When a host renderFeedbackFormOverrideComponent is provided, skip the native form. */} + {isExpanded && renderFeedbackFormOverrideComponent ? customFormElement : null} + ); } diff --git a/packages/component/src/ActivityFeedback/providers/ActivityFeedbackComposer.tsx b/packages/component/src/ActivityFeedback/providers/ActivityFeedbackComposer.tsx index b4d9edea66..a83736d6fc 100644 --- a/packages/component/src/ActivityFeedback/providers/ActivityFeedbackComposer.tsx +++ b/packages/component/src/ActivityFeedback/providers/ActivityFeedbackComposer.tsx @@ -163,15 +163,18 @@ function ActivityFeedbackComposer(props: ActivityFeedbackComposerProps) { useMemo(() => { const activeOrCompletedAction = rawActions.find( (action): action is OrgSchemaAction & { actionStatus: 'ActiveActionStatus' | 'CompletedActionStatus' } => - action.actionStatus === 'ActiveActionStatus' || action.actionStatus === 'CompletedActionStatus' + !!action['@id'] && + (action.actionStatus === 'ActiveActionStatus' || action.actionStatus === 'CompletedActionStatus') ); + const activeOrCompletedActionId = activeOrCompletedAction?.['@id']; - actionStateRef.current = activeOrCompletedAction - ? { - actionId: activeOrCompletedAction['@id'], - actionStatus: activeOrCompletedAction.actionStatus - } - : undefined; + actionStateRef.current = + activeOrCompletedAction && activeOrCompletedActionId + ? { + actionId: activeOrCompletedActionId, + actionStatus: activeOrCompletedAction.actionStatus + } + : undefined; }, [rawActions]); // Workaround ESLint on saying actionStateRef.current is redundant when using it directly. @@ -204,6 +207,10 @@ function ActivityFeedbackComposer(props: ActivityFeedbackComposerProps) { const submit = useCallback( (action: OrgSchemaAction) => { + if (!action['@id']) { + return console.warn('botframework-webchat internal: Cannot submit an action without id, ignoring the call.'); + } + if (actionStateRef.current?.actionStatus === 'CompletedActionStatus') { return console.warn( 'botframework-webchat internal: useFeedbackActions().submitCallback() must not be called after feedback is completed, ignoring the call.' @@ -257,6 +264,10 @@ function ActivityFeedbackComposer(props: ActivityFeedbackComposerProps) { const setSelectedAction = useCallback( (action: OrgSchemaAction | undefined) => { + if (action && !action['@id']) { + return console.warn('botframework-webchat internal: Cannot select an action without id, ignoring the call.'); + } + // If the action require a UserReview, do not allow resubmit. const shouldAllowResubmit = canActionResubmit(action); @@ -270,10 +281,12 @@ function ActivityFeedbackComposer(props: ActivityFeedbackComposerProps) { ); } + const selectedActionId = action?.['@id']; + setActionStateWithRefresh( - action + selectedActionId ? Object.freeze({ - actionId: action['@id'], + actionId: selectedActionId, actionStatus: 'ActiveActionStatus' }) : undefined @@ -283,10 +296,11 @@ function ActivityFeedbackComposer(props: ActivityFeedbackComposerProps) { clearTimeout(autoSubmitTimeoutRef.current); if (action?.['@id']) { - autoSubmitTimeoutRef.current = setTimeout( - () => submit(actionsRef.current.find(({ '@id': id }) => id === action['@id'])), - DEBOUNCE_TIMEOUT - ); + autoSubmitTimeoutRef.current = setTimeout(() => { + const actionToSubmit = actionsRef.current.find(({ '@id': id }) => id === action['@id']); + + actionToSubmit && submit(actionToSubmit); + }, DEBOUNCE_TIMEOUT); } } }, @@ -294,7 +308,11 @@ function ActivityFeedbackComposer(props: ActivityFeedbackComposerProps) { ); const selectedActionState = useMemo void]>( - () => Object.freeze([selectedAction, setSelectedAction]), + () => + Object.freeze([selectedAction, setSelectedAction]) as readonly [ + OrgSchemaAction, + (action: OrgSchemaAction) => void + ], [selectedAction, setSelectedAction] ); @@ -304,7 +322,7 @@ function ActivityFeedbackComposer(props: ActivityFeedbackComposerProps) { ); const feedbackTextState = useMemo>]>( - () => Object.freeze([feedbackText, setFeedbackText]), + () => Object.freeze([feedbackText, setFeedbackText]) as readonly [string, Dispatch>], [feedbackText, setFeedbackText] ); diff --git a/samples/05.custom-components/h.feedback-form/README.md b/samples/05.custom-components/h.feedback-form/README.md new file mode 100644 index 0000000000..5907d50317 --- /dev/null +++ b/samples/05.custom-components/h.feedback-form/README.md @@ -0,0 +1,281 @@ +# Sample - Replace the native feedback form with an inline host component + +This sample shows how to keep Web Chat's native thumbs up/down behavior while replacing the native feedback form with a host-provided inline component mounted through `styleOptions.renderFeedbackFormOverrideComponent`. + +The inline component simulates a third-party feedback library that only mounts when the user clicks a feedback button. The host component receives Web Chat callbacks and imperatively mounts the third-party UI when the component appears. + +# Test out the hosted sample + +- [Try out MockBot](https://microsoft.github.io/BotFramework-WebChat/05.custom-components/h.feedback-form) + +# How to run locally + +- Fork this repository +- Navigate to `/Your-Local-WebChat/samples/05.custom-components/h.feedback-form` in command line +- Run `npx serve` +- Browse to [http://localhost:5000/](http://localhost:5000/) + +# Things to try out + +- Type `help` + - Click thumbs up or thumbs down on the bot reply + - Observe the inline feedback host replacing the native textarea form +- Submit feedback from the simulated third-party widget + - Observe Web Chat still submits the existing feedback invoke payload for the selected vote + +# Code + +> Jump to [completed code](#completed-code) to see the end-result `index.html`. + +## Overview + +First, define a simulated third-party library API that mounts into a DOM node imperatively. + +```js +function handleFeedbackSubmitClick({ container, onCancel, onSubmit, reaction }) { + function ThirdPartyFeedback() { + return ( +
+

Third-party feedback

+

You selected {reaction}.

+
+ + +
+
+ ); + } + + ReactDOM.render(, container); + + return () => ReactDOM.unmountComponentAtNode(container); +} +``` + +Next, build an inline host component that receives the override context from `styleOptions.renderFeedbackFormOverrideComponent`. When it mounts, it calls the third-party API once and maps Web Chat helpers to the third-party callbacks. + +```js +function InlineFeedbackHost({ onDismiss, onSubmit, selectedAction }) { + const containerRef = React.useRef(null); + const hasMountedRef = React.useRef(false); + + React.useEffect(() => { + if (!containerRef.current || hasMountedRef.current) { + return; + } + + hasMountedRef.current = true; + + const dispose = handleFeedbackSubmitClick({ + container: containerRef.current, + onCancel: onDismiss, + onSubmit, + reaction: selectedAction['@type'] === 'LikeAction' ? 'like' : 'dislike' + }); + + return dispose; + }, [onDismiss, onSubmit, selectedAction]); + + return
; +} +``` + +Then, pass the override renderer into `ReactWebChat`. Web Chat will still own the thumbs buttons and selected-action state. The override only replaces the expanded form area. + +```js +ReactDOM.render( + ( + + ) + }} + />, + document.getElementById('webchat') +); +``` + +## Completed code + + +```html + + + + Web Chat: Replace the native feedback form with an inline host component + + + + + + + + + +
+ + + +``` + + +# Further reading + +- [Activity status middleware sample](https://github.com/microsoft/BotFramework-WebChat/tree/main/samples/05.custom-components/g.activity-status) + +## Full list of Web Chat hosted samples + +View the list of [available Web Chat samples](https://github.com/microsoft/BotFramework-WebChat/tree/main/samples) \ No newline at end of file diff --git a/samples/05.custom-components/h.feedback-form/index.html b/samples/05.custom-components/h.feedback-form/index.html new file mode 100644 index 0000000000..35ce8aee85 --- /dev/null +++ b/samples/05.custom-components/h.feedback-form/index.html @@ -0,0 +1,166 @@ + + + + Web Chat: Replace the native feedback form with an inline host component + + + + + + + + + + + +
+ + + \ No newline at end of file