Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add decorators #5205

Merged
merged 31 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
02be55b
Add decorators
OEvgeny Jun 6, 2024
0bf63b0
Fix lint
OEvgeny Jun 6, 2024
4fa4d16
Rework into a single ActivityBorder middleware stack
OEvgeny Jun 10, 2024
662588f
Add test
OEvgeny Jun 10, 2024
38918fe
Add William's suggestions
OEvgeny Jun 12, 2024
f1aeac7
Polish
OEvgeny Jun 13, 2024
a7c5d78
Remove Fluent Decorator and update test
OEvgeny Jun 13, 2024
500d0a9
Roll own Proxy implementation to bypass missing context
OEvgeny Jun 13, 2024
9d55558
Update test
OEvgeny Jun 13, 2024
e8e1552
Rework according to RCoR changes
OEvgeny Jun 14, 2024
20c39f8
Changelog
OEvgeny Jun 14, 2024
4c2b70d
Tweak changelog
OEvgeny Jun 14, 2024
ae7456c
Update RCoR
OEvgeny Jun 17, 2024
e863988
Sort
compulim Jun 17, 2024
4a878f0
Typo and sort
compulim Jun 17, 2024
4606eab
Better type checking
compulim Jun 17, 2024
076af8c
Sort
compulim Jun 18, 2024
cc75fe7
Sort
compulim Jun 18, 2024
b376200
Sort
compulim Jun 18, 2024
565f6ee
Newline
compulim Jun 18, 2024
fa1a8da
Newline
compulim Jun 18, 2024
f4cb3e3
Code styling
compulim Jun 18, 2024
30a04ec
Props optional/undefined
compulim Jun 18, 2024
49c1ddc
Code styling
compulim Jun 18, 2024
04b1ed5
Clean up
compulim Jun 18, 2024
756965d
Use EmptyObject for props instead of {} any
compulim Jun 18, 2024
dba3191
Sort and typo
compulim Jun 18, 2024
3ac54e6
Converge templateMiddleware and build user story
compulim Jun 18, 2024
1130f23
Fix no middleware and stabilize middleware prop
OEvgeny Jun 18, 2024
5e9eb3f
Refactor init
OEvgeny Jun 18, 2024
3bf9b41
Fix type
OEvgeny Jun 18, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
89 changes: 89 additions & 0 deletions __tests__/html/fluentTheme/withDecorator.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react@16.8.6/umd/react.development.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16.8.6/umd/react-dom.development.js"></script>
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
<script crossorigin="anonymous" src="/__dist__/botframework-webchat-fluent-theme.production.min.js"></script>
</head>
<body>
<main id="webchat"></main>
<script type="text/babel">
run(async function () {
const {
React,
ReactDOM: { render },
WebChat: { FluentThemeProvider, ReactWebChat }
} = window; // Imports in UMD fashion.

const { directLine, store } = testHelpers.createDirectLineEmulator();

const App = () => <ReactWebChat
directLine={directLine}
store={store}
styleOptions={{
bubbleBorderRadius: 10
}}
/>;

render(
<FluentThemeProvider>
<App />
</FluentThemeProvider>,
document.getElementById('webchat')
);

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity({
from: { role: 'bot' },
text: 'Please wait',
type: 'message',
channelData: { streamType: 'informative' }
});

const attachments = [
{
content: {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.5',
actions: [
{ type: 'Action.Submit', title: 'Button 1' },
{
type: 'Action.ShowCard',
title: 'Show card',
card: {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.5',
actions: [
{ type: 'Action.Submit', title: 'Button 2' },
{ type: 'Action.Submit', title: 'Button 3' }
]
}
}
]
},
contentType: 'application/vnd.microsoft.card.adaptive'
}
];
await directLine.emulateIncomingActivity({
from: { role: 'bot' },
text: 'Working on it',
channelData: { streamType: 'completion' },
attachments
});

await pageConditions.numActivitiesShown(2);

// THEN: Should render the activity.
await host.snapshot();
});
</script>
</body>
</html>
5 changes: 5 additions & 0 deletions __tests__/html/fluentTheme/withDecorator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */

