Skip to content
Open
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
16 changes: 16 additions & 0 deletions lib/Db/Poll.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
* @method void setAllowComment(int $value)
* @method int getAllowMaybe()
* @method void setAllowMaybe(int $value)
* @method string getChosenRank()
* @method void setChosenRank(string $value)
* @method string getAllowProposals()
* @method void setAllowProposals(string $value)
* @method int getProposalsExpire()
Expand Down Expand Up @@ -81,6 +83,7 @@ class Poll extends EntityWithUser implements JsonSerializable {
public const TYPE_DATE = 'datePoll';
public const TYPE_TEXT = 'textPoll';
public const VARIANT_SIMPLE = 'simple';
public const VARIANT_GENERIC = 'generic';
/** @deprecated use ACCESS_PRIVATE instead */
public const ACCESS_HIDDEN = 'hidden';
/** @deprecated use ACCESS_OPEN instead */
Expand Down Expand Up @@ -148,6 +151,7 @@ class Poll extends EntityWithUser implements JsonSerializable {
protected string $access = '';
protected int $anonymous = 0;
protected int $allowMaybe = 0;
protected ?string $chosenRank = '';
protected string $allowProposals = '';
protected int $proposalsExpire = 0;
protected int $voteLimit = 0;
Expand Down Expand Up @@ -250,6 +254,7 @@ public function getConfigurationArray(): array {
'access' => $this->getAccess(),
'allowComment' => boolval($this->getAllowComment()),
'allowMaybe' => boolval($this->getAllowMaybe()),
'chosenRank' => $this->getChosenRank(),
'allowProposals' => $this->getAllowProposals(),
'anonymous' => boolval($this->getAnonymous()),
'autoReminder' => $this->getAutoReminder(),
Expand Down Expand Up @@ -319,6 +324,17 @@ public function deserializeArray(array $pollConfiguration): self {
$this->setAccess($pollConfiguration['access'] ?? $this->getAccess());
$this->setAllowComment($pollConfiguration['allowComment'] ?? $this->getAllowComment());
$this->setAllowMaybe($pollConfiguration['allowMaybe'] ?? $this->getAllowMaybe());
$chosenRank = $pollConfiguration['chosenRank'] ?? $this->getChosenRank();
if (is_array($chosenRank)) {
$chosenRank = json_encode($chosenRank); // explicit serialisation
} elseif (is_string($chosenRank)) {
if (!json_decode($chosenRank)) {
$chosenRank = '[]';
}
} else {
$chosenRank = '[]';
}
$this->setChosenRank($chosenRank);
$this->setAllowProposals($pollConfiguration['allowProposals'] ?? $this->getAllowProposals());
$this->setAnonymousSafe($pollConfiguration['anonymous'] ?? $this->getAnonymous());
$this->setAutoReminder($pollConfiguration['autoReminder'] ?? $this->getAutoReminder());
Expand Down
19 changes: 19 additions & 0 deletions lib/Exceptions/InvalidVotingVariantException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Polls\Exceptions;

use OCP\AppFramework\Http;

class InvalidVotingVariantException extends Exception {
public function __construct(
string $e = 'Invalid votingVariant value',
) {
parent::__construct($e, Http::STATUS_CONFLICT);
}
}
1 change: 1 addition & 0 deletions lib/Migration/V2/TableSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ abstract class TableSchema {
'access' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => 'private', 'length' => 1024]],
'anonymous' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]],
'allow_maybe' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 1, 'length' => 20]],
'chosen_rank' => ['type' => Types::TEXT, 'options' => ['notnull' => false, 'default' => null, 'length' => 200]],
'allow_proposals' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => 'disallow', 'length' => 64]],
'proposals_expire' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]],
'vote_limit' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]],
Expand Down
1 change: 1 addition & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
* access: string,
* allowComment: boolean,
* allowMaybe: boolean,
* chosenRank: String,
* allowProposals: string,
* anonymous: boolean,
* autoReminder: boolean,
Expand Down
21 changes: 20 additions & 1 deletion lib/Service/PollService.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use OCA\Polls\Exceptions\InvalidPollTypeException;
use OCA\Polls\Exceptions\InvalidShowResultsException;
use OCA\Polls\Exceptions\InvalidUsernameException;
use OCA\Polls\Exceptions\InvalidVotingVariantException;
use OCA\Polls\Exceptions\NotFoundException;
use OCA\Polls\Exceptions\UserNotFoundException;
use OCA\Polls\Model\Settings\AppSettings;
Expand Down Expand Up @@ -194,11 +195,16 @@ public function add(string $type, string $title, string $votingVariant = Poll::V
throw new ForbiddenException('Poll creation is disabled');
}

