diff --git a/CHANGELOG.md b/CHANGELOG.md index 326bc166c4..c94b4d4aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - If you customized `renderMarkdown` with a custom HTML sanitizer, please move the HTML sanitizer to the new HTML content transformer middleware - `useGroupActivities` hook is being deprecated in favor of the `useGroupActivitiesByName` hook. The hook will be removed on or after 2027-05-04 - `useSuggestedActions()` hook is being deprecated in favor of the `useSuggestedActionsHooks().useSuggestedActions()` hook. The hook will be removed on or after 2027-05-30 +- The following middleware should be created using their respective factory function: + - `activityBorderDecoratorMiddleware`, related to PR [#5504](https://github.com/microsoft/BotFramework-WebChat/pull/5504) + - `activityGroupingDecoratorMiddleware`, related to PR [#5504](https://github.com/microsoft/BotFramework-WebChat/pull/5504) + - `sendBoxMiddleware`, related to PR [#5504](https://github.com/microsoft/BotFramework-WebChat/pull/5504) + - `sendBoxToolbarMiddleware`, related to PR [#5504](https://github.com/microsoft/BotFramework-WebChat/pull/5504) ### Added @@ -329,7 +334,8 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - `useSendMessage` hook is updated to support sending attachments with a message - `useSendBoxAttachments` hook is added to get/set attachments in the send box - Resolves [#5081](https://github.com/microsoft/BotFramework-WebChat/issues/5081). Added `uploadAccept` and `uploadMultiple` style options, by [@ms-jb](https://github.com/ms-jb), in PR [#5048](https://github.com/microsoft/BotFramework-WebChat/pull/5048) -- Added `sendBoxMiddleware` and `sendBoxToolbarMiddleware`, by [@compulim](https://github.com/compulim), in PR [#5120](https://github.com/microsoft/BotFramework-WebChat/pull/5120) +- Added `sendBoxMiddleware` and `sendBoxToolbarMiddleware`, by [@compulim](https://github.com/compulim), in PR [#5120](https://github.com/microsoft/BotFramework-WebChat/pull/5120) and [#5504](https://github.com/microsoft/BotFramework-WebChat/pull/5504) + - Instead of passing barebone middleware, use the `createSendBoxMiddleware()` and `createSendBoxToolbarMiddleware()` factory function correspondingly, related to PR [#5504](https://github.com/microsoft/BotFramework-WebChat/pull/5504) - (Experimental) Added `botframework-webchat-fluent-theme` package for applying Fluent UI theme to Web Chat, by [@compulim](https://github.com/compulim) and [@OEvgeny](https://github.com/OEvgeny) - Initial commit, in PR [#5120](https://github.com/microsoft/BotFramework-WebChat/pull/5120) - Inherits Fluent CSS palette if available, in PR [#5122](https://github.com/microsoft/BotFramework-WebChat/pull/5122) diff --git a/__tests__/html/fluentTheme/withCustomDecorator.html b/__tests__/html/fluentTheme/withCustomDecorator.html index 051c265105..1bd583a4b4 100644 --- a/__tests__/html/fluentTheme/withCustomDecorator.html +++ b/__tests__/html/fluentTheme/withCustomDecorator.html @@ -28,7 +28,7 @@ React, ReactDOM: { render }, WebChat: { - decorator: { DecoratorComposer }, + decorator: { createActivityBorderMiddleware, DecoratorComposer }, FluentThemeProvider, ReactWebChat } @@ -43,12 +43,12 @@ } const decoratorMiddleware = [ - init => - init === 'activity border' && - (next => request => (request.livestreamingState === 'completing' ? Flair : next(request))), - init => - init === 'activity border' && - (next => request => (request.livestreamingState === 'preparing' ? Loader : next(request))) + createActivityBorderMiddleware( + next => request => (request.livestreamingState === 'completing' ? Flair : next(request)) + ), + createActivityBorderMiddleware( + next => request => (request.livestreamingState === 'preparing' ? Loader : next(request)) + ) ]; const { directLine, store } = testHelpers.createDirectLineEmulator(); diff --git a/__tests__/html/sendBoxMiddleware/warnIfInvalid.html b/__tests__/html/sendBoxMiddleware/warnIfInvalid.html index 79e0228fc5..7245816f2b 100644 --- a/__tests__/html/sendBoxMiddleware/warnIfInvalid.html +++ b/__tests__/html/sendBoxMiddleware/warnIfInvalid.html @@ -18,7 +18,7 @@ await pageConditions.uiConnected(); // THEN: It should warn about the invalid middleware. - await pageConditions.warnMessageLogged('"sendBoxMiddleware" prop is invalid.'); + await pageConditions.warnMessageLogged('must be an array of function'); // THEN: It should render the default send box. await host.snapshot(); diff --git a/__tests__/html/sendBoxToolbarMiddleware/warnIfInvalid.html b/__tests__/html/sendBoxToolbarMiddleware/warnIfInvalid.html index d861ef7cb8..8f3d03c39e 100644 --- a/__tests__/html/sendBoxToolbarMiddleware/warnIfInvalid.html +++ b/__tests__/html/sendBoxToolbarMiddleware/warnIfInvalid.html @@ -18,7 +18,7 @@ await pageConditions.uiConnected(); // THEN: It should warn about the invalid middleware. - await pageConditions.warnMessageLogged('"sendBoxToolbarMiddleware" prop is invalid.'); + await pageConditions.warnMessageLogged('must be an array of function'); // THEN: It should render the default send box. await host.snapshot(); diff --git a/__tests__/html2/feedbackForm/behavior.resetByEscapeKey.html b/__tests__/html2/feedbackForm/behavior.resetByEscapeKey.html index 5853662308..6b8ebfff32 100644 --- a/__tests__/html2/feedbackForm/behavior.resetByEscapeKey.html +++ b/__tests__/html2/feedbackForm/behavior.resetByEscapeKey.html @@ -94,7 +94,9 @@ // WHEN: Feedback form is opened. document.querySelector(`[data-testid="${testIds.sendBoxTextBox}"]`).focus(); await host.sendShiftTab(3); - await host.sendKeys('UP', 'ENTER', 'RIGHT', 'SPACE'); + + // WHEN: Select the activity, then press right arrow key to select the dislike button (radio button). + await host.sendKeys('UP', 'ENTER', 'RIGHT'); // THEN: The dislike button should be pressed. expect(Array.from(pageElements.allByTestId(testIds.feedbackButton)).map(element => element.checked)).toEqual([ diff --git a/__tests__/html2/feedbackForm/feedback.form.activity.html b/__tests__/html2/feedbackForm/feedback.form.activity.html index ee3261fb09..ba7c1c9dd2 100644 --- a/__tests__/html2/feedbackForm/feedback.form.activity.html +++ b/__tests__/html2/feedbackForm/feedback.form.activity.html @@ -93,8 +93,8 @@ // THEN: Should match snapshot. await host.snapshot('local'); - // WHEN: Click on dislike button to re-open feedback form - await host.sendKeys('RIGHT', 'SPACE'); + // WHEN: Press right arrow key to select the dislike button (radio button). + await host.sendKeys('RIGHT'); await pageConditions.became( 'feedback form is open', diff --git a/__tests__/html2/grouping/customGrouping.html b/__tests__/html2/grouping/customGrouping.html index 1a1b213398..110707fcea 100644 --- a/__tests__/html2/grouping/customGrouping.html +++ b/__tests__/html2/grouping/customGrouping.html @@ -32,7 +32,10 @@ const { React: { createElement }, - WebChat: { renderWebChat } + WebChat: { + decorator: { createActivityGroupingMiddleware }, + renderWebChat + } } = window; // Imports in UMD fashion. const clock = lolex.createClock(); @@ -62,22 +65,20 @@ : undefined; const decoratorMiddleware = [ - init => - init === 'activity grouping' && - (next => request => { - const DownstreamComponent = next(request); - - if (request.groupingName) { - return ({ activities, children }) => - createElement( - 'div', - { className: `grouping grouping--${request.groupingName}` }, - createElement(DownstreamComponent, { activities }, children) - ); - } + createActivityGroupingMiddleware(next => request => { + const DownstreamComponent = next(request); + + if (request.groupingName) { + return ({ activities, children }) => + createElement( + 'div', + { className: `grouping grouping--${request.groupingName}` }, + createElement(DownstreamComponent, { activities }, children) + ); + } - return DownstreamComponent; - }) + return DownstreamComponent; + }) ]; renderWebChat( diff --git a/__tests__/html2/grouping/disableAll.html b/__tests__/html2/grouping/disableAll.html index bdea2c6894..6c07fd81a4 100644 --- a/__tests__/html2/grouping/disableAll.html +++ b/__tests__/html2/grouping/disableAll.html @@ -16,7 +16,10 @@ run(async function () { const { React: { createElement }, - WebChat: { renderWebChat } + WebChat: { + decorator: { createActivityGroupingMiddleware }, + renderWebChat + } } = window; // Imports in UMD fashion. const clock = lolex.createClock(); @@ -24,22 +27,20 @@ const { directLine, store } = testHelpers.createDirectLineEmulator({ ponyfill: clock }); const decoratorMiddleware = [ - init => - init === 'activity grouping' && - (next => request => { - const DownstreamComponent = next(request); - - if (request.groupingName) { - return ({ activities, children }) => - createElement( - 'div', - { className: `grouping grouping--${request.groupingName}` }, - createElement(DownstreamComponent, { activities }, children) - ); - } - - return DownstreamComponent; - }) + createActivityGroupingMiddleware(next => request => { + const DownstreamComponent = next(request); + + if (request.groupingName) { + return ({ activities, children }) => + createElement( + 'div', + { className: `grouping grouping--${request.groupingName}` }, + createElement(DownstreamComponent, { activities }, children) + ); + } + + return DownstreamComponent; + }) ]; renderWebChat( diff --git a/__tests__/html2/grouping/disableSender.html b/__tests__/html2/grouping/disableSender.html index 2570e2b1bc..80a4c91478 100644 --- a/__tests__/html2/grouping/disableSender.html +++ b/__tests__/html2/grouping/disableSender.html @@ -16,7 +16,10 @@ run(async function () { const { React: { createElement }, - WebChat: { renderWebChat } + WebChat: { + decorator: { createActivityGroupingMiddleware }, + renderWebChat + } } = window; // Imports in UMD fashion. const clock = lolex.createClock(); @@ -24,22 +27,20 @@ const { directLine, store } = testHelpers.createDirectLineEmulator({ ponyfill: clock }); const decoratorMiddleware = [ - init => - init === 'activity grouping' && - (next => request => { - const DownstreamComponent = next(request); - - if (request.groupingName) { - return ({ activities, children }) => - createElement( - 'div', - { className: `grouping grouping--${request.groupingName}` }, - createElement(DownstreamComponent, { activities }, children) - ); - } - - return DownstreamComponent; - }) + createActivityGroupingMiddleware(next => request => { + const DownstreamComponent = next(request); + + if (request.groupingName) { + return ({ activities, children }) => + createElement( + 'div', + { className: `grouping grouping--${request.groupingName}` }, + createElement(DownstreamComponent, { activities }, children) + ); + } + + return DownstreamComponent; + }) ]; renderWebChat( diff --git a/__tests__/html2/grouping/disableStatus.html b/__tests__/html2/grouping/disableStatus.html index b4a03102af..eb2ffa8827 100644 --- a/__tests__/html2/grouping/disableStatus.html +++ b/__tests__/html2/grouping/disableStatus.html @@ -16,7 +16,10 @@ run(async function () { const { React: { createElement }, - WebChat: { renderWebChat } + WebChat: { + decorator: { createActivityGroupingMiddleware }, + renderWebChat + } } = window; // Imports in UMD fashion. const clock = lolex.createClock(); @@ -24,22 +27,20 @@ const { directLine, store } = testHelpers.createDirectLineEmulator({ ponyfill: clock }); const decoratorMiddleware = [ - init => - init === 'activity grouping' && - (next => request => { - const DownstreamComponent = next(request); - - if (request.groupingName) { - return ({ activities, children }) => - createElement( - 'div', - { className: `grouping grouping--${request.groupingName}` }, - createElement(DownstreamComponent, { activities }, children) - ); - } - - return DownstreamComponent; - }) + createActivityGroupingMiddleware(next => request => { + const DownstreamComponent = next(request); + + if (request.groupingName) { + return ({ activities, children }) => + createElement( + 'div', + { className: `grouping grouping--${request.groupingName}` }, + createElement(DownstreamComponent, { activities }, children) + ); + } + + return DownstreamComponent; + }) ]; renderWebChat( diff --git a/__tests__/html2/grouping/disableStatus.perSender.html b/__tests__/html2/grouping/disableStatus.perSender.html index 2ca2619f39..e662f9255d 100644 --- a/__tests__/html2/grouping/disableStatus.perSender.html +++ b/__tests__/html2/grouping/disableStatus.perSender.html @@ -16,7 +16,10 @@ run(async function () { const { React: { createElement }, - WebChat: { renderWebChat } + WebChat: { + decorator: { createActivityGroupingMiddleware }, + renderWebChat + } } = window; // Imports in UMD fashion. const clock = lolex.createClock(); @@ -24,22 +27,20 @@ const { directLine, store } = testHelpers.createDirectLineEmulator({ ponyfill: clock }); const decoratorMiddleware = [ - init => - init === 'activity grouping' && - (next => request => { - const DownstreamComponent = next(request); - - if (request.groupingName) { - return ({ activities, children }) => - createElement( - 'div', - { className: `grouping grouping--${request.groupingName}` }, - createElement(DownstreamComponent, { activities }, children) - ); - } - - return DownstreamComponent; - }) + createActivityGroupingMiddleware(next => request => { + const DownstreamComponent = next(request); + + if (request.groupingName) { + return ({ activities, children }) => + createElement( + 'div', + { className: `grouping grouping--${request.groupingName}` }, + createElement(DownstreamComponent, { activities }, children) + ); + } + + return DownstreamComponent; + }) ]; renderWebChat( diff --git a/__tests__/html2/grouping/extraneousGroup.html b/__tests__/html2/grouping/extraneousGroup.html index 043bce0f48..d8eaa69285 100644 --- a/__tests__/html2/grouping/extraneousGroup.html +++ b/__tests__/html2/grouping/extraneousGroup.html @@ -28,7 +28,10 @@ run(async function () { const { React: { createElement }, - WebChat: { renderWebChat } + WebChat: { + decorator: { createActivityGroupingMiddleware }, + renderWebChat + } } = window; // Imports in UMD fashion. const clock = lolex.createClock(); @@ -50,22 +53,20 @@ }); const decoratorMiddleware = [ - init => - init === 'activity grouping' && - (next => request => { - const DownstreamComponent = next(request); - - if (request.groupingName) { - return ({ activities, children }) => - createElement( - 'div', - { className: `grouping grouping--${request.groupingName}` }, - createElement(DownstreamComponent, { activities }, children) - ); - } - - return DownstreamComponent; - }) + createActivityGroupingMiddleware(next => request => { + const DownstreamComponent = next(request); + + if (request.groupingName) { + return ({ activities, children }) => + createElement( + 'div', + { className: `grouping grouping--${request.groupingName}` }, + createElement(DownstreamComponent, { activities }, children) + ); + } + + return DownstreamComponent; + }) ]; renderWebChat( diff --git a/__tests__/html2/grouping/fluentTheme.html b/__tests__/html2/grouping/fluentTheme.html index eb3b773135..8a2d97fb07 100644 --- a/__tests__/html2/grouping/fluentTheme.html +++ b/__tests__/html2/grouping/fluentTheme.html @@ -35,7 +35,11 @@ const { React: { createElement }, ReactDOM: { render }, - WebChat: { FluentThemeProvider, ReactWebChat } + WebChat: { + decorator: { createActivityGroupingMiddleware }, + FluentThemeProvider, + ReactWebChat + } } = window; // Imports in UMD fashion. const clock = lolex.createClock(); @@ -59,22 +63,20 @@ : undefined; const decoratorMiddleware = [ - init => - init === 'activity grouping' && - (next => request => { - const DownstreamComponent = next(request); - - if (request.groupingName) { - return ({ activities, children }) => - createElement( - 'div', - { className: `grouping grouping--${request.groupingName}` }, - DownstreamComponent ? createElement(DownstreamComponent, { activities }, children) : children - ); - } - - return DownstreamComponent; - }) + createActivityGroupingMiddleware(next => request => { + const DownstreamComponent = next(request); + + if (request.groupingName) { + return ({ activities, children }) => + createElement( + 'div', + { className: `grouping grouping--${request.groupingName}` }, + DownstreamComponent ? createElement(DownstreamComponent, { activities }, children) : children + ); + } + + return DownstreamComponent; + }) ]; render( diff --git a/__tests__/html2/grouping/groupingBorder.html b/__tests__/html2/grouping/groupingBorder.html index 46ea8cae59..0cfce558c4 100644 --- a/__tests__/html2/grouping/groupingBorder.html +++ b/__tests__/html2/grouping/groupingBorder.html @@ -16,7 +16,10 @@ run(async function () { const { React: { createElement }, - WebChat: { renderWebChat } + WebChat: { + decorator: { createActivityGroupingMiddleware }, + renderWebChat + } } = window; // Imports in UMD fashion. const clock = lolex.createClock(); @@ -24,22 +27,20 @@ const { directLine, store } = testHelpers.createDirectLineEmulator({ ponyfill: clock }); const decoratorMiddleware = [ - init => - init === 'activity grouping' && - (next => request => { - const DownstreamComponent = next(request); - - if (request.groupingName) { - return ({ activities, children }) => - createElement( - 'div', - { className: `grouping grouping--${request.groupingName}` }, - createElement(DownstreamComponent, { activities }, children) - ); - } - - return DownstreamComponent; - }) + createActivityGroupingMiddleware(next => request => { + const DownstreamComponent = next(request); + + if (request.groupingName) { + return ({ activities, children }) => + createElement( + 'div', + { className: `grouping grouping--${request.groupingName}` }, + createElement(DownstreamComponent, { activities }, children) + ); + } + + return DownstreamComponent; + }) ]; renderWebChat( diff --git a/__tests__/html2/grouping/noSuchGroup.html b/__tests__/html2/grouping/noSuchGroup.html index 0ef9031c78..7bbeccb798 100644 --- a/__tests__/html2/grouping/noSuchGroup.html +++ b/__tests__/html2/grouping/noSuchGroup.html @@ -30,7 +30,10 @@ run(async function () { const { React: { createElement }, - WebChat: { renderWebChat } + WebChat: { + decorator: { createActivityGroupingMiddleware }, + renderWebChat + } } = window; // Imports in UMD fashion. const clock = lolex.createClock(); @@ -41,22 +44,20 @@ const groupActivitiesMiddleware = () => next => request => ({}); const decoratorMiddleware = [ - init => - init === 'activity grouping' && - (next => request => { - const DownstreamComponent = next(request); + createActivityGroupingMiddleware(next => request => { + const DownstreamComponent = next(request); - if (request.groupingName) { - return ({ activities, children }) => - createElement( - 'div', - { className: `grouping grouping--${request.groupingName}` }, - createElement(DownstreamComponent, { activities }, children) - ); - } + if (request.groupingName) { + return ({ activities, children }) => + createElement( + 'div', + { className: `grouping grouping--${request.groupingName}` }, + createElement(DownstreamComponent, { activities }, children) + ); + } - return DownstreamComponent; - }) + return DownstreamComponent; + }) ]; renderWebChat( diff --git a/__tests__/html2/livestream/layout.html b/__tests__/html2/livestream/layout.html index c15f62b759..949bd06d9a 100644 --- a/__tests__/html2/livestream/layout.html +++ b/__tests__/html2/livestream/layout.html @@ -33,7 +33,7 @@ React, ReactDOM: { render }, WebChat: { - decorator: { DecoratorComposer }, + decorator: { createActivityBorderMiddleware, DecoratorComposer }, FluentThemeProvider, ReactWebChat } @@ -52,15 +52,15 @@ } const decoratorMiddleware = [ - init => - init === 'activity border' && - (next => request => (request.livestreamingState === 'completing' ? Flair : next(request))), - init => - init === 'activity border' && - (next => request => (request.livestreamingState === 'preparing' ? Loader : next(request))), - init => - init === 'activity border' && - (next => request => (request.livestreamingState === 'ongoing' ? FlairOngoing : next(request))) + createActivityBorderMiddleware( + next => request => (request.livestreamingState === 'completing' ? Flair : next(request)) + ), + createActivityBorderMiddleware( + next => request => (request.livestreamingState === 'preparing' ? Loader : next(request)) + ), + createActivityBorderMiddleware( + next => request => (request.livestreamingState === 'ongoing' ? FlairOngoing : next(request)) + ) ]; const { directLine, store } = testHelpers.createDirectLineEmulator(); diff --git a/packages/api/src/decorator.ts b/packages/api/src/decorator.ts index 27e0307f02..c8ec728dfc 100644 --- a/packages/api/src/decorator.ts +++ b/packages/api/src/decorator.ts @@ -1,16 +1,19 @@ // Decorator general export { default as DecoratorComposer } from './decorator/DecoratorComposer'; -export { - type DecoratorMiddleware, - type DecoratorMiddlewareInit, - type DecoratorMiddlewareTypes -} from './decorator/types'; +export { type DecoratorMiddleware } from './decorator/types'; // ActivityBorderDecorator -export { default as ActivityBorderDecorator } from './decorator/ActivityBorder/ActivityBorderDecorator'; +export { + default as ActivityBorderDecorator, + createActivityBorderMiddleware, + type ActivityBorderDecoratorProps +} from './decorator/ActivityBorder/ActivityBorderDecorator'; // ActivityGroupingDecorator -export { default as ActivityGroupingDecorator } from './decorator/ActivityGrouping/ActivityGroupingDecorator'; +export { + default as ActivityGroupingDecorator, + createActivityGroupingMiddleware +} from './decorator/ActivityGrouping/ActivityGroupingDecorator'; diff --git a/packages/api/src/decorator/ActivityBorder/ActivityBorderDecorator.tsx b/packages/api/src/decorator/ActivityBorder/ActivityBorderDecorator.tsx index b141279e14..0259910e6b 100644 --- a/packages/api/src/decorator/ActivityBorder/ActivityBorderDecorator.tsx +++ b/packages/api/src/decorator/ActivityBorder/ActivityBorderDecorator.tsx @@ -1,8 +1,10 @@ import { getActivityLivestreamingMetadata, type WebChatActivity } from 'botframework-webchat-core'; import React, { memo, useMemo, type ReactNode } from 'react'; + import PassthroughFallback from '../private/PassthroughFallback'; import { ActivityBorderDecoratorMiddlewareProxy, + createActivityBorderMiddleware, type ActivityBorderDecoratorMiddlewareRequest } from './private/ActivityBorderDecoratorMiddleware'; @@ -23,6 +25,7 @@ function ActivityBorderDecorator({ activity, children }: ActivityBorderDecorator const { type } = getActivityLivestreamingMetadata(activity) || {}; return { + from: supportedActivityRoles.includes(activity?.from?.role) ? activity?.from?.role : undefined, livestreamingState: type === 'final activity' ? 'completing' @@ -32,8 +35,7 @@ function ActivityBorderDecorator({ activity, children }: ActivityBorderDecorator ? 'ongoing' : type === 'contentless' ? undefined // No bubble is shown for "contentless" livestream, should not decorate. - : undefined, - from: supportedActivityRoles.includes(activity?.from?.role) ? activity?.from?.role : undefined + : undefined }; }, [activity]); @@ -45,4 +47,4 @@ function ActivityBorderDecorator({ activity, children }: ActivityBorderDecorator } export default memo(ActivityBorderDecorator); -export { type ActivityBorderDecoratorProps }; +export { createActivityBorderMiddleware, type ActivityBorderDecoratorProps }; diff --git a/packages/api/src/decorator/ActivityBorder/private/ActivityBorderDecoratorMiddleware.ts b/packages/api/src/decorator/ActivityBorder/private/ActivityBorderDecoratorMiddleware.ts index 8d4620c674..fc7c415c51 100644 --- a/packages/api/src/decorator/ActivityBorder/private/ActivityBorderDecoratorMiddleware.ts +++ b/packages/api/src/decorator/ActivityBorder/private/ActivityBorderDecoratorMiddleware.ts @@ -1,6 +1,9 @@ -import type { EmptyObject } from 'type-fest'; -import templateMiddleware from '../../private/templateMiddleware'; -import { type activityBorderDecoratorTypeName } from '../types'; +import { type ReactNode } from 'react'; +import templateMiddleware, { + type InferMiddleware, + type InferProps, + type InferRequest +} from '../../../middleware/private/templateMiddleware'; type Request = Readonly<{ /** @@ -24,28 +27,27 @@ type Request = Readonly<{ from: 'bot' | 'channel' | `user` | undefined; }>; -type Props = EmptyObject; +type Props = Readonly<{ children?: ReactNode | undefined }>; + +const template = templateMiddleware('activity border'); const { - initMiddleware: initActivityBorderDecoratorMiddleware, + createMiddleware: createActivityBorderMiddleware, + extractMiddleware: extractActivityBorderDecoratorMiddleware, Provider: ActivityBorderDecoratorMiddlewareProvider, - Proxy: ActivityBorderDecoratorMiddlewareProxy, - // False positive, `types` is used for its typing. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - types -} = templateMiddleware('ActivityBorderDecoratorMiddleware'); + Proxy: ActivityBorderDecoratorMiddlewareProxy +} = template; -type ActivityBorderDecoratorMiddleware = typeof types.middleware; -type ActivityBorderDecoratorMiddlewareInit = typeof types.init; -type ActivityBorderDecoratorMiddlewareProps = typeof types.props; -type ActivityBorderDecoratorMiddlewareRequest = typeof types.request; +type ActivityBorderDecoratorMiddleware = InferMiddleware; +type ActivityBorderDecoratorMiddlewareProps = InferProps; +type ActivityBorderDecoratorMiddlewareRequest = InferRequest; export { ActivityBorderDecoratorMiddlewareProvider, ActivityBorderDecoratorMiddlewareProxy, - initActivityBorderDecoratorMiddleware, + createActivityBorderMiddleware, + extractActivityBorderDecoratorMiddleware, type ActivityBorderDecoratorMiddleware, - type ActivityBorderDecoratorMiddlewareInit, type ActivityBorderDecoratorMiddlewareProps, type ActivityBorderDecoratorMiddlewareRequest }; diff --git a/packages/api/src/decorator/ActivityBorder/types.ts b/packages/api/src/decorator/ActivityBorder/types.ts deleted file mode 100644 index 62e754ed24..0000000000 --- a/packages/api/src/decorator/ActivityBorder/types.ts +++ /dev/null @@ -1 +0,0 @@ -export const activityBorderDecoratorTypeName = 'activity border' as const; diff --git a/packages/api/src/decorator/ActivityGrouping/ActivityGroupingDecorator.tsx b/packages/api/src/decorator/ActivityGrouping/ActivityGroupingDecorator.tsx index 3d14af2772..79f220b5fd 100644 --- a/packages/api/src/decorator/ActivityGrouping/ActivityGroupingDecorator.tsx +++ b/packages/api/src/decorator/ActivityGrouping/ActivityGroupingDecorator.tsx @@ -4,6 +4,7 @@ import React, { memo, useMemo, type ReactNode } from 'react'; import PassthroughFallback from '../private/PassthroughFallback'; import { ActivityGroupingDecoratorMiddlewareProxy, + createActivityGroupingMiddleware, type ActivityGroupingDecoratorMiddlewareRequest } from './private/ActivityGroupingDecoratorMiddleware'; @@ -28,4 +29,4 @@ function ActivityGroupingDecorator({ activities, children, groupingName }: Activ } export default memo(ActivityGroupingDecorator); -export { type ActivityGroupingDecoratorProps }; +export { createActivityGroupingMiddleware, type ActivityGroupingDecoratorProps }; diff --git a/packages/api/src/decorator/ActivityGrouping/private/ActivityGroupingDecoratorMiddleware.ts b/packages/api/src/decorator/ActivityGrouping/private/ActivityGroupingDecoratorMiddleware.ts index e8be671482..e0ec6e57e2 100644 --- a/packages/api/src/decorator/ActivityGrouping/private/ActivityGroupingDecoratorMiddleware.ts +++ b/packages/api/src/decorator/ActivityGrouping/private/ActivityGroupingDecoratorMiddleware.ts @@ -1,6 +1,9 @@ import { type WebChatActivity } from 'botframework-webchat-core'; -import templateMiddleware from '../../private/templateMiddleware'; -import { type activityGroupingDecoratorTypeName } from '../types'; +import templateMiddleware, { + type InferMiddleware, + type InferProps, + type InferRequest +} from '../../../middleware/private/templateMiddleware'; type Request = Readonly<{ /** @@ -13,26 +16,25 @@ type Props = Readonly<{ activities: readonly WebChatActivity[]; }>; +const template = templateMiddleware('activity grouping'); + const { - initMiddleware: initActivityGroupingDecoratorMiddleware, + createMiddleware: createActivityGroupingMiddleware, + extractMiddleware: extractActivityGroupingDecoratorMiddleware, Provider: ActivityGroupingDecoratorMiddlewareProvider, - Proxy: ActivityGroupingDecoratorMiddlewareProxy, - // False positive, `types` is used for its typing. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - types -} = templateMiddleware('ActivityGroupingDecoratorMiddleware'); + Proxy: ActivityGroupingDecoratorMiddlewareProxy +} = template; -type ActivityGroupingDecoratorMiddleware = typeof types.middleware; -type ActivityGroupingDecoratorMiddlewareInit = typeof types.init; -type ActivityGroupingDecoratorMiddlewareProps = typeof types.props; -type ActivityGroupingDecoratorMiddlewareRequest = typeof types.request; +type ActivityGroupingDecoratorMiddleware = InferMiddleware; +type ActivityGroupingDecoratorMiddlewareProps = InferProps; +type ActivityGroupingDecoratorMiddlewareRequest = InferRequest; export { ActivityGroupingDecoratorMiddlewareProvider, ActivityGroupingDecoratorMiddlewareProxy, - initActivityGroupingDecoratorMiddleware, + createActivityGroupingMiddleware, + extractActivityGroupingDecoratorMiddleware, type ActivityGroupingDecoratorMiddleware, - type ActivityGroupingDecoratorMiddlewareInit, type ActivityGroupingDecoratorMiddlewareProps, type ActivityGroupingDecoratorMiddlewareRequest }; diff --git a/packages/api/src/decorator/ActivityGrouping/types.ts b/packages/api/src/decorator/ActivityGrouping/types.ts deleted file mode 100644 index 59dbd0b014..0000000000 --- a/packages/api/src/decorator/ActivityGrouping/types.ts +++ /dev/null @@ -1 +0,0 @@ -export const activityGroupingDecoratorTypeName = 'activity grouping' as const; diff --git a/packages/api/src/decorator/DecoratorComposer.tsx b/packages/api/src/decorator/DecoratorComposer.tsx index 02b0cc1bdb..ab83ed5a52 100644 --- a/packages/api/src/decorator/DecoratorComposer.tsx +++ b/packages/api/src/decorator/DecoratorComposer.tsx @@ -1,13 +1,42 @@ -import React, { Fragment, memo, type ReactNode } from 'react'; +import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; +import React, { Fragment, memo, useMemo } from 'react'; +import { array, custom, object, optional, pipe, readonly, safeParse, type InferInput } from 'valibot'; + +import { middlewareFactoryMarker } from '../middleware/private/templateMiddleware'; import InternalDecoratorComposer from './internal/InternalDecoratorComposer'; import { type DecoratorMiddleware } from './types'; -type DecoratorComposerProps = Readonly<{ - children?: ReactNode | undefined; - middleware?: readonly DecoratorMiddleware[] | undefined; -}>; +const decoratorComposerPropsSchema = pipe( + object({ + children: optional(reactNode()), + middleware: optional(pipe(array(custom(value => typeof value === 'function')), readonly())) + }), + readonly() +); + +const warnInvalidMiddlewarePropsSchema = optional( + array(custom(value => value[middlewareFactoryMarker satisfies symbol] === middlewareFactoryMarker)) +); + +type DecoratorComposerProps = Omit, 'middleware'> & { + // Mark "middleware" as read-only. + // Otherwise, passing a read-only middleware would fail because we prefer writable. + // eslint-disable-next-line react/require-default-props, react/no-unused-prop-types + readonly middleware?: readonly DecoratorMiddleware[] | undefined; +}; + +function DecoratorComposer(props: DecoratorComposerProps) { + const { children, middleware } = validateProps(decoratorComposerPropsSchema, props); + + useMemo(() => { + if (!safeParse(warnInvalidMiddlewarePropsSchema, middleware).success) { + console.warn( + 'botframework-webchat: "middleware" props passed to should be created using createXXXMiddleware() functions.', + { middleware } + ); + } + }, [middleware]); -function DecoratorComposer({ children, middleware }: DecoratorComposerProps) { return middleware ? ( {children} @@ -19,4 +48,4 @@ function DecoratorComposer({ children, middleware }: DecoratorComposerProps) { } export default memo(DecoratorComposer); -export { type DecoratorComposerProps }; +export { decoratorComposerPropsSchema, type DecoratorComposerProps }; diff --git a/packages/api/src/decorator/internal/InternalDecoratorComposer.tsx b/packages/api/src/decorator/internal/InternalDecoratorComposer.tsx index 827d432eba..d05341078f 100644 --- a/packages/api/src/decorator/internal/InternalDecoratorComposer.tsx +++ b/packages/api/src/decorator/internal/InternalDecoratorComposer.tsx @@ -1,14 +1,12 @@ import React, { memo, useContext, useMemo, type ReactNode } from 'react'; import { ActivityBorderDecoratorMiddlewareProvider, - initActivityBorderDecoratorMiddleware + extractActivityBorderDecoratorMiddleware } from '../ActivityBorder/private/ActivityBorderDecoratorMiddleware'; -import { activityBorderDecoratorTypeName } from '../ActivityBorder/types'; import { ActivityGroupingDecoratorMiddlewareProvider, - initActivityGroupingDecoratorMiddleware + extractActivityGroupingDecoratorMiddleware } from '../ActivityGrouping/private/ActivityGroupingDecoratorMiddleware'; -import { activityGroupingDecoratorTypeName } from '../ActivityGrouping/types'; import DecoratorComposerContext from '../private/DecoratorComposerContext'; import { type DecoratorMiddleware } from '../types'; @@ -34,13 +32,10 @@ function InternalDecoratorComposer({ [existingContext, middlewareFromProps, priority] ); - const activityBorderMiddleware = useMemo( - () => initActivityBorderDecoratorMiddleware(middleware, activityBorderDecoratorTypeName), - [middleware] - ); + const activityBorderMiddleware = useMemo(() => extractActivityBorderDecoratorMiddleware(middleware), [middleware]); const activityGroupingMiddleware = useMemo( - () => initActivityGroupingDecoratorMiddleware(middleware, activityGroupingDecoratorTypeName), + () => extractActivityGroupingDecoratorMiddleware(middleware), [middleware] ); diff --git a/packages/api/src/decorator/private/templateMiddleware.ts b/packages/api/src/decorator/private/templateMiddleware.ts deleted file mode 100644 index 0d345490b9..0000000000 --- a/packages/api/src/decorator/private/templateMiddleware.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { warnOnce } from 'botframework-webchat-core'; -import { createChainOfResponsibility, type ComponentMiddleware } from 'react-chain-of-responsibility'; -import { type EmptyObject } from 'type-fest'; -import { any, array, function_, pipe, safeParse, type InferOutput } from 'valibot'; - -export type MiddlewareWithInit, I> = (init: I) => ReturnType | false; - -const EMPTY_ARRAY = Object.freeze([]); - -// Following @types/react to use {} for props. -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export default function templateMiddleware( - name: string -) { - type Middleware = ComponentMiddleware; - - const middlewareSchema = array(pipe(any(), function_())); - - const isMiddleware = (middleware: unknown): middleware is InferOutput => - safeParse(middlewareSchema, middleware).success; - - const warnInvalid = warnOnce(`"${name}" prop is invalid`); - - const initMiddleware = ( - middleware: readonly MiddlewareWithInit, Init>[], - init: Init - ): readonly Middleware[] => { - if (middleware) { - if (isMiddleware(middleware)) { - return Object.freeze( - middleware - .map(middleware => middleware(init) as ReturnType) - .filter((enhancer): enhancer is ReturnType => !!enhancer) - .map(enhancer => () => enhancer) - ); - } - - warnInvalid(); - } - - return EMPTY_ARRAY; - }; - - const { Provider, Proxy } = createChainOfResponsibility(); - - Provider.displayName = `${name}Provider`; - Proxy.displayName = `${name}Proxy`; - - return { - initMiddleware, - Provider, - Proxy, - types: { - init: undefined as Init, - middleware: undefined as Middleware, - props: undefined as Props, - request: undefined as Request - } - }; -} diff --git a/packages/api/src/decorator/types.ts b/packages/api/src/decorator/types.ts index f326d5ff17..52b9e6e73c 100644 --- a/packages/api/src/decorator/types.ts +++ b/packages/api/src/decorator/types.ts @@ -1,15 +1,4 @@ -import { type ActivityBorderDecoratorMiddleware } from './ActivityBorder/private/ActivityBorderDecoratorMiddleware'; -import { type activityBorderDecoratorTypeName } from './ActivityBorder/types'; -import { type ActivityGroupingDecoratorMiddleware } from './ActivityGrouping/private/ActivityGroupingDecoratorMiddleware'; -import { type activityGroupingDecoratorTypeName } from './ActivityGrouping/types'; +import { ActivityBorderDecoratorMiddleware } from './ActivityBorder/private/ActivityBorderDecoratorMiddleware'; +import { ActivityGroupingDecoratorMiddleware } from './ActivityGrouping/private/ActivityGroupingDecoratorMiddleware'; -export type DecoratorMiddlewareTypes = { - [activityBorderDecoratorTypeName]: ReturnType; - [activityGroupingDecoratorTypeName]: ReturnType; -}; - -export type DecoratorMiddlewareInit = keyof DecoratorMiddlewareTypes; - -export interface DecoratorMiddleware { - (init: keyof DecoratorMiddlewareTypes): DecoratorMiddlewareTypes[typeof init] | false; -} +export type DecoratorMiddleware = ActivityBorderDecoratorMiddleware | ActivityGroupingDecoratorMiddleware; diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx index 21e1d64276..07d8665698 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -40,6 +40,11 @@ import updateIn from 'simple-update-in'; import StyleOptions from '../StyleOptions'; import usePonyfill from '../hooks/usePonyfill'; import getAllLocalizedStrings from '../localization/getAllLocalizedStrings'; +import { SendBoxMiddlewareProvider, type SendBoxMiddleware } from '../middleware/SendBoxMiddleware'; +import { + SendBoxToolbarMiddlewareProvider, + type SendBoxToolbarMiddleware +} from '../middleware/SendBoxToolbarMiddleware'; import normalizeStyleOptions from '../normalizeStyleOptions'; import patchStyleOptionsFromDeprecatedProps from '../patchStyleOptionsFromDeprecatedProps'; import ActivityAcknowledgementComposer from '../providers/ActivityAcknowledgement/ActivityAcknowledgementComposer'; @@ -67,8 +72,6 @@ import createCustomEvent from '../utils/createCustomEvent'; import isObject from '../utils/isObject'; import mapMap from '../utils/mapMap'; import normalizeLanguage from '../utils/normalizeLanguage'; -import { SendBoxMiddlewareProvider, type SendBoxMiddleware } from './internal/SendBoxMiddleware'; -import { SendBoxToolbarMiddlewareProvider, type SendBoxToolbarMiddleware } from './internal/SendBoxToolbarMiddleware'; import Tracker from './internal/Tracker'; import WebChatAPIContext, { type WebChatAPIContextType } from './internal/WebChatAPIContext'; import WebChatReduxContext, { useDispatch } from './internal/WebChatReduxContext'; diff --git a/packages/api/src/hooks/internal/SendBoxMiddleware.ts b/packages/api/src/hooks/internal/SendBoxMiddleware.ts deleted file mode 100644 index 8ae65f8576..0000000000 --- a/packages/api/src/hooks/internal/SendBoxMiddleware.ts +++ /dev/null @@ -1,23 +0,0 @@ -import templateMiddleware from './private/templateMiddleware'; - -const { - initMiddleware: initSendBoxMiddleware, - Provider: SendBoxMiddlewareProvider, - Proxy: SendBoxMiddlewareProxy, - // False positive, `types` is used for its typing. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - types -} = templateMiddleware('sendBoxMiddleware'); - -type SendBoxMiddleware = typeof types.middleware; -type SendBoxMiddlewareProps = typeof types.props; -type SendBoxMiddlewareRequest = typeof types.request; - -export { - SendBoxMiddlewareProvider, - SendBoxMiddlewareProxy, - initSendBoxMiddleware, - type SendBoxMiddleware, - type SendBoxMiddlewareProps, - type SendBoxMiddlewareRequest -}; diff --git a/packages/api/src/hooks/internal/SendBoxToolbarMiddleware.ts b/packages/api/src/hooks/internal/SendBoxToolbarMiddleware.ts deleted file mode 100644 index bd2819dc09..0000000000 --- a/packages/api/src/hooks/internal/SendBoxToolbarMiddleware.ts +++ /dev/null @@ -1,23 +0,0 @@ -import templateMiddleware from './private/templateMiddleware'; - -const { - initMiddleware: initSendBoxToolbarMiddleware, - Provider: SendBoxToolbarMiddlewareProvider, - Proxy: SendBoxToolbarMiddlewareProxy, - // False positive, `types` is used for its typing. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - types -} = templateMiddleware('sendBoxToolbarMiddleware'); - -type SendBoxToolbarMiddleware = typeof types.middleware; -type SendBoxToolbarMiddlewareProps = typeof types.props; -type SendBoxToolbarMiddlewareRequest = typeof types.request; - -export { - SendBoxToolbarMiddlewareProvider, - SendBoxToolbarMiddlewareProxy, - initSendBoxToolbarMiddleware, - type SendBoxToolbarMiddleware, - type SendBoxToolbarMiddlewareProps, - type SendBoxToolbarMiddlewareRequest -}; diff --git a/packages/api/src/hooks/internal/private/templateMiddleware.ts b/packages/api/src/hooks/internal/private/templateMiddleware.ts deleted file mode 100644 index b6bbd29f3e..0000000000 --- a/packages/api/src/hooks/internal/private/templateMiddleware.ts +++ /dev/null @@ -1,4 +0,0 @@ -import templateMiddleware from '../../../decorator/private/templateMiddleware'; - -// TODO: We should move them to a common directory. -export default templateMiddleware; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 47ad86b2de..0699b05427 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,21 +1,8 @@ +// TODO: Move the pattern to re-export. import StyleOptions, { StrictStyleOptions } from './StyleOptions'; import defaultStyleOptions from './defaultStyleOptions'; import Composer, { ComposerProps } from './hooks/Composer'; import * as hooks from './hooks/index'; -import { - SendBoxMiddlewareProxy, - initSendBoxMiddleware, - type SendBoxMiddleware, - type SendBoxMiddlewareProps, - type SendBoxMiddlewareRequest -} from './hooks/internal/SendBoxMiddleware'; -import { - SendBoxToolbarMiddlewareProxy, - initSendBoxToolbarMiddleware, - type SendBoxToolbarMiddleware, - type SendBoxToolbarMiddlewareProps, - type SendBoxToolbarMiddlewareRequest -} from './hooks/internal/SendBoxToolbarMiddleware'; import concatMiddleware from './hooks/middleware/concatMiddleware'; import { type ActivityStatusRenderer } from './hooks/useCreateActivityStatusRenderer'; // TODO: [P1] This line should export the one from the version from "middleware rework" workstream. import { type DebouncedNotification, type DebouncedNotifications } from './hooks/useDebouncedNotifications'; @@ -43,26 +30,31 @@ import TypingIndicatorMiddleware, { type RenderTypingIndicator } from './types/T import { type WebSpeechPonyfill } from './types/WebSpeechPonyfill'; import { type WebSpeechPonyfillFactory } from './types/WebSpeechPonyfillFactory'; +// #region Re-export +export { + extractSendBoxMiddleware, + SendBoxMiddlewareProxy, + type SendBoxMiddleware, + type SendBoxMiddlewareProps, + type SendBoxMiddlewareRequest +} from './middleware/SendBoxMiddleware'; + +export { + extractSendBoxToolbarMiddleware, + SendBoxToolbarMiddlewareProxy, + type SendBoxToolbarMiddleware, + type SendBoxToolbarMiddlewareProps, + type SendBoxToolbarMiddlewareRequest +} from './middleware/SendBoxToolbarMiddleware'; +// #endregion + const buildTool = process.env.build_tool; const moduleFormat = process.env.module_format; const version = process.env.npm_package_version; const buildInfo = { buildTool, moduleFormat, version }; -export { - Composer, - SendBoxMiddlewareProxy, - SendBoxToolbarMiddlewareProxy, - buildInfo, - concatMiddleware, - defaultStyleOptions, - hooks, - initSendBoxMiddleware, - initSendBoxToolbarMiddleware, - localize, - normalizeStyleOptions, - version -}; +export { buildInfo, Composer, concatMiddleware, defaultStyleOptions, hooks, localize, normalizeStyleOptions, version }; export type { ActivityComponentFactory, @@ -90,12 +82,6 @@ export type { RenderTypingIndicator, ScrollToEndButtonComponentFactory, ScrollToEndButtonMiddleware, - SendBoxMiddleware, - SendBoxMiddlewareProps, - SendBoxMiddlewareRequest, - SendBoxToolbarMiddleware, - SendBoxToolbarMiddlewareProps, - SendBoxToolbarMiddlewareRequest, SendStatus, StrictStyleOptions, StyleOptions, diff --git a/packages/api/src/middleware/SendBoxMiddleware.ts b/packages/api/src/middleware/SendBoxMiddleware.ts new file mode 100644 index 0000000000..84deed5a48 --- /dev/null +++ b/packages/api/src/middleware/SendBoxMiddleware.ts @@ -0,0 +1,28 @@ +import templateMiddleware, { + type InferMiddleware, + type InferProps, + type InferRequest +} from './private/templateMiddleware'; + +const template = templateMiddleware('sendBoxMiddleware'); + +const { + createMiddleware: createSendBoxMiddleware, + extractMiddleware: extractSendBoxMiddleware, + Provider: SendBoxMiddlewareProvider, + Proxy: SendBoxMiddlewareProxy +} = template; + +type SendBoxMiddleware = InferMiddleware; +type SendBoxMiddlewareProps = InferProps; +type SendBoxMiddlewareRequest = InferRequest; + +export { + createSendBoxMiddleware, + extractSendBoxMiddleware, + SendBoxMiddlewareProvider, + SendBoxMiddlewareProxy, + type SendBoxMiddleware, + type SendBoxMiddlewareProps, + type SendBoxMiddlewareRequest +}; diff --git a/packages/api/src/middleware/SendBoxToolbarMiddleware.ts b/packages/api/src/middleware/SendBoxToolbarMiddleware.ts new file mode 100644 index 0000000000..33d4173803 --- /dev/null +++ b/packages/api/src/middleware/SendBoxToolbarMiddleware.ts @@ -0,0 +1,28 @@ +import templateMiddleware, { + type InferMiddleware, + type InferProps, + type InferRequest +} from './private/templateMiddleware'; + +const template = templateMiddleware('sendBoxToolbarMiddleware'); + +const { + createMiddleware: createSendBoxToolbarMiddleware, + extractMiddleware: extractSendBoxToolbarMiddleware, + Provider: SendBoxToolbarMiddlewareProvider, + Proxy: SendBoxToolbarMiddlewareProxy +} = template; + +type SendBoxToolbarMiddleware = InferMiddleware; +type SendBoxToolbarMiddlewareProps = InferProps; +type SendBoxToolbarMiddlewareRequest = InferRequest; + +export { + createSendBoxToolbarMiddleware, + extractSendBoxToolbarMiddleware, + SendBoxToolbarMiddlewareProvider, + SendBoxToolbarMiddlewareProxy, + type SendBoxToolbarMiddleware, + type SendBoxToolbarMiddlewareProps, + type SendBoxToolbarMiddlewareRequest +}; diff --git a/packages/api/src/middleware/private/templateMiddleware.check.test.tsx b/packages/api/src/middleware/private/templateMiddleware.check.test.tsx new file mode 100644 index 0000000000..3f78da54ae --- /dev/null +++ b/packages/api/src/middleware/private/templateMiddleware.check.test.tsx @@ -0,0 +1,47 @@ +import templateMiddleware from './templateMiddleware'; + +test('should warn if middleware is not an array of function', () => { + const warn = jest.fn(); + const template = templateMiddleware('Check'); + + jest.spyOn(console, 'warn').mockImplementation(warn); + + template.extractMiddleware(1 as any); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenNthCalledWith(1, expect.stringContaining('must be an array of function')); +}); + +test('should warn if middleware did not return function', () => { + const warn = jest.fn(); + const template = templateMiddleware('Check'); + + jest.spyOn(console, 'warn').mockImplementation(warn); + + template.extractMiddleware([() => 1 as any]); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenNthCalledWith(1, expect.stringContaining('must return enhancer function')); +}); + +test('should not warn if middleware return false', () => { + const warn = jest.fn(); + const template = templateMiddleware('Check'); + + jest.spyOn(console, 'warn').mockImplementation(warn); + + template.extractMiddleware([() => false]); + + expect(warn).toHaveBeenCalledTimes(0); +}); + +test('should not warn if middleware return function', () => { + const warn = jest.fn(); + const template = templateMiddleware('Check'); + + jest.spyOn(console, 'warn').mockImplementation(warn); + + template.extractMiddleware([() => () => 1 as any]); + + expect(warn).toHaveBeenCalledTimes(0); +}); diff --git a/packages/api/src/decorator/private/templateMiddleware.test.tsx b/packages/api/src/middleware/private/templateMiddleware.test.tsx similarity index 62% rename from packages/api/src/decorator/private/templateMiddleware.test.tsx rename to packages/api/src/middleware/private/templateMiddleware.test.tsx index 42d341a6e4..08a750bf63 100644 --- a/packages/api/src/decorator/private/templateMiddleware.test.tsx +++ b/packages/api/src/middleware/private/templateMiddleware.test.tsx @@ -3,7 +3,7 @@ import { render } from '@testing-library/react'; import React, { Fragment, type ReactNode } from 'react'; -import templateMiddleware from './templateMiddleware'; +import templateMiddleware, { type InferMiddleware } from './templateMiddleware'; type ButtonProps = Readonly<{ children?: ReactNode | undefined }>; type LinkProps = Readonly<{ children?: ReactNode | undefined; href: string }>; @@ -20,32 +20,30 @@ const InternalLinkImpl = ({ children, href }: LinkProps) => {chil // User story for using templateMiddleware as a building block for uber middleware. test('an uber middleware', () => { + const buttonTemplate = templateMiddleware('Button'); const { - initMiddleware: initButtonMiddleware, + createMiddleware: createButtonMiddleware, + extractMiddleware: extractButtonMiddleware, Provider: ButtonProvider, - Proxy: Button, - // False positive, `types` is used for its typing. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - types: buttonTypes - } = templateMiddleware<'button', void, ButtonProps>('Button'); + Proxy: Button + } = buttonTemplate; - type ButtonMiddleware = typeof buttonTypes.middleware; + type ButtonMiddleware = InferMiddleware; + const linkTemplate = templateMiddleware<{ external: boolean }, LinkProps>('Link'); const { - initMiddleware: initLinkMiddleware, + createMiddleware: createLinkMiddleware, + extractMiddleware: extractLinkMiddleware, Provider: LinkProvider, - Proxy: Link, - // False positive, `types` is used for its typing. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - types: linkTypes - } = templateMiddleware<'link', { external: boolean }, LinkProps>('Link'); + Proxy: Link + } = linkTemplate; - type LinkMiddleware = typeof linkTypes.middleware; + type LinkMiddleware = InferMiddleware; - const buttonMiddleware: ButtonMiddleware[] = [init => init === 'button' && (() => () => ButtonImpl)]; + const buttonMiddleware: ButtonMiddleware[] = [createButtonMiddleware(() => () => ButtonImpl)]; const linkMiddleware: LinkMiddleware[] = [ - init => init === 'link' && (next => request => (request.external ? ExternalLinkImpl : next(request))), - init => init === 'link' && (() => () => InternalLinkImpl) + createLinkMiddleware(next => request => (request.external ? ExternalLinkImpl : next(request))), + createLinkMiddleware(() => () => InternalLinkImpl) ]; const App = ({ @@ -57,9 +55,9 @@ test('an uber middleware', () => { }>) => ( {/* TODO: Should not case middleware to any */} - + {/* TODO: Should not case middleware to any */} - {children} + {children} ); diff --git a/packages/api/src/middleware/private/templateMiddleware.ts b/packages/api/src/middleware/private/templateMiddleware.ts new file mode 100644 index 0000000000..1bc97235ea --- /dev/null +++ b/packages/api/src/middleware/private/templateMiddleware.ts @@ -0,0 +1,84 @@ +import { warnOnce } from 'botframework-webchat-core'; +import { createChainOfResponsibility, type ComponentMiddleware } from 'react-chain-of-responsibility'; +import { array, function_, safeParse, type InferOutput } from 'valibot'; + +type MiddlewareWithInit, I> = (init: I) => ReturnType | false; + +const arrayOfFunctionSchema = array(function_()); +const middlewareFactoryMarker = Symbol(); + +const isArrayOfFunction = (middleware: unknown): middleware is InferOutput => + safeParse(arrayOfFunctionSchema, middleware).success; + +const EMPTY_ARRAY = Object.freeze([]); + +// Following @types/react to use {} for props. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +function templateMiddleware(name: string) { + type Middleware = ComponentMiddleware; + + const createMiddleware = (enhancer: ReturnType): Middleware => { + const factory: Middleware = init => init === name && enhancer; + + // This is for checking if the middleware is created via factory function or not. + // We recommend middleware to be created using factory function. + factory[middlewareFactoryMarker satisfies symbol] = middlewareFactoryMarker; + + return factory; + }; + + const warnInvalidExtraction = warnOnce(`Middleware passed for extraction of "${name}" must be an array of function`); + + const extractMiddleware = ( + middleware: readonly MiddlewareWithInit, string>[] | undefined + ): readonly Middleware[] => { + if (middleware) { + if (isArrayOfFunction(middleware)) { + return Object.freeze( + middleware + .map(middleware => { + const result = middleware(name); + + if (typeof result !== 'function' && result !== false) { + console.warn(`botframework-webchat: ${name}.middleware must return enhancer function or false`); + + return false; + } + + return result; + }) + .filter((enhancer): enhancer is ReturnType => !!enhancer) + .map(enhancer => () => enhancer) + ); + } + + warnInvalidExtraction(); + } + + return EMPTY_ARRAY; + }; + + const { Provider, Proxy } = createChainOfResponsibility(); + + Provider.displayName = `${name}Provider`; + Proxy.displayName = `${name}Proxy`; + + return { + createMiddleware, + extractMiddleware, + Provider, + Proxy, + '~types': undefined as { + middleware: Middleware; + props: Props; + request: Request; + } + }; +} + +type InferMiddleware = T['~types']['middleware']; +type InferProps = T['~types']['props']; +type InferRequest = T['~types']['request']; + +export default templateMiddleware; +export { middlewareFactoryMarker, type InferMiddleware, type InferProps, type InferRequest }; diff --git a/packages/component/src/Composer.tsx b/packages/component/src/Composer.tsx index a8eb2dfdae..67ef2973e6 100644 --- a/packages/component/src/Composer.tsx +++ b/packages/component/src/Composer.tsx @@ -5,9 +5,9 @@ import type { } from 'botframework-webchat-api'; import { Composer as APIComposer, + extractSendBoxMiddleware, + extractSendBoxToolbarMiddleware, hooks, - initSendBoxMiddleware, - initSendBoxToolbarMiddleware, WebSpeechPonyfillFactory } from 'botframework-webchat-api'; import { DecoratorComposer, type DecoratorMiddleware } from 'botframework-webchat-api/decorator'; @@ -51,8 +51,8 @@ import useTheme from './providers/Theme/useTheme'; import createDefaultSendBoxMiddleware from './SendBox/createMiddleware'; import createDefaultSendBoxToolbarMiddleware from './SendBoxToolbar/createMiddleware'; import createStyleSet from './Styles/createStyleSet'; -import WebChatTheme from './Styles/WebChatTheme'; import useCustomPropertiesClassName from './Styles/useCustomPropertiesClassName'; +import WebChatTheme from './Styles/WebChatTheme'; import { type ContextOf } from './types/ContextOf'; import { type FocusTranscriptInit } from './types/internal/FocusTranscriptInit'; import addTargetBlankToHyperlinksMarkdown from './Utils/addTargetBlankToHyperlinksMarkdown'; @@ -424,8 +424,8 @@ const InternalComposer = ({ const sendBoxMiddleware = useMemo( () => Object.freeze([ - ...initSendBoxMiddleware(sendBoxMiddlewareFromProps, undefined), - ...initSendBoxMiddleware(theme.sendBoxMiddleware, undefined), + ...extractSendBoxMiddleware(sendBoxMiddlewareFromProps), + ...extractSendBoxMiddleware(theme.sendBoxMiddleware), ...createDefaultSendBoxMiddleware() ]), [sendBoxMiddlewareFromProps, theme.sendBoxMiddleware] @@ -434,8 +434,8 @@ const InternalComposer = ({ const sendBoxToolbarMiddleware = useMemo( () => Object.freeze([ - ...initSendBoxToolbarMiddleware(sendBoxToolbarMiddlewareFromProps, undefined), - ...initSendBoxToolbarMiddleware(theme.sendBoxToolbarMiddleware, undefined), + ...extractSendBoxToolbarMiddleware(sendBoxToolbarMiddlewareFromProps), + ...extractSendBoxToolbarMiddleware(theme.sendBoxToolbarMiddleware), ...createDefaultSendBoxToolbarMiddleware() ]), [sendBoxToolbarMiddlewareFromProps, theme.sendBoxToolbarMiddleware] diff --git a/packages/component/src/Middleware/ActivityGrouping/createDefaultActivityGroupingDecoratorMiddleware.tsx b/packages/component/src/Middleware/ActivityGrouping/createDefaultActivityGroupingDecoratorMiddleware.tsx index 8040afb345..bb684d5611 100644 --- a/packages/component/src/Middleware/ActivityGrouping/createDefaultActivityGroupingDecoratorMiddleware.tsx +++ b/packages/component/src/Middleware/ActivityGrouping/createDefaultActivityGroupingDecoratorMiddleware.tsx @@ -1,22 +1,18 @@ -import { - type DecoratorMiddleware, - type DecoratorMiddlewareInit, - type DecoratorMiddlewareTypes -} from 'botframework-webchat-api/decorator'; +import { createActivityGroupingMiddleware, type DecoratorMiddleware } from 'botframework-webchat-api/decorator'; import RenderActivityGrouping from './ui/RenderActivityGrouping'; import SenderGrouping from './ui/SenderGrouping/SenderGrouping'; import StatusGrouping from './ui/StatusGrouping/StatusGrouping'; export default function createDefaultActivityGroupingDecoratorMiddleware(): readonly DecoratorMiddleware[] { return Object.freeze([ - (init: DecoratorMiddlewareInit) => - init === 'activity grouping' && - ((() => + createActivityGroupingMiddleware( + () => ({ groupingName }) => groupingName === 'sender' ? SenderGrouping : groupingName === 'status' ? StatusGrouping - : RenderActivityGrouping) satisfies DecoratorMiddlewareTypes['activity grouping']) + : RenderActivityGrouping + ) ]); } diff --git a/packages/component/src/decorator/private/WebChatDecorator.tsx b/packages/component/src/decorator/private/WebChatDecorator.tsx index defb349993..aefd67d7df 100644 --- a/packages/component/src/decorator/private/WebChatDecorator.tsx +++ b/packages/component/src/decorator/private/WebChatDecorator.tsx @@ -1,24 +1,37 @@ -import { DecoratorComposer, type DecoratorMiddleware } from 'botframework-webchat-api/decorator'; -import React, { memo, type ReactNode } from 'react'; +import { + createActivityBorderMiddleware, + DecoratorComposer, + type DecoratorMiddleware +} from 'botframework-webchat-api/decorator'; +import { reactNode, validateProps } from 'botframework-webchat-react-valibot'; +import React, { memo } from 'react'; +import { object, optional, pipe, readonly, type InferInput } from 'valibot'; import BorderFlair from './BorderFlair'; import BorderLoader from './BorderLoader'; import WebChatTheme from './WebChatTheme'; const middleware: readonly DecoratorMiddleware[] = Object.freeze([ - init => - init === 'activity border' && - (next => request => (request.livestreamingState === 'completing' ? BorderFlair : next(request))), - init => - init === 'activity border' && - (next => request => (request.livestreamingState === 'preparing' ? BorderLoader : next(request))) + createActivityBorderMiddleware( + next => request => (request.livestreamingState === 'completing' ? BorderFlair : next(request)) + ), + createActivityBorderMiddleware( + next => request => (request.livestreamingState === 'preparing' ? BorderLoader : next(request)) + ) ]); -type WebChatDecoratorProps = Readonly<{ - readonly children?: ReactNode | undefined; -}>; +const webChatDecoratorPropsSchema = pipe( + object({ + children: optional(reactNode()) + }), + readonly() +); + +type WebChatDecoratorProps = InferInput; + +function WebChatDecorator(props: WebChatDecoratorProps) { + const { children } = validateProps(webChatDecoratorPropsSchema, props); -function WebChatDecorator({ children }: WebChatDecoratorProps) { return ( {children} @@ -27,4 +40,4 @@ function WebChatDecorator({ children }: WebChatDecoratorProps) { } export default memo(WebChatDecorator); -export { type WebChatDecoratorProps }; +export { webChatDecoratorPropsSchema, type WebChatDecoratorProps }; diff --git a/packages/fluent-theme/src/private/FluentThemeProvider.tsx b/packages/fluent-theme/src/private/FluentThemeProvider.tsx index 37f8f0cd6c..14e4d50457 100644 --- a/packages/fluent-theme/src/private/FluentThemeProvider.tsx +++ b/packages/fluent-theme/src/private/FluentThemeProvider.tsx @@ -1,9 +1,8 @@ import { type ActivityMiddleware, type StyleOptions, type TypingIndicatorMiddleware } from 'botframework-webchat-api'; import { + createActivityBorderMiddleware, DecoratorComposer, - DecoratorMiddleware, - type DecoratorMiddlewareInit, - type DecoratorMiddlewareTypes + type DecoratorMiddleware } from 'botframework-webchat-api/decorator'; import { Components } from 'botframework-webchat-component'; import { WebChatDecorator } from 'botframework-webchat-component/decorator'; @@ -54,12 +53,9 @@ const activityMiddleware: readonly ActivityMiddleware[] = Object.freeze([ const sendBoxMiddleware = [() => () => () => PrimarySendBox]; const decoratorMiddleware: readonly DecoratorMiddleware[] = Object.freeze([ - (init: DecoratorMiddlewareInit) => - init === 'activity border' && - ((next => request => - request.livestreamingState === 'preparing' - ? ActivityLoader - : next(request)) satisfies DecoratorMiddlewareTypes['activity border']) + createActivityBorderMiddleware( + next => request => (request.livestreamingState === 'preparing' ? ActivityLoader : next(request)) + ) ]); const styles = createStyles('fluent-theme');