Skip to content

Commit

Permalink
feat(MultipleVote): new poll dialog card that used to do multiple votes
Browse files Browse the repository at this point in the history
  • Loading branch information
Yaskur committed Mar 12, 2024
1 parent c07aed9 commit d4eb6d7
Show file tree
Hide file tree
Showing 13 changed files with 404 additions and 12 deletions.
8 changes: 6 additions & 2 deletions src/cards/PollCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
64 changes: 64 additions & 0 deletions src/cards/PollDialogCard.ts
Original file line number Diff line number Diff line change
@@ -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,
},
};
}
}
6 changes: 6 additions & 0 deletions src/cards/__mocks__/PollDialogCard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const mockCreatePollDialogCard = jest.fn(() => 'card');
export default jest.fn(() => {
return {
create: mockCreatePollDialogCard,
};
});
71 changes: 70 additions & 1 deletion src/handlers/ActionHandler.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -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':
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/handlers/TaskHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/helpers/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface PollConfig {
topic: string,
type?: ClosableType,
closedTime?: number,
voteLimit?: number,
}

export interface PollForm extends PollConfig {
Expand Down
14 changes: 14 additions & 0 deletions src/helpers/state.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -80,3 +81,16 @@ function getStateFromParameter(event: chatV1.Schema$DeprecatedEvent) {

return parameters?.['state'];
}

export async function getStateFromMessageId(eventId: string): Promise<PollState> {
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;
}
49 changes: 42 additions & 7 deletions src/helpers/vote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
66 changes: 66 additions & 0 deletions tests/action-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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();
});
2 changes: 1 addition & 1 deletion tests/command-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading

0 comments on commit d4eb6d7

Please sign in to comment.