describe('Fluent theme applied', () => {
test('with decorators', () => runHTML('fluentTheme/withDecorator'));
});
3 changes: 3 additions & 0 deletions packages/api/decorator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This is required for Webpack 4 which does not support named exports.
// eslint-disable-next-line no-undef
module.exports = require('./lib/decorator');
10 changes: 10 additions & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@
"types": "./lib/internal.d.ts",
"default": "./lib/internal.js"
}
},
"./decorator": {
compulim marked this conversation as resolved.
Show resolved Hide resolved
"import": {
"types": "./dist/botframework-webchat-api.decorator.d.mts",
"default": "./dist/botframework-webchat-api.decorator.mjs"
},
"require": {
"types": "./lib/decorator.d.ts",
"default": "./lib/decorator.js"
}
}
},
"publishConfig": {
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/decorator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { DecoratorComposer } from './private/DecoratorComposer';
export { default as ActivityDecorator } from './private/ActivityDecorator';
export { type DecoratorMiddleware } from './private/createDecoratorComposer';
export { default as ActivityDecoratorRequest } from './private/activityDecoratorRequest';
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import ActivityDecoratorRequest from './activityDecoratorRequest';
import templateMiddleware from './templateMiddleware';

const {
Provider: ActivityBorderDecoratorMiddlewareProvider,
Proxy: ActivityBorderDecoratorMiddlewareProxy,
rectifyProps: rectifyActivityBorderDecoratorMiddlewareProps,
types
} = templateMiddleware<{}, ActivityDecoratorRequest>('ActivityBorderDecoratorMiddleware');

type ActivityBorderDecoratorMiddleware = typeof types.middleware;
type ActivityBorderDecoratorMiddlewareProps = typeof types.props;
type ActivityBorderDecoratorMiddlewareRequest = typeof types.request;

const activityBorderDecoratorTypeName = 'activity border' as const;

export {
ActivityBorderDecoratorMiddlewareProvider,
ActivityBorderDecoratorMiddlewareProxy,
activityBorderDecoratorTypeName,
rectifyActivityBorderDecoratorMiddlewareProps,
type ActivityBorderDecoratorMiddleware,
type ActivityBorderDecoratorMiddlewareProps,
type ActivityBorderDecoratorMiddlewareRequest
};
31 changes: 31 additions & 0 deletions packages/api/src/decorator/private/ActivityDecorator.tsx
OEvgeny marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React, { type ReactNode, memo, useMemo } from 'react';
import { ActivityBorderDecoratorMiddlewareProxy } from './ActivityBorderDecoratorMiddleware';
import { WebChatActivity } from 'botframework-webchat-core';
import { ActivityDecoratorRequest } from '..';

const ActivityDecoratorFallback = memo(({ children }) => <React.Fragment>{children}</React.Fragment>);

function ActivityDecorator({ children, activity }: Readonly<{ activity?: WebChatActivity; children?: ReactNode }>) {
const request = useMemo<ActivityDecoratorRequest>(
() =>
activity && {
from: activity.from?.role,
OEvgeny marked this conversation as resolved.
Show resolved Hide resolved
state:
activity.channelData.streamType === 'informative'
? 'informative'
: activity.channelData.streamType === 'completion'
? 'completion'
: undefined
},
[activity]
);
return request ? (
<ActivityBorderDecoratorMiddlewareProxy fallbackComponent={ActivityDecoratorFallback} request={request}>
{children}
</ActivityBorderDecoratorMiddlewareProxy>
) : (
<React.Fragment>{children}</React.Fragment>
compulim marked this conversation as resolved.
Show resolved Hide resolved
);
}

export default memo(ActivityDecorator);
6 changes: 6 additions & 0 deletions packages/api/src/decorator/private/DecoratorComposer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { memo } from 'react';
import createDecoratorComposer from './createDecoratorComposer';

export const DecoratorComposer = memo(createDecoratorComposer());

DecoratorComposer.displayName = 'DecoratorComposer';
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type ActivityDecoratorRequestType = {
from: 'user' | 'bot' | 'channel';
OEvgeny marked this conversation as resolved.
Show resolved Hide resolved
state: 'completion' | 'informative' | undefined;
};

export default ActivityDecoratorRequestType;
43 changes: 43 additions & 0 deletions packages/api/src/decorator/private/createDecoratorComposer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, { type ReactNode, useMemo } from 'react';
import {
ActivityBorderDecoratorMiddlewareProvider,
activityBorderDecoratorTypeName,
type ActivityBorderDecoratorMiddleware
} from './ActivityBorderDecoratorMiddleware';

type DecoratorMiddlewareByInit = {
[activityBorderDecoratorTypeName]: ActivityBorderDecoratorMiddleware;
};

type DecoratorMiddlewareInit = keyof DecoratorMiddlewareByInit;

export type DecoratorComposerComponent = (
props: Readonly<{
children: ReactNode;
middleware: DecoratorMiddleware[];
}>
) => React.JSX.Element;

export type DecoratorMiddleware = (
init: DecoratorMiddlewareInit
) => ReturnType<ActivityBorderDecoratorMiddleware> | false;

const initMiddlewares = <Init extends DecoratorMiddlewareInit>(
OEvgeny marked this conversation as resolved.
Show resolved Hide resolved
middleware: DecoratorMiddleware[],
init: Init
): DecoratorMiddlewareByInit[Init][] =>
middleware
.map(md => md(init))
.filter((enhancer): enhancer is ReturnType<DecoratorMiddlewareByInit[Init]> => !!enhancer)
.map(enhancer => () => enhancer);

export default (): DecoratorComposerComponent =>
({ children, middleware }) => {
const borderMiddlewares = useMemo(() => initMiddlewares(middleware, activityBorderDecoratorTypeName), [middleware]);

return (
<ActivityBorderDecoratorMiddlewareProvider middleware={borderMiddlewares}>
{children}
</ActivityBorderDecoratorMiddlewareProvider>
);
};
47 changes: 47 additions & 0 deletions packages/api/src/decorator/private/templateMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { warnOnce } from 'botframework-webchat-core';
import { createChainOfResponsibility, type ComponentMiddleware } from 'react-chain-of-responsibility';
import { type EmptyObject } from 'type-fest';
import { any, array, custom, safeParse, type Output } from 'valibot';

export default function createMiddlewareFacility<Props extends {} = EmptyObject, Request extends {} = EmptyObject>(
name: string
) {
type Middleware = ComponentMiddleware<Request, Props>;

const validateMiddleware = custom<Middleware>(input => typeof input === 'function', 'Middleware must be a function.');

const middlewareSchema = array(any([validateMiddleware]));

const isMiddleware = (middleware: unknown): middleware is Output<typeof middlewareSchema> =>
safeParse(middlewareSchema, middleware).success;

const warnInvalid = warnOnce(`"${name}" prop is invalid`);

const rectifyProps = (middleware: unknown): readonly Middleware[] => {
OEvgeny marked this conversation as resolved.
Show resolved Hide resolved
if (middleware) {
if (isMiddleware(middleware)) {
return Object.isFrozen(middleware) ? middleware : Object.freeze([...middleware]);
}

warnInvalid();
}

return Object.freeze([]);
};

const { Provider, Proxy } = createChainOfResponsibility<Request, Props>();

Provider.displayName = `${name}Provider`;
Proxy.displayName = `${name}Proxy`;

return {
types: {
middleware: {} as Middleware,
props: {} as Props,
request: {} as Request
},
Provider,
Proxy,
rectifyProps
};
}
3 changes: 2 additions & 1 deletion packages/api/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ export default defineConfig({
...baseConfig,
entry: {
'botframework-webchat-api': './src/index.ts',
'botframework-webchat-api.internal': './src/internal.ts'
'botframework-webchat-api.internal': './src/internal.ts',
'botframework-webchat-api.decorator': './src/decorator/index.ts'
},
noExternal: ['globalize']
});
2 changes: 2 additions & 0 deletions packages/bundle/src/index-minimal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// window['WebChat'] is required for TypeScript

import { StrictStyleOptions, StyleOptions } from 'botframework-webchat-api';
import * as decorator from 'botframework-webchat-api/decorator';
import { Constants, createStore, createStoreWithDevTools, createStoreWithOptions } from 'botframework-webchat-core';

import ReactWebChat, {
Expand Down Expand Up @@ -75,6 +76,7 @@ window['WebChat'] = {
createStore,
createStoreWithOptions,
createStyleSet,
decorator,
hooks,
ReactWebChat,
renderWebChat,
Expand Down
6 changes: 4 additions & 2 deletions packages/component/src/Activity/Bubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { hooks } from 'botframework-webchat-api';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { FC, ReactNode } from 'react';
import React, { FC, ReactNode, memo } from 'react';

import isZeroOrPositive from '../Utils/isZeroOrPositive';
import useStyleSet from '../hooks/useStyleSet';
Expand Down Expand Up @@ -139,4 +139,6 @@ Bubble.propTypes = {
nub: PropTypes.oneOf([true, false, 'hidden'])
};

export default Bubble;
Bubble.displayName = 'Bubble';

export default memo(Bubble);
21 changes: 12 additions & 9 deletions packages/component/src/Activity/StackedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { hooks } from 'botframework-webchat-api';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { memo } from 'react';
import { ActivityDecorator } from 'botframework-webchat-api/decorator';

import Bubble from './Bubble';
import isZeroOrPositive from '../Utils/isZeroOrPositive';
Expand Down Expand Up @@ -156,15 +157,17 @@ const StackedLayout: FC<StackedLayoutProps> = ({
fromUser={fromUser}
nub={showNub || (hasAvatar || hasNub ? 'hidden' : false)}
>
{renderAttachment({
activity,
attachment: isMessage
? {
content: activityDisplayText,
contentType: textFormatToContentType(activity.textFormat)
}
: undefined
})}
<ActivityDecorator activity={activity}>
OEvgeny marked this conversation as resolved.
Show resolved Hide resolved
{renderAttachment({
activity,
attachment: isMessage
? {
content: activityDisplayText,
contentType: textFormatToContentType(activity.textFormat)
}
: undefined
})}
</ActivityDecorator>
</Bubble>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

:global(.webchat-fluent-decorator).decorator {
display: contents;
}
25 changes: 25 additions & 0 deletions packages/fluent-theme/src/components/decorator/Decorator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, { memo, type ReactNode } from 'react';
import cx from 'classnames';
import styles from './Decorator.module.css';
import { useStyles } from '../../styles';
import { DecoratorComposer, type DecoratorMiddleware } from 'botframework-webchat-api/decorator';
import Flair from './Flair';
import Loader from './Loader';

export const rootClassName = 'webchat-fluent-decorator';

const middleware: DecoratorMiddleware[] = [
init => init === 'activity border' && (next => request => (request.state === 'completion' ? Flair : next(request))),
init => init === 'activity border' && (next => request => (request.state === 'informative' ? Loader : next(request)))
];

function WebchatDecorator(props: Readonly<{ readonly children: ReactNode | undefined }>) {
const classNames = useStyles(styles);
return (
<div className={cx(rootClassName, classNames['decorator'])}>
<DecoratorComposer middleware={middleware}>{props.children}</DecoratorComposer>
</div>
);
}

export default memo(WebchatDecorator);
Loading
Loading