From 8440afdb89043b41e559b211e8dd9863836d7925 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sun, 15 Jun 2025 15:01:38 +0200 Subject: [PATCH 01/31] base implementation Signed-off-by: dartcafe --- lib/Controller/PollController.php | 1 + lib/Db/EntityWithUser.php | 10 ++- lib/Db/PollGroup.php | 110 ++++++++++++++++++++++++++++++ lib/Db/PollGroupMapper.php | 35 ++++++++++ lib/Migration/TableSchema.php | 11 +++ lib/Service/PollService.php | 6 ++ src/Api/modules/polls.ts | 9 ++- src/stores/polls.ts | 35 ++++++++-- src/views/Navigation.vue | 43 ++++++++++++ 9 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 lib/Db/PollGroup.php create mode 100644 lib/Db/PollGroupMapper.php diff --git a/lib/Controller/PollController.php b/lib/Controller/PollController.php index 766bb0eab9..3194cfba18 100644 --- a/lib/Controller/PollController.php +++ b/lib/Controller/PollController.php @@ -56,6 +56,7 @@ public function list(): JSONResponse { 'pollCreationAllowed' => $appSettings->getPollCreationAllowed(), 'comboAllowed' => $appSettings->getComboAllowed(), ], + 'groups' => $this->pollService->groups(), ]; }); } diff --git a/lib/Db/EntityWithUser.php b/lib/Db/EntityWithUser.php index bfdbeb19b4..c1e3aed2f9 100644 --- a/lib/Db/EntityWithUser.php +++ b/lib/Db/EntityWithUser.php @@ -8,6 +8,7 @@ namespace OCA\Polls\Db; +use Exception; use OCA\Polls\Helper\Container; use OCA\Polls\Model\User\Anon; use OCA\Polls\Model\UserBase; @@ -101,7 +102,14 @@ public function getUser(): UserBase { $userMapper = (Container::queryClass(UserMapper::class)); - $user = $userMapper->getParticipant($this->getUserId(), $this->getPollId()); + try { + $pollId = $this->getPollId(); + $user = $userMapper->getParticipant($this->getUserId(), $pollId); + // Get user from userbase + } catch (Exception $e) { + // If pollId is not set, we assume that the user is not a participant of a poll + $user = $userMapper->getUserFromUserBase($this->getUserId()); + } return $user; } } diff --git a/lib/Db/PollGroup.php b/lib/Db/PollGroup.php new file mode 100644 index 0000000000..5b6999f261 --- /dev/null +++ b/lib/Db/PollGroup.php @@ -0,0 +1,110 @@ + + */ + public function getPolls(): array { + if ($this->polls === '') { + return []; + } + $polls = explode(self::CONCAT_SEPARATOR, $this->polls); + return array_map('intval', $polls); + } + + public function setPolls(array $polls): void { + $this->polls = implode(self::CONCAT_SEPARATOR, $polls); + } + + public function addPoll(int $pollId): void { + $polls = $this->getPolls(); + if (!in_array($pollId, $polls, true)) { + $polls[] = $pollId; + $this->setPolls($polls); + } + } + + public function removePoll(int $pollId): void { + $polls = $this->getPolls(); + if (($key = array_search($pollId, $polls, true)) !== false) { + unset($polls[$key]); + $this->setPolls(array_values($polls)); + } + } + + // alias of getOwner() + public function getUserId(): string { + return $this->getOwner(); + } + + // alias of setOwner($value) + public function setUserId(string $userId): void { + $this->setOwner($userId); + } + + /** + * @return array + * + * @psalm-suppress PossiblyUnusedMethod + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'created' => $this->getCreated(), + 'deleted' => $this->getDeleted(), + 'description' => $this->getDescription(), + 'owner' => $this->getUser(), + 'title' => $this->getTitle(), + 'titleExt' => $this->getTitleExt(), + 'polls' => $this->getPolls(), + ]; + } +} diff --git a/lib/Db/PollGroupMapper.php b/lib/Db/PollGroupMapper.php new file mode 100644 index 0000000000..bfc1b9d86a --- /dev/null +++ b/lib/Db/PollGroupMapper.php @@ -0,0 +1,35 @@ + + */ +class PollGroupMapper extends QBMapper { + public const TABLE = PollGroup::TABLE; + + /** @psalm-suppress PossiblyUnusedMethod */ + public function __construct( + IDBConnection $db, + ) { + parent::__construct($db, PollGroup::TABLE, PollGroup::class); + } + + public function list(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->orderBy('title', 'ASC'); + return $this->findEntities($qb); + } + +} diff --git a/lib/Migration/TableSchema.php b/lib/Migration/TableSchema.php index 9334bf40ec..d1ad4f85d5 100644 --- a/lib/Migration/TableSchema.php +++ b/lib/Migration/TableSchema.php @@ -13,6 +13,7 @@ use OCA\Polls\Db\Log; use OCA\Polls\Db\Option; use OCA\Polls\Db\Poll; +use OCA\Polls\Db\PollGroup; use OCA\Polls\Db\Preferences; use OCA\Polls\Db\Share; use OCA\Polls\Db\Subscription; @@ -148,6 +149,16 @@ abstract class TableSchema { * */ public const TABLES = [ + PollGroup::TABLE => [ + 'id' => ['type' => Types::BIGINT, 'options' => ['autoincrement' => true, 'notnull' => true, 'length' => 20]], + 'created' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], + 'deleted' => ['type' => Types::BIGINT, 'options' => ['notnull' => true, 'default' => 0, 'length' => 20]], + 'description' => ['type' => Types::TEXT, 'options' => ['notnull' => false, 'default' => null, 'length' => 65535]], + 'owner' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => null, 'length' => 256]], + 'polls' => ['type' => Types::STRING, 'options' => ['notnull' => false, 'default' => null, 'length' => 256]], + 'title' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 128]], + 'title_ext' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => '', 'length' => 128]], + ], Poll::TABLE => [ 'id' => ['type' => Types::BIGINT, 'options' => ['autoincrement' => true, 'notnull' => true, 'length' => 20]], 'type' => ['type' => Types::STRING, 'options' => ['notnull' => true, 'default' => 'datePoll', 'length' => 64]], diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index c8d0e3ddac..79a9d825ad 100644 --- a/lib/Service/PollService.php +++ b/lib/Service/PollService.php @@ -9,6 +9,7 @@ namespace OCA\Polls\Service; use OCA\Polls\Db\Poll; +use OCA\Polls\Db\PollGroupMapper; use OCA\Polls\Db\PollMapper; use OCA\Polls\Db\UserMapper; use OCA\Polls\Db\VoteMapper; @@ -47,6 +48,7 @@ public function __construct( private UserMapper $userMapper, private UserSession $userSession, private VoteMapper $voteMapper, + private PollGroupMapper $pollGroupMapper, ) { } @@ -64,6 +66,10 @@ public function list(): array { })); } + public function groups(): array { + return $this->pollGroupMapper->list(); + } + /** * Get list of polls */ diff --git a/src/Api/modules/polls.ts b/src/Api/modules/polls.ts index 1a20edb190..0a173ea1af 100644 --- a/src/Api/modules/polls.ts +++ b/src/Api/modules/polls.ts @@ -9,6 +9,7 @@ import { Option } from '../../stores/options.js' import { Vote } from '../../stores/votes.js' import { Share } from '../../stores/shares.js' import { ApiEmailAdressList, Comment } from '../../Types/index.js' +import { PollGroup } from '../../stores/polls.js' export type Confirmations = { sentMails: { emailAddress: string; displayName: string }[] @@ -18,7 +19,13 @@ export type Confirmations = { } const polls = { - getPolls(): Promise> { + getPolls(): Promise< + AxiosResponse<{ + list: Poll[] + permissions: { pollCreationAllowed: boolean; comboAllowed: true } + groups: PollGroup[] + }> + > { return httpInstance.request({ method: 'GET', url: 'polls', diff --git a/src/stores/polls.ts b/src/stores/polls.ts index c67934fa3f..0758a44e9b 100644 --- a/src/stores/polls.ts +++ b/src/stores/polls.ts @@ -13,7 +13,7 @@ import { PollsAPI } from '../Api/index.ts' import { AccessType, Poll, PollType } from './poll.ts' import { useSessionStore } from './session.ts' -import { StatusResults } from '../Types/index.ts' +import { StatusResults, User } from '../Types/index.ts' import { AxiosError } from '@nextcloud/axios' export enum SortType { @@ -51,8 +51,18 @@ export type PollCategory = { showInNavigation(): boolean filterCondition(poll: Poll): boolean } +export type PollGroup = { + id: number + created: number + deleted: number + description: string + owner: User + title: string + titleExt: string + polls: number[] +} -export type PollCategorieList = Record +export type PollCategoryList = Record export type Meta = { chunksize: number @@ -63,12 +73,13 @@ export type Meta = { export type PollList = { list: Poll[] + groups: PollGroup[] meta: Meta sort: { by: SortType reverse: boolean } - categories: PollCategorieList + categories: PollCategoryList } export const sortColumnsMapping: { [key in SortType]: string } = { @@ -89,7 +100,7 @@ export const sortTitlesMapping: { [key in SortType]: string } = { interaction: t('polls', 'Last interaction'), } -export const pollCategories: PollCategorieList = { +export const pollCategories: PollCategoryList = { [FilterType.Relevant]: { id: FilterType.Relevant, title: t('polls', 'Relevant'), @@ -213,6 +224,7 @@ export const pollCategories: PollCategorieList = { export const usePollsStore = defineStore('polls', { state: (): PollList => ({ list: [], + groups: [], meta: { chunksize: 20, loadedChunks: 1, @@ -247,6 +259,19 @@ export const usePollsStore = defineStore('polls', { [SortDirection.Desc], ).slice(0, state.meta.maxPollsInNavigation), + /* + * Sliced filtered and sorted polls for navigation + */ + groupList: + (state: PollList) => + (filterList: number[]): Poll[] => + orderBy( + state.list.filter((poll: Poll) => filterList.includes(poll.id)) + ?? [], + [SortType.Created], + [SortDirection.Desc], + ).slice(0, state.meta.maxPollsInNavigation), + currentCategory(state: PollList): PollCategory { const sessionStore = useSessionStore() @@ -334,6 +359,7 @@ export const usePollsStore = defineStore('polls', { try { const response = await PollsAPI.getPolls() this.list = response.data.list + this.groups = response.data.groups this.meta.status = StatusResults.Loaded } catch (error) { if ((error as AxiosError)?.code === 'ERR_CANCELED') { @@ -420,6 +446,7 @@ export const usePollsStore = defineStore('polls', { this.load() } }, + async takeOver(payload: { pollId: number }) { try { await PollsAPI.takeOver(payload.pollId) diff --git a/src/views/Navigation.vue b/src/views/Navigation.vue index bc0fbb6440..57516ca9f4 100644 --- a/src/views/Navigation.vue +++ b/src/views/Navigation.vue @@ -28,6 +28,7 @@ import AllPollsIcon from 'vue-material-design-icons/Poll.vue' import ClosedPollsIcon from 'vue-material-design-icons/Lock.vue' import ArchivedPollsIcon from 'vue-material-design-icons/Archive.vue' import GoToIcon from 'vue-material-design-icons/ArrowRight.vue' +import GroupIcon from 'vue-material-design-icons/AccountGroup.vue' import { Logger } from '../helpers/index.ts' import PollCreateDlg from '../components/Create/PollCreateDlg.vue' @@ -186,6 +187,48 @@ onMounted(() => { @close="createDlgToggle = false" /> + + pollMapper->findForMe($this->userSession->getCurrentUserId()); if ($this->userSession->getCurrentUser()->getIsAdmin()) { return $pollList; @@ -66,38 +68,69 @@ public function list(): array { })); } - public function groups(): array { + public function listPollGroups(): array { return $this->pollGroupMapper->list(); } - public function addPollToPollGroup(int $pollId, int $pollGroupId): Poll { + public function addPollToPollGroup( + int $pollId, + ?int $pollGroupId = null, + ?string $newPollGroupName = null, + ): PollGroup { $poll = $this->pollMapper->find($pollId); $poll->request(Poll::PERMISSION_POLL_EDIT); - $pollGroup = $this->pollGroupMapper->find($pollGroupId); + if ($pollGroupId === null && $newPollGroupName) { + if (!$this->appSettings->getPollCreationAllowed()) { + throw new ForbiddenException('Poll group creation is disabled'); + } + // Create new poll group + $pollGroup = $this->pollGroupMapper->addGroup($newPollGroupName); + } else { + $pollGroup = $this->pollGroupMapper->find($pollGroupId); + } if (!$pollGroup->hasPoll($pollId)) { - $pollGroup->addPoll($pollId); - $this->pollGroupMapper->update($pollGroup); + try { + $this->pollGroupMapper->addPollToGroup($pollId, $pollGroup->getId()); + } catch (Exception $e) { + if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + // Poll is already member of this group + } else { + throw $e; + } + } + $this->eventDispatcher->dispatchTyped(new PollUpdatedEvent($poll)); } - return $poll; + return $this->pollGroupMapper->find($pollGroup->getId()); } - public function removePollFromPollGroup(int $pollId, int $pollGroupId): Poll { + public function removePollFromPollGroup( + int $pollId, + int $pollGroupId, + ): ?PollGroup { $poll = $this->pollMapper->find($pollId); $poll->request(Poll::PERMISSION_POLL_EDIT); $pollGroup = $this->pollGroupMapper->find($pollGroupId); if ($pollGroup->hasPoll($pollId)) { - $pollGroup->removePoll($pollId); - $this->pollGroupMapper->update($pollGroup); + $this->pollGroupMapper->removePollFromGroup($pollId, $pollGroupId); $this->eventDispatcher->dispatchTyped(new PollUpdatedEvent($poll)); + } else { + throw new NotFoundException('Poll not found in group'); } - return $poll; + $this->pollGroupMapper->tidyPollGroups(); + try { + $pollGroup = $this->pollGroupMapper->find($pollGroupId); + } catch (DoesNotExistException $e) { + // Poll group was deleted, return null + return null; + } + return $pollGroup; } /** diff --git a/src/Api/modules/polls.ts b/src/Api/modules/polls.ts index 8f3cb4b1d9..7d84a42d39 100644 --- a/src/Api/modules/polls.ts +++ b/src/Api/modules/polls.ts @@ -2,14 +2,14 @@ * SPDX-FileCopyrightText: 2022 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { Poll, PollConfiguration, PollType } from '../../stores/poll.js' +import { Poll, PollConfiguration, PollType } from '../../stores/poll.ts' import { AxiosResponse } from '@nextcloud/axios' import { httpInstance, createCancelTokenHandler } from './HttpApi.js' -import { Option } from '../../stores/options.js' -import { Vote } from '../../stores/votes.js' -import { Share } from '../../stores/shares.js' -import { ApiEmailAdressList, Comment } from '../../Types/index.js' -import { PollGroup } from '../../stores/polls.js' +import { Option } from '../../stores/options.ts' +import { Vote } from '../../stores/votes.ts' +import { Share } from '../../stores/shares.ts' +import { ApiEmailAdressList, Comment } from '../../Types/index.ts' +import { PollGroup } from '../../stores/polls.ts' export type Confirmations = { sentMails: { emailAddress: string; displayName: string }[] @@ -19,32 +19,80 @@ export type Confirmations = { } const polls = { - getPolls(): Promise< - AxiosResponse<{ - list: Poll[] - permissions: { pollCreationAllowed: boolean; comboAllowed: true } - groups: PollGroup[] - }> - > { + getPollGroups(): Promise> { return httpInstance.request({ method: 'GET', - url: 'polls', + url: 'pollgroups', params: { time: +new Date() }, cancelToken: cancelTokenHandlerObject[ - this.getPolls.name + this.getPollGroups.name ].handleRequestCancellation().token, }) }, - getPollGroups(): Promise> { + createPollGroupForPoll( + newPollGroupName: string, + pollId: number, + ): Promise> { + return httpInstance.request({ + method: 'POST', + url: `pollgroup/new/poll/${pollId}`, + data: { + newPollGroupName, + }, + cancelToken: + cancelTokenHandlerObject[ + this.createPollGroupForPoll.name + ].handleRequestCancellation().token, + }) + }, + + addPollToGroup( + pollGroupId: number, + pollId: number, + ): Promise> { + return httpInstance.request({ + method: 'PUT', + url: `pollgroup/${pollGroupId}/poll/${pollId}`, + cancelToken: + cancelTokenHandlerObject[ + this.addPollToGroup.name + ].handleRequestCancellation().token, + }) + }, + + removePollFromGroup( + pollGroupId: number, + pollId: number, + ): Promise> { + return httpInstance.request({ + method: 'DELETE', + url: `pollgroup/${pollGroupId}/poll/${pollId}`, + cancelToken: + cancelTokenHandlerObject[ + this.removePollFromGroup.name + ].handleRequestCancellation().token, + }) + }, + + getPolls(): Promise< + AxiosResponse<{ + polls: Poll[] + permissions: { + pollCreationAllowed: boolean + comboAllowed: true + } + pollGroups: PollGroup[] + }> + > { return httpInstance.request({ method: 'GET', - url: 'pollgroups', + url: 'polls', params: { time: +new Date() }, cancelToken: cancelTokenHandlerObject[ - this.getPollGroups.name + this.getPolls.name ].handleRequestCancellation().token, }) }, diff --git a/src/components/PollList/PollItemActions.vue b/src/components/PollList/PollItemActions.vue index fc821e33bd..9ae4a6f197 100644 --- a/src/components/PollList/PollItemActions.vue +++ b/src/components/PollList/PollItemActions.vue @@ -15,13 +15,15 @@ import { useSessionStore } from '../../stores/session.ts' import { Poll } from '../../stores/poll.ts' import { computed, ref } from 'vue' -import { NcDialog } from '@nextcloud/vue' +import { NcActionInput, NcDialog } from '@nextcloud/vue' import DeletePollDialog from '../Modals/DeletePollDialog.vue' import TransferPollDialog from '../Modals/TransferPollDialog.vue' import ArchivePollIcon from 'vue-material-design-icons/Archive.vue' import ClonePollIcon from 'vue-material-design-icons/ContentCopy.vue' import DeletePollIcon from 'vue-material-design-icons/Delete.vue' +import IconArrowLeft from 'vue-material-design-icons/ArrowLeft.vue' +import MinusIcon from 'vue-material-design-icons/Minus.vue' import PlusIcon from 'vue-material-design-icons/Plus.vue' import RestorePollIcon from 'vue-material-design-icons/Recycle.vue' import TransferPollIcon from 'vue-material-design-icons/AccountSwitchOutline.vue' @@ -38,6 +40,9 @@ const adminAccess = computed( const showDeleteDialog = ref(false) const showTransferDialog = ref(false) +const subMenu = ref<'addToGroup' | 'removeFromGroup' | null>(null) + +const newGroupTitle = ref('') const showTakeOverDialog = ref(false) const takeOverDialog = { @@ -60,6 +65,49 @@ const takeOverDialog = { ], } +async function toggleSubMenu( + action: 'addToGroup' | 'removeFromGroup' | null = null, +) { + subMenu.value = subMenu.value === action ? null : action +} + +async function removePollFromGroup(pollId: number, pollGroupId: number) { + subMenu.value = null + try { + pollsStore.removePollFromGroup({ + pollId, + pollGroupId, + }) + } catch { + showError(t('polls', 'Error removing poll from group.')) + } +} + +async function addPollToPollGroup(pollId: number, pollGroupId: number) { + subMenu.value = null + pollsStore.addPollToPollGroup({ + pollId, + pollGroupId, + }) +} + +async function addPollToNewPollGroup(pollId: number) { + if (!newGroupTitle.value.trim()) { + return + } + + try { + await pollsStore.addPollToPollGroup({ + pollId, + groupTitle: newGroupTitle.value.trim(), + }) + newGroupTitle.value = '' + subMenu.value = null + } catch (error) { + showError(t('polls', 'Error creating new poll group.')) + } +} + async function toggleArchive() { try { await pollsStore.toggleArchive({ pollId: poll.id }) @@ -91,72 +139,134 @@ async function takeOverPoll(): Promise { @@ -339,7 +341,7 @@ const descriptionLine = computed(() => { min-height: 1.4rem; } } - .action-item { + .actions { display: flex; flex: 0 0 2.7rem; justify-content: center; diff --git a/src/components/PollList/PollItemActions.vue b/src/components/PollList/PollItemActions.vue index 9ae4a6f197..1b9a73300e 100644 --- a/src/components/PollList/PollItemActions.vue +++ b/src/components/PollList/PollItemActions.vue @@ -253,6 +253,7 @@ async function takeOverPoll(): Promise { :name="pollGroup.title" @click="addPollToPollGroup(poll.id, pollGroup.id)" /> From 4de1dfa4189c0ae4842b7f2fb1681dd1dd3b8efb Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 21 Jun 2025 00:18:26 +0200 Subject: [PATCH 26/31] @%$&g format fix Signed-off-by: dartcafe --- src/views/PollList.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/views/PollList.vue b/src/views/PollList.vue index 49fb4b017e..1def4be5cb 100644 --- a/src/views/PollList.vue +++ b/src/views/PollList.vue @@ -149,7 +149,10 @@ async function loadMore() { @goto-poll="gotoPoll(poll.id)"> From a9c6e0fe192a376dc1fa7dcbdc4d80ef01e9e293 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 21 Jun 2025 08:48:40 +0200 Subject: [PATCH 27/31] just a test Signed-off-by: dartcafe --- lib/Db/TableManager.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/Db/TableManager.php b/lib/Db/TableManager.php index c89b96cc3c..96612ee1d1 100644 --- a/lib/Db/TableManager.php +++ b/lib/Db/TableManager.php @@ -448,9 +448,8 @@ public static function getConcatenatedArray( string $dbProvider, string $separator = ',', ): void { - $qb->addSelect(match ($dbProvider) { - IDBConnection::PLATFORM_POSTGRES => $qb->createFunction('string_agg(distinct ' . $concatColumn . ', \'' . $separator . '\') AS ' . $asColumn), + IDBConnection::PLATFORM_POSTGRES => $qb->createFunction('string_agg(distinct ' . $concatColumn . '::varchar, \'' . $separator . '\') AS ' . $asColumn), IDBConnection::PLATFORM_ORACLE => $qb->createFunction('listagg(distinct ' . $concatColumn . ', \'' . $separator . '\') WITHIN GROUP (ORDER BY ' . $concatColumn . ') AS ' . $asColumn), IDBConnection::PLATFORM_SQLITE => $qb->createFunction('group_concat(replace(distinct ' . $concatColumn . ' ,\'\',\'\'), \'' . $separator . '\') AS ' . $asColumn), default => $qb->createFunction('group_concat(distinct ' . $concatColumn . ' SEPARATOR "' . $separator . '") AS ' . $asColumn), From f85a5b6148b4e646caca79766fa459c391c02284 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 21 Jun 2025 08:52:10 +0200 Subject: [PATCH 28/31] remove aftermath Signed-off-by: dartcafe --- lib/Db/TableManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Db/TableManager.php b/lib/Db/TableManager.php index 96612ee1d1..ae5a160390 100644 --- a/lib/Db/TableManager.php +++ b/lib/Db/TableManager.php @@ -449,7 +449,7 @@ public static function getConcatenatedArray( string $separator = ',', ): void { $qb->addSelect(match ($dbProvider) { - IDBConnection::PLATFORM_POSTGRES => $qb->createFunction('string_agg(distinct ' . $concatColumn . '::varchar, \'' . $separator . '\') AS ' . $asColumn), + IDBConnection::PLATFORM_POSTGRES => $qb->createFunction('string_agg(distinct ' . $concatColumn . '::varchar, \'' . $separator . '\') AS ' . $asColumn), IDBConnection::PLATFORM_ORACLE => $qb->createFunction('listagg(distinct ' . $concatColumn . ', \'' . $separator . '\') WITHIN GROUP (ORDER BY ' . $concatColumn . ') AS ' . $asColumn), IDBConnection::PLATFORM_SQLITE => $qb->createFunction('group_concat(replace(distinct ' . $concatColumn . ' ,\'\',\'\'), \'' . $separator . '\') AS ' . $asColumn), default => $qb->createFunction('group_concat(distinct ' . $concatColumn . ' SEPARATOR "' . $separator . '") AS ' . $asColumn), From ed447049211bfca2e07c9b61567027ee94c8b59b Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 21 Jun 2025 10:23:32 +0200 Subject: [PATCH 29/31] poll overview for groups Signed-off-by: dartcafe --- lib/Controller/PageController.php | 1 + lib/Db/PollGroup.php | 11 +++++++++++ src/router.ts | 13 +++++++++++++ src/stores/polls.ts | 24 ++++++++++++++++++++++++ src/stores/session.ts | 3 +++ src/views/Navigation.vue | 8 ++++++-- src/views/PollList.vue | 29 +++++++++++++++++++---------- 7 files changed, 77 insertions(+), 12 deletions(-) diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index dbd1ad1aa3..a7b6fc229f 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -44,6 +44,7 @@ public function __construct( #[FrontpageRoute(verb: 'GET', url: '/combo', postfix: 'combo')] #[FrontpageRoute(verb: 'GET', url: '/not-found', postfix: 'notFound')] #[FrontpageRoute(verb: 'GET', url: '/list/{category}', postfix: 'list')] + #[FrontpageRoute(verb: 'GET', url: '/group/{slug}', postfix: 'group')] public function index(): TemplateResponse { Util::addScript(AppConstants::APP_ID, 'polls-main'); $this->eventDispatcher->dispatchTyped(new LoadAdditionalScriptsEvent()); diff --git a/lib/Db/PollGroup.php b/lib/Db/PollGroup.php index 042b5549df..392e997f25 100644 --- a/lib/Db/PollGroup.php +++ b/lib/Db/PollGroup.php @@ -68,6 +68,16 @@ public function hasPoll(int $pollId): bool { return in_array($pollId, $polls, true); } + public function getSlug(): string { + // sanitize the title to remove any unwanted characters + $slug = preg_replace('/[^a-zA-Z0-9\s]/', '', $this->getTitle()); + // in case the title is empty, use a default slug + if ($slug === '') { + $slug = 'group'; + } + return strtolower(str_replace(' ', '-', $slug)) . '-' . $this->getId(); + } + // alias of getOwner() public function getUserId(): string { return $this->getOwner(); @@ -93,6 +103,7 @@ public function jsonSerialize(): array { 'title' => $this->getTitle(), 'titleExt' => $this->getTitleExt(), 'pollIds' => $this->getPollIds(), + 'slug' => $this->getSlug(), ]; } } diff --git a/src/router.ts b/src/router.ts index 2b68ee75d8..01beec816b 100644 --- a/src/router.ts +++ b/src/router.ts @@ -94,6 +94,19 @@ const routes: RouteRecordRaw[] = [ votePage: false, }, }, + { + path: '/group/:slug', + components: { + default: List, + navigation: Navigation, + }, + props: true, + name: 'group', + meta: { + publicPage: false, + votePage: false, + }, + }, { path: '/combo', components: { diff --git a/src/stores/polls.ts b/src/stores/polls.ts index 05e0ffdc32..0a97979b36 100644 --- a/src/stores/polls.ts +++ b/src/stores/polls.ts @@ -60,6 +60,7 @@ export type PollGroup = { title: string titleExt: string pollIds: number[] + slug: string } export type PollCategoryList = Record @@ -285,6 +286,7 @@ export const usePollsStore = defineStore('polls', { pollsWithGroups(state: PollList): Poll[] { return state.polls.filter((poll: Poll) => poll.pollGroups.length > 0) }, + currentCategory(state: PollList): PollCategory { const sessionStore = useSessionStore() @@ -297,10 +299,32 @@ export const usePollsStore = defineStore('polls', { return state.categories[FilterType.Relevant] }, + currentGroup(state: PollList): PollGroup | undefined { + const sessionStore = useSessionStore() + if (sessionStore.route.name === 'group') { + return state.pollGroups.find( + (group) => group.slug === sessionStore.route.params.slug, + ) + } + return undefined + }, + + groupPolls(state: PollList): Poll[] { + if (!this.currentGroup) { + return [] + } + return state.polls.filter((poll) => this.currentGroup?.pollIds.includes(poll.id)) + }, + /* * polls list, filtered by current category and sorted */ pollsFilteredSorted(state: PollList): Poll[] { + const sessionStore = useSessionStore() + if (sessionStore.route.name === 'group') { + return this.groupPolls + } + return orderBy( state.polls.filter((poll: Poll) => this.currentCategory?.filterCondition(poll), diff --git a/src/stores/session.ts b/src/stores/session.ts index 28c0542159..11b08719c8 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -24,6 +24,7 @@ interface RouteParams { id: number token: string type: FilterType + slug: string } export type Route = { @@ -121,6 +122,7 @@ export const useSessionStore = defineStore('session', { id: 0, token: '', type: FilterType.Relevant, + slug: '', }, }, userStatus: { @@ -227,6 +229,7 @@ export const useSessionStore = defineStore('session', { this.route.params.id = payload.params.id as unknown as number this.route.params.token = payload.params.token as string this.route.params.type = payload.params.type as FilterType + this.route.params.slug = payload.params.slug as string }, // Share store diff --git a/src/views/Navigation.vue b/src/views/Navigation.vue index a7276612d6..9263e5712d 100644 --- a/src/views/Navigation.vue +++ b/src/views/Navigation.vue @@ -194,6 +194,10 @@ onMounted(() => { :name="pollGroup.title" :title="pollGroup.titleExt" :allow-collapse="sessionStore.appSettings.navigationPollsInList" + :to="{ + name: 'group', + params: { slug: pollGroup.slug }, + }" :open="false">