// Validate valuess
// Validate values
if (!in_array($type, $this->getValidPollType())) {
throw new InvalidPollTypeException('Invalid poll type');
}

if (!in_array($votingVariant, $this->getValidVotingVariant())) {
throw new InvalidVotingVariantException('Invalid voting variant');
}


if (!$title) {
throw new EmptyTitleException('Title must not be empty');
}
Expand All @@ -222,6 +228,7 @@ public function add(string $type, string $title, string $votingVariant = Poll::V
$this->poll->setExpire(0);
$this->poll->setAnonymousSafe(0);
$this->poll->setAllowMaybe(0);
$this->poll->setChosenRank('');
$this->poll->setVoteLimit(0);
$this->poll->setShowResults(Poll::SHOW_RESULTS_ALWAYS);
$this->poll->setDeleted(0);
Expand Down Expand Up @@ -428,6 +435,7 @@ public function clone(int $pollId): Poll {
// deanonymize cloned polls by default, to avoid locked anonymous polls
$this->poll->setAnonymous(0);
$this->poll->setAllowMaybe($origin->getAllowMaybe());
$this->poll->setChosenRank($origin->getChosenRank());
$this->poll->setVoteLimit($origin->getVoteLimit());
$this->poll->setShowResults($origin->getShowResults());
$this->poll->setAdminAccess($origin->getAdminAccess());
Expand Down Expand Up @@ -484,6 +492,17 @@ private function getValidPollType(): array {
return [Poll::TYPE_DATE, Poll::TYPE_TEXT];
}

/**
* Get valid values for votingVariant
*
* @return string[]
*
* @psalm-return array{0: string, 1: string}
*/
private function getValidVotingVariant(): array {
return [Poll::VARIANT_SIMPLE, Poll::VARIANT_GENERIC];
}

/**
* Get valid values for access
*
Expand Down
14 changes: 12 additions & 2 deletions src/Api/modules/polls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { httpInstance, createCancelTokenHandler } from './HttpApi'
import type { AxiosResponse } from '@nextcloud/axios'
import type { ApiEmailAdressList, FullPollResponse } from './api.types'
import type { PollGroup } from '../../stores/pollGroups.types'
import type { Poll, PollConfiguration, PollType } from '../../stores/poll.types'
import type {
Poll,
PollConfiguration,
PollType,
VotingVariant,
} from '../../stores/poll.types'

export type Confirmations = {
sentMails: { emailAddress: string; displayName: string }[]
Expand Down Expand Up @@ -87,13 +92,18 @@ const polls = {
})
},

addPoll(type: PollType, title: string): Promise<AxiosResponse<{ poll: Poll }>> {
addPoll(
type: PollType,
title: string,
votingVariant: VotingVariant,
): Promise<AxiosResponse<{ poll: Poll }>> {
return httpInstance.request({
method: 'POST',
url: 'poll/add',
data: {
type,
title,
votingVariant,
},
cancelToken:
cancelTokenHandlerObject[
Expand Down
118 changes: 118 additions & 0 deletions src/components/Configuration/ConfigRankOptions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<div class="option-container">
<!-- Menu to choose the rank for this poll -->
<select v-model="selectedOption">
<option
v-for="(option, index) in internalChosenRank"
:key="index"
:value="option">
{{ option }}
</option>
</select>
<!-- text field to add a new value to the rank -->
<NcTextField
v-model="newOption"
:placeholder="t('polls', 'Enter a new option')"
:label="t('polls', 'New option')"
class="nc-text-field" />
<NcButton icon @click="addOption">
<PlusIcon />
</NcButton>
<!-- Delete selected rank from the select -->
<NcButton :disabled="!selectedOption" @click="removeOption">
<CloseIcon />
</NcButton>
</div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { usePollStore } from '../../stores/poll.js'
import { t } from '@nextcloud/l10n'
import { NcButton, NcTextField } from '@nextcloud/vue'
import PlusIcon from 'vue-material-design-icons/Plus.vue'
import CloseIcon from 'vue-material-design-icons/Close.vue'
import { showError } from '@nextcloud/dialogs'

const pollStore = usePollStore()

// Parse chosenRank string from store into array
const internalChosenRank = ref([])
const selectedOption = ref(null)
const newOption = ref('')

onMounted(() => {
try {
const initialValue = JSON.parse(pollStore.configuration.chosenRank || '[]')
if (Array.isArray(initialValue)) {
internalChosenRank.value = initialValue
} else {
internalChosenRank.value = [initialValue]
}
if (internalChosenRank.value.length > 0) {
selectedOption.value = internalChosenRank.value[0]
}
} catch (e) {
console.error('Erreur de parsing chosenRank:', e)
internalChosenRank.value = []
}
})

// update in store + API
async function updateChosenRank(newValue) {
try {
await pollStore.setChosenRank(newValue)
await pollStore.write()
} catch (err) {
console.error('Update failed:', err)
showError(t('polls', 'Failed to update options'))
}
}

async function addOption() {
const value = newOption.value.trim()
if (value && !internalChosenRank.value.includes(value)) {
const updated = [...internalChosenRank.value, value].sort()
internalChosenRank.value = updated
newOption.value = ''
selectedOption.value = updated[0]
await updateChosenRank(updated)
}
}

async function removeOption() {
const updated = internalChosenRank.value.filter(
(o) => o !== selectedOption.value,
)
internalChosenRank.value = updated
selectedOption.value = updated[0] || null
await updateChosenRank(updated)
}
</script>

<style scoped>
.option-container {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}

.nc-text-field {
flex-grow: 1;
margin-right: 8px;
margin-bottom: 8px;
width: 100px;
}

.option-item {
display: flex;
align-items: center;
gap: 4px;
}
</style>
22 changes: 20 additions & 2 deletions src/components/Create/PollCreateDlg.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import InputDiv from '../Base/modules/InputDiv.vue'
import RadioGroupDiv from '../Base/modules/RadioGroupDiv.vue'
import ConfigBox from '../Base/modules/ConfigBox.vue'

import { pollTypes, usePollStore } from '../../stores/poll'
import { pollTypes, usePollStore, votingVariants } from '../../stores/poll'
import { usePreferencesStore } from '../../stores/preferences'
import { showError, showSuccess } from '@nextcloud/dialogs'

import type { PollType } from '../../stores/poll.types'
import type { PollType, VotingVariant } from '../../stores/poll.types'

const pollStore = usePollStore()
const preferencesStore = usePreferencesStore()

const emit = defineEmits<{
(e: 'close'): void
Expand All @@ -36,6 +38,7 @@ const emit = defineEmits<{

const pollTitle = ref('')
const pollType = ref<PollType>('datePoll')
const votingVariant = ref<VotingVariant>('simple')
const pollId = ref<number | null>(null)
const adding = ref(false)

Expand All @@ -44,6 +47,11 @@ const pollTypeOptions = Object.entries(pollTypes).map(([key, value]) => ({
label: value.name,
}))

const votingVariantOptions = Object.entries(votingVariants).map(([key, value]) => ({
value: key,
label: value.name,
}))

const titleIsEmpty = computed(() => pollTitle.value === '')
const disableAddButton = computed(() => titleIsEmpty.value || adding.value)

Expand All @@ -55,6 +63,7 @@ async function addPoll() {
const poll = await pollStore.add({
type: pollType.value,
title: pollTitle.value,
votingVariant: votingVariant.value,
})

if (poll) {
Expand Down Expand Up @@ -111,6 +120,15 @@ function resetPoll() {
<RadioGroupDiv v-model="pollType" :options="pollTypeOptions" />
</ConfigBox>

<ConfigBox
v-if="preferencesStore.user.variantsCreation"
:name="t('polls', 'Vote variant')">
<template #icon>
<CheckIcon />
</template>
<RadioGroupDiv v-model="votingVariant" :options="votingVariantOptions" />
</ConfigBox>

<div class="create-buttons">
<NcButton @click="emit('close')">
<template #default>
Expand Down
12 changes: 12 additions & 0 deletions src/components/Settings/UserSettings/FeatureSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -104,5 +104,17 @@ const defaultViewDatePoll = computed({
"
@change="preferencesStore.write()" />
</div>

<div class="user_settings">
<NcCheckboxRadioSwitch
v-model="preferencesStore.user.variantsCreation"
type="switch"
@update:model-value="preferencesStore.write()">
{{ t('polls', 'Variant creation') }}
</NcCheckboxRadioSwitch>
<div class="settings_details">
{{ t('polls', 'Check this to allow variants polls creation.') }}
</div>
</div>
</div>
</template>
Loading