From c07aed9c8e082b5fdae117d2335dc8d0b26c1430 Mon Sep 17 00:00:00 2001 From: Yaskur Date: Tue, 12 Mar 2024 05:06:20 +0700 Subject: [PATCH 1/9] feat(MultipleVote): create a config input for the vote limit --- src/cards/NewPollFormCard.ts | 62 ++++++++++++++++++++++++++++++ src/helpers/state.ts | 1 + tests/json/configuration_form.json | 48 +++++++++++++++++++++++ 3 files changed, 111 insertions(+) diff --git a/src/cards/NewPollFormCard.ts b/src/cards/NewPollFormCard.ts index 4dc50c1..0561d51 100644 --- a/src/cards/NewPollFormCard.ts +++ b/src/cards/NewPollFormCard.ts @@ -28,6 +28,7 @@ export default class NewPollFormCard extends BaseCard { if (this.config.autoClose) { this.buildAutoCloseSection(); } + this.buildMultipleVoteSection(); } buildTopicInputSection() { @@ -159,6 +160,67 @@ export default class NewPollFormCard extends BaseCard { }); } + buildMultipleVoteSection() { + const widgets: chatV1.Schema$GoogleAppsCardV1Widget[] = []; + + const items = [ + { + 'text': 'No Limit', + 'value': '0', + 'selected': false, + }, + { + 'text': '1', + 'value': '1', + 'selected': false, + }, + { + 'text': '2', + 'value': '2', + 'selected': false, + }, + { + 'text': '3', + 'value': '3', + 'selected': false, + }, + { + 'text': '4', + 'value': '4', + 'selected': false, + }, + { + 'text': '5', + 'value': '5', + 'selected': false, + }, + { + 'text': '6', + 'value': '6', + 'selected': false, + }, + ]; + // set selected item + if (this.config.voteLimit !== undefined && items?.[this.config.voteLimit]) { + items[this.config.voteLimit].selected = true; + } else { + items[1].selected = true; + } + widgets.push( + { + 'selectionInput': { + 'type': 'DROPDOWN', + 'label': 'Vote Limit (Max options that can be voted)', + 'name': 'vote_limit', + items, + }, + }); + + this.card.sections!.push({ + widgets, + }); + } + buildHelpText() { return { textParagraph: { diff --git a/src/helpers/state.ts b/src/helpers/state.ts index 34485b5..a7116a0 100644 --- a/src/helpers/state.ts +++ b/src/helpers/state.ts @@ -57,6 +57,7 @@ export function getConfigFromInput(formValues: PollFormInputs) { state.autoMention = getStringInputValue(formValues.auto_mention) === '1'; state.closedTime = parseInt(formValues.close_schedule_time?.dateTimeInput!.msSinceEpoch ?? '0'); state.choices = getChoicesFromInput(formValues); + state.voteLimit = parseInt(getStringInputValue(formValues.vote_limit) || '1'); return state; } diff --git a/tests/json/configuration_form.json b/tests/json/configuration_form.json index a4d8c1a..b9e2f87 100644 --- a/tests/json/configuration_form.json +++ b/tests/json/configuration_form.json @@ -174,6 +174,54 @@ } } ] + }, + { + "widgets": [ + { + "selectionInput": { + "type": "DROPDOWN", + "label": "Vote Limit (Max options that can be voted)", + "name": "vote_limit", + "items": [ + { + "text": "No Limit", + "value": "0", + "selected": false + }, + { + "text": "1", + "value": "1", + "selected": true + }, + { + "text": "2", + "value": "2", + "selected": false + }, + { + "text": "3", + "value": "3", + "selected": false + }, + { + "text": "4", + "value": "4", + "selected": false + }, + { + "text": "5", + "value": "5", + "selected": false + }, + { + "text": "6", + "value": "6", + "selected": false + } + ] + } + } + ] } ], "fixedFooter": { From d4eb6d796710fd56f340801ab2e504d8bea5bd60 Mon Sep 17 00:00:00 2001 From: Yaskur Date: Wed, 13 Mar 2024 05:28:25 +0700 Subject: [PATCH 2/9] feat(MultipleVote): new poll dialog card that used to do multiple votes --- src/cards/PollCard.ts | 8 ++- src/cards/PollDialogCard.ts | 64 ++++++++++++++++++++++++ src/cards/__mocks__/PollDialogCard.ts | 6 +++ src/handlers/ActionHandler.ts | 71 ++++++++++++++++++++++++++- src/handlers/TaskHandler.ts | 1 + src/helpers/interfaces.ts | 1 + src/helpers/state.ts | 14 ++++++ src/helpers/vote.ts | 49 +++++++++++++++--- tests/action-handler.test.ts | 66 +++++++++++++++++++++++++ tests/command-handler.test.ts | 2 +- tests/helpers/state.test.ts | 33 ++++++++++++- tests/json/vote_dialog.json | 53 ++++++++++++++++++++ tests/vote-card.test.ts | 48 ++++++++++++++++++ 13 files changed, 404 insertions(+), 12 deletions(-) create mode 100644 src/cards/PollDialogCard.ts create mode 100644 src/cards/__mocks__/PollDialogCard.ts create mode 100644 tests/json/vote_dialog.json diff --git a/src/cards/PollCard.ts b/src/cards/PollCard.ts index 6fefbdf..2569366 100644 --- a/src/cards/PollCard.ts +++ b/src/cards/PollCard.ts @@ -6,8 +6,8 @@ import {progressBarText} from '../helpers/vote'; import {createButton} from '../helpers/cards'; export default class PollCard extends BaseCard { - private readonly state: PollState; - private readonly timezone: LocaleTimezone; + protected readonly state: PollState; + protected readonly timezone: LocaleTimezone; constructor(state: PollState, timezone: LocaleTimezone) { super(); @@ -190,6 +190,10 @@ export default class PollCard extends BaseCard { }, }, }; + if (this.state.voteLimit !== undefined && this.state.voteLimit !== 1) { + voteButton.onClick!.action!.interaction = 'OPEN_DIALOG'; + voteButton.onClick!.action!.function = 'vote_form'; + } if (this.isClosed()) { voteButton.disabled = true; diff --git a/src/cards/PollDialogCard.ts b/src/cards/PollDialogCard.ts new file mode 100644 index 0000000..fa71f83 --- /dev/null +++ b/src/cards/PollDialogCard.ts @@ -0,0 +1,64 @@ +import PollCard from './PollCard'; +import {LocaleTimezone, PollState, Voter} from '../helpers/interfaces'; +import {chat_v1 as chatV1} from '@googleapis/chat'; +import {progressBarText} from '../helpers/vote'; + +export default class PollDialogCard extends PollCard { + private readonly voter: Voter; + private userVotes: number[] | undefined; + + constructor(state: PollState, timezone: LocaleTimezone, voter: Voter) { + super(state, timezone); + this.voter = voter; + } + create() { + this.buildHeader(); + this.buildSections(); + this.buildButtons(); + this.buildFooter(); + this.card.name = this.getSerializedState(); + return this.card; + } + + getUserVotes(): number[] { + if (this.state.votes === undefined) { + return []; + } + const votes = []; + const voter = this.voter; + for (let i = 0; i < this.state.choices.length; i++) { + if (this.state.votes[i] !== undefined && this.state.votes[i].findIndex((x) => x.uid === voter.uid) > -1) { + votes.push(i); + } + } + return votes; + } + choice(index: number, text: string, voteCount: number, totalVotes: number): chatV1.Schema$GoogleAppsCardV1Widget { + this.userVotes = this.getUserVotes(); + + const progressBar = progressBarText(voteCount, totalVotes); + + const voteSwitch: chatV1.Schema$GoogleAppsCardV1SwitchControl = { + 'controlType': 'SWITCH', + 'name': 'mySwitchControl', + 'value': 'myValue', + 'selected': this.userVotes.includes(index), + 'onChangeAction': { + 'function': 'switch_vote', + 'parameters': [ + { + key: 'index', + value: index.toString(10), + }, + ], + }, + }; + return { + decoratedText: { + 'bottomLabel': `${progressBar} ${voteCount}`, + 'text': text, + 'switchControl': voteSwitch, + }, + }; + } +} diff --git a/src/cards/__mocks__/PollDialogCard.ts b/src/cards/__mocks__/PollDialogCard.ts new file mode 100644 index 0000000..ea705fa --- /dev/null +++ b/src/cards/__mocks__/PollDialogCard.ts @@ -0,0 +1,6 @@ +export const mockCreatePollDialogCard = jest.fn(() => 'card'); +export default jest.fn(() => { + return { + create: mockCreatePollDialogCard, + }; +}); diff --git a/src/handlers/ActionHandler.ts b/src/handlers/ActionHandler.ts index 983c347..f6d2ae1 100644 --- a/src/handlers/ActionHandler.ts +++ b/src/handlers/ActionHandler.ts @@ -1,7 +1,7 @@ import {chat_v1 as chatV1} from '@googleapis/chat'; import BaseHandler from './BaseHandler'; import NewPollFormCard from '../cards/NewPollFormCard'; -import {addOptionToState, getConfigFromInput, getStateFromCard} from '../helpers/state'; +import {addOptionToState, getConfigFromInput, getStateFromCard, getStateFromMessageId} from '../helpers/state'; import {callMessageApi} from '../helpers/api'; import {createDialogActionResponse, createStatusActionResponse} from '../helpers/response'; import PollCard from '../cards/PollCard'; @@ -13,6 +13,7 @@ import ClosePollFormCard from '../cards/ClosePollFormCard'; import MessageDialogCard from '../cards/MessageDialogCard'; import {createAutoCloseTask} from '../helpers/task'; import ScheduleClosePollFormCard from '../cards/ScheduleClosePollFormCard'; +import PollDialogCard from '../cards/PollDialogCard'; /* This list methods are used in the poll chat message @@ -31,6 +32,10 @@ export default class ActionHandler extends BaseHandler implements PollAction { return await this.startPoll(); case 'vote': return this.recordVote(); + case 'switch_vote': + return this.switchVote(); + case 'vote_form': + return this.voteForm(); case 'add_option_form': return this.addOptionForm(); case 'add_option': @@ -131,6 +136,43 @@ export default class ActionHandler extends BaseHandler implements PollAction { }; } + /** + * Handle the custom vote action from poll dialog. Updates the state to record + * the UI will be showed as a dialog + * + * @returns {object} Response to send back to Chat + */ + async switchVote() { + const parameters = this.event.common?.parameters; + if (!(parameters?.['index'])) { + throw new Error('Index Out of Bounds'); + } + const choice = parseInt(parameters['index']); + const userId = this.event.user?.name ?? ''; + const userName = this.event.user?.displayName ?? ''; + const voter: Voter = {uid: userId, name: userName}; + let state; + if (this.event!.message!.name) { + state = await getStateFromMessageId(this.event!.message!.name); + } else { + state = this.getEventPollState(); + } + + + // Add or update the user's selected option + state.votes = saveVotes(choice, voter, state.votes!, state.anon, state.voteLimit); + const cardMessage = new PollCard(state, this.getUserTimezone()).createMessage(); + const request = { + name: this.event!.message!.name, + requestBody: cardMessage, + updateMask: 'cardsV2', + }; + callMessageApi('update', request); + + const card = new PollDialogCard(state, this.getUserTimezone(), voter); + return createDialogActionResponse(card.create()); + } + /** * Opens and starts a dialog that allows users to add details about a contact. * @@ -265,6 +307,33 @@ export default class ActionHandler extends BaseHandler implements PollAction { return createDialogActionResponse(new ScheduleClosePollFormCard(state, this.getUserTimezone()).create()); } + voteForm() { + const parameters = this.event.common?.parameters; + if (!(parameters?.['index'])) { + throw new Error('Index Out of Bounds'); + } + const state = this.getEventPollState(); + const userId = this.event.user?.name ?? ''; + const userName = this.event.user?.displayName ?? ''; + const voter: Voter = {uid: userId, name: userName}; + const choice = parseInt(parameters['index']); + + // Add or update the user's selected option + state.votes = saveVotes(choice, voter, state.votes!, state.anon, state.voteLimit); + + const cardMessage = new PollCard(state, this.getUserTimezone()).createMessage(); + const request = { + name: this.event!.message!.name, + requestBody: cardMessage, + updateMask: 'cardsV2', + }; + // Avoid using await here to allow parallel execution with returning response. + // However, be aware that occasionally the promise might be terminated. + // Although rare, if this becomes a frequent issue, we'll resort to using await. + callMessageApi('update', request); + return createDialogActionResponse(new PollDialogCard(state, this.getUserTimezone(), voter).create()); + } + newPollOnChange() { const formValues: PollFormInputs = this.event.common!.formInputs! as PollFormInputs; const config = getConfigFromInput(formValues); diff --git a/src/handlers/TaskHandler.ts b/src/handlers/TaskHandler.ts index 9a33a3d..6513520 100644 --- a/src/handlers/TaskHandler.ts +++ b/src/handlers/TaskHandler.ts @@ -56,6 +56,7 @@ export default class TaskHandler { const apiResponse = await callMessageApi('get', request); const currentState = getStateFromCardName(apiResponse.data.cardsV2?.[0].card ?? {}); if (!currentState) { + console.log(apiResponse ? JSON.stringify(apiResponse) : 'empty response:' + this.event.id); throw new Error('State not found'); } this.event.space = apiResponse.data.space; diff --git a/src/helpers/interfaces.ts b/src/helpers/interfaces.ts index 8c0525b..4c9e0c8 100644 --- a/src/helpers/interfaces.ts +++ b/src/helpers/interfaces.ts @@ -28,6 +28,7 @@ export interface PollConfig { topic: string, type?: ClosableType, closedTime?: number, + voteLimit?: number, } export interface PollForm extends PollConfig { diff --git a/src/helpers/state.ts b/src/helpers/state.ts index a7116a0..9893cc4 100644 --- a/src/helpers/state.ts +++ b/src/helpers/state.ts @@ -1,6 +1,7 @@ import {ClosableType, PollForm, PollFormInputs, PollState} from './interfaces'; import {chat_v1 as chatV1} from '@googleapis/chat'; import {MAX_NUM_OF_OPTIONS} from '../config/default'; +import {callMessageApi} from './api'; /** * Add a new option to the state(like DB) @@ -80,3 +81,16 @@ function getStateFromParameter(event: chatV1.Schema$DeprecatedEvent) { return parameters?.['state']; } + +export async function getStateFromMessageId(eventId: string): Promise { + const request = { + name: eventId, + }; + const apiResponse = await callMessageApi('get', request); + const currentState = getStateFromCardName(apiResponse.data.cardsV2?.[0].card ?? {}); + if (!currentState) { + console.log(apiResponse ? JSON.stringify(apiResponse) : 'empty response:' + eventId); + throw new Error('State not found'); + } + return JSON.parse(currentState) as PollState; +} diff --git a/src/helpers/vote.ts b/src/helpers/vote.ts index f717129..f71fb9f 100644 --- a/src/helpers/vote.ts +++ b/src/helpers/vote.ts @@ -9,20 +9,55 @@ import {PollState, Voter, Votes} from './interfaces'; * @param {object} voter - The voter * @param {object} votes - Total votes cast in the poll * @param {boolean} isAnonymous - save name or not + * @param {number} maxVotes - save name or not * @returns {Votes} Map of cast votes keyed by choice index */ -export function saveVotes(choice: number, voter: Voter, votes: Votes, isAnonymous = false) { - Object.keys(votes).forEach(function(choiceIndex) { - if (votes[choiceIndex]) { - const existed = votes[choiceIndex].findIndex((x) => x.uid === voter.uid); - if (existed > -1) { - votes[choiceIndex].splice(existed, 1); +export function saveVotes(choice: number, voter: Voter, votes: Votes, isAnonymous = false, maxVotes = 1) { + if (maxVotes === 1) { + Object.keys(votes).forEach(function(choiceIndex) { + if (votes[choiceIndex]) { + const existed = votes[choiceIndex].findIndex((x) => x.uid === voter.uid); + if (existed > -1) { + votes[choiceIndex].splice(existed, 1); + } } + }); + } else { + // get current voter total vote + let voteCount = 0; + let voted = false; + Object.keys(votes).forEach(function(choiceIndex) { + if (votes[choiceIndex]) { + const existed = votes[choiceIndex].findIndex((x) => x.uid === voter.uid); + if (existed > -1) { + voteCount += 1; + } + if (existed > -1 && parseInt(choiceIndex) === choice) { + voted = true; + } + } + }); + if (voteCount >= maxVotes || voted) { + let deleted = false; + Object.keys(votes).forEach(function(choiceIndex) { + if (votes[choiceIndex]) { + const existed = votes[choiceIndex].findIndex((x) => x.uid === voter.uid); + if (((voteCount >= maxVotes && existed > -1 && !voted) || + (voted && parseInt(choiceIndex) === choice)) && !deleted) { + votes[choiceIndex].splice(existed, 1); + deleted = true; + } + } + }); } - }); + if (voted) { + return votes; + } + } if (isAnonymous) { delete voter.name; } + if (votes[choice]) { votes[choice].push(voter); } else { diff --git a/tests/action-handler.test.ts b/tests/action-handler.test.ts index ad8fa02..e879aa8 100644 --- a/tests/action-handler.test.ts +++ b/tests/action-handler.test.ts @@ -17,8 +17,10 @@ import {PROHIBITED_ICON_URL} from '../src/config/default'; import MessageDialogCard from '../src/cards/MessageDialogCard'; import {dummyLocalTimezone} from './dummy'; import {DEFAULT_LOCALE_TIMEZONE} from '../src/helpers/time'; +import PollDialogCard, {mockCreatePollDialogCard} from '../src/cards/PollDialogCard'; jest.mock('../src/cards/PollCard'); +jest.mock('../src/cards/PollDialogCard'); jest.mock('../src/cards/ClosePollFormCard'); jest.mock('../src/cards/ScheduleClosePollFormCard'); @@ -673,3 +675,67 @@ it('should update message if close_schedule_time is correct', async () => { expect(state.closedTime).toEqual(ms - dummyLocalTimezone.offset); // todo: create task toHaveBeenCalled }); + + +it('voteForm action', () => { + const state = { + type: ClosableType.CLOSEABLE_BY_CREATOR, + author: {name: 'creator'}, + votes: {}, + }; + const event = { + user: {name: '1123124124124', displayName: 'creator'}, + common: { + parameters: { + index: '1', + }, + timeZone: {'id': dummyLocalTimezone.id, 'offset': dummyLocalTimezone.offset}, + userLocale: dummyLocalTimezone.locale, + }, + message: { + thread: { + 'name': 'spaces/AAAAN0lf83o/threads/DJXfo5DXcTA', + }, + cardsV2: [{cardId: 'card', card: {}}], + }, + }; + const actionHandler = new ActionHandler(event); + actionHandler.getEventPollState = jest.fn().mockReturnValue(state); + // Act + actionHandler.voteForm(); + expect(PollCard).toHaveBeenCalledWith(state, dummyLocalTimezone); + expect(PollDialogCard).toHaveBeenCalledWith(state, dummyLocalTimezone, {name: 'creator', uid: '1123124124124'}); + expect(mockCreatePollDialogCard).toHaveBeenCalled(); +}); + + +it('switchVote action', () => { + const state = { + type: ClosableType.CLOSEABLE_BY_CREATOR, + author: {name: 'creator'}, + votes: {}, + }; + const event = { + user: {name: '1123124124124', displayName: 'creator'}, + common: { + parameters: { + index: '1', + }, + timeZone: {'id': dummyLocalTimezone.id, 'offset': dummyLocalTimezone.offset}, + userLocale: dummyLocalTimezone.locale, + }, + message: { + thread: { + 'name': 'spaces/AAAAN0lf83o/threads/DJXfo5DXcTA', + }, + cardsV2: [{cardId: 'card', card: {}}], + }, + }; + const actionHandler = new ActionHandler(event); + actionHandler.getEventPollState = jest.fn().mockReturnValue(state); + // Act + actionHandler.switchVote(); + expect(PollCard).toHaveBeenCalledWith(state, dummyLocalTimezone); + expect(PollDialogCard).toHaveBeenCalledWith(state, dummyLocalTimezone, {name: 'creator', uid: '1123124124124'}); + expect(mockCreatePollDialogCard).toHaveBeenCalled(); +}); diff --git a/tests/command-handler.test.ts b/tests/command-handler.test.ts index 8beb40c..abf7cc7 100644 --- a/tests/command-handler.test.ts +++ b/tests/command-handler.test.ts @@ -124,7 +124,7 @@ describe('process command from google chat message event', () => { }).create(); expect(expectedCard).toEqual(result.actionResponse.dialogAction.dialog.body); expect(expectedCard.fixedFooter.primaryButton.text).toEqual('Submit'); - expect(expectedCard.sections.length).toEqual(3); + expect(expectedCard.sections.length).toEqual(4); }); it('should limit the number of options to 10', () => { diff --git a/tests/helpers/state.test.ts b/tests/helpers/state.test.ts index 58f0acf..1a2bf04 100644 --- a/tests/helpers/state.test.ts +++ b/tests/helpers/state.test.ts @@ -1,6 +1,7 @@ // Generated by CodiumAI -import {getStateFromCard} from '../../src/helpers/state'; +import {getStateFromCard, getStateFromMessageId} from '../../src/helpers/state'; +import * as api from '../../src/helpers/api'; describe('getStateFromCard', () => { it('should return the state from the card name when it exists', () => { @@ -131,4 +132,34 @@ describe('getStateFromCard', () => { const result = getStateFromCard(event); expect(result).toBeUndefined(); }); + + it('should return the current state when it exists in the response from callMessageApi', async () => { + const eventId = 'exampleEventId'; + const apiResponse = { + data: { + cardsV2: [ + { + card: { + name: '{}', + sections: [], + widgets: [], + }, + }, + ], + }, + }; + jest.spyOn(api, 'callMessageApi').mockResolvedValue(apiResponse); + + const result = await getStateFromMessageId(eventId); + + expect(result).toEqual({}); + }); + + it('should handle apiResponse with empty cardsV2 array and throw an error', async () => { + const eventId = 'testEventId'; + const apiResponse = {status: 200, data: {cardsV2: null}}; + jest.spyOn(api, 'callMessageApi').mockResolvedValue(apiResponse); + + await expect(getStateFromMessageId(eventId)).rejects.toThrow('State not found'); + }); }); diff --git a/tests/json/vote_dialog.json b/tests/json/vote_dialog.json new file mode 100644 index 0000000..8a32754 --- /dev/null +++ b/tests/json/vote_dialog.json @@ -0,0 +1,53 @@ +{ + "header": { + "title": "What is the most beautiful worms?", + "subtitle": "Posted by Dyas Yaskur", + "imageUrl": "https://raw.githubusercontent.com/dyaskur/google-chat-poll/master/assets/logo48x48.png", + "imageType": "CIRCLE" + }, + "sections": [ + { + "widgets": [ + { + "textParagraph": { + "text": "" + } + }, + { + "selectionInput": { + "name": "options", + "label": "Select option(s)", + "type": "SWITCH", + "items": [ + { + "text": "Feather Duster Worm", + "value": "0", + "selected": false + }, + { + "text": "Christmas Tree Worm", + "value": "1", + "selected": true + }, + { + "text": "Coco Worm", + "value": "2", + "selected": false + }, + { + "text": "Bearded Fireworm", + "value": "3", + "selected": false + }, + { + "text": "Giant Tube Worm", + "value": "3", + "selected": false + } + ] + } + } + ] + } + ] +} diff --git a/tests/vote-card.test.ts b/tests/vote-card.test.ts index 3ffeaa9..6db5c76 100644 --- a/tests/vote-card.test.ts +++ b/tests/vote-card.test.ts @@ -71,6 +71,54 @@ test('test save voter anonymously', () => { '4': [{uid: 'users/103846892623842357554'}], }); }); +test('test save voter multiple vote allowed', () => { + const voter = {uid: 'users/103846892623842357554', name: 'Muhammad'}; + const votes: Votes = { + '0': [], + '1': [], + '2': [], + '3': [], + }; + const voterResult = saveVotes(2, voter, votes, true, 2); + expect(voterResult).toStrictEqual({ + '0': [], + '1': [], + '2': [ + {uid: 'users/103846892623842357554'}, + ], + '3': [], + }); + + const voterResult2 = saveVotes(4, voter, votes, true, 2); + + expect(voterResult2).toStrictEqual({ + '0': [], + '1': [], + '2': [{uid: 'users/103846892623842357554'}], + '3': [], + '4': [{uid: 'users/103846892623842357554'}], + }); + + const voterResult3 = saveVotes(3, voter, votes, true, 2); + + expect(voterResult3).toStrictEqual({ + '0': [], + '1': [], + '2': [], + '3': [{uid: 'users/103846892623842357554'}], + '4': [{uid: 'users/103846892623842357554'}], + }); + + const voterResult4 = saveVotes(3, voter, votes, true, 2); + + expect(voterResult4).toStrictEqual({ + '0': [], + '1': [], + '2': [], + '3': [], + '4': [{uid: 'users/103846892623842357554'}], + }); +}); test('build progress bar text', () => { const progressBar = progressBarText(2, 4); From 0aafca837df73479396ad5c146ea0cb12f381ffa Mon Sep 17 00:00:00 2001 From: Yaskur Date: Wed, 13 Mar 2024 06:16:03 +0700 Subject: [PATCH 3/9] feat(MultipleVote): improve logic and unit tests --- src/handlers/ActionHandler.ts | 31 ++++--------------------------- tests/action-handler.test.ts | 29 +++++++---------------------- tests/cards/poll-card.test.ts | 13 +++++++++++++ 3 files changed, 24 insertions(+), 49 deletions(-) diff --git a/src/handlers/ActionHandler.ts b/src/handlers/ActionHandler.ts index f6d2ae1..2ba816c 100644 --- a/src/handlers/ActionHandler.ts +++ b/src/handlers/ActionHandler.ts @@ -142,7 +142,7 @@ export default class ActionHandler extends BaseHandler implements PollAction { * * @returns {object} Response to send back to Chat */ - async switchVote() { + async switchVote(eventPollState: boolean=false) { const parameters = this.event.common?.parameters; if (!(parameters?.['index'])) { throw new Error('Index Out of Bounds'); @@ -152,7 +152,7 @@ export default class ActionHandler extends BaseHandler implements PollAction { const userName = this.event.user?.displayName ?? ''; const voter: Voter = {uid: userId, name: userName}; let state; - if (this.event!.message!.name) { + if (!eventPollState && this.event!.message!.name) { state = await getStateFromMessageId(this.event!.message!.name); } else { state = this.getEventPollState(); @@ -307,31 +307,8 @@ export default class ActionHandler extends BaseHandler implements PollAction { return createDialogActionResponse(new ScheduleClosePollFormCard(state, this.getUserTimezone()).create()); } - voteForm() { - const parameters = this.event.common?.parameters; - if (!(parameters?.['index'])) { - throw new Error('Index Out of Bounds'); - } - const state = this.getEventPollState(); - const userId = this.event.user?.name ?? ''; - const userName = this.event.user?.displayName ?? ''; - const voter: Voter = {uid: userId, name: userName}; - const choice = parseInt(parameters['index']); - - // Add or update the user's selected option - state.votes = saveVotes(choice, voter, state.votes!, state.anon, state.voteLimit); - - const cardMessage = new PollCard(state, this.getUserTimezone()).createMessage(); - const request = { - name: this.event!.message!.name, - requestBody: cardMessage, - updateMask: 'cardsV2', - }; - // Avoid using await here to allow parallel execution with returning response. - // However, be aware that occasionally the promise might be terminated. - // Although rare, if this becomes a frequent issue, we'll resort to using await. - callMessageApi('update', request); - return createDialogActionResponse(new PollDialogCard(state, this.getUserTimezone(), voter).create()); + async voteForm() { + return await this.switchVote(true); } newPollOnChange() { diff --git a/tests/action-handler.test.ts b/tests/action-handler.test.ts index e879aa8..9ed1bca 100644 --- a/tests/action-handler.test.ts +++ b/tests/action-handler.test.ts @@ -709,33 +709,18 @@ it('voteForm action', () => { }); -it('switchVote action', () => { - const state = { - type: ClosableType.CLOSEABLE_BY_CREATOR, - author: {name: 'creator'}, - votes: {}, - }; +it('switchVote action', async () => { + const event = { - user: {name: '1123124124124', displayName: 'creator'}, common: { parameters: { - index: '1', }, - timeZone: {'id': dummyLocalTimezone.id, 'offset': dummyLocalTimezone.offset}, - userLocale: dummyLocalTimezone.locale, - }, - message: { - thread: { - 'name': 'spaces/AAAAN0lf83o/threads/DJXfo5DXcTA', - }, - cardsV2: [{cardId: 'card', card: {}}], }, }; const actionHandler = new ActionHandler(event); - actionHandler.getEventPollState = jest.fn().mockReturnValue(state); - // Act - actionHandler.switchVote(); - expect(PollCard).toHaveBeenCalledWith(state, dummyLocalTimezone); - expect(PollDialogCard).toHaveBeenCalledWith(state, dummyLocalTimezone, {name: 'creator', uid: '1123124124124'}); - expect(mockCreatePollDialogCard).toHaveBeenCalled(); + + await expect(async () => { + await actionHandler.voteForm(); + }).rejects.toThrowError('Index Out of Bounds'); + }); diff --git a/tests/cards/poll-card.test.ts b/tests/cards/poll-card.test.ts index ccb3e66..eb10088 100644 --- a/tests/cards/poll-card.test.ts +++ b/tests/cards/poll-card.test.ts @@ -16,6 +16,19 @@ describe('PollCard', () => { expect(result).toBeDefined(); expect(result.sections).toBeDefined(); }); + it('should return dialog interaction if voteLimit is set more than 1', () => { + const state: PollState = { + topic: 'Test Topic', + choices: ['Choice 1', 'Choice 2'], + votes: {}, + voteLimit: 2, + }; + const pollCard = new PollCard(state, dummyLocalTimezone); + const result = pollCard.create(); + expect(result).toBeDefined(); + expect(result.sections).toBeDefined(); + expect(result.sections![0].widgets![0].decoratedText.button.onClick.action.interaction).toEqual('OPEN_DIALOG'); + }); it('should add a header to the card when the topic is less than or equal to 40 characters', () => { const state: PollState = { From aa0d34204cc7b9876f60078a5c9e74aace2fb973 Mon Sep 17 00:00:00 2001 From: Yaskur Date: Wed, 13 Mar 2024 06:21:47 +0700 Subject: [PATCH 4/9] chore(eslint): fix eslint problem --- src/handlers/ActionHandler.ts | 2 +- tests/action-handler.test.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/handlers/ActionHandler.ts b/src/handlers/ActionHandler.ts index 2ba816c..ca21b2c 100644 --- a/src/handlers/ActionHandler.ts +++ b/src/handlers/ActionHandler.ts @@ -139,7 +139,7 @@ export default class ActionHandler extends BaseHandler implements PollAction { /** * Handle the custom vote action from poll dialog. Updates the state to record * the UI will be showed as a dialog - * + * @param {boolean} eventPollState If true, the event state is from current event instead of calling API to get it * @returns {object} Response to send back to Chat */ async switchVote(eventPollState: boolean=false) { diff --git a/tests/action-handler.test.ts b/tests/action-handler.test.ts index 9ed1bca..2694e6e 100644 --- a/tests/action-handler.test.ts +++ b/tests/action-handler.test.ts @@ -710,7 +710,6 @@ it('voteForm action', () => { it('switchVote action', async () => { - const event = { common: { parameters: { @@ -722,5 +721,4 @@ it('switchVote action', async () => { await expect(async () => { await actionHandler.voteForm(); }).rejects.toThrowError('Index Out of Bounds'); - }); From d34c8f5d3fa8fd3e4fb0a09f7b022bc3e74b5fd3 Mon Sep 17 00:00:00 2001 From: Yaskur Date: Wed, 13 Mar 2024 06:27:03 +0700 Subject: [PATCH 5/9] build(ci): add node 19.x again --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 46227ef..5bb0da5 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x] + node-version: [18.x, 19.x, 20.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: From a8514b6f9e9cba8ba5151259c390aa6335b04082 Mon Sep 17 00:00:00 2001 From: Yaskur Date: Wed, 13 Mar 2024 06:28:05 +0700 Subject: [PATCH 6/9] build(ci): remove node 19.x again --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 5bb0da5..46227ef 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [18.x, 19.x, 20.x] + node-version: [18.x, 20.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: From e43340e9e47e9629467a3c7ae9ac66e49fcc3de7 Mon Sep 17 00:00:00 2001 From: Yaskur Date: Wed, 13 Mar 2024 07:47:23 +0700 Subject: [PATCH 7/9] feat: add info box in the poll message and dialog --- src/cards/PollCard.ts | 21 +++++++++++++++++++++ src/cards/PollDialogCard.ts | 34 +++++++++++++++++++++++----------- tests/action-handler.test.ts | 17 +++++++++++++++++ tests/cards/poll-card.test.ts | 2 +- 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/src/cards/PollCard.ts b/src/cards/PollCard.ts index 2569366..3abbe4f 100644 --- a/src/cards/PollCard.ts +++ b/src/cards/PollCard.ts @@ -31,6 +31,14 @@ export default class PollCard extends BaseCard { } else { this.card.header = this.cardHeader(); } + this.buildInfoSection(); + } + + buildInfoSection() { + if (this.state.voteLimit === 0 || (this.state.voteLimit && this.state.voteLimit > 1)) { + const widgetHeader = this.sectionInfo(); + this.card.sections!.push(widgetHeader); + } } getAuthorName() { @@ -68,6 +76,19 @@ export default class PollCard extends BaseCard { ], }; } + sectionInfo(): chatV1.Schema$GoogleAppsCardV1Section { + return { + widgets: [ + { + 'decoratedText': { + 'text': '', + 'wrapText': true, + 'topLabel': `This poll allow multiple votes. Max Votes: ${this.state.voteLimit || 'No limit'}`, + }, + }, + ], + }; + } buildSections() { const votes: Array> = Object.values(this.state.votes ?? {}); diff --git a/src/cards/PollDialogCard.ts b/src/cards/PollDialogCard.ts index fa71f83..bf79456 100644 --- a/src/cards/PollDialogCard.ts +++ b/src/cards/PollDialogCard.ts @@ -5,19 +5,12 @@ import {progressBarText} from '../helpers/vote'; export default class PollDialogCard extends PollCard { private readonly voter: Voter; - private userVotes: number[] | undefined; + private readonly userVotes: number[]; constructor(state: PollState, timezone: LocaleTimezone, voter: Voter) { super(state, timezone); this.voter = voter; - } - create() { - this.buildHeader(); - this.buildSections(); - this.buildButtons(); - this.buildFooter(); - this.card.name = this.getSerializedState(); - return this.card; + this.userVotes = this.getUserVotes(); } getUserVotes(): number[] { @@ -33,9 +26,28 @@ export default class PollDialogCard extends PollCard { } return votes; } - choice(index: number, text: string, voteCount: number, totalVotes: number): chatV1.Schema$GoogleAppsCardV1Widget { - this.userVotes = this.getUserVotes(); + sectionInfo(): chatV1.Schema$GoogleAppsCardV1Section { + const votedCount = this.userVotes.length; + const voteLimit = this.state.voteLimit || this.state.choices.length+1; + const voteRemaining = voteLimit - votedCount; + let warningMessage = ''; + if (voteRemaining === 0) { + warningMessage = 'Vote limit reached. Your vote will be overwritten.'; + } + return { + widgets: [ + { + 'decoratedText': { + 'text': `You have voted: ${votedCount} out of ${voteLimit} (remaining: ${voteRemaining})`, + 'wrapText': true, + 'bottomLabel': warningMessage, + }, + }, + ], + }; + } + choice(index: number, text: string, voteCount: number, totalVotes: number): chatV1.Schema$GoogleAppsCardV1Widget { const progressBar = progressBarText(voteCount, totalVotes); const voteSwitch: chatV1.Schema$GoogleAppsCardV1SwitchControl = { diff --git a/tests/action-handler.test.ts b/tests/action-handler.test.ts index 2694e6e..749a420 100644 --- a/tests/action-handler.test.ts +++ b/tests/action-handler.test.ts @@ -255,6 +255,23 @@ describe('process', () => { expect(closePollFormMock).toHaveBeenCalled(); }); + it('other actions"', async () => { + const switchVoteMock = jest.fn().mockReturnValue({}); + const voteFormMock = jest.fn().mockReturnValue({}); + + const actionHandler = new ActionHandler({common: {invokedFunction: 'switch_vote'}}); + actionHandler.switchVote = switchVoteMock; + await actionHandler.process(); + + const actionHandler2 = new ActionHandler({common: {invokedFunction: 'vote_form'}}); + actionHandler2.voteForm = voteFormMock; + await actionHandler2.process(); + + + expect(switchVoteMock).toHaveBeenCalled(); + expect(voteFormMock).toHaveBeenCalled(); + }); + // Tests that the 'unknown' action returns a message with an updated poll card it('should return a message with an updated poll card when the action is "add_option"', async () => { // Create an instance of ActionHandler diff --git a/tests/cards/poll-card.test.ts b/tests/cards/poll-card.test.ts index eb10088..413a9ac 100644 --- a/tests/cards/poll-card.test.ts +++ b/tests/cards/poll-card.test.ts @@ -27,7 +27,7 @@ describe('PollCard', () => { const result = pollCard.create(); expect(result).toBeDefined(); expect(result.sections).toBeDefined(); - expect(result.sections![0].widgets![0].decoratedText.button.onClick.action.interaction).toEqual('OPEN_DIALOG'); + expect(result.sections![1].widgets![0].decoratedText.button.onClick.action.interaction).toEqual('OPEN_DIALOG'); }); it('should add a header to the card when the topic is less than or equal to 40 characters', () => { From 4f65937b6aac99e13bf380ce870b5829a8d5ef0f Mon Sep 17 00:00:00 2001 From: Yaskur Date: Wed, 13 Mar 2024 08:08:43 +0700 Subject: [PATCH 8/9] refactor: simplify saveVotes params --- src/cards/PollDialogCard.ts | 2 +- src/handlers/ActionHandler.ts | 4 ++-- src/helpers/vote.ts | 9 +++++---- tests/vote-card.test.ts | 31 ++++++++++++++++++++++--------- 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/cards/PollDialogCard.ts b/src/cards/PollDialogCard.ts index bf79456..ede6eb1 100644 --- a/src/cards/PollDialogCard.ts +++ b/src/cards/PollDialogCard.ts @@ -29,7 +29,7 @@ export default class PollDialogCard extends PollCard { sectionInfo(): chatV1.Schema$GoogleAppsCardV1Section { const votedCount = this.userVotes.length; - const voteLimit = this.state.voteLimit || this.state.choices.length+1; + const voteLimit = this.state.voteLimit || this.state.choices.length; const voteRemaining = voteLimit - votedCount; let warningMessage = ''; if (voteRemaining === 0) { diff --git a/src/handlers/ActionHandler.ts b/src/handlers/ActionHandler.ts index ca21b2c..f0c6e25 100644 --- a/src/handlers/ActionHandler.ts +++ b/src/handlers/ActionHandler.ts @@ -125,7 +125,7 @@ export default class ActionHandler extends BaseHandler implements PollAction { const state = this.getEventPollState(); // Add or update the user's selected option - state.votes = saveVotes(choice, voter, state.votes!, state.anon); + state.votes = saveVotes(choice, voter, state); const card = new PollCard(state, this.getUserTimezone()); return { thread: this.event.message?.thread, @@ -160,7 +160,7 @@ export default class ActionHandler extends BaseHandler implements PollAction { // Add or update the user's selected option - state.votes = saveVotes(choice, voter, state.votes!, state.anon, state.voteLimit); + state.votes = saveVotes(choice, voter, state); const cardMessage = new PollCard(state, this.getUserTimezone()).createMessage(); const request = { name: this.event!.message!.name, diff --git a/src/helpers/vote.ts b/src/helpers/vote.ts index f71fb9f..1ff49c0 100644 --- a/src/helpers/vote.ts +++ b/src/helpers/vote.ts @@ -7,12 +7,13 @@ import {PollState, Voter, Votes} from './interfaces'; * * @param {number} choice - The choice index * @param {object} voter - The voter - * @param {object} votes - Total votes cast in the poll - * @param {boolean} isAnonymous - save name or not - * @param {number} maxVotes - save name or not + * @param {PollState} state - PollState * @returns {Votes} Map of cast votes keyed by choice index */ -export function saveVotes(choice: number, voter: Voter, votes: Votes, isAnonymous = false, maxVotes = 1) { +export function saveVotes(choice: number, voter: Voter, state: PollState) { + const votes: Votes = state.votes!; + const isAnonymous = state.anon || false; + const maxVotes = state.voteLimit === 0 ? state.choices.length : state.voteLimit || 1; if (maxVotes === 1) { Object.keys(votes).forEach(function(choiceIndex) { if (votes[choiceIndex]) { diff --git a/tests/vote-card.test.ts b/tests/vote-card.test.ts index 6db5c76..77f7d01 100644 --- a/tests/vote-card.test.ts +++ b/tests/vote-card.test.ts @@ -1,7 +1,7 @@ import {dummyPollState} from './dummy'; // @ts-ignore: unreasonable error import {saveVotes, choiceSection, progressBarText} from '../src/helpers/vote'; -import {Votes} from '../src/helpers/interfaces'; +import {PollState, Votes} from '../src/helpers/interfaces'; test('test save voter', () => { const voter = {uid: 'users/103846892623842357554', name: 'Muhammad'}; @@ -15,7 +15,10 @@ test('test save voter', () => { {uid: 'users/222423423523532523532', name: 'Ammar'}, ], }; - const voterResult = saveVotes(2, voter, votes); + const state: PollState = { + votes, + }; + const voterResult = saveVotes(2, voter, state); expect(voterResult).toStrictEqual({ '0': [], '1': [], @@ -29,7 +32,7 @@ test('test save voter', () => { ], }); - const voterResult2 = saveVotes(1, voter, votes); + const voterResult2 = saveVotes(1, voter, state); expect(voterResult2).toStrictEqual({ '0': [], @@ -51,7 +54,12 @@ test('test save voter anonymously', () => { '2': [], '3': [], }; - const voterResult = saveVotes(2, voter, votes, true); + + const state: PollState = { + votes, + anon: true, + }; + const voterResult = saveVotes(2, voter, state); expect(voterResult).toStrictEqual({ '0': [], '1': [], @@ -61,7 +69,7 @@ test('test save voter anonymously', () => { '3': [], }); - const voterResult2 = saveVotes(4, voter, votes, true); + const voterResult2 = saveVotes(4, voter, state); expect(voterResult2).toStrictEqual({ '0': [], @@ -79,7 +87,12 @@ test('test save voter multiple vote allowed', () => { '2': [], '3': [], }; - const voterResult = saveVotes(2, voter, votes, true, 2); + const state: PollState = { + voteLimit: 2, + votes, + anon: true, + }; + const voterResult = saveVotes(2, voter, state); expect(voterResult).toStrictEqual({ '0': [], '1': [], @@ -89,7 +102,7 @@ test('test save voter multiple vote allowed', () => { '3': [], }); - const voterResult2 = saveVotes(4, voter, votes, true, 2); + const voterResult2 = saveVotes(4, voter, state); expect(voterResult2).toStrictEqual({ '0': [], @@ -99,7 +112,7 @@ test('test save voter multiple vote allowed', () => { '4': [{uid: 'users/103846892623842357554'}], }); - const voterResult3 = saveVotes(3, voter, votes, true, 2); + const voterResult3 = saveVotes(3, voter, state); expect(voterResult3).toStrictEqual({ '0': [], @@ -109,7 +122,7 @@ test('test save voter multiple vote allowed', () => { '4': [{uid: 'users/103846892623842357554'}], }); - const voterResult4 = saveVotes(3, voter, votes, true, 2); + const voterResult4 = saveVotes(3, voter, state); expect(voterResult4).toStrictEqual({ '0': [], From e52a3dc2cc9bc4293e1fa69a83efb1c98924e6d5 Mon Sep 17 00:00:00 2001 From: Yaskur Date: Thu, 14 Mar 2024 03:21:23 +0700 Subject: [PATCH 9/9] fix: long option is truncated with ellipsis, fix #52 --- src/cards/PollCard.ts | 1 + src/cards/PollDialogCard.ts | 1 + tests/json/vote_card.json | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/src/cards/PollCard.ts b/src/cards/PollCard.ts index 3abbe4f..cc4d28e 100644 --- a/src/cards/PollCard.ts +++ b/src/cards/PollCard.ts @@ -223,6 +223,7 @@ export default class PollCard extends BaseCard { decoratedText: { bottomLabel: `${progressBar} ${voteCount}`, text: text, + wrapText: true, button: voteButton, }, }; diff --git a/src/cards/PollDialogCard.ts b/src/cards/PollDialogCard.ts index ede6eb1..642192b 100644 --- a/src/cards/PollDialogCard.ts +++ b/src/cards/PollDialogCard.ts @@ -69,6 +69,7 @@ export default class PollDialogCard extends PollCard { decoratedText: { 'bottomLabel': `${progressBar} ${voteCount}`, 'text': text, + 'wrapText': true, 'switchControl': voteSwitch, }, }; diff --git a/tests/json/vote_card.json b/tests/json/vote_card.json index 968ee2d..8d6531d 100644 --- a/tests/json/vote_card.json +++ b/tests/json/vote_card.json @@ -13,6 +13,7 @@ "decoratedText": { "bottomLabel": " 0", "text": "Feather Duster Worm", + "wrapText": true, "button": { "text": "vote", "onClick": { @@ -37,6 +38,7 @@ "decoratedText": { "bottomLabel": " 0", "text": "Christmas Tree Worm", + "wrapText": true, "button": { "text": "vote", "onClick": { @@ -63,6 +65,7 @@ "decoratedText": { "bottomLabel": "██████████████████ 2", "text": "Coco Worm", + "wrapText": true, "button": { "text": "vote", "onClick": { @@ -94,6 +97,7 @@ "decoratedText": { "bottomLabel": "██████████████████ 2", "text": "Bearded Fireworm", + "wrapText": true, "button": { "text": "vote", "onClick": { @@ -123,6 +127,7 @@ "decoratedText": { "bottomLabel": " 0", "text": "Giant Tube Worm", + "wrapText": true, "button": { "text": "vote", "onClick": {