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

Fixes #737 - Add new HeroCard prompt style to ChoiceFactory #773

Merged
merged 1 commit into from
Feb 13, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 31 additions & 6 deletions libraries/botbuilder-dialogs/src/choices/choiceFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@
* Licensed under the MIT License.
*/

import { ActionTypes, Activity, CardAction, InputHints, MessageFactory, TurnContext } from 'botbuilder-core';
import {
ActionTypes,
Activity,
CardAction,
CardFactory,
InputHints,
MessageFactory,
TurnContext
} from 'botbuilder-core';
import * as channel from './channel';
import { Choice } from './findChoices';

Expand Down Expand Up @@ -97,7 +105,7 @@ export class ChoiceFactory {
const list: Choice[] = ChoiceFactory.toChoices(choices);

// Find maximum title length
let maxTitleLength: number = 0;
let maxTitleLength = 0;
list.forEach((choice: Choice) => {
const l: number = choice.action && choice.action.title ? choice.action.title.length : choice.value.length;
if (l > maxTitleLength) {
Expand All @@ -108,8 +116,13 @@ export class ChoiceFactory {
// Determine list style
const supportsSuggestedActions: boolean = channel.supportsSuggestedActions(channelId, choices.length);
const maxActionTitleLength: number = channel.maxActionTitleLength(channelId);
const supportsCardActions = channel.supportsCardActions(channelId, choices.length);
const longTitles: boolean = maxTitleLength > maxActionTitleLength;
if (!longTitles && supportsSuggestedActions) {
if (!longTitles && !supportsSuggestedActions && supportsCardActions) {
// SuggestedActions is the preferred approach, but for channels that don't
// support them (e.g. Teams, Cortana) we should use a HeroCard with CardActions
return ChoiceFactory.heroCard(list, text, speak);
} else if (!longTitles && supportsSuggestedActions) {
// We always prefer showing choices using suggested actions. If the titles are too long, however,
// we'll have to show them as a text list.
return ChoiceFactory.suggestedAction(list, text, speak);
Expand All @@ -122,6 +135,18 @@ export class ChoiceFactory {
}
}

public static heroCard(choices: Choice[] = [], text = '', speak = ''): Activity {
const buttons: CardAction[] = choices.map(choice => ({
title: choice.value,
type: ActionTypes.ImBack,
value: choice.value
} as CardAction));
const attachment = CardFactory.heroCard(null, text, null, buttons);

return MessageFactory.attachment(attachment, null, speak, InputHints.ExpectingInput) as Activity;
}


/**
* Returns a 'message' activity containing a list of choices that has been formatted as an
* inline list.
Expand Down Expand Up @@ -225,7 +250,7 @@ export class ChoiceFactory {
if (choice.action) {
return choice.action;
} else {
return { type: ActionTypes.ImBack, value: choice.value, title: choice.value, channelData: undefined };
return {type: ActionTypes.ImBack, value: choice.value, title: choice.value, channelData: undefined};
}
});

Expand All @@ -238,7 +263,7 @@ export class ChoiceFactory {
*
* @remarks
* This example converts a simple array of string based choices to a properly formated `Choice[]`.
*
*
* If the `Choice` has a `Partial<CardAction>` for `Choice.action`, `.toChoices()` will attempt to
* fill the `Choice.action`.
*
Expand All @@ -249,7 +274,7 @@ export class ChoiceFactory {
*/
public static toChoices(choices: (string | Choice)[] | undefined): Choice[] {
return (choices || []).map(
(choice: Choice) => typeof choice === 'string' ? { value: choice } : choice
(choice: Choice) => typeof choice === 'string' ? {value: choice} : choice
).map((choice: Choice) => {
const action: CardAction = choice.action;
// If the choice.action is incomplete, populate the missing fields.
Expand Down
29 changes: 21 additions & 8 deletions libraries/botbuilder-dialogs/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ export enum ListStyle {
/**
* Add choices to prompt as suggested actions.
*/
suggestedAction
suggestedAction,

/**
* Add choices to prompt as a HeroCard with buttons.
*/
heroCard
}

/**
Expand All @@ -48,17 +53,17 @@ export interface PromptOptions {
/**
* (Optional) Initial prompt to send the user.
*/
prompt?: string|Partial<Activity>;
prompt?: string | Partial<Activity>;

/**
* (Optional) Retry prompt to send the user.
*/
retryPrompt?: string|Partial<Activity>;
retryPrompt?: string | Partial<Activity>;

/**
* (Optional) List of choices associated with the prompt.
*/
choices?: (string|Choice)[];
choices?: (string | Choice)[];

/**
* (Optional) Additional validation rules to pass the prompts validator routine.
Expand Down Expand Up @@ -152,7 +157,7 @@ export abstract class Prompt<T> extends Dialog {
* @param dialogId Unique ID of the prompt within its parent `DialogSet` or `ComponentDialog`.
* @param validator (Optional) custom validator used to provide additional validation and re-prompting logic for the prompt.
*/
constructor(dialogId: string, private validator?: PromptValidator<T>) {
protected constructor(dialogId: string, private validator?: PromptValidator<T>) {
super(dialogId);
}

Expand Down Expand Up @@ -258,9 +263,9 @@ export abstract class Prompt<T> extends Dialog {
* @param options (Optional) options to configure the underlying ChoiceFactory call.
*/
protected appendChoices(
prompt: string|Partial<Activity>,
prompt: string | Partial<Activity>,
channelId: string,
choices: (string|Choice)[],
choices: (string | Choice)[],
style: ListStyle,
options?: ChoiceFactoryOptions
): Partial<Activity> {
Expand All @@ -287,6 +292,10 @@ export abstract class Prompt<T> extends Dialog {
msg = ChoiceFactory.suggestedAction(choices, text);
break;

case ListStyle.heroCard:
msg = ChoiceFactory.heroCard(choices as Choice[], text);
break;

case ListStyle.none:
msg = MessageFactory.text(text);
break;
Expand All @@ -296,7 +305,7 @@ export abstract class Prompt<T> extends Dialog {
break;
}

// Update prompt with text and actions
// Update prompt with text, actions and attachments
if (typeof prompt === 'object') {
// Clone the prompt Activity as to not modify the original prompt.
prompt = JSON.parse(JSON.stringify(prompt)) as Activity;
Expand All @@ -305,6 +314,10 @@ export abstract class Prompt<T> extends Dialog {
prompt.suggestedActions = msg.suggestedActions;
}

if (msg.attachments) {
prompt.attachments = msg.attachments;
}

return prompt;
} else {
msg.inputHint = InputHints.ExpectingInput;
Expand Down
146 changes: 104 additions & 42 deletions libraries/botbuilder-dialogs/tests/choices_choiceFactory.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const assert = require('assert');
const { ChoiceFactory } = require('../');
const { ActionTypes } = require('botbuilder-core');
const { ActionTypes, CardAction } = require('botbuilder-core');

function assertActivity(received, expected) {
assert(received, `Activity not returned.`);
Expand Down Expand Up @@ -74,15 +74,15 @@ const choicesWithActionValue = [
const choicesWithEmptyActions = [
{
value: 'red',
action: { }
action: {}
},
{
value: 'green',
action: { }
action: {}
},
{
value: 'blue',
action: { }
action: {}
}
];

Expand Down Expand Up @@ -112,33 +112,29 @@ function assertChoices(choices, actionValues, actionType = 'imBack') {
for (let i = 0; i < choices.length; i++) {
const choice = choices[i];
const val = actionValues[i];
assert(choice.action.type === actionType, `Expected action.type === ${ actionType }, received ${ choice.action.type }`);
assert(choice.action.value === val, `Expected action.value === ${ val }, received ${ choice.action.value }`);
assert(choice.action.title === val, `Expected action.title === ${ val }, received ${ choice.action.title }`);
assert(choice.action.type === actionType, `Expected action.type === ${actionType}, received ${choice.action.type}`);
assert(choice.action.value === val, `Expected action.value === ${val}, received ${choice.action.value}`);
assert(choice.action.title === val, `Expected action.title === ${val}, received ${choice.action.title}`);

}
}

describe('ChoiceFactory', function() {
this.timeout(5000);

it('should render choices inline.', done => {
describe('The ChoiceFactory', function () {
it('should render choices inline.', () => {
const activity = ChoiceFactory.inline(colorChoices, 'select from:');
assertActivity(activity, {
text: `select from: (1) red, (2) green, or (3) blue`
});
done();
});

it('should render choices as a list.', done => {
it('should render choices as a list.', () => {
const activity = ChoiceFactory.list(colorChoices, 'select from:');
assertActivity(activity, {
text: `select from:\n\n 1. red\n 2. green\n 3. blue`
});
done();
});

it('should render choices as suggested actions.', done => {
it('should render choices as suggested actions.', () => {
const activity = ChoiceFactory.suggestedAction(colorChoices, 'select from:');
assertActivity(activity, {
text: `select from:`,
Expand All @@ -150,10 +146,94 @@ describe('ChoiceFactory', function() {
]
}
});
done();
});

it('should automatically choose render style based on channel type.', done => {
it('should suggest the same action when a suggested action is provided', () => {
const activity = ChoiceFactory.suggestedAction([{ value: 'Signin', action: { type: ActionTypes.Signin } }]);
assert.ok(activity.suggestedActions.actions[0].type === ActionTypes.Signin,
`Expected the suggestion action to be ${ActionTypes.Signin} but got: ${activity.suggestedActions.actions[0].type}`);
});

it('should use hero cards for channels that do not support choices (Teams, Cortana)', () => {
let activities = ChoiceFactory.forChannel('cortana', colorChoices, 'select from:');
assertActivity(activities, {
'type': 'message',
'attachmentLayout': 'list',
'attachments': [
{
'contentType': 'application/vnd.microsoft.card.hero',
'content': {
'text': 'select from:',
'buttons': [
{
'title': 'red',
'type': 'imBack',
'value': 'red'
},
{
'title': 'green',
'type': 'imBack',
'value': 'green'
},
{
'title': 'blue',
'type': 'imBack',
'value': 'blue'
}
]
}
}
],
'inputHint': 'expectingInput'
});
const choices = colorChoices.map(value => ({
value,
action: { type: ActionTypes.ImBack }
}));

activities = ChoiceFactory.forChannel('msteams', choices, 'select from:');
assertActivity(activities, {
'type': 'message',
'attachmentLayout': 'list',
'attachments': [
{
'contentType': 'application/vnd.microsoft.card.hero',
'content': {
'text': 'select from:',
'buttons': [
{
'title': 'red',
'type': 'imBack',
'value': 'red'
},
{
'title': 'green',
'type': 'imBack',
'value': 'green'
},
{
'title': 'blue',
'type': 'imBack',
'value': 'blue'
}
]
}
}
],
'inputHint': 'expectingInput'
});
});

it('should render an inline list based on title length, choice length and channel', () => {
const activity = ChoiceFactory.forChannel('skypeforbusiness', colorChoices, 'select from:');
assertActivity(activity, {
'type': 'message',
'text': 'select from: (1) red, (2) green, or (3) blue',
'inputHint': 'expectingInput'
});
});

it('should automatically choose render style based on channel type.', () => {
const activity = ChoiceFactory.forChannel('emulator', colorChoices, 'select from:');
assertActivity(activity, {
text: `select from:`,
Expand All @@ -165,51 +245,33 @@ describe('ChoiceFactory', function() {
]
}
});
done();
});

it('should choose correct styles for Cortana.', done => {
const inlineActivity = ChoiceFactory.forChannel('cortana', colorChoices, 'select from:');
assertActivity(inlineActivity, {
text: `select from: (1) red, (2) green, or (3) blue`
});
const listActivity = ChoiceFactory.forChannel('cortana', extraChoices, 'select from:');
assertActivity(listActivity, {
text: `select from:\n\n 1. red\n 2. green\n 3. blue\n 4. alpha`
});
done();
});

it('should use action.title to populate action.value if action.value is falsey.', done => {
it('should use action.title to populate action.value if action.value is falsey.', () => {
const preparedChoices = ChoiceFactory.toChoices(choicesWithActionTitle);
assertChoices(preparedChoices, ['Red Color', 'Green Color', 'Blue Color']);
done();
});

it('should use action.value to populate action.title if action.title is falsey.', done => {
it('should use action.value to populate action.title if action.title is falsey.', () => {
const preparedChoices = ChoiceFactory.toChoices(choicesWithActionValue);
assertChoices(preparedChoices, ['Red Color', 'Green Color', 'Blue Color']);
done();
});
it('should use choice.value to populate action.title and action.value if both are missing.', done => {

it('should use choice.value to populate action.title and action.value if both are missing.', () => {
const preparedChoices = ChoiceFactory.toChoices(choicesWithEmptyActions);
assertChoices(preparedChoices, ['red', 'green', 'blue']);
done();
});

it('should use provided ActionType.', done => {
it('should use provided ActionType.', () => {
const preparedChoices = ChoiceFactory.toChoices(choicesWithPostBacks);
assertChoices(preparedChoices, ['red', 'green', 'blue'], ActionTypes.PostBack);
done();
});

it('should return a stylized list.', done => {
it('should return a stylized list.', () => {
const listActivity = ChoiceFactory.forChannel('emulator',
['choiceTitleOverTwentyChars'],
'Test'
);
assert(listActivity.text === 'Test\n\n 1. choiceTitleOverTwentyChars');
done();
});
});