diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index 4a57bd1482a..21f7be42725 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -84,6 +84,7 @@ export const mockedCapabilities: Capabilities = { 'chat-reference-id', 'mention-permissions', 'edit-messages-note-to-self', + 'talk-polls-drafts', ], 'features-local': [ 'favorites', diff --git a/src/constants.js b/src/constants.js index ec8fb232c93..9dccdb9b350 100644 --- a/src/constants.js +++ b/src/constants.js @@ -242,6 +242,7 @@ export const POLL = { STATUS: { OPEN: 0, CLOSED: 1, + DRAFT: 2, }, MODE: { diff --git a/src/services/pollService.ts b/src/services/pollService.ts index 60ed202c755..d5d01541d8d 100644 --- a/src/services/pollService.ts +++ b/src/services/pollService.ts @@ -8,8 +8,11 @@ import { generateOcsUrl } from '@nextcloud/router' import type { closePollResponse, + createPollDraftResponse, createPollParams, createPollResponse, + deletePollDraftResponse, + getPollDraftsResponse, getPollResponse, votePollParams, votePollResponse, @@ -31,9 +34,35 @@ const createPoll = async ({ token, question, options, resultMode, maxVotes }: cr options, resultMode, maxVotes, + draft: false, + } as createPollParams) +} + +/** + * @param payload The payload + * @param payload.token The conversation token + * @param payload.question The question of the poll + * @param payload.options The options participants can vote for + * @param payload.resultMode Result mode of the poll (0 - always visible | 1 - hidden until the poll is closed) + * @param payload.maxVotes Maximum amount of options a user can vote for (0 - unlimited | 1 - single answer) + */ +const createPollDraft = async ({ token, question, options, resultMode, maxVotes }: createPollPayload): createPollDraftResponse => { + return axios.post(generateOcsUrl('apps/spreed/api/v1/poll/{token}', { token }), { + question, + options, + resultMode, + maxVotes, + draft: true, } as createPollParams) } +/** + * @param token The conversation token + */ +const getPollDrafts = async (token: string): getPollDraftsResponse => { + return axios.get(generateOcsUrl('apps/spreed/api/v1/poll/{token}/drafts', { token })) +} + /** * @param token The conversation token * @param pollId Id of the poll @@ -60,10 +89,20 @@ const submitVote = async (token: string, pollId: string, optionIds: votePollPara const endPoll = async (token: string, pollId: string): closePollResponse => { return axios.delete(generateOcsUrl('apps/spreed/api/v1/poll/{token}/{pollId}', { token, pollId })) } +/** + * @param token The conversation token + * @param pollId Id of the poll draft + */ +const deletePollDraft = async (token: string, pollId: string): deletePollDraftResponse => { + return axios.delete(generateOcsUrl('apps/spreed/api/v1/poll/{token}/{pollId}', { token, pollId })) +} export { createPoll, + createPollDraft, + getPollDrafts, getPollData, submitVote, endPoll, + deletePollDraft, } diff --git a/src/stores/__tests__/polls.spec.js b/src/stores/__tests__/polls.spec.js index 1ebd24befec..4704a0086f6 100644 --- a/src/stores/__tests__/polls.spec.js +++ b/src/stores/__tests__/polls.spec.js @@ -8,7 +8,9 @@ import { setActivePinia, createPinia } from 'pinia' import { ATTENDEE } from '../../constants.js' import { createPoll, + createPollDraft, getPollData, + getPollDrafts, submitVote, endPoll, } from '../../services/pollService.ts' @@ -17,9 +19,12 @@ import { usePollsStore } from '../polls.ts' jest.mock('../../services/pollService', () => ({ createPoll: jest.fn(), + createPollDraft: jest.fn(), getPollData: jest.fn(), + getPollDrafts: jest.fn(), submitVote: jest.fn(), endPoll: jest.fn(), + deletePollDraft: jest.fn(), })) describe('pollsStore', () => { @@ -31,18 +36,23 @@ describe('pollsStore', () => { resultMode: 0, maxVotes: 1, } + const pollDraft = { + ...pollRequest, + status: 2, + id: 1, + actorType: ATTENDEE.ACTOR_TYPE.USERS, + actorId: 'user', + actorDisplayName: 'User', + } const poll = { + ...pollRequest, id: 1, - question: 'What is the answer to the universe?', - options: ['42', '24'], votes: [], numVoters: 0, actorType: ATTENDEE.ACTOR_TYPE.USERS, actorId: 'user', actorDisplayName: 'User', status: 0, - resultMode: 0, - maxVotes: 1, votedSelf: [], } const pollWithVote = { @@ -90,110 +100,163 @@ describe('pollsStore', () => { pollsStore = usePollsStore() }) - it('receives a poll from server and adds it to the store', async () => { - // Arrange - const response = generateOCSResponse({ payload: poll }) - getPollData.mockResolvedValue(response) + describe('polls management', () => { + it('receives a poll from server and adds it to the store', async () => { + // Arrange + const response = generateOCSResponse({ payload: poll }) + getPollData.mockResolvedValue(response) - // Act - await pollsStore.getPollData({ token: TOKEN, pollId: poll.id }) + // Act + await pollsStore.getPollData({ token: TOKEN, pollId: poll.id }) - // Assert - expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(poll) - }) + // Assert + expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(poll) + }) - it('debounces a function to get a poll from server', async () => { - // Arrange - jest.useFakeTimers() - const response = generateOCSResponse({ payload: poll }) - getPollData.mockResolvedValue(response) + it('debounces a function to get a poll from server', async () => { + // Arrange + jest.useFakeTimers() + const response = generateOCSResponse({ payload: poll }) + getPollData.mockResolvedValue(response) - // Act - pollsStore.debounceGetPollData({ token: TOKEN, pollId: poll.id }) - jest.advanceTimersByTime(5000) - await flushPromises() + // Act + pollsStore.debounceGetPollData({ token: TOKEN, pollId: poll.id }) + jest.advanceTimersByTime(5000) + await flushPromises() - // Assert - expect(pollsStore.debouncedFunctions[TOKEN][poll.id]).toBeDefined() - expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(poll) - }) + // Assert + expect(pollsStore.debouncedFunctions[TOKEN][poll.id]).toBeDefined() + expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(poll) + }) - it('creates a poll and adds it to the store', async () => { - // Arrange - const response = generateOCSResponse({ payload: poll }) - createPoll.mockResolvedValue(response) + it('creates a poll and adds it to the store', async () => { + // Arrange + const response = generateOCSResponse({ payload: poll }) + createPoll.mockResolvedValue(response) - // Act - await pollsStore.createPoll({ token: TOKEN, form: pollRequest }) + // Act + await pollsStore.createPoll({ token: TOKEN, form: pollRequest }) - // Assert - expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(poll) - }) + // Assert + expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(poll) + }) - it('submits a vote and updates it in the store', async () => { - // Arrange - pollsStore.addPoll({ token: TOKEN, poll }) - const response = generateOCSResponse({ payload: pollWithVote }) - submitVote.mockResolvedValue(response) + it('submits a vote and updates it in the store', async () => { + // Arrange + pollsStore.addPoll({ token: TOKEN, poll }) + const response = generateOCSResponse({ payload: pollWithVote }) + submitVote.mockResolvedValue(response) - // Act - await pollsStore.submitVote({ token: TOKEN, pollId: poll.id, optionIds: [0] }) + // Act + await pollsStore.submitVote({ token: TOKEN, pollId: poll.id, optionIds: [0] }) - // Assert - expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(pollWithVote) - }) + // Assert + expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(pollWithVote) + }) - it('ends a poll and updates it in the store', async () => { - // Arrange - pollsStore.addPoll({ token: TOKEN, poll: pollWithVote }) - const response = generateOCSResponse({ payload: pollWithVoteEnded }) - endPoll.mockResolvedValue(response) + it('ends a poll and updates it in the store', async () => { + // Arrange + pollsStore.addPoll({ token: TOKEN, poll: pollWithVote }) + const response = generateOCSResponse({ payload: pollWithVoteEnded }) + endPoll.mockResolvedValue(response) - // Act - await pollsStore.endPoll({ token: TOKEN, pollId: poll.id }) + // Act + await pollsStore.endPoll({ token: TOKEN, pollId: poll.id }) - // Assert - expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(pollWithVoteEnded) + // Assert + expect(pollsStore.getPoll(TOKEN, poll.id)).toMatchObject(pollWithVoteEnded) + }) }) - it('adds poll toast to the queue from message', async () => { - // Act - pollsStore.addPollToast({ token: TOKEN, message: messageWithPoll }) + describe('drafts management', () => { + it('receives drafts from server and adds them to the store', async () => { + // Arrange + const response = generateOCSResponse({ payload: [pollDraft] }) + getPollDrafts.mockResolvedValue(response) - // Assert - expect(pollsStore.isNewPoll(poll.id)).toBeTruthy() - }) + // Act + await pollsStore.getPollDrafts(TOKEN) - it('sets active poll from the toast', async () => { - // Arrange - pollsStore.addPollToast({ token: TOKEN, message: messageWithPoll }) + // Assert + expect(pollsStore.getDrafts(TOKEN)).toMatchObject([pollDraft]) + }) - // Act - pollsStore.pollToastsQueue[poll.id].options.onClick() + it('receives no drafts from server', async () => { + // Arrange + const response = generateOCSResponse({ payload: [] }) + getPollDrafts.mockResolvedValue(response) - // Assert - expect(pollsStore.activePoll).toMatchObject({ token: TOKEN, id: poll.id, name: poll.question }) - }) + // Act + await pollsStore.getPollDrafts(TOKEN) + + // Assert + expect(pollsStore.getDrafts(TOKEN)).toMatchObject([]) + }) + + it('creates a draft and adds it to the store', async () => { + // Arrange + const response = generateOCSResponse({ payload: pollDraft }) + createPollDraft.mockResolvedValue(response) + + // Act + await pollsStore.createPollDraft({ token: TOKEN, form: pollRequest }) - it('removes active poll', async () => { - // Arrange - pollsStore.setActivePoll({ token: TOKEN, pollId: poll.id, name: poll.question }) + // Assert + expect(pollsStore.getDrafts(TOKEN, poll.id)).toMatchObject([pollDraft]) + }) - // Act - pollsStore.removeActivePoll() + it('deletes a draft from the store', async () => { + // Arrange + pollsStore.addPollDraft({ token: TOKEN, draft: pollDraft }) - // Assert - expect(pollsStore.activePoll).toEqual(null) + // Act + await pollsStore.deletePollDraft({ token: TOKEN, pollId: pollDraft.id }) + + // Assert + expect(pollsStore.getDrafts(TOKEN, poll.id)).toMatchObject([]) + }) }) - it('hides all poll toasts', async () => { - // Arrange - pollsStore.addPollToast({ token: TOKEN, message: messageWithPoll }) + describe('poll toasts in call', () => { + it('adds poll toast to the queue from message', async () => { + // Act + pollsStore.addPollToast({ token: TOKEN, message: messageWithPoll }) + + // Assert + expect(pollsStore.isNewPoll(poll.id)).toBeTruthy() + }) + + it('sets active poll from the toast', async () => { + // Arrange + pollsStore.addPollToast({ token: TOKEN, message: messageWithPoll }) + + // Act + pollsStore.pollToastsQueue[poll.id].options.onClick() + + // Assert + expect(pollsStore.activePoll).toMatchObject({ token: TOKEN, id: poll.id, name: poll.question }) + }) + + it('removes active poll', async () => { + // Arrange + pollsStore.setActivePoll({ token: TOKEN, pollId: poll.id, name: poll.question }) + + // Act + pollsStore.removeActivePoll() + + // Assert + expect(pollsStore.activePoll).toEqual(null) + }) + + it('hides all poll toasts', async () => { + // Arrange + pollsStore.addPollToast({ token: TOKEN, message: messageWithPoll }) - // Act - pollsStore.hideAllPollToasts() + // Act + pollsStore.hideAllPollToasts() - // Assert - expect(pollsStore.pollToastsQueue).toMatchObject({}) + // Assert + expect(pollsStore.pollToastsQueue).toMatchObject({}) + }) }) }) diff --git a/src/stores/polls.ts b/src/stores/polls.ts index b71fc12f737..bc6e5877174 100644 --- a/src/stores/polls.ts +++ b/src/stores/polls.ts @@ -11,20 +11,25 @@ import { t } from '@nextcloud/l10n' import { createPoll, + createPollDraft, + getPollDrafts, getPollData, submitVote, endPoll, + deletePollDraft, } from '../services/pollService.ts' import type { ChatMessage, createPollParams, - Poll, votePollParams + votePollParams, + Poll, + PollDraft, } from '../types/index.ts' -type createPollPayload = { token: string } & createPollParams type submitVotePayload = { token: string, pollId: string } & Pick type State = { polls: Record>, + drafts: Record>, debouncedFunctions: Record void>>, activePoll: null, pollToastsQueue: Record>, @@ -32,6 +37,7 @@ type State = { export const usePollsStore = defineStore('polls', { state: (): State => ({ polls: {}, + drafts: {}, debouncedFunctions: {}, activePoll: null, pollToastsQueue: {}, @@ -42,6 +48,10 @@ export const usePollsStore = defineStore('polls', { return state.polls[token]?.[pollId] }, + getDrafts: (state) => (token: string): PollDraft[] => { + return Object.values(Object(state.drafts[token])) + }, + isNewPoll: (state) => (pollId: number) => { return state.pollToastsQueue[pollId] !== undefined }, @@ -55,6 +65,34 @@ export const usePollsStore = defineStore('polls', { Vue.set(this.polls[token], poll.id, poll) }, + addPollDraft({ token, draft }: { token: string, draft: PollDraft }) { + if (!this.drafts[token]) { + Vue.set(this.drafts, token, {}) + } + Vue.set(this.drafts[token], draft.id, draft) + }, + + async getPollDrafts(token: string) { + try { + const response = await getPollDrafts(token) + if (response.data.ocs.data.length === 0) { + Vue.set(this.drafts, token, {}) + return + } + for (const draft of response.data.ocs.data) { + this.addPollDraft({ token, draft }) + } + } catch (error) { + console.error(error) + } + }, + + deleteDraft({ token, pollId }: { token: string, pollId: string }) { + if (this.drafts[token]?.[pollId]) { + Vue.delete(this.drafts[token], pollId) + } + }, + async getPollData({ token, pollId }: { token: string, pollId: string }) { try { const response = await getPollData(token, pollId) @@ -90,7 +128,7 @@ export const usePollsStore = defineStore('polls', { async createPoll({ token, form }: { token: string, form: createPollParams }) { try { - const response = await createPoll({ token, ...form }) + const response = await createPoll({ token, ...form, draft: false }) this.addPoll({ token, poll: response.data.ocs.data }) return response.data.ocs.data @@ -99,6 +137,16 @@ export const usePollsStore = defineStore('polls', { } }, + async createPollDraft({ token, form }: { token: string, form: createPollParams }) { + try { + const response = await createPollDraft({ token, ...form, draft: true }) + this.addPollDraft({ token, draft: response.data.ocs.data }) + return response.data.ocs.data + } catch (error) { + console.error(error) + } + }, + async submitVote({ token, pollId, optionIds }: submitVotePayload) { try { const response = await submitVote(token, pollId, optionIds) @@ -119,6 +167,16 @@ export const usePollsStore = defineStore('polls', { } }, + async deletePollDraft({ token, pollId }: { token: string, pollId: string }) { + try { + await deletePollDraft(token, pollId) + this.deleteDraft({ token, pollId }) + } catch (error) { + console.error(error) + showError(t('spreed', 'An error occurred while deleting the poll draft')) + } + }, + setActivePoll({ token, pollId, name }: { token: string, pollId: string, name: string }) { Vue.set(this, 'activePoll', { token, id: pollId, name }) }, diff --git a/src/types/index.ts b/src/types/index.ts index 86d22a1c251..27b2e828dcd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -163,10 +163,14 @@ export type switchToBreakoutRoomResponse = ApiResponse +export type getPollDraftsResponse = ApiResponse export type createPollParams = operations['poll-create-poll']['requestBody']['content']['application/json'] export type createPollResponse = ApiResponse +export type createPollDraftResponse = ApiResponse export type votePollParams = Required['requestBody']['content']['application/json'] export type votePollResponse = ApiResponse export type closePollResponse = ApiResponse +export type deletePollDraftResponse = ApiResponse