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

feat(MultipleVote): create a config input for the vote limit #67

Merged
merged 8 commits into from
Mar 13, 2024
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
62 changes: 62 additions & 0 deletions src/cards/NewPollFormCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default class NewPollFormCard extends BaseCard {
if (this.config.autoClose) {
this.buildAutoCloseSection();
}
this.buildMultipleVoteSection();
}

buildTopicInputSection() {
Expand Down Expand Up @@ -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: {
Expand Down
29 changes: 27 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 All @@ -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() {
Expand Down Expand Up @@ -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<Array<Voter>> = Object.values(this.state.votes ?? {});
Expand Down Expand Up @@ -190,6 +211,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
76 changes: 76 additions & 0 deletions src/cards/PollDialogCard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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 readonly userVotes: number[];

constructor(state: PollState, timezone: LocaleTimezone, voter: Voter) {
super(state, timezone);
this.voter = voter;
this.userVotes = this.getUserVotes();
}

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;
}

sectionInfo(): chatV1.Schema$GoogleAppsCardV1Section {
const votedCount = this.userVotes.length;
const voteLimit = this.state.voteLimit || this.state.choices.length;
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 = {
'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,
};
});
50 changes: 48 additions & 2 deletions 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 @@ -120,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,
Expand All @@ -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
* @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) {
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 (!eventPollState && 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);
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,10 @@ export default class ActionHandler extends BaseHandler implements PollAction {
return createDialogActionResponse(new ScheduleClosePollFormCard(state, this.getUserTimezone()).create());
}

async voteForm() {
return await this.switchVote(true);
}

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
15 changes: 15 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 @@ -57,6 +58,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;
}

Expand All @@ -79,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;
}
Loading
Loading