From e84bbc0d1893cc5ff162cca63eb9198c1d8562f8 Mon Sep 17 00:00:00 2001 From: justinwilaby Date: Tue, 12 Feb 2019 07:50:44 -0800 Subject: [PATCH] Fixes #737 - Add new HeroCard prompt style to ChoiceFactory --- .../src/choices/choiceFactory.ts | 37 ++++- .../botbuilder-dialogs/src/prompts/prompt.ts | 29 +++- .../tests/choices_choiceFactory.test.js | 146 +++++++++++++----- 3 files changed, 156 insertions(+), 56 deletions(-) diff --git a/libraries/botbuilder-dialogs/src/choices/choiceFactory.ts b/libraries/botbuilder-dialogs/src/choices/choiceFactory.ts index 6600e3a29c..3fe12e92b4 100644 --- a/libraries/botbuilder-dialogs/src/choices/choiceFactory.ts +++ b/libraries/botbuilder-dialogs/src/choices/choiceFactory.ts @@ -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'; @@ -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) { @@ -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); @@ -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. @@ -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}; } }); @@ -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` for `Choice.action`, `.toChoices()` will attempt to * fill the `Choice.action`. * @@ -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. diff --git a/libraries/botbuilder-dialogs/src/prompts/prompt.ts b/libraries/botbuilder-dialogs/src/prompts/prompt.ts index f2f8c383c3..0c0a2ad15d 100644 --- a/libraries/botbuilder-dialogs/src/prompts/prompt.ts +++ b/libraries/botbuilder-dialogs/src/prompts/prompt.ts @@ -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 } /** @@ -48,17 +53,17 @@ export interface PromptOptions { /** * (Optional) Initial prompt to send the user. */ - prompt?: string|Partial; + prompt?: string | Partial; /** * (Optional) Retry prompt to send the user. */ - retryPrompt?: string|Partial; + retryPrompt?: string | Partial; /** * (Optional) List of choices associated with the prompt. */ - choices?: (string|Choice)[]; + choices?: (string | Choice)[]; /** * (Optional) Additional validation rules to pass the prompts validator routine. @@ -152,7 +157,7 @@ export abstract class Prompt 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) { + protected constructor(dialogId: string, private validator?: PromptValidator) { super(dialogId); } @@ -258,9 +263,9 @@ export abstract class Prompt extends Dialog { * @param options (Optional) options to configure the underlying ChoiceFactory call. */ protected appendChoices( - prompt: string|Partial, + prompt: string | Partial, channelId: string, - choices: (string|Choice)[], + choices: (string | Choice)[], style: ListStyle, options?: ChoiceFactoryOptions ): Partial { @@ -287,6 +292,10 @@ export abstract class Prompt 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; @@ -296,7 +305,7 @@ export abstract class Prompt 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; @@ -305,6 +314,10 @@ export abstract class Prompt extends Dialog { prompt.suggestedActions = msg.suggestedActions; } + if (msg.attachments) { + prompt.attachments = msg.attachments; + } + return prompt; } else { msg.inputHint = InputHints.ExpectingInput; diff --git a/libraries/botbuilder-dialogs/tests/choices_choiceFactory.test.js b/libraries/botbuilder-dialogs/tests/choices_choiceFactory.test.js index 578a3cf6e5..2fc26bbe43 100644 --- a/libraries/botbuilder-dialogs/tests/choices_choiceFactory.test.js +++ b/libraries/botbuilder-dialogs/tests/choices_choiceFactory.test.js @@ -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.`); @@ -74,15 +74,15 @@ const choicesWithActionValue = [ const choicesWithEmptyActions = [ { value: 'red', - action: { } + action: {} }, { value: 'green', - action: { } + action: {} }, { value: 'blue', - action: { } + action: {} } ]; @@ -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:`, @@ -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:`, @@ -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(); }); });