Skip to content

Commit

Permalink
feat: effect and response extension support (DX-1100) (#115)
Browse files Browse the repository at this point in the history
  • Loading branch information
effervescentia authored Mar 5, 2024
1 parent 36593c4 commit 6f195b3
Show file tree
Hide file tree
Showing 17 changed files with 443 additions and 23 deletions.
125 changes: 125 additions & 0 deletions packages/react-chat/e2e/extensions.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html>

<head>
<title>Embedded Mode</title>
<style>
body {
background-color: #f9f9f9;
}

#voiceflow-chat-frame {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
}

#order-status {
position: absolute;
visibility: hidden;
left: 50%;
top: 8px;
transform: translateX(-50%);
padding: 8px;
border-radius: 10px;
background-color: orangered;
z-index: 10;

font-family: 'Open Sans', sans-serif;
font-size: 20px;
font-weight: 400;
}
</style>
</head>

<body>
<span id="order-status" data-testid="status"></span>
<div id="voiceflow-chat-frame">
</div>
<template id="complex-form">
<form>
<input name="name" placeholder="What is your name?" />
<fieldset>
<legend>What kind of hair do you have?</legend>
<div>
<input type="radio" name="hair" id="straight" value="straight" checked /><label
for="straight">Straight</label>
</div>
<div>
<input type="radio" name="hair" id="curly" value="curly" /><label for="curly">Curly</label>
</div>
<div>
<input type="radio" name="hair" id="wavy" value="wavy" /><label for="wavy">Wavy</label>
</div>
</fieldset>
<button>submit</button>
</form>
</template>

<script type="text/javascript">
(function (d, t) {
var v = d.createElement(t), s = d.getElementsByTagName(t)[0];
v.onload = () => {
window.voiceflow.chat.load({
verify: { projectID: 'projectID' },
render: { mode: 'embedded' },
autostart: true,
assistant: {
extensions: [
{
name: 'order_tracker',
type: 'effect',
match: ({ trace }) => trace.type === 'update_order_status',
effect({ trace }) {
const element = document.getElementById('order-status');
const status = trace.payload;

element.style.visibility = 'visible';
element.innerText = status;
}
},
{
name: 'onboarding_form',
type: 'response',
match: ({ trace }) => trace.type === 'onboarding',
render({ trace, element }) {
const template = document.getElementById('complex-form').content.cloneNode(true);
const id = 'onboarding-form-' + Date.now();
template.firstElementChild.id = id;

element.appendChild(template);

const form = element.querySelector(`#${id}`);
form.addEventListener('submit', async (event) => {
event.preventDefault();

await window.voiceflow.chat.interact({
type: 'submit',
payload: {
name: form.elements.name.value,
hair: form.elements.hair.value
}
});
while (form.firstChild) {
form.removeChild(form.firstChild);
}

const confirmation = document.createElement('em');
confirmation.innerText = `submitted ✅`;

form.appendChild(confirmation);
});
}
}
]
}
});
}
v.src = "../dist/bundle.mjs"; v.type = "text/javascript"; s.parentNode.insertBefore(v, s);
})(document, 'script');
</script>
</body>

</html>
113 changes: 113 additions & 0 deletions packages/react-chat/e2e/extensions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { expect, test } from '@playwright/test';

import { slateMessage } from './utils';

const RUNTIME_URL = 'https://general-runtime.voiceflow.com/public/projectID/state/user/*/interact';

test('trigger effect extension on incoming trace', async ({ page }) => {
const systemMessages = ['Welcome to the pizza palace!', 'What kind of pizza do you want?', 'One cheese pizza coming right up'];
const userMessages = ['I want to order a pizza', 'Cheese please'];
const traceType = 'update_order_status';
let count = 0;

// eslint-disable-next-line consistent-return
await page.route(RUNTIME_URL, async (route) => {
count++;

switch (count) {
case 1:
return route.fulfill({
json: {
trace: [{ type: traceType, payload: 'idle' }, slateMessage(systemMessages[0])],
},
});

case 2:
return route.fulfill({
json: {
trace: [{ type: traceType, payload: 'in progress' }, slateMessage(systemMessages[1])],
},
});

case 3:
return route.fulfill({
json: {
trace: [{ type: traceType, payload: 'ordered' }, slateMessage(systemMessages[2])],
},
});

default:
}
});

await page.goto('extensions');

const chat = page.locator('.vfrc-chat');
await chat.waitFor({ state: 'visible' });
expect(chat).toBeInViewport();

await page.locator('[data-testid="status"]', { hasText: 'idle' }).waitFor({ state: 'visible' });
await page.locator('.vfrc-message', { hasText: systemMessages[0] }).waitFor({ state: 'visible' });

const input = page.locator('.vfrc-chat-input textarea');
await input.waitFor({ state: 'visible' });
await input.fill(userMessages[0]);

const submit = page.locator('.vfrc-chat-input .vfrc-bubble');
await submit.click();

await page.locator('.vfrc-message', { hasText: userMessages[0] }).waitFor({ state: 'visible' });
await page.locator('.vfrc-message', { hasText: systemMessages[1] }).waitFor({ state: 'visible' });
await page.locator('[data-testid="status"]', { hasText: 'in progress' }).waitFor({ state: 'visible' });

await input.fill(userMessages[1]);
await submit.click();

await page.locator('.vfrc-message', { hasText: userMessages[1] }).waitFor({ state: 'visible' });
await page.locator('.vfrc-message', { hasText: systemMessages[2] }).waitFor({ state: 'visible' });
await page.locator('[data-testid="status"]', { hasText: 'ordered' }).waitFor({ state: 'visible' });
});

test('render response extension from incoming trace', async ({ page }) => {
let count = 0;

await page.route(RUNTIME_URL, (route) => {
count++;

switch (count) {
case 1:
return route.fulfill({
json: {
trace: [slateMessage("Welcome to Sal's Salon! Tell me about yourself."), { type: 'onboarding' }],
},
});
case 2:
default:
expect(route.request().postDataJSON()).toEqual({
action: {
type: 'submit',
payload: { name: 'Alex', hair: 'curly' },
},
});

return route.fulfill({ json: { trace: [] } });
}
});

await page.goto('extensions');

const chat = page.locator('.vfrc-chat');
await chat.waitFor({ state: 'visible' });
expect(chat).toBeInViewport();

await page.locator('.vfrc-message').waitFor({ state: 'visible' });

const extensionMessage = page.locator('.vfrc-message--extension-onboarding_form');
await extensionMessage.waitFor({ state: 'visible' });

await extensionMessage.locator('[name="name"]').fill('Alex');
await extensionMessage.locator('[name="hair"][id="curly"]').click();
await extensionMessage.getByRole('button').click();
await page.locator('.vfrc-message--extension-onboarding_form', { hasText: `submitted ✅` }).waitFor({ state: 'visible' });
});
12 changes: 12 additions & 0 deletions packages/react-chat/e2e/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const slateMessage = (text: string) => ({
type: 'text',
payload: {
slate: {
id: text,
content: [{ children: [{ text }] }],
messageDelayMilliseconds: 100,
},
message: text,
delay: 100,
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Trace } from '@voiceflow/base-types';
import { useEffect, useRef } from 'react';

import { ResponseExtension } from '@/dtos/Extension.dto';

import Message from '../Message';

export interface ExtensionMessageProps {
extension: ResponseExtension;
trace: Trace.AnyTrace;
}

export const ExtensionMessage: React.FC<ExtensionMessageProps> = ({ extension, trace }) => {
const ref = useRef<HTMLSpanElement>(null);

useEffect(() => {
try {
// eslint-disable-next-line xss/no-mixed-html
const unmount = extension.render?.({ trace, element: ref.current as HTMLElement });
if (!unmount) return undefined;

return () => {
try {
unmount?.();
} catch (e) {
console.error(`Extension '${extension.name}' threw an error while unmounting: ${e}`);
}
};
} catch (e) {
console.error(`Extension '${extension.name}' threw an error while mounting: ${e}`);
return undefined;
}
}, []);

return (
<Message from="system" className={`vfrc-message--extension-${extension.name}`}>
<span ref={ref} />
</Message>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { RuntimeStateAPIContext } from '@/contexts';

import Feedback, { FeedbackProps } from '../Feedback';
import { MessageType } from './constants';
import { ExtensionMessage } from './ExtensionMessage';
import EndState from './state/end';
import { Controls, List, MessageContainer } from './styled';
import { MessageProps } from './types';
Expand Down Expand Up @@ -68,6 +69,7 @@ const SystemMessage: React.FC<SystemMessageProps> = ({ avatar, feedback, timesta
.with({ type: MessageType.CAROUSEL }, (props) => (
<Carousel {...R.omit(props, ['type'])} containerRef={containerRef} controlsRef={controlsRef} />
))
.with({ type: MessageType.EXTENSION }, ({ payload }) => <ExtensionMessage extension={payload.extension} trace={payload.trace} />)
.otherwise(() => null)}
{feedback && <Feedback {...feedback} />}
</List>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export enum MessageType {
CARD = 'card',
CAROUSEL = 'carousel',
END = 'END',
EXTENSION = 'EXTENSION',
}

export const DEFAULT_MESSAGE_DELAY = 1000;
20 changes: 18 additions & 2 deletions packages/react-chat/src/components/SystemResponse/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Text } from '@voiceflow/base-types';
import { Text, Trace } from '@voiceflow/base-types';

import { CardProps } from '@/components/Card/types';
import { ResponseExtension } from '@/dtos/Extension.dto';
import { StringifiedEnum } from '@/types/util';

import { MessageType } from './constants';
Expand Down Expand Up @@ -33,9 +34,24 @@ export interface EndMessage extends BaseMessageProps {
type: StringifiedEnum<MessageType.END>;
}

export interface ExtensionMessage extends BaseMessageProps {
type: StringifiedEnum<MessageType.EXTENSION>;
payload: {
trace: Trace.AnyTrace;
extension: ResponseExtension;
};
}

export interface CustomMessage extends BaseMessageProps {
type: `custom_${string}`;
payload: any;
}

export type MessageProps = TextMessageProps | ImageMessageProps | CardMessageProps | CarouselMessageProps | EndMessage | CustomMessage;
export type MessageProps =
| TextMessageProps
| ImageMessageProps
| CardMessageProps
| CarouselMessageProps
| EndMessage
| ExtensionMessage
| CustomMessage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Trace } from '@voiceflow/base-types';
import { TraceDeclaration } from '@voiceflow/sdk-runtime';

import { AnyExtension, EffectExtension, ExtensionType } from '@/dtos/Extension.dto';

import { RuntimeMessage } from '../messages';

export const EffectExtensions = (extensions: AnyExtension[]): TraceDeclaration<RuntimeMessage, Trace.AnyTrace>[] => {
return extensions
.filter((extension): extension is EffectExtension => extension.type === ExtensionType.EFFECT)
.map((extension) => ({
canHandle: (trace) => extension.match({ trace }),

handle: ({ context }, trace) => {
// NOTE: this promise is intentionally left unhandled
// we just want to capture and raise any errors thrown
(async () => {
try {
await extension.effect?.({ trace });
} catch (e) {
console.error(`Extension '${extension.name}' threw an error: ${e}`);
}
})();

return context;
},
}));
};
Loading

0 comments on commit 6f195b3

Please sign in to comment.