From 8831324c3f168310356e6e39ba331dcc0a624c4f Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 23 Oct 2026 12:28:46 +0200 Subject: [PATCH 01/19] Add standalone polls embeddable via [poll] BBCode with localized questions and options --- com.woltlab.wcf/bbcode.xml | 10 + com.woltlab.wcf/objectType.xml | 9 + com.woltlab.wcf/package.xml | 1 + com.woltlab.wcf/templates/poll.tpl | 2 +- .../templates/shared_pollManagerListItems.tpl | 24 + .../shared_pollOptionListFormField.tpl | 24 + com.woltlab.wcf/templates/shared_wysiwyg.tpl | 12 + com.woltlab.wcf/userGroupOption.xml | 37 ++ phpstan-baseline.neon | 8 +- ts/WoltLabSuite/Core/Api/Polls/GetPoll.ts | 28 ++ ts/WoltLabSuite/Core/Api/Polls/GetResults.ts | 23 + ts/WoltLabSuite/Core/Api/Polls/GetVoteForm.ts | 23 + ts/WoltLabSuite/Core/Api/Polls/Vote.ts | 26 ++ ts/WoltLabSuite/Core/Component/Ckeditor.ts | 6 + .../Core/Component/Ckeditor/Configuration.ts | 1 + .../Core/Component/Ckeditor/Poll.ts | 44 ++ .../Core/Component/Poll/Manager.ts | 301 +++++++++++++ .../Core/Component/Poll/OptionList.ts | 200 +++++++++ .../Core/Form/Builder/Field/PollOptionList.ts | 26 ++ ts/WoltLabSuite/Core/Ui/Poll/Poll.ts | 20 +- ts/WoltLabSuite/Core/Ui/Poll/View/Results.ts | 10 +- ts/WoltLabSuite/Core/Ui/Poll/View/Vote.ts | 10 +- ts/WoltLabSuite/Core/Ui/Poll/Vote.ts | 21 +- .../update_com.woltlab.wcf_6.3_step1.php | 67 +++ .../update_com.woltlab.wcf_6.3_step3.php | 14 + .../update_com.woltlab.wcf_6.3_pollL10n.php | 35 ++ .../js/WoltLabSuite/Core/Api/Polls/GetPoll.js | 19 + .../WoltLabSuite/Core/Api/Polls/GetResults.js | 19 + .../Core/Api/Polls/GetVoteForm.js | 19 + .../js/WoltLabSuite/Core/Api/Polls/Vote.js | 19 + .../WoltLabSuite/Core/Component/Ckeditor.js | 8 +- .../Core/Component/Ckeditor/Poll.js | 71 +++ .../Core/Component/Poll/Manager.js | 236 ++++++++++ .../Core/Component/Poll/OptionList.js | 162 +++++++ .../Core/Form/Builder/Field/PollOptionList.js | 23 + .../js/WoltLabSuite/Core/Ui/Poll/Poll.js | 20 +- .../WoltLabSuite/Core/Ui/Poll/View/Results.js | 7 +- .../js/WoltLabSuite/Core/Ui/Poll/View/Vote.js | 7 +- .../js/WoltLabSuite/Core/Ui/Poll/Vote.js | 15 +- .../files/lib/action/PollEditAction.class.php | 286 ++++++++++++ .../files/lib/command/poll/CopyPoll.class.php | 71 +++ .../lib/command/poll/CreatePoll.class.php | 32 ++ .../lib/command/poll/DeletePolls.class.php | 40 ++ .../lib/command/poll/UpdatePoll.class.php | 32 ++ .../files/lib/command/poll/VotePoll.class.php | 110 +++++ .../lib/command/user/MergeUsers.class.php | 1 + .../lib/command/user/UpdateUser.class.php | 1 + .../files/lib/data/poll/Poll.class.php | 193 +++++++- .../files/lib/data/poll/PollAction.class.php | 347 +++++--------- .../files/lib/data/poll/PollBuilder.class.php | 425 ++++++++++++++++++ .../lib/data/poll/PollCollection.class.php | 78 ++++ .../files/lib/data/poll/PollEditor.class.php | 1 + .../lib/data/poll/option/PollOption.class.php | 87 +++- .../poll/option/PollOptionAction.class.php | 1 + .../poll/option/PollOptionBuilder.class.php | 102 +++++ .../option/PollOptionCollection.class.php | 28 ++ .../poll/option/PollOptionEditor.class.php | 1 + .../user/PollInteractionCollecting.class.php | 19 + .../PollManagerListViewInitialized.class.php | 19 + .../lib/event/poll/PollCreated.class.php | 23 + .../lib/event/poll/PollUpdated.class.php | 23 + .../files/lib/event/poll/PollVoted.class.php | 27 ++ .../lib/event/poll/PollsDeleted.class.php | 24 + .../system/bbcode/HtmlBBCodeParser.class.php | 1 + .../lib/system/bbcode/PollBBCode.class.php | 68 +++ .../core/polls/DeletePoll.class.php | 39 ++ .../controller/core/polls/GetPoll.class.php | 44 ++ .../core/polls/GetResults.class.php | 43 ++ .../core/polls/GetVoteForm.class.php | 43 ++ .../controller/core/polls/Vote.class.php | 83 ++++ .../poll/PollOptionListFormField.class.php | 219 +++++++++ .../importer/AbstractPollImporter.class.php | 28 +- .../AbstractPollOptionImporter.class.php | 18 +- .../user/PollInteractions.class.php | 49 ++ .../user/PollManagerListView.class.php | 128 ++++++ ...PollMessageEmbeddedObjectHandler.class.php | 42 ++ .../lib/system/poll/PollManager.class.php | 112 +++-- wcfsetup/install/files/style/ui/poll.scss | 195 ++++++++ wcfsetup/install/lang/de.xml | 20 + wcfsetup/install/lang/en.xml | 20 + wcfsetup/setup/db/install_com.woltlab.wcf.php | 65 ++- 81 files changed, 4409 insertions(+), 386 deletions(-) create mode 100644 com.woltlab.wcf/templates/shared_pollManagerListItems.tpl create mode 100644 com.woltlab.wcf/templates/shared_pollOptionListFormField.tpl create mode 100644 ts/WoltLabSuite/Core/Api/Polls/GetPoll.ts create mode 100644 ts/WoltLabSuite/Core/Api/Polls/GetResults.ts create mode 100644 ts/WoltLabSuite/Core/Api/Polls/GetVoteForm.ts create mode 100644 ts/WoltLabSuite/Core/Api/Polls/Vote.ts create mode 100644 ts/WoltLabSuite/Core/Component/Ckeditor/Poll.ts create mode 100644 ts/WoltLabSuite/Core/Component/Poll/Manager.ts create mode 100644 ts/WoltLabSuite/Core/Component/Poll/OptionList.ts create mode 100644 ts/WoltLabSuite/Core/Form/Builder/Field/PollOptionList.ts create mode 100644 wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_pollL10n.php create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Polls/GetPoll.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Polls/GetResults.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Polls/GetVoteForm.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Polls/Vote.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/Ckeditor/Poll.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/Manager.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/OptionList.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/PollOptionList.js create mode 100644 wcfsetup/install/files/lib/action/PollEditAction.class.php create mode 100644 wcfsetup/install/files/lib/command/poll/CopyPoll.class.php create mode 100644 wcfsetup/install/files/lib/command/poll/CreatePoll.class.php create mode 100644 wcfsetup/install/files/lib/command/poll/DeletePolls.class.php create mode 100644 wcfsetup/install/files/lib/command/poll/UpdatePoll.class.php create mode 100644 wcfsetup/install/files/lib/command/poll/VotePoll.class.php create mode 100644 wcfsetup/install/files/lib/data/poll/PollBuilder.class.php create mode 100644 wcfsetup/install/files/lib/data/poll/PollCollection.class.php create mode 100644 wcfsetup/install/files/lib/data/poll/option/PollOptionBuilder.class.php create mode 100644 wcfsetup/install/files/lib/data/poll/option/PollOptionCollection.class.php create mode 100644 wcfsetup/install/files/lib/event/interaction/user/PollInteractionCollecting.class.php create mode 100644 wcfsetup/install/files/lib/event/listView/user/PollManagerListViewInitialized.class.php create mode 100644 wcfsetup/install/files/lib/event/poll/PollCreated.class.php create mode 100644 wcfsetup/install/files/lib/event/poll/PollUpdated.class.php create mode 100644 wcfsetup/install/files/lib/event/poll/PollVoted.class.php create mode 100644 wcfsetup/install/files/lib/event/poll/PollsDeleted.class.php create mode 100644 wcfsetup/install/files/lib/system/bbcode/PollBBCode.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/polls/DeletePoll.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/polls/GetPoll.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/polls/GetResults.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/polls/GetVoteForm.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/polls/Vote.class.php create mode 100644 wcfsetup/install/files/lib/system/form/builder/field/poll/PollOptionListFormField.class.php create mode 100644 wcfsetup/install/files/lib/system/interaction/user/PollInteractions.class.php create mode 100644 wcfsetup/install/files/lib/system/listView/user/PollManagerListView.class.php create mode 100644 wcfsetup/install/files/lib/system/message/embedded/object/PollMessageEmbeddedObjectHandler.class.php diff --git a/com.woltlab.wcf/bbcode.xml b/com.woltlab.wcf/bbcode.xml index 3d4c6c554c9..4628366c5e0 100644 --- a/com.woltlab.wcf/bbcode.xml +++ b/com.woltlab.wcf/bbcode.xml @@ -212,6 +212,16 @@ + + wcf\system\bbcode\PollBBCode + 1 + + + + 1 + + + wcf\system\bbcode\WoltLabSuitePageBBCode diff --git a/com.woltlab.wcf/objectType.xml b/com.woltlab.wcf/objectType.xml index 9680b6b5f35..e9ed304059c 100644 --- a/com.woltlab.wcf/objectType.xml +++ b/com.woltlab.wcf/objectType.xml @@ -675,6 +675,11 @@ com.woltlab.wcf.message.embeddedObject wcf\system\message\embedded\object\MediaMessageEmbeddedObjectHandler + + com.woltlab.wcf.poll + com.woltlab.wcf.message.embeddedObject + wcf\system\message\embedded\object\PollMessageEmbeddedObjectHandler + com.woltlab.wcf.user com.woltlab.wcf.message.embeddedObject @@ -1029,6 +1034,10 @@ com.woltlab.wcf.media com.woltlab.wcf.acl.simple + + com.woltlab.wcf.poll + com.woltlab.wcf.acl.simple + com.woltlab.wcf.media.category com.woltlab.wcf.category diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml index bc136faa0db..51930330813 100644 --- a/com.woltlab.wcf/package.xml +++ b/com.woltlab.wcf/package.xml @@ -83,6 +83,7 @@ acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php acp/update_com.woltlab.wcf_6.3_mediaL10n.php + acp/update_com.woltlab.wcf_6.3_pollL10n.php acp/database/update_com.woltlab.wcf_6.3_step3.php The following step requires the new files and must run before the `cronjob` PIP, which precedes the `objectType` PIP: diff --git a/com.woltlab.wcf/templates/poll.tpl b/com.woltlab.wcf/templates/poll.tpl index 6ad66254ab0..7e226bc2532 100644 --- a/com.woltlab.wcf/templates/poll.tpl +++ b/com.woltlab.wcf/templates/poll.tpl @@ -7,7 +7,7 @@ {/if} -
getItems() item='poll'} +
+ + +
+
{$poll->getQuestion()}
+
+ {lang}wcf.poll.manager.item.meta{/lang} +
+
+ + {hascontent} +
+ {content}{unsafe:$view->renderInteractionContextMenuButton($poll)}{/content} +
+ {/hascontent} +
+{/foreach} diff --git a/com.woltlab.wcf/templates/shared_pollOptionListFormField.tpl b/com.woltlab.wcf/templates/shared_pollOptionListFormField.tpl new file mode 100644 index 00000000000..17f04aebfae --- /dev/null +++ b/com.woltlab.wcf/templates/shared_pollOptionListFormField.tpl @@ -0,0 +1,24 @@ + + + + diff --git a/com.woltlab.wcf/templates/shared_wysiwyg.tpl b/com.woltlab.wcf/templates/shared_wysiwyg.tpl index 8447439b6e6..edf7e877b95 100644 --- a/com.woltlab.wcf/templates/shared_wysiwyg.tpl +++ b/com.woltlab.wcf/templates/shared_wysiwyg.tpl @@ -50,6 +50,10 @@ {jsphrase name='wcf.editor.restoreDraft'} {jsphrase name='wcf.editor.restoreDraft.preview'} {jsphrase name='wcf.editor.restoreDraft.restoreOrDiscard'} + {if MODULE_POLL && $__wcf->session->getPermission('user.poll.canCreatePoll')} + {jsphrase name='wcf.poll.manager'} + {jsphrase name='wcf.poll.manager.selection.none'} + {/if} {if $__wcf->language->languageCode !== 'en'} window.CKEDITOR_TRANSLATIONS['{unsafe:$__wcf->getBBCodeHandler()->getEditorLocalization(true)|strtolower|encodeJS}'].dictionary["Single line code"] = '{jslang}wcf.editor.button.code{/jslang}'; @@ -82,6 +86,7 @@ mark: {if $__wcf->getBBCodeHandler()->isAvailableBBCode('mark')}true{else}false{/if}, media: {if $__wcf->session->getPermission('admin.content.cms.canUseMedia')}true{else}false{/if}, mention: {if $__wcf->session->hasPermission('user.message.canMention')}element.dataset.supportMention === "true"{else}false{/if}, + poll: {if MODULE_POLL && $__wcf->session->getPermission('user.poll.canCreatePoll') && $__wcf->getBBCodeHandler()->isAvailableBBCode('poll')}true{else}false{/if}, quoteBlock: true, strikethrough: true, submitOnEnter: false, @@ -109,6 +114,13 @@ label: '{jslang}wcf.editor.button.media{/jslang}', }); } + if (features.poll) { + bbcodes.push({ + icon: "chart-bar;false", + name: "poll", + label: '{jslang}wcf.editor.button.poll{/jslang}', + }); + } const smileys = [ {foreach from=$__wcf->getSmileyCache()->getEmojis() key=__code item=__smiley} { diff --git a/com.woltlab.wcf/userGroupOption.xml b/com.woltlab.wcf/userGroupOption.xml index c00134d16ca..048de9f162b 100644 --- a/com.woltlab.wcf/userGroupOption.xml +++ b/com.woltlab.wcf/userGroupOption.xml @@ -39,6 +39,10 @@ user.message + + user.message + module_poll + user @@ -64,6 +68,10 @@ mod.cms + + mod.general + module_poll + admin @@ -991,6 +999,35 @@ bmp 1 1 + + + + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 2397762b774..0593336c674 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -761,6 +761,13 @@ You can access the error log at: {link controller='ExceptionLogView' isEmail=tru + + + + + + + @@ -878,6 +885,9 @@ You can access the error log at: {link controller='ExceptionLogView' isEmail=tru + + + @@ -3769,6 +3779,7 @@ Attachments: + @@ -4473,6 +4484,15 @@ Attachments: votes} vote{if $poll->votes != 1}s{/if}]]> endTime|plainTime}]]> + + + + + votes} vote{if $poll->votes != 1}s{/if} · created {@$poll->time|plainTime}{if $poll->isFinished()} · ended{/if}]]> + + + + diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index 3f99455bd7d..9b6d658e878 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -3155,12 +3155,13 @@ DatabaseTable::create('wcf1_poll') ->columns([ ObjectIdDatabaseTableColumn::create('pollID'), - NotNullInt10DatabaseTableColumn::create('objectTypeID'), + IntDatabaseTableColumn::create('objectTypeID'), NotNullInt10DatabaseTableColumn::create('objectID') ->defaultValue(0), - VarcharDatabaseTableColumn::create('question') - ->length(255) + IntDatabaseTableColumn::create('userID'), + NotNullVarchar255DatabaseTableColumn::create('username') ->defaultValue(''), + DefaultFalseBooleanDatabaseTableColumn::create('isMultilingual'), NotNullInt10DatabaseTableColumn::create('time') ->defaultValue(0), NotNullInt10DatabaseTableColumn::create('endTime') @@ -3185,13 +3186,42 @@ ->referencedColumns(['objectTypeID']) ->onDelete('CASCADE') ->onUpdate('NO ACTION'), + DatabaseTableForeignKey::create() + ->columns(['userID']) + ->referencedTable('wcf1_user') + ->referencedColumns(['userID']) + ->onDelete('SET NULL') + ->onUpdate('NO ACTION'), + ]), + DatabaseTable::create('wcf1_poll_l10n') + ->columns([ + NotNullInt10DatabaseTableColumn::create('pollID'), + IntDatabaseTableColumn::create('languageID'), + VarcharDatabaseTableColumn::create('question') + ->length(255), + ]) + ->indices([ + DatabaseTableIndex::create('pollID') + ->columns(['pollID', 'languageID']), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['pollID']) + ->referencedTable('wcf1_poll') + ->referencedColumns(['pollID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), + DatabaseTableForeignKey::create() + ->columns(['languageID']) + ->referencedTable('wcf1_language') + ->referencedColumns(['languageID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), ]), DatabaseTable::create('wcf1_poll_option') ->columns([ ObjectIdDatabaseTableColumn::create('optionID'), NotNullInt10DatabaseTableColumn::create('pollID'), - NotNullVarchar255DatabaseTableColumn::create('optionValue') - ->defaultValue(''), NotNullInt10DatabaseTableColumn::create('votes') ->defaultValue(0), NotNullInt10DatabaseTableColumn::create('showOrder') @@ -3209,6 +3239,31 @@ ->onDelete('CASCADE') ->onUpdate('NO ACTION'), ]), + DatabaseTable::create('wcf1_poll_option_l10n') + ->columns([ + NotNullInt10DatabaseTableColumn::create('optionID'), + IntDatabaseTableColumn::create('languageID'), + VarcharDatabaseTableColumn::create('optionValue') + ->length(255), + ]) + ->indices([ + DatabaseTableIndex::create('optionID') + ->columns(['optionID', 'languageID']), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['optionID']) + ->referencedTable('wcf1_poll_option') + ->referencedColumns(['optionID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), + DatabaseTableForeignKey::create() + ->columns(['languageID']) + ->referencedTable('wcf1_language') + ->referencedColumns(['languageID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), + ]), DatabaseTable::create('wcf1_poll_option_vote') ->columns([ NotNullInt10DatabaseTableColumn::create('pollID'), From f1aa23bf3ea54f4a5262300902d8771a43393b0b Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 23 Oct 2026 13:39:18 +0200 Subject: [PATCH 02/19] Render embedded standalone polls as an interactive widget with in-place voting and result toggle --- .../templates/shared_bbcode_poll.tpl | 125 ++++++++ ts/WoltLabSuite/Core/Api/Polls/GetWidget.ts | 23 ++ ts/WoltLabSuite/Core/Bootstrap.ts | 4 + ts/WoltLabSuite/Core/Component/Poll/Widget.ts | 162 ++++++++++ .../WoltLabSuite/Core/Api/Polls/GetWidget.js | 19 ++ .../files/js/WoltLabSuite/Core/Bootstrap.js | 11 +- .../Core/Component/Poll/Widget.js | 125 ++++++++ .../lib/system/bbcode/PollBBCode.class.php | 8 +- .../controller/core/polls/GetWidget.class.php | 39 +++ .../system/view/poll/PollWidgetView.class.php | 64 ++++ .../install/files/style/ui/pollWidget.scss | 300 ++++++++++++++++++ wcfsetup/install/lang/de.xml | 10 + wcfsetup/install/lang/en.xml | 10 + 13 files changed, 890 insertions(+), 10 deletions(-) create mode 100644 com.woltlab.wcf/templates/shared_bbcode_poll.tpl create mode 100644 ts/WoltLabSuite/Core/Api/Polls/GetWidget.ts create mode 100644 ts/WoltLabSuite/Core/Component/Poll/Widget.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Api/Polls/GetWidget.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/Widget.js create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/polls/GetWidget.class.php create mode 100644 wcfsetup/install/files/lib/system/view/poll/PollWidgetView.class.php create mode 100644 wcfsetup/install/files/style/ui/pollWidget.scss diff --git a/com.woltlab.wcf/templates/shared_bbcode_poll.tpl b/com.woltlab.wcf/templates/shared_bbcode_poll.tpl new file mode 100644 index 00000000000..4809de018bd --- /dev/null +++ b/com.woltlab.wcf/templates/shared_bbcode_poll.tpl @@ -0,0 +1,125 @@ +
+
+
+ {icon name='chart-simple' type='solid'} + {lang}wcf.poll.widget.label{/lang} + + {lang}wcf.poll.widget.votes{/lang} +
+ +

{$poll->getQuestion()}

+ + {if $poll->canVote()} +
+ {if $poll->maxVotes > 1} + {lang}wcf.poll.widget.hint.multiple{/lang} + {else} + {lang}wcf.poll.widget.hint.single{/lang} + {/if} +
+ {elseif !$poll->canSeeResult()} +
{lang}wcf.poll.restrictedResult{/lang}
+ {/if} + {if $poll->isParticipant()} +
{lang}wcf.poll.widget.hint.voted{/lang}
+ {/if} +
+ +
+ {foreach from=$options item=option} + + {/foreach} +
+ +
+ {hascontent} +
+ {content} + {if $poll->endTime} +
+ {icon name='clock'} + + {if $poll->isFinished()} + {lang}wcf.poll.widget.ended{/lang} + {else} + {lang}wcf.poll.widget.endTime{/lang} + {/if} + +
+ {/if} + {if $poll->isPublic} +
+ {icon name='eye'} + {lang}wcf.poll.isPublic{/lang} +
+ {/if} + + {event name='meta'} + {/content} +
+ {/hascontent} + + {hascontent} +
+ {content} + {if $poll->canVote()} + + {/if} + {if $poll->canSeeResult()} + + {/if} + {/content} +
+ {/hascontent} + + {hascontent} +
+ {content} + {if $poll->canVote()} + + {/if} + {if $poll->canViewParticipants()} + + {/if} + {/content} +
+ {/hascontent} + + {event name='footer'} +
+
diff --git a/ts/WoltLabSuite/Core/Api/Polls/GetWidget.ts b/ts/WoltLabSuite/Core/Api/Polls/GetWidget.ts new file mode 100644 index 00000000000..a787911076f --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Polls/GetWidget.ts @@ -0,0 +1,23 @@ +/** + * Gets the rendered widget of a standalone poll. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { fromInfallibleApiRequest } from "WoltLabSuite/Core/Api/Result"; + +type Response = { + template: string; +}; + +export async function getWidget(pollId: number): Promise { + const url = new URL(`${window.WSC_RPC_API_URL}core/polls/${pollId}/widget`); + + return fromInfallibleApiRequest(() => { + return prepareRequest(url).get().fetchAsJson(); + }); +} diff --git a/ts/WoltLabSuite/Core/Bootstrap.ts b/ts/WoltLabSuite/Core/Bootstrap.ts index 7fe4235971f..b2d80366e54 100644 --- a/ts/WoltLabSuite/Core/Bootstrap.ts +++ b/ts/WoltLabSuite/Core/Bootstrap.ts @@ -199,6 +199,10 @@ export function setup(options: BoostrapOptions): void { void import("./Component/Media/Gallery").then(({ setup }) => setup()); }); + whenFirstSeen(".pollWidget", () => { + void import("./Component/Poll/Widget").then(({ setup }) => setup()); + }); + whenFirstSeen("[data-fancybox]", () => { void import("./Component/Image/Viewer").then(({ setup }) => setup()); }); diff --git a/ts/WoltLabSuite/Core/Component/Poll/Widget.ts b/ts/WoltLabSuite/Core/Component/Poll/Widget.ts new file mode 100644 index 00000000000..d04d4791e78 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Poll/Widget.ts @@ -0,0 +1,162 @@ +/** + * Handles the widget of standalone polls that have been embedded into a + * message. The widget contains both the vote form and the results, switching + * between them does not require a request. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import { getWidget } from "WoltLabSuite/Core/Api/Polls/GetWidget"; +import { vote } from "WoltLabSuite/Core/Api/Polls/Vote"; +import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog"; +import DomUtil from "WoltLabSuite/Core/Dom/Util"; +import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex"; +import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; + +type State = "vote" | "results"; + +class PollWidget { + readonly #element: HTMLElement; + readonly #inputs: HTMLInputElement[]; + readonly #maxVotes: number; + readonly #pollId: number; + readonly #voteButton: HTMLButtonElement | null; + + constructor(element: HTMLElement) { + this.#element = element; + this.#inputs = Array.from(element.querySelectorAll(".pollWidget__input")); + this.#maxVotes = parseInt(element.dataset.maxVotes!); + this.#pollId = parseInt(element.dataset.pollId!); + this.#voteButton = element.querySelector(".pollWidget__vote"); + + this.#initEventListeners(); + this.#setState(element.dataset.state as State, false); + this.#updateInputs(); + } + + #initEventListeners(): void { + for (const input of this.#inputs) { + input.addEventListener("change", () => this.#updateInputs()); + } + + // The inputs remain part of the rows while the results are displayed. + this.#element.querySelector(".pollWidget__options")!.addEventListener("click", (event) => { + if (this.#state === "results") { + event.preventDefault(); + } + }); + + this.#element.querySelector(".pollWidget__showResults")?.addEventListener("click", () => { + this.#setState("results"); + }); + + this.#element.querySelector(".pollWidget__showVote")?.addEventListener("click", () => { + this.#setState("vote"); + }); + + this.#voteButton?.addEventListener( + "click", + promiseMutex(() => this.#submit()), + ); + + this.#element.querySelector(".pollWidget__showParticipants")?.addEventListener( + "click", + promiseMutex(() => { + return dialogFactory() + .usingListView() + .fromPreset( + this.#element.dataset.question!, + "wcf\\system\\listView\\user\\PollParticipantListView", + new Map([["pollID", this.#pollId.toString()]]), + ); + }), + ); + } + + get #state(): State { + return this.#element.dataset.state as State; + } + + #setState(state: State, moveFocus = true): void { + this.#element.dataset.state = state; + + for (const input of this.#inputs) { + input.inert = state === "results"; + } + + if (!moveFocus) { + return; + } + + if (state === "vote") { + const input = this.#inputs.find((input) => input.checked) ?? this.#inputs.find((input) => !input.disabled); + input?.focus(); + } else { + this.#getQuestion().focus(); + } + } + + #updateInputs(): void { + if (this.#voteButton === null) { + return; + } + + const selectedCount = this.#getSelectedOptionIds().length; + this.#voteButton.disabled = selectedCount === 0; + + if (this.#maxVotes > 1) { + for (const input of this.#inputs) { + input.disabled = !input.checked && selectedCount >= this.#maxVotes; + } + } + } + + #getSelectedOptionIds(): number[] { + return this.#inputs.filter((input) => input.checked).map((input) => parseInt(input.value)); + } + + #getQuestion(): HTMLElement { + return this.#element.querySelector(".pollWidget__question")!; + } + + async #submit(): Promise { + const optionIds = this.#getSelectedOptionIds(); + if (optionIds.length === 0) { + return; + } + + this.#voteButton!.disabled = true; + this.#element.setAttribute("aria-busy", "true"); + + try { + await vote(this.#pollId, optionIds); + const { template } = await getWidget(this.#pollId); + + const container = document.createElement("div"); + DomUtil.setInnerHtml(container, template); + + const widget = container.querySelector(".pollWidget"); + if (widget === null) { + return; + } + + // The new widget is set up by the selector observer. + this.#element.replaceWith(widget); + widget.querySelector(".pollWidget__question")!.focus(); + } finally { + if (this.#element.isConnected) { + this.#element.removeAttribute("aria-busy"); + this.#updateInputs(); + } + } + } +} + +export function setup(): void { + wheneverFirstSeen(".pollWidget", (element) => { + new PollWidget(element); + }); +} diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Polls/GetWidget.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Polls/GetWidget.js new file mode 100644 index 00000000000..4932b2d4b3c --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Polls/GetWidget.js @@ -0,0 +1,19 @@ +/** + * Gets the rendered widget of a standalone poll. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Api/Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.getWidget = getWidget; + async function getWidget(pollId) { + const url = new URL(`${window.WSC_RPC_API_URL}core/polls/${pollId}/widget`); + return (0, Result_1.fromInfallibleApiRequest)(() => { + return (0, Backend_1.prepareRequest)(url).get().fetchAsJson(); + }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js index 08d1b177890..deff1c7a2b9 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js @@ -160,18 +160,21 @@ define(["require", "exports", "tslib", "./Core", "./Date/Picker", "./Devtools", (0, LazyLoader_1.whenFirstSeen)(".mediaGallery", () => { void new Promise((resolve_13, reject_13) => { require(["./Component/Media/Gallery"], resolve_13, reject_13); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); + (0, LazyLoader_1.whenFirstSeen)(".pollWidget", () => { + void new Promise((resolve_14, reject_14) => { require(["./Component/Poll/Widget"], resolve_14, reject_14); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + }); (0, LazyLoader_1.whenFirstSeen)("[data-fancybox]", () => { - void new Promise((resolve_14, reject_14) => { require(["./Component/Image/Viewer"], resolve_14, reject_14); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_15, reject_15) => { require(["./Component/Image/Viewer"], resolve_15, reject_15); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)(".jsImageViewer", () => { console.warn("The class `jsImageViewer` is deprecated. Use the attribute `data-fancybox` instead."); - void new Promise((resolve_15, reject_15) => { require(["./Component/Image/Viewer"], resolve_15, reject_15); }).then(tslib_1.__importStar).then(({ setupLegacy }) => setupLegacy()); + void new Promise((resolve_16, reject_16) => { require(["./Component/Image/Viewer"], resolve_16, reject_16); }).then(tslib_1.__importStar).then(({ setupLegacy }) => setupLegacy()); }); (0, LazyLoader_1.whenFirstSeen)(".jsEnablesOptions", () => { - void new Promise((resolve_16, reject_16) => { require(["./Component/Option/Enable"], resolve_16, reject_16); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_17, reject_17) => { require(["./Component/Option/Enable"], resolve_17, reject_17); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)("[data-edit-cover-photo]", () => { - void new Promise((resolve_17, reject_17) => { require(["./Component/User/CoverPhoto"], resolve_17, reject_17); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_18, reject_18) => { require(["./Component/User/CoverPhoto"], resolve_18, reject_18); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); // Move the reCAPTCHA widget overlay to the `pageOverlayContainer` // when widget form elements are placed in a dialog. diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/Widget.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/Widget.js new file mode 100644 index 00000000000..0880d690820 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/Widget.js @@ -0,0 +1,125 @@ +/** + * Handles the widget of standalone polls that have been embedded into a + * message. The widget contains both the vote form and the results, switching + * between them does not require a request. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Polls/GetWidget", "WoltLabSuite/Core/Api/Polls/Vote", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Helper/Selector"], function (require, exports, tslib_1, GetWidget_1, Vote_1, Dialog_1, Util_1, PromiseMutex_1, Selector_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = setup; + Util_1 = tslib_1.__importDefault(Util_1); + class PollWidget { + #element; + #inputs; + #maxVotes; + #pollId; + #voteButton; + constructor(element) { + this.#element = element; + this.#inputs = Array.from(element.querySelectorAll(".pollWidget__input")); + this.#maxVotes = parseInt(element.dataset.maxVotes); + this.#pollId = parseInt(element.dataset.pollId); + this.#voteButton = element.querySelector(".pollWidget__vote"); + this.#initEventListeners(); + this.#setState(element.dataset.state, false); + this.#updateInputs(); + } + #initEventListeners() { + for (const input of this.#inputs) { + input.addEventListener("change", () => this.#updateInputs()); + } + // The inputs remain part of the rows while the results are displayed. + this.#element.querySelector(".pollWidget__options").addEventListener("click", (event) => { + if (this.#state === "results") { + event.preventDefault(); + } + }); + this.#element.querySelector(".pollWidget__showResults")?.addEventListener("click", () => { + this.#setState("results"); + }); + this.#element.querySelector(".pollWidget__showVote")?.addEventListener("click", () => { + this.#setState("vote"); + }); + this.#voteButton?.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => this.#submit())); + this.#element.querySelector(".pollWidget__showParticipants")?.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => { + return (0, Dialog_1.dialogFactory)() + .usingListView() + .fromPreset(this.#element.dataset.question, "wcf\\system\\listView\\user\\PollParticipantListView", new Map([["pollID", this.#pollId.toString()]])); + })); + } + get #state() { + return this.#element.dataset.state; + } + #setState(state, moveFocus = true) { + this.#element.dataset.state = state; + for (const input of this.#inputs) { + input.inert = state === "results"; + } + if (!moveFocus) { + return; + } + if (state === "vote") { + const input = this.#inputs.find((input) => input.checked) ?? this.#inputs.find((input) => !input.disabled); + input?.focus(); + } + else { + this.#getQuestion().focus(); + } + } + #updateInputs() { + if (this.#voteButton === null) { + return; + } + const selectedCount = this.#getSelectedOptionIds().length; + this.#voteButton.disabled = selectedCount === 0; + if (this.#maxVotes > 1) { + for (const input of this.#inputs) { + input.disabled = !input.checked && selectedCount >= this.#maxVotes; + } + } + } + #getSelectedOptionIds() { + return this.#inputs.filter((input) => input.checked).map((input) => parseInt(input.value)); + } + #getQuestion() { + return this.#element.querySelector(".pollWidget__question"); + } + async #submit() { + const optionIds = this.#getSelectedOptionIds(); + if (optionIds.length === 0) { + return; + } + this.#voteButton.disabled = true; + this.#element.setAttribute("aria-busy", "true"); + try { + await (0, Vote_1.vote)(this.#pollId, optionIds); + const { template } = await (0, GetWidget_1.getWidget)(this.#pollId); + const container = document.createElement("div"); + Util_1.default.setInnerHtml(container, template); + const widget = container.querySelector(".pollWidget"); + if (widget === null) { + return; + } + // The new widget is set up by the selector observer. + this.#element.replaceWith(widget); + widget.querySelector(".pollWidget__question").focus(); + } + finally { + if (this.#element.isConnected) { + this.#element.removeAttribute("aria-busy"); + this.#updateInputs(); + } + } + } + } + function setup() { + (0, Selector_1.wheneverFirstSeen)(".pollWidget", (element) => { + new PollWidget(element); + }); + } +}); diff --git a/wcfsetup/install/files/lib/system/bbcode/PollBBCode.class.php b/wcfsetup/install/files/lib/system/bbcode/PollBBCode.class.php index 06d44ad5062..845a490aa55 100644 --- a/wcfsetup/install/files/lib/system/bbcode/PollBBCode.class.php +++ b/wcfsetup/install/files/lib/system/bbcode/PollBBCode.class.php @@ -5,7 +5,7 @@ use wcf\data\poll\Poll; use wcf\system\message\embedded\object\MessageEmbeddedObjectManager; use wcf\system\view\ContentNotVisibleView; -use wcf\system\WCF; +use wcf\system\view\poll\PollWidgetView; use wcf\util\StringUtil; /** @@ -37,11 +37,7 @@ public function getParsedTag(array $openingTag, string $content, array $closingT } if ($parser->getOutputType() === 'text/html') { - return WCF::getTPL()->render('wcf', 'poll', [ - 'poll' => $poll, - // The same poll can be embedded multiple times on a page. - 'pollElementID' => 'poll' . $poll->pollID . '_' . \substr(StringUtil::getRandomID(), 0, 8), - ]); + return (string)new PollWidgetView($poll); } $options = \array_map( diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/polls/GetWidget.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/polls/GetWidget.class.php new file mode 100644 index 00000000000..89258b55164 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/polls/GetWidget.class.php @@ -0,0 +1,39 @@ + + * @since 6.3 + */ +#[GetRequest('/core/polls/{id:\d+}/widget')] +final class GetWidget implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $poll = Helper::fetchObjectFromRequestParameter($variables['id'], Poll::class); + + if (\MODULE_POLL === 0 || !$poll->isStandalone() || !$poll->isAccessible()) { + throw new PermissionDeniedException(); + } + + return new JsonResponse([ + 'template' => (string)new PollWidgetView($poll), + ]); + } +} diff --git a/wcfsetup/install/files/lib/system/view/poll/PollWidgetView.class.php b/wcfsetup/install/files/lib/system/view/poll/PollWidgetView.class.php new file mode 100644 index 00000000000..1ade8c470d1 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/poll/PollWidgetView.class.php @@ -0,0 +1,64 @@ + + * @since 6.3 + */ +final class PollWidgetView implements \Stringable +{ + public function __construct( + private readonly Poll $poll, + ) {} + + #[\Override] + public function __toString(): string + { + // The order of the results can differ from the order of the options, + // the results are reordered through CSS to keep the rows in place. + $resultPositions = []; + $leadingVotes = 0; + foreach (\array_values($this->poll->getOptions(true)) as $position => $option) { + $resultPositions[$option->optionID] = $position; + $leadingVotes = \max($leadingVotes, $option->votes); + } + + return WCF::getTPL()->render('wcf', 'shared_bbcode_poll', [ + 'poll' => $this->poll, + 'options' => $this->poll->getOptions(), + 'resultPositions' => $resultPositions, + 'leadingVotes' => $leadingVotes, + 'initialState' => $this->getInitialState(), + // The same poll can be embedded multiple times on a page. + 'elementID' => 'poll' . $this->poll->pollID . '_' . \substr(StringUtil::getRandomID(), 0, 8), + ]); + } + + /** + * @return 'vote'|'results' + */ + private function getInitialState(): string + { + if ($this->poll->canVote() && !$this->poll->isParticipant()) { + return 'vote'; + } + + if ($this->poll->canSeeResult()) { + return 'results'; + } + + return 'vote'; + } +} diff --git a/wcfsetup/install/files/style/ui/pollWidget.scss b/wcfsetup/install/files/style/ui/pollWidget.scss new file mode 100644 index 00000000000..df79430c673 --- /dev/null +++ b/wcfsetup/install/files/style/ui/pollWidget.scss @@ -0,0 +1,300 @@ +/* embedded standalone poll, the question is qualified to override the styles of `.htmlContent` */ +.pollWidget { + background-color: var(--wcfContentContainerBackground); + border: 1px solid var(--wcfContentBorderInner); + border-radius: 12px; + color: var(--wcfContentText); + margin: 20px 0; + max-width: 600px; + + &[aria-busy="true"] { + cursor: progress; + } +} + +.pollWidget__header { + padding: 18px 18px 14px; +} + +.pollWidget__eyebrow { + align-items: center; + color: var(--wcfContentDimmedText); + display: flex; + flex-wrap: wrap; + font-size: 11px; + font-weight: 500; + gap: 6px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.pollWidget__separator { + background-color: currentColor; + border-radius: 50%; + height: 3px; + width: 3px; +} + +.pollWidget__totalVotes { + font-size: 12px; + letter-spacing: normal; + text-transform: none; +} + +.pollWidget h3.pollWidget__question { + color: var(--wcfContentHeadlineText); + font-size: 18px; + font-weight: 600; + line-height: 1.3; + margin: 8px 0 0 !important; + text-wrap: pretty; +} + +.pollWidget__hint { + color: var(--wcfContentDimmedText); + font-size: 12.5px; + margin-top: 4px; +} + +.pollWidget__options { + display: flex; + flex-direction: column; + gap: 6px; + padding: 0 12px; +} + +.pollWidget__option { + align-items: center; + background-color: color-mix(in srgb, var(--wcfContentText) 3%, transparent); + border: 1px solid var(--wcfContentBorderInner); + border-radius: 9px; + display: flex; + font-size: 14px; + gap: 11px; + isolation: isolate; + min-height: 42px; + overflow: hidden; + padding: 0 12px; + position: relative; +} + +/* The native input remains focusable for the keyboard navigation. */ +.pollWidget__input { + clip-path: inset(50%); + height: 1px; + margin: 0; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.pollWidget__indicator { + border: 1.5px solid var(--wcfContentDimmedText); + border-radius: 50%; + flex: 0 0 auto; + height: 16px; + position: relative; + width: 16px; +} + +.pollWidget__input[type="checkbox"] + .pollWidget__indicator { + border-radius: 4px; +} + +.pollWidget__input[type="radio"]:checked + .pollWidget__indicator { + background-color: var(--wcfButtonPrimaryBackground); + border-color: var(--wcfButtonPrimaryBackground); + box-shadow: inset 0 0 0 3px var(--wcfContentContainerBackground); +} + +.pollWidget__input[type="checkbox"]:checked + .pollWidget__indicator { + background-color: var(--wcfButtonPrimaryBackground); + border-color: var(--wcfButtonPrimaryBackground); +} + +.pollWidget__input[type="checkbox"]:checked + .pollWidget__indicator::after, +.pollWidget__ownVote::after { + border: solid var(--wcfButtonPrimaryText); + border-width: 0 2px 2px 0; + content: ""; + height: 8px; + left: 50%; + position: absolute; + top: 45%; + transform: translate(-50%, -50%) rotate(45deg); + width: 4px; +} + +.pollWidget__text { + line-height: 1.4; + min-width: 0; + overflow-wrap: anywhere; + padding: 10px 0; +} + +.pollWidget__ownVote { + background-color: var(--wcfButtonPrimaryBackground); + border-radius: 50%; + flex: 0 0 auto; + height: 16px; + position: relative; + width: 16px; + + &::after { + border-width: 0 1.5px 1.5px 0; + height: 7px; + width: 3.5px; + } +} + +.pollWidget__bar { + animation: pollWidgetBar 0.5s cubic-bezier(0.2, 0.7, 0.2, 1); + background-color: color-mix(in srgb, var(--wcfContentText) 8%, transparent); + inset: 0 auto 0 0; + position: absolute; + width: var(--pollWidget-percent); + z-index: -1; +} + +.pollWidget__option--leading .pollWidget__bar { + background-color: color-mix(in srgb, var(--wcfButtonPrimaryBackground) 18%, transparent); +} + +@keyframes pollWidgetBar { + from { + width: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .pollWidget__bar { + animation: none; + } +} + +.pollWidget__value { + align-items: baseline; + display: flex; + flex: 0 0 auto; + font-variant-numeric: tabular-nums; + gap: 8px; + margin-left: auto; +} + +.pollWidget__count { + color: var(--wcfContentDimmedText); + font-size: 12px; +} + +.pollWidget__percent { + color: var(--wcfContentDimmedText); + font-size: 13.5px; + font-weight: 500; + min-width: 38px; + text-align: right; +} + +.pollWidget__option--leading .pollWidget__percent { + color: var(--wcfContentText); + font-weight: 600; +} + +/* vote state */ +.pollWidget[data-state="vote"] { + .pollWidget__hint--results, + .pollWidget__actions--results, + .pollWidget__bar, + .pollWidget__value, + .pollWidget__resultLabel, + .pollWidget__ownVote { + display: none; + } +} + +.pollWidget[data-state="vote"][data-can-vote="true"] .pollWidget__option { + cursor: pointer; + + &:hover { + background-color: var(--wcfTabularBoxBackgroundActive); + } + + &:has(.pollWidget__input:checked) { + background-color: color-mix(in srgb, var(--wcfButtonPrimaryBackground) 9%, transparent); + border-color: var(--wcfButtonPrimaryBackground); + } + + &:has(.pollWidget__input:focus-visible) { + outline: 2px solid var(--wcfContentLink); + outline-offset: 1px; + } + + &:has(.pollWidget__input:disabled) { + cursor: not-allowed; + opacity: 0.5; + } +} + +/* results state */ +.pollWidget[data-state="results"] { + .pollWidget__hint--vote, + .pollWidget__actions--vote, + .pollWidget__indicator { + display: none; + } + + .pollWidget__option { + border-color: transparent; + order: var(--pollWidget-order); + } +} + +.pollWidget__footer { + border-top: 1px solid var(--wcfContentBorderInner); + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 14px; + padding: 12px 18px 16px; + + &:not(:has(> *)) { + display: none; + } +} + +.pollWidget__meta { + color: var(--wcfContentDimmedText); + display: flex; + flex-wrap: wrap; + font-size: 12.5px; + gap: 6px 14px; +} + +.pollWidget__metaItem { + align-items: center; + display: flex; + gap: 6px; +} + +.pollWidget__actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.pollWidget__button { + border-radius: 8px; + min-height: 36px; +} + +.pollWidget__link { + color: var(--wcfContentDimmedText); + cursor: pointer; + font-size: 13px; + margin-left: auto; + + &:hover { + text-decoration: underline; + } +} diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index e57d94189ba..79acb67dc88 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -4462,6 +4462,7 @@ Dateianhänge:
+ @@ -4494,6 +4495,15 @@ Dateianhänge: + + votes} Stimme{if $poll->votes != 1}n{/if}]]> + + maxVotes} Antworten.]]> + + + endTime|plainTime}]]> + endTime|plainTime}]]> + votes} Stimme{if $option->votes != 1}n{/if}, {#$option->getRelativeVotes($poll)} %]]> diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 0593336c674..48838467256 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -4461,6 +4461,7 @@ Attachments: + @@ -4493,6 +4494,15 @@ Attachments: + + votes} Vote{if $poll->votes != 1}s{/if}]]> + + maxVotes} answers.]]> + + + endTime|plainTime}]]> + endTime|plainTime}]]> + votes} vote{if $option->votes != 1}s{/if}, {#$option->getRelativeVotes($poll)} %]]> From 3885d3f1e1f6f32918e0f203c31697714d9e0963 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 23 Oct 2026 13:43:33 +0200 Subject: [PATCH 03/19] Prevent leaking restricted poll results through inline vote percentages, result order and leading-option class --- com.woltlab.wcf/templates/shared_bbcode_poll.tpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/com.woltlab.wcf/templates/shared_bbcode_poll.tpl b/com.woltlab.wcf/templates/shared_bbcode_poll.tpl index 4809de018bd..235d520edae 100644 --- a/com.woltlab.wcf/templates/shared_bbcode_poll.tpl +++ b/com.woltlab.wcf/templates/shared_bbcode_poll.tpl @@ -32,8 +32,8 @@
{foreach from=$options item=option} -

{$poll->getQuestion()}

@@ -122,4 +123,6 @@ {event name='footer'} + + {unsafe:$interactionInitialization} diff --git a/ts/WoltLabSuite/Core/Api/Polls/GetWidget.ts b/ts/WoltLabSuite/Core/Api/Polls/GetWidget.ts new file mode 100644 index 00000000000..a787911076f --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Polls/GetWidget.ts @@ -0,0 +1,23 @@ +/** + * Gets the rendered widget of a standalone poll. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { fromInfallibleApiRequest } from "WoltLabSuite/Core/Api/Result"; + +type Response = { + template: string; +}; + +export async function getWidget(pollId: number): Promise { + const url = new URL(`${window.WSC_RPC_API_URL}core/polls/${pollId}/widget`); + + return fromInfallibleApiRequest(() => { + return prepareRequest(url).get().fetchAsJson(); + }); +} diff --git a/ts/WoltLabSuite/Core/Component/Poll/Widget.ts b/ts/WoltLabSuite/Core/Component/Poll/Widget.ts index 63cc02233a1..cdb46a4237c 100644 --- a/ts/WoltLabSuite/Core/Component/Poll/Widget.ts +++ b/ts/WoltLabSuite/Core/Component/Poll/Widget.ts @@ -9,11 +9,14 @@ * @since 6.3 */ +import { getWidget } from "WoltLabSuite/Core/Api/Polls/GetWidget"; import { vote } from "WoltLabSuite/Core/Api/Polls/Vote"; import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog"; +import { trigger as triggerDomChange } from "WoltLabSuite/Core/Dom/Change/Listener"; import DomUtil from "WoltLabSuite/Core/Dom/Util"; import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex"; import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; +import UiDropdownSimple from "WoltLabSuite/Core/Ui/Dropdown/Simple"; type State = "vote" | "results"; @@ -32,6 +35,7 @@ class PollWidget { this.#voteButton = element.querySelector(".pollWidget__vote"); this.#initEventListeners(); + this.#initInteractions(); this.#setState(element.dataset.state as State, false); this.#updateInputs(); } @@ -75,6 +79,51 @@ class PollWidget { ); } + /** + * The widget serves as the container of the interactions, which allow the + * owner and moderators to edit or delete the poll. + */ + #initInteractions(): void { + const toggle = this.#element.querySelector(".pollWidget__interactions .dropdownToggle"); + if (toggle === null) { + return; + } + + let dropdown = UiDropdownSimple.getDropdownMenu(toggle.dataset.target!); + dropdown ??= toggle.closest(".dropdown")!.querySelector(".dropdownMenu")!; + + dropdown.querySelectorAll("[data-interaction]").forEach((button) => { + button.addEventListener("click", () => { + // The interactions are set up on the widget itself, the event must not + // reach the interactions of a surrounding container. + this.#element.dispatchEvent( + new CustomEvent("interaction:execute", { + detail: button.dataset, + }), + ); + }); + }); + + this.#element.addEventListener("interaction:invalidate", (event: CustomEvent>) => { + event.stopPropagation(); + + if (event.detail._reloadPage === "true") { + window.location.reload(); + } else { + void refreshWidgets(this.#pollId, this.#element); + } + }); + + this.#element.addEventListener("interaction:remove", (event) => { + event.stopPropagation(); + + getWidgets(this.#pollId).forEach((element) => { + destroyInteractions(element); + element.remove(); + }); + }); + } + get #state(): State { return this.#element.dataset.state as State; } @@ -133,17 +182,7 @@ class PollWidget { try { const { template } = await vote(this.#pollId, optionIds); - const container = document.createElement("div"); - DomUtil.setInnerHtml(container, template); - - const widget = container.querySelector(".pollWidget"); - if (widget === null) { - return; - } - - // The new widget is set up by the selector observer. - this.#element.replaceWith(widget); - widget.querySelector(".pollWidget__question")!.focus(); + replaceWidget(this.#element, template)?.querySelector(".pollWidget__question")!.focus(); } finally { if (this.#element.isConnected) { this.#element.removeAttribute("aria-busy"); @@ -153,6 +192,63 @@ class PollWidget { } } +/** + * The same poll can be embedded multiple times on a page. + */ +function getWidgets(pollId: number): HTMLElement[] { + return Array.from(document.querySelectorAll(`.pollWidget[data-poll-id="${pollId}"]`)); +} + +/** + * The dropdown menu of the interactions has been moved into the global + * container and must be removed together with the widget. + */ +function destroyInteractions(element: HTMLElement): void { + const toggle = element.querySelector(".pollWidget__interactions .dropdownToggle"); + if (toggle?.dataset.target) { + UiDropdownSimple.destroy(toggle.dataset.target); + } +} + +/** + * Replaces the widget with the rendered template and returns the new widget, + * which is set up by the selector observer. + */ +function replaceWidget(element: HTMLElement, template: string): HTMLElement | null { + const fragment = DomUtil.createFragmentFromHtml(template); + const widget = fragment.querySelector(".pollWidget"); + if (widget === null) { + return null; + } + + // The scripts initialize the interactions and are executed on insertion. + const scripts = Array.from(fragment.querySelectorAll("script")); + destroyInteractions(element); + element.replaceWith(fragment); + scripts.forEach((script) => script.remove()); + + triggerDomChange(); + + return widget; +} + +async function refreshWidgets(pollId: number, source: HTMLElement): Promise { + await Promise.all( + getWidgets(pollId).map(async (element) => { + // Every instance requires its own rendering because of the unique ids. + const { template } = await getWidget(pollId); + if (!element.isConnected) { + return; + } + + const widget = replaceWidget(element, template); + if (element === source) { + widget?.querySelector(".pollWidget__question")!.focus(); + } + }), + ); +} + export function setup(): void { wheneverFirstSeen(".pollWidget", (element) => { new PollWidget(element); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Polls/GetWidget.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Polls/GetWidget.js new file mode 100644 index 00000000000..4932b2d4b3c --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Polls/GetWidget.js @@ -0,0 +1,19 @@ +/** + * Gets the rendered widget of a standalone poll. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Api/Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.getWidget = getWidget; + async function getWidget(pollId) { + const url = new URL(`${window.WSC_RPC_API_URL}core/polls/${pollId}/widget`); + return (0, Result_1.fromInfallibleApiRequest)(() => { + return (0, Backend_1.prepareRequest)(url).get().fetchAsJson(); + }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/Widget.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/Widget.js index 66ae2fd0af4..9ac2433d7ee 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/Widget.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/Widget.js @@ -8,11 +8,12 @@ * @license GNU Lesser General Public License * @since 6.3 */ -define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Polls/Vote", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Helper/Selector"], function (require, exports, tslib_1, Vote_1, Dialog_1, Util_1, PromiseMutex_1, Selector_1) { +define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Polls/GetWidget", "WoltLabSuite/Core/Api/Polls/Vote", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Dom/Change/Listener", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Ui/Dropdown/Simple"], function (require, exports, tslib_1, GetWidget_1, Vote_1, Dialog_1, Listener_1, Util_1, PromiseMutex_1, Selector_1, Simple_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = setup; Util_1 = tslib_1.__importDefault(Util_1); + Simple_1 = tslib_1.__importDefault(Simple_1); class PollWidget { #element; #inputs; @@ -26,6 +27,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Polls/Vote", "Wolt this.#pollId = parseInt(element.dataset.pollId); this.#voteButton = element.querySelector(".pollWidget__vote"); this.#initEventListeners(); + this.#initInteractions(); this.#setState(element.dataset.state, false); this.#updateInputs(); } @@ -52,6 +54,43 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Polls/Vote", "Wolt .fromPreset(this.#element.dataset.question, "wcf\\system\\listView\\user\\PollParticipantListView", new Map([["pollID", this.#pollId.toString()]])); })); } + /** + * The widget serves as the container of the interactions, which allow the + * owner and moderators to edit or delete the poll. + */ + #initInteractions() { + const toggle = this.#element.querySelector(".pollWidget__interactions .dropdownToggle"); + if (toggle === null) { + return; + } + let dropdown = Simple_1.default.getDropdownMenu(toggle.dataset.target); + dropdown ??= toggle.closest(".dropdown").querySelector(".dropdownMenu"); + dropdown.querySelectorAll("[data-interaction]").forEach((button) => { + button.addEventListener("click", () => { + // The interactions are set up on the widget itself, the event must not + // reach the interactions of a surrounding container. + this.#element.dispatchEvent(new CustomEvent("interaction:execute", { + detail: button.dataset, + })); + }); + }); + this.#element.addEventListener("interaction:invalidate", (event) => { + event.stopPropagation(); + if (event.detail._reloadPage === "true") { + window.location.reload(); + } + else { + void refreshWidgets(this.#pollId, this.#element); + } + }); + this.#element.addEventListener("interaction:remove", (event) => { + event.stopPropagation(); + getWidgets(this.#pollId).forEach((element) => { + destroyInteractions(element); + element.remove(); + }); + }); + } get #state() { return this.#element.dataset.state; } @@ -98,15 +137,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Polls/Vote", "Wolt this.#element.setAttribute("aria-busy", "true"); try { const { template } = await (0, Vote_1.vote)(this.#pollId, optionIds); - const container = document.createElement("div"); - Util_1.default.setInnerHtml(container, template); - const widget = container.querySelector(".pollWidget"); - if (widget === null) { - return; - } - // The new widget is set up by the selector observer. - this.#element.replaceWith(widget); - widget.querySelector(".pollWidget__question").focus(); + replaceWidget(this.#element, template)?.querySelector(".pollWidget__question").focus(); } finally { if (this.#element.isConnected) { @@ -116,6 +147,53 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Polls/Vote", "Wolt } } } + /** + * The same poll can be embedded multiple times on a page. + */ + function getWidgets(pollId) { + return Array.from(document.querySelectorAll(`.pollWidget[data-poll-id="${pollId}"]`)); + } + /** + * The dropdown menu of the interactions has been moved into the global + * container and must be removed together with the widget. + */ + function destroyInteractions(element) { + const toggle = element.querySelector(".pollWidget__interactions .dropdownToggle"); + if (toggle?.dataset.target) { + Simple_1.default.destroy(toggle.dataset.target); + } + } + /** + * Replaces the widget with the rendered template and returns the new widget, + * which is set up by the selector observer. + */ + function replaceWidget(element, template) { + const fragment = Util_1.default.createFragmentFromHtml(template); + const widget = fragment.querySelector(".pollWidget"); + if (widget === null) { + return null; + } + // The scripts initialize the interactions and are executed on insertion. + const scripts = Array.from(fragment.querySelectorAll("script")); + destroyInteractions(element); + element.replaceWith(fragment); + scripts.forEach((script) => script.remove()); + (0, Listener_1.trigger)(); + return widget; + } + async function refreshWidgets(pollId, source) { + await Promise.all(getWidgets(pollId).map(async (element) => { + // Every instance requires its own rendering because of the unique ids. + const { template } = await (0, GetWidget_1.getWidget)(pollId); + if (!element.isConnected) { + return; + } + const widget = replaceWidget(element, template); + if (element === source) { + widget?.querySelector(".pollWidget__question").focus(); + } + })); + } function setup() { (0, Selector_1.wheneverFirstSeen)(".pollWidget", (element) => { new PollWidget(element); diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/polls/GetWidget.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/polls/GetWidget.class.php new file mode 100644 index 00000000000..89258b55164 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/polls/GetWidget.class.php @@ -0,0 +1,39 @@ + + * @since 6.3 + */ +#[GetRequest('/core/polls/{id:\d+}/widget')] +final class GetWidget implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $poll = Helper::fetchObjectFromRequestParameter($variables['id'], Poll::class); + + if (\MODULE_POLL === 0 || !$poll->isStandalone() || !$poll->isAccessible()) { + throw new PermissionDeniedException(); + } + + return new JsonResponse([ + 'template' => (string)new PollWidgetView($poll), + ]); + } +} diff --git a/wcfsetup/install/files/lib/system/view/poll/PollWidgetView.class.php b/wcfsetup/install/files/lib/system/view/poll/PollWidgetView.class.php index 1ade8c470d1..de86eb2d474 100644 --- a/wcfsetup/install/files/lib/system/view/poll/PollWidgetView.class.php +++ b/wcfsetup/install/files/lib/system/view/poll/PollWidgetView.class.php @@ -3,6 +3,9 @@ namespace wcf\system\view\poll; use wcf\data\poll\Poll; +use wcf\system\interaction\InteractionContextMenuComponent; +use wcf\system\interaction\InteractionContextMenuComponentConfiguration; +use wcf\system\interaction\user\PollInteractions; use wcf\system\WCF; use wcf\util\StringUtil; @@ -35,14 +38,34 @@ public function __toString(): string $leadingVotes = \max($leadingVotes, $option->votes); } + // The same poll can be embedded multiple times on a page. + $elementID = 'poll' . $this->poll->pollID . '_' . \substr(StringUtil::getRandomID(), 0, 8); + + $interactionButton = $interactionInitialization = ''; + if ($this->poll->canManage()) { + // The widget itself serves as the container of the interactions. + $component = new InteractionContextMenuComponent( + new PollInteractions(), + new InteractionContextMenuComponentConfiguration( + cssClassName: 'pollWidget__interactions', + buttonCssClassName: 'pollWidget__interactionButton', + ) + ); + $interactionButton = $component->renderButton($this->poll); + if ($interactionButton !== '') { + $interactionInitialization = $component->renderInitialization($elementID); + } + } + return WCF::getTPL()->render('wcf', 'shared_bbcode_poll', [ 'poll' => $this->poll, 'options' => $this->poll->getOptions(), 'resultPositions' => $resultPositions, 'leadingVotes' => $leadingVotes, 'initialState' => $this->getInitialState(), - // The same poll can be embedded multiple times on a page. - 'elementID' => 'poll' . $this->poll->pollID . '_' . \substr(StringUtil::getRandomID(), 0, 8), + 'elementID' => $elementID, + 'interactionButton' => $interactionButton, + 'interactionInitialization' => $interactionInitialization, ]); } diff --git a/wcfsetup/install/files/style/ui/pollWidget.scss b/wcfsetup/install/files/style/ui/pollWidget.scss index df79430c673..2b7947f2bf0 100644 --- a/wcfsetup/install/files/style/ui/pollWidget.scss +++ b/wcfsetup/install/files/style/ui/pollWidget.scss @@ -41,6 +41,33 @@ text-transform: none; } +.pollWidget__interactions { + margin-left: auto; +} + +.pollWidget__interactionButton { + align-items: center; + border-radius: 6px; + color: var(--wcfContentDimmedText); + cursor: pointer; + display: flex; + height: 28px; + justify-content: center; + margin: -6px -6px -6px 0; + width: 28px; + + &:hover, + .dropdownOpen > & { + background-color: var(--wcfTabularBoxBackgroundActive); + color: var(--wcfContentText); + } + + &:focus-visible { + outline: 2px solid var(--wcfContentLink); + outline-offset: 1px; + } +} + .pollWidget h3.pollWidget__question { color: var(--wcfContentHeadlineText); font-size: 18px; From ee1eadf9f29f6e5482d63a07bfe2eb650645e319 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 23 Oct 2026 14:23:56 +0200 Subject: [PATCH 06/19] Prevent list, grid and tree views from binding the interactions of embedded poll widgets --- .../templates/shared_bbcode_poll.tpl | 2 +- ts/WoltLabSuite/Core/Component/GridView.ts | 9 ++++++++ .../Core/Component/Interaction/Container.ts | 19 ++++++++++++++++ ts/WoltLabSuite/Core/Component/ListView.ts | 5 +++++ .../Core/Component/NodeTreeView.ts | 9 ++++++++ .../WoltLabSuite/Core/Component/GridView.js | 8 ++++++- .../Core/Component/Interaction/Container.js | 22 +++++++++++++++++++ .../WoltLabSuite/Core/Component/ListView.js | 5 ++++- .../Core/Component/NodeTreeView.js | 8 ++++++- 9 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 ts/WoltLabSuite/Core/Component/Interaction/Container.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/Container.js diff --git a/com.woltlab.wcf/templates/shared_bbcode_poll.tpl b/com.woltlab.wcf/templates/shared_bbcode_poll.tpl index 9c0960e42d8..8051441dc60 100644 --- a/com.woltlab.wcf/templates/shared_bbcode_poll.tpl +++ b/com.woltlab.wcf/templates/shared_bbcode_poll.tpl @@ -1,4 +1,4 @@ -
(".dropdownToggle").forEach((element) => { + if (!isOwnInteraction(row, element)) { + return; + } + const dropdown = UiDropdownSimple.getDropdownMenu(element.dataset.target!); if (dropdown) { containers.push(dropdown); @@ -120,6 +125,10 @@ export class GridView { for (const container of containers) { container.querySelectorAll("[data-interaction]").forEach((element) => { + if (container === row && !isOwnInteraction(row, element)) { + return; + } + element.addEventListener("click", () => { row.dispatchEvent( new CustomEvent("interaction:execute", { diff --git a/ts/WoltLabSuite/Core/Component/Interaction/Container.ts b/ts/WoltLabSuite/Core/Component/Interaction/Container.ts new file mode 100644 index 00000000000..42f37e8e291 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Interaction/Container.ts @@ -0,0 +1,19 @@ +/** + * Helper functions for containers that handle interactions. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +/** + * Returns true if the element belongs to the interactions of the given + * container rather than to a nested container that handles its own + * interactions, such as an embedded poll widget. + */ +export function isOwnInteraction(container: HTMLElement, element: HTMLElement): boolean { + const nested = element.closest("[data-interaction-container]"); + + return nested === null || nested === container || !container.contains(nested); +} diff --git a/ts/WoltLabSuite/Core/Component/ListView.ts b/ts/WoltLabSuite/Core/Component/ListView.ts index 541dd9369e8..ed7f05790d8 100644 --- a/ts/WoltLabSuite/Core/Component/ListView.ts +++ b/ts/WoltLabSuite/Core/Component/ListView.ts @@ -17,6 +17,7 @@ import UiDropdownSimple from "../Ui/Dropdown/Simple"; import { getItem } from "../Api/ListViews/GetItem"; import { getBulkContextMenuOptions } from "../Api/Interactions/GetBulkContextMenuOptions"; import { promiseMutex } from "../Helper/PromiseMutex"; +import { isOwnInteraction } from "./Interaction/Container"; export class ListView { readonly #viewClassName: string; @@ -104,6 +105,10 @@ export class ListView { #initInteractions(): void { wheneverFirstSeen(`#${this.#viewElement.id} .listView__item`, (item) => { item.querySelectorAll(".dropdownToggle").forEach((element) => { + if (!isOwnInteraction(item, element)) { + return; + } + let dropdown = UiDropdownSimple.getDropdownMenu(element.dataset.target!); if (!dropdown) { dropdown = element.closest(".dropdown")!.querySelector(".dropdownMenu")!; diff --git a/ts/WoltLabSuite/Core/Component/NodeTreeView.ts b/ts/WoltLabSuite/Core/Component/NodeTreeView.ts index a7b0e14f618..fd6118f7e52 100644 --- a/ts/WoltLabSuite/Core/Component/NodeTreeView.ts +++ b/ts/WoltLabSuite/Core/Component/NodeTreeView.ts @@ -15,6 +15,7 @@ import { wheneverFirstSeen } from "../Helper/Selector"; import { createFragmentFromHtml, setInnerHtml } from "../Dom/Util"; import UiDropdownSimple from "../Ui/Dropdown/Simple"; import Sortable from "sortablejs"; +import { isOwnInteraction } from "./Interaction/Container"; export class NodeTreeView { readonly #id: string; @@ -48,6 +49,10 @@ export class NodeTreeView { const containers = [content]; content.querySelectorAll(".dropdownToggle").forEach((element) => { + if (!isOwnInteraction(content, element)) { + return; + } + const dropdown = UiDropdownSimple.getDropdownMenu(element.dataset.target!); if (dropdown) { containers.push(dropdown); @@ -56,6 +61,10 @@ export class NodeTreeView { for (const container of containers) { container.querySelectorAll("[data-interaction]").forEach((element) => { + if (container === content && !isOwnInteraction(content, element)) { + return; + } + element.addEventListener("click", () => { node.dispatchEvent( new CustomEvent("interaction:execute", { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js index 9b8db3d3a2b..c0254d1ae5c 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js @@ -6,7 +6,7 @@ * @license GNU Lesser General Public License * @since 6.2 */ -define(["require", "exports", "tslib", "../Api/GridViews/GetRow", "../Api/GridViews/GetRows", "../Api/Interactions/GetBulkContextMenuOptions", "../Api/PostObject", "../Dom/Change/Listener", "../Dom/Util", "../Helper/PromiseMutex", "../Helper/Selector", "../Ui/Dropdown/Simple", "./GridView/State"], function (require, exports, tslib_1, GetRow_1, GetRows_1, GetBulkContextMenuOptions_1, PostObject_1, Listener_1, Util_1, PromiseMutex_1, Selector_1, Simple_1, State_1) { +define(["require", "exports", "tslib", "../Api/GridViews/GetRow", "../Api/GridViews/GetRows", "../Api/Interactions/GetBulkContextMenuOptions", "../Api/PostObject", "../Dom/Change/Listener", "../Dom/Util", "../Helper/PromiseMutex", "../Helper/Selector", "../Ui/Dropdown/Simple", "./GridView/State", "./Interaction/Container"], function (require, exports, tslib_1, GetRow_1, GetRows_1, GetBulkContextMenuOptions_1, PostObject_1, Listener_1, Util_1, PromiseMutex_1, Selector_1, Simple_1, State_1, Container_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.GridView = void 0; @@ -60,6 +60,9 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRow", "../Api/GridVi (0, Selector_1.wheneverFirstSeen)(`#${this.#table.id} tbody tr`, (row) => { const containers = [row]; row.querySelectorAll(".dropdownToggle").forEach((element) => { + if (!(0, Container_1.isOwnInteraction)(row, element)) { + return; + } const dropdown = Simple_1.default.getDropdownMenu(element.dataset.target); if (dropdown) { containers.push(dropdown); @@ -67,6 +70,9 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRow", "../Api/GridVi }); for (const container of containers) { container.querySelectorAll("[data-interaction]").forEach((element) => { + if (container === row && !(0, Container_1.isOwnInteraction)(row, element)) { + return; + } element.addEventListener("click", () => { row.dispatchEvent(new CustomEvent("interaction:execute", { detail: element.dataset, diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/Container.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/Container.js new file mode 100644 index 00000000000..b3863718c9d --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/Container.js @@ -0,0 +1,22 @@ +/** + * Helper functions for containers that handle interactions. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports"], function (require, exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.isOwnInteraction = isOwnInteraction; + /** + * Returns true if the element belongs to the interactions of the given + * container rather than to a nested container that handles its own + * interactions, such as an embedded poll widget. + */ + function isOwnInteraction(container, element) { + const nested = element.closest("[data-interaction-container]"); + return nested === null || nested === container || !container.contains(nested); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView.js index b24b52b24ec..32626ed4217 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/ListView.js @@ -6,7 +6,7 @@ * @license GNU Lesser General Public License * @since 6.2 */ -define(["require", "exports", "tslib", "./ListView/State", "../Dom/Change/Listener", "../Dom/Util", "../Api/ListViews/GetItems", "WoltLabSuite/Core/Ui/Scroll", "../Helper/Selector", "../Ui/Dropdown/Simple", "../Api/ListViews/GetItem", "../Api/Interactions/GetBulkContextMenuOptions", "../Helper/PromiseMutex"], function (require, exports, tslib_1, State_1, Listener_1, Util_1, GetItems_1, Scroll_1, Selector_1, Simple_1, GetItem_1, GetBulkContextMenuOptions_1, PromiseMutex_1) { +define(["require", "exports", "tslib", "./ListView/State", "../Dom/Change/Listener", "../Dom/Util", "../Api/ListViews/GetItems", "WoltLabSuite/Core/Ui/Scroll", "../Helper/Selector", "../Ui/Dropdown/Simple", "../Api/ListViews/GetItem", "../Api/Interactions/GetBulkContextMenuOptions", "../Helper/PromiseMutex", "./Interaction/Container"], function (require, exports, tslib_1, State_1, Listener_1, Util_1, GetItems_1, Scroll_1, Selector_1, Simple_1, GetItem_1, GetBulkContextMenuOptions_1, PromiseMutex_1, Container_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ListView = void 0; @@ -58,6 +58,9 @@ define(["require", "exports", "tslib", "./ListView/State", "../Dom/Change/Listen #initInteractions() { (0, Selector_1.wheneverFirstSeen)(`#${this.#viewElement.id} .listView__item`, (item) => { item.querySelectorAll(".dropdownToggle").forEach((element) => { + if (!(0, Container_1.isOwnInteraction)(item, element)) { + return; + } let dropdown = Simple_1.default.getDropdownMenu(element.dataset.target); if (!dropdown) { dropdown = element.closest(".dropdown").querySelector(".dropdownMenu"); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js index 517e72bf62c..f09986a1794 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/NodeTreeView.js @@ -6,7 +6,7 @@ * @license GNU Lesser General Public License * @since 6.3 */ -define(["require", "exports", "tslib", "../Api/PostObject", "../Api/NodeTreeViews/GetNode", "../Api/NodeTreeViews/GetNodes", "../Helper/PromiseMutex", "../Helper/Selector", "../Dom/Util", "../Ui/Dropdown/Simple", "sortablejs"], function (require, exports, tslib_1, PostObject_1, GetNode_1, GetNodes_1, PromiseMutex_1, Selector_1, Util_1, Simple_1, sortablejs_1) { +define(["require", "exports", "tslib", "../Api/PostObject", "../Api/NodeTreeViews/GetNode", "../Api/NodeTreeViews/GetNodes", "../Helper/PromiseMutex", "../Helper/Selector", "../Dom/Util", "../Ui/Dropdown/Simple", "sortablejs", "./Interaction/Container"], function (require, exports, tslib_1, PostObject_1, GetNode_1, GetNodes_1, PromiseMutex_1, Selector_1, Util_1, Simple_1, sortablejs_1, Container_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.NodeTreeView = void 0; @@ -34,6 +34,9 @@ define(["require", "exports", "tslib", "../Api/PostObject", "../Api/NodeTreeView const content = node.querySelector(":scope > .nodeTreeView__item__content"); const containers = [content]; content.querySelectorAll(".dropdownToggle").forEach((element) => { + if (!(0, Container_1.isOwnInteraction)(content, element)) { + return; + } const dropdown = Simple_1.default.getDropdownMenu(element.dataset.target); if (dropdown) { containers.push(dropdown); @@ -41,6 +44,9 @@ define(["require", "exports", "tslib", "../Api/PostObject", "../Api/NodeTreeView }); for (const container of containers) { container.querySelectorAll("[data-interaction]").forEach((element) => { + if (container === content && !(0, Container_1.isOwnInteraction)(content, element)) { + return; + } element.addEventListener("click", () => { node.dispatchEvent(new CustomEvent("interaction:execute", { detail: element.dataset, From 8b96791e4ce6cabfa83fc57956aeeebdddc1ed83 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 23 Oct 2026 14:45:04 +0200 Subject: [PATCH 07/19] Add option to hide poll results from everyone until the poll has ended --- .../templates/shared_bbcode_poll.tpl | 12 +++++-- .../update_com.woltlab.wcf_6.3_step1.php | 1 + .../files/lib/action/PollEditAction.class.php | 32 +++++++++++++++++++ .../files/lib/command/poll/CopyPoll.class.php | 1 + .../files/lib/data/poll/Poll.class.php | 5 +++ .../files/lib/data/poll/PollAction.class.php | 1 + .../files/lib/data/poll/PollBuilder.class.php | 11 +++++++ .../importer/AbstractPollImporter.class.php | 1 + wcfsetup/install/lang/de.xml | 4 +++ wcfsetup/install/lang/en.xml | 4 +++ wcfsetup/setup/db/install_com.woltlab.wcf.php | 1 + 11 files changed, 71 insertions(+), 2 deletions(-) diff --git a/com.woltlab.wcf/templates/shared_bbcode_poll.tpl b/com.woltlab.wcf/templates/shared_bbcode_poll.tpl index 8051441dc60..6f45aaa2c69 100644 --- a/com.woltlab.wcf/templates/shared_bbcode_poll.tpl +++ b/com.woltlab.wcf/templates/shared_bbcode_poll.tpl @@ -23,11 +23,19 @@ {lang}wcf.poll.widget.hint.single{/lang} {/if}
- {elseif !$poll->canSeeResult()} + {elseif !$poll->canSeeResult() && !$poll->resultsRequireEnd}
{lang}wcf.poll.restrictedResult{/lang}
{/if} {if $poll->isParticipant()} -
{lang}wcf.poll.widget.hint.voted{/lang}
+ {if $poll->canSeeResult()} +
{lang}wcf.poll.widget.hint.voted{/lang}
+ {else} + {* Participants remain in the vote state while the results are hidden until the end. *} +
{lang}wcf.poll.widget.hint.voted{/lang}
+ {/if} + {/if} + {if $poll->resultsRequireEnd && !$poll->isFinished()} +
{lang}wcf.poll.widget.hint.resultsAfterEnd{/lang}
{/if} diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php index 6fb8ab89299..6529d9ab6f1 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php @@ -163,6 +163,7 @@ NotNullVarchar255DatabaseTableColumn::create('username') ->defaultValue(''), DefaultFalseBooleanDatabaseTableColumn::create('isMultilingual'), + DefaultFalseBooleanDatabaseTableColumn::create('resultsRequireEnd'), ]) ->foreignKeys([ DatabaseTableForeignKey::create() diff --git a/wcfsetup/install/files/lib/action/PollEditAction.class.php b/wcfsetup/install/files/lib/action/PollEditAction.class.php index a5022a7b921..ad602c8e70a 100644 --- a/wcfsetup/install/files/lib/action/PollEditAction.class.php +++ b/wcfsetup/install/files/lib/action/PollEditAction.class.php @@ -220,6 +220,38 @@ static function (IntegerFormField $field): void { ->loadValueCallback(static function (Poll $object, IFormField $field): void { $field->value($object->resultsRequireVote); }), + BooleanFormField::create('resultsRequireEnd') + ->label('wcf.poll.resultsRequireEnd') + ->description('wcf.poll.resultsRequireEnd.description') + ->addValidator(new FormFieldValidator( + 'endTimeRequired', + static function (BooleanFormField $field): void { + if ($field->getSaveValue() === 0) { + return; + } + + $endTimeField = $field->getDocument()->getNodeById('endTime'); + \assert($endTimeField instanceof DateFormField); + + // A malformed end time is already reported by the field itself. + if ($endTimeField->getValidationErrors() !== []) { + return; + } + + if ($endTimeField->getValue() === null) { + $field->addValidationError(new FormFieldValidationError( + 'endTimeRequired', + 'wcf.poll.resultsRequireEnd.error.endTimeRequired' + )); + } + } + )) + ->saveValueCallback(static function (PollBuilder $builder, BooleanFormField $field): void { + $builder->setResultsRequireEnd($field->getSaveValue() !== 0); + }) + ->loadValueCallback(static function (Poll $object, IFormField $field): void { + $field->value($object->resultsRequireEnd); + }), BooleanFormField::create('sortByVotes') ->label('wcf.poll.sortByVotes') ->saveValueCallback(static function (PollBuilder $builder, BooleanFormField $field): void { diff --git a/wcfsetup/install/files/lib/command/poll/CopyPoll.class.php b/wcfsetup/install/files/lib/command/poll/CopyPoll.class.php index 225bfda2cf1..f92615484d7 100644 --- a/wcfsetup/install/files/lib/command/poll/CopyPoll.class.php +++ b/wcfsetup/install/files/lib/command/poll/CopyPoll.class.php @@ -43,6 +43,7 @@ public function __invoke(): Poll ->setIsPublic($this->poll->isPublic === 1) ->setSortByVotes($this->poll->sortByVotes === 1) ->setResultsRequireVote($this->poll->resultsRequireVote === 1) + ->setResultsRequireEnd($this->poll->resultsRequireEnd === 1) ->setMaxVotes($this->poll->maxVotes); $newPoll = new CreatePoll($builder)(); diff --git a/wcfsetup/install/files/lib/data/poll/Poll.class.php b/wcfsetup/install/files/lib/data/poll/Poll.class.php index e2ae509d7ab..5a00a0318c9 100644 --- a/wcfsetup/install/files/lib/data/poll/Poll.class.php +++ b/wcfsetup/install/files/lib/data/poll/Poll.class.php @@ -41,6 +41,7 @@ * @property-read 0|1 $isPublic is `1` if the result of the poll is public, otherwise `0` * @property-read 0|1 $sortByVotes is `1` if the results will be sorted by votes, otherwise `0` * @property-read 0|1 $resultsRequireVote is `1` if a user has to have voted to see the results, otherwise `0` + * @property-read 0|1 $resultsRequireEnd is `1` if the results are hidden from everyone until the poll has ended, otherwise `0` * @property-read int $maxVotes maximum number of options the user can select * @property-read int $votes number of votes in the poll * @@ -255,6 +256,10 @@ public function canSeeResult() return false; } + if ($this->resultsRequireEnd === 1) { + return $this->isFinished(); + } + if ($this->isFinished() || $this->isParticipant() || $this->resultsRequireVote === 0) { return true; } diff --git a/wcfsetup/install/files/lib/data/poll/PollAction.class.php b/wcfsetup/install/files/lib/data/poll/PollAction.class.php index ad70c4e02d7..b88059a5a8e 100644 --- a/wcfsetup/install/files/lib/data/poll/PollAction.class.php +++ b/wcfsetup/install/files/lib/data/poll/PollAction.class.php @@ -110,6 +110,7 @@ private function applyData(PollBuilder $builder, array $data): void 'isPublic' => $builder->setIsPublic((bool)$value), 'sortByVotes' => $builder->setSortByVotes((bool)$value), 'resultsRequireVote' => $builder->setResultsRequireVote((bool)$value), + 'resultsRequireEnd' => $builder->setResultsRequireEnd((bool)$value), 'maxVotes' => $builder->setMaxVotes($value), 'votes' => $builder->setVotes($value), default => $builder->setCustomProperty($key, $value), diff --git a/wcfsetup/install/files/lib/data/poll/PollBuilder.class.php b/wcfsetup/install/files/lib/data/poll/PollBuilder.class.php index 1aea3b782ae..74056b7a0e4 100644 --- a/wcfsetup/install/files/lib/data/poll/PollBuilder.class.php +++ b/wcfsetup/install/files/lib/data/poll/PollBuilder.class.php @@ -172,6 +172,17 @@ public function setResultsRequireVote(bool $resultsRequireVote): static return $this; } + /** + * Hides the results from everyone until the poll has ended, this requires + * an end time. + */ + public function setResultsRequireEnd(bool $resultsRequireEnd): static + { + $this->properties['resultsRequireEnd'] = (int)$resultsRequireEnd; + + return $this; + } + public function setMaxVotes(int $maxVotes): static { $this->properties['maxVotes'] = \max($maxVotes, 1); diff --git a/wcfsetup/install/files/lib/system/importer/AbstractPollImporter.class.php b/wcfsetup/install/files/lib/system/importer/AbstractPollImporter.class.php index 71c3957c5a2..6405cea88b5 100644 --- a/wcfsetup/install/files/lib/system/importer/AbstractPollImporter.class.php +++ b/wcfsetup/install/files/lib/system/importer/AbstractPollImporter.class.php @@ -52,6 +52,7 @@ public function import(mixed $oldID, array $data, array $additionalData = []) 'isPublic' => $builder->setIsPublic((bool)$value), 'sortByVotes' => $builder->setSortByVotes((bool)$value), 'resultsRequireVote' => $builder->setResultsRequireVote((bool)$value), + 'resultsRequireEnd' => $builder->setResultsRequireEnd((bool)$value), 'maxVotes' => $builder->setMaxVotes($value), 'votes' => $builder->setVotes($value), default => $builder->setCustomProperty($key, $value), diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 79acb67dc88..047ffff5aec 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -4483,6 +4483,9 @@ Dateianhänge: + + + votes} Stimme{if $poll->votes != 1}n{/if}]]> endTime|plainTime}]]> @@ -4500,6 +4503,7 @@ Dateianhänge: maxVotes} Antworten.]]> + endTime|plainTime}]]> endTime|plainTime}]]> diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 48838467256..5769129ccc8 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -4482,6 +4482,9 @@ Attachments: + + + votes} vote{if $poll->votes != 1}s{/if}]]> endTime|plainTime}]]> @@ -4499,6 +4502,7 @@ Attachments: maxVotes} answers.]]> + endTime|plainTime}]]> endTime|plainTime}]]> diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index 9b6d658e878..98b12f33ee3 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -3170,6 +3170,7 @@ DefaultFalseBooleanDatabaseTableColumn::create('isPublic'), DefaultFalseBooleanDatabaseTableColumn::create('sortByVotes'), DefaultFalseBooleanDatabaseTableColumn::create('resultsRequireVote'), + DefaultFalseBooleanDatabaseTableColumn::create('resultsRequireEnd'), NotNullInt10DatabaseTableColumn::create('maxVotes') ->defaultValue(1), NotNullInt10DatabaseTableColumn::create('votes') From c014f59dc9d7ce26dd0d4adf0791858c79765599 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 23 Oct 2026 14:49:02 +0200 Subject: [PATCH 08/19] Render nothing for [poll] embeds of deleted or unavailable polls instead of a "not available" notice --- wcfsetup/install/files/lib/system/bbcode/PollBBCode.class.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/wcfsetup/install/files/lib/system/bbcode/PollBBCode.class.php b/wcfsetup/install/files/lib/system/bbcode/PollBBCode.class.php index 845a490aa55..08298bb78a2 100644 --- a/wcfsetup/install/files/lib/system/bbcode/PollBBCode.class.php +++ b/wcfsetup/install/files/lib/system/bbcode/PollBBCode.class.php @@ -29,7 +29,9 @@ public function getParsedTag(array $openingTag, string $content, array $closingT /** @var ?Poll $poll */ $poll = MessageEmbeddedObjectManager::getInstance()->getObject('com.woltlab.wcf.poll', $pollID); if ($poll === null || !$poll->isStandalone() || \MODULE_POLL === 0) { - return ContentNotVisibleView::forNotAvailable(); + // Intentionally don't use `ContentNotVisibleView::forNotAvailable()`, + // because deleted polls do not automatically remove the BBCode from the content. + return ''; } if (!$poll->isAccessible()) { From d887c1412cbfc60c608f2e94937c4c8a7efc2433 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 23 Oct 2026 15:36:59 +0200 Subject: [PATCH 09/19] Add a minimum number of required answers to polls ref https://www.woltlab.com/community/thread/298041-umfragen-checkbox-option-um-das-nutzen-aller-stimmen-zu-erzwingen/ --- .../templates/shared_bbcode_poll.tpl | 9 ++++++- ts/WoltLabSuite/Core/Component/Poll/Widget.ts | 4 ++- .../update_com.woltlab.wcf_6.3_step1.php | 2 ++ .../Core/Component/Poll/Widget.js | 4 ++- .../files/lib/action/PollEditAction.class.php | 26 +++++++++++++++++++ .../files/lib/command/poll/CopyPoll.class.php | 1 + .../files/lib/data/poll/Poll.class.php | 12 +++++++++ .../files/lib/data/poll/PollAction.class.php | 5 ++++ .../files/lib/data/poll/PollBuilder.class.php | 7 +++++ .../controller/core/polls/Vote.class.php | 4 +++ .../importer/AbstractPollImporter.class.php | 1 + wcfsetup/install/lang/de.xml | 5 ++++ wcfsetup/install/lang/en.xml | 5 ++++ wcfsetup/setup/db/install_com.woltlab.wcf.php | 2 ++ 14 files changed, 84 insertions(+), 3 deletions(-) diff --git a/com.woltlab.wcf/templates/shared_bbcode_poll.tpl b/com.woltlab.wcf/templates/shared_bbcode_poll.tpl index 6f45aaa2c69..1b9ff73b726 100644 --- a/com.woltlab.wcf/templates/shared_bbcode_poll.tpl +++ b/com.woltlab.wcf/templates/shared_bbcode_poll.tpl @@ -3,6 +3,7 @@ *} data-state="{$initialState}"{* *} data-can-vote="{if $poll->canVote()}true{else}false{/if}"{* *} data-max-votes="{$poll->maxVotes}"{* + *} data-required-votes="{$poll->getRequiredVotes()}"{* *} data-question="{$poll->getQuestion()}">
@@ -18,7 +19,13 @@ {if $poll->canVote()}
{if $poll->maxVotes > 1} - {lang}wcf.poll.widget.hint.multiple{/lang} + {if $poll->getRequiredVotes() == $poll->maxVotes} + {lang}wcf.poll.widget.hint.exactly{/lang} + {elseif $poll->getRequiredVotes() > 1} + {lang}wcf.poll.widget.hint.range{/lang} + {else} + {lang}wcf.poll.widget.hint.multiple{/lang} + {/if} {else} {lang}wcf.poll.widget.hint.single{/lang} {/if} diff --git a/ts/WoltLabSuite/Core/Component/Poll/Widget.ts b/ts/WoltLabSuite/Core/Component/Poll/Widget.ts index cdb46a4237c..bbe7dd956c5 100644 --- a/ts/WoltLabSuite/Core/Component/Poll/Widget.ts +++ b/ts/WoltLabSuite/Core/Component/Poll/Widget.ts @@ -24,6 +24,7 @@ class PollWidget { readonly #element: HTMLElement; readonly #inputs: HTMLInputElement[]; readonly #maxVotes: number; + readonly #requiredVotes: number; readonly #pollId: number; readonly #voteButton: HTMLButtonElement | null; @@ -31,6 +32,7 @@ class PollWidget { this.#element = element; this.#inputs = Array.from(element.querySelectorAll(".pollWidget__input")); this.#maxVotes = parseInt(element.dataset.maxVotes!); + this.#requiredVotes = parseInt(element.dataset.requiredVotes!); this.#pollId = parseInt(element.dataset.pollId!); this.#voteButton = element.querySelector(".pollWidget__vote"); @@ -153,7 +155,7 @@ class PollWidget { } const selectedCount = this.#getSelectedOptionIds().length; - this.#voteButton.disabled = selectedCount === 0; + this.#voteButton.disabled = selectedCount < this.#requiredVotes; if (this.#maxVotes > 1) { for (const input of this.#inputs) { diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php index 6529d9ab6f1..4274bef53e9 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php @@ -164,6 +164,8 @@ ->defaultValue(''), DefaultFalseBooleanDatabaseTableColumn::create('isMultilingual'), DefaultFalseBooleanDatabaseTableColumn::create('resultsRequireEnd'), + NotNullInt10DatabaseTableColumn::create('minVotes') + ->defaultValue(1), ]) ->foreignKeys([ DatabaseTableForeignKey::create() diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/Widget.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/Widget.js index 9ac2433d7ee..13ab92d04f5 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/Widget.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Poll/Widget.js @@ -18,12 +18,14 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Polls/GetWidget", #element; #inputs; #maxVotes; + #requiredVotes; #pollId; #voteButton; constructor(element) { this.#element = element; this.#inputs = Array.from(element.querySelectorAll(".pollWidget__input")); this.#maxVotes = parseInt(element.dataset.maxVotes); + this.#requiredVotes = parseInt(element.dataset.requiredVotes); this.#pollId = parseInt(element.dataset.pollId); this.#voteButton = element.querySelector(".pollWidget__vote"); this.#initEventListeners(); @@ -115,7 +117,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Polls/GetWidget", return; } const selectedCount = this.#getSelectedOptionIds().length; - this.#voteButton.disabled = selectedCount === 0; + this.#voteButton.disabled = selectedCount < this.#requiredVotes; if (this.#maxVotes > 1) { for (const input of this.#inputs) { input.disabled = !input.checked && selectedCount >= this.#maxVotes; diff --git a/wcfsetup/install/files/lib/action/PollEditAction.class.php b/wcfsetup/install/files/lib/action/PollEditAction.class.php index ad602c8e70a..a92279810e7 100644 --- a/wcfsetup/install/files/lib/action/PollEditAction.class.php +++ b/wcfsetup/install/files/lib/action/PollEditAction.class.php @@ -168,6 +168,32 @@ static function (DateFormField $field) use ($poll): void { ->loadValueCallback(static function (Poll $object, IFormField $field): void { $field->value($object->endTime); }), + IntegerFormField::create('minVotes') + ->label('wcf.poll.minVotes') + ->description('wcf.poll.minVotes.description') + ->minimum(1) + ->maximum(\POLL_MAX_OPTIONS) + ->value(1) + ->addValidator(new FormFieldValidator( + 'minVotes', + static function (IntegerFormField $field): void { + $maxVotesField = $field->getDocument()->getNodeById('maxVotes'); + \assert($maxVotesField instanceof IntegerFormField); + + if ($field->getSaveValue() > $maxVotesField->getSaveValue()) { + $field->addValidationError(new FormFieldValidationError( + 'invalid', + 'wcf.poll.minVotes.error.invalid' + )); + } + } + )) + ->saveValueCallback(static function (PollBuilder $builder, IntegerFormField $field): void { + $builder->setMinVotes($field->getSaveValue()); + }) + ->loadValueCallback(static function (Poll $object, IFormField $field): void { + $field->value($object->minVotes); + }), IntegerFormField::create('maxVotes') ->label('wcf.poll.maxVotes') ->minimum(1) diff --git a/wcfsetup/install/files/lib/command/poll/CopyPoll.class.php b/wcfsetup/install/files/lib/command/poll/CopyPoll.class.php index f92615484d7..f67c401cb8d 100644 --- a/wcfsetup/install/files/lib/command/poll/CopyPoll.class.php +++ b/wcfsetup/install/files/lib/command/poll/CopyPoll.class.php @@ -44,6 +44,7 @@ public function __invoke(): Poll ->setSortByVotes($this->poll->sortByVotes === 1) ->setResultsRequireVote($this->poll->resultsRequireVote === 1) ->setResultsRequireEnd($this->poll->resultsRequireEnd === 1) + ->setMinVotes($this->poll->minVotes) ->setMaxVotes($this->poll->maxVotes); $newPoll = new CreatePoll($builder)(); diff --git a/wcfsetup/install/files/lib/data/poll/Poll.class.php b/wcfsetup/install/files/lib/data/poll/Poll.class.php index 5a00a0318c9..0606a55fb34 100644 --- a/wcfsetup/install/files/lib/data/poll/Poll.class.php +++ b/wcfsetup/install/files/lib/data/poll/Poll.class.php @@ -42,6 +42,7 @@ * @property-read 0|1 $sortByVotes is `1` if the results will be sorted by votes, otherwise `0` * @property-read 0|1 $resultsRequireVote is `1` if a user has to have voted to see the results, otherwise `0` * @property-read 0|1 $resultsRequireEnd is `1` if the results are hidden from everyone until the poll has ended, otherwise `0` + * @property-read int $minVotes minimum number of options the user has to select * @property-read int $maxVotes maximum number of options the user can select * @property-read int $votes number of votes in the poll * @@ -146,6 +147,17 @@ protected function loadOptions() } } + /** + * Returns the number of options the user has to select at least, which + * never exceeds the maximum number of votes or the number of options. + * + * @since 6.3 + */ + public function getRequiredVotes(): int + { + return \max(1, \min($this->minVotes, $this->maxVotes, \count($this->getOptions()))); + } + /** * Returns true if poll is already finished. * diff --git a/wcfsetup/install/files/lib/data/poll/PollAction.class.php b/wcfsetup/install/files/lib/data/poll/PollAction.class.php index b88059a5a8e..72699013bc0 100644 --- a/wcfsetup/install/files/lib/data/poll/PollAction.class.php +++ b/wcfsetup/install/files/lib/data/poll/PollAction.class.php @@ -111,6 +111,7 @@ private function applyData(PollBuilder $builder, array $data): void 'sortByVotes' => $builder->setSortByVotes((bool)$value), 'resultsRequireVote' => $builder->setResultsRequireVote((bool)$value), 'resultsRequireEnd' => $builder->setResultsRequireEnd((bool)$value), + 'minVotes' => $builder->setMinVotes($value), 'maxVotes' => $builder->setMaxVotes($value), 'votes' => $builder->setVotes($value), default => $builder->setCustomProperty($key, $value), @@ -168,6 +169,10 @@ public function validateVote(): void throw new UserInputException('optionIDs', 'maxVotes'); } + if (\count(\array_unique($this->parameters['optionIDs'])) < $this->poll->getRequiredVotes()) { + throw new UserInputException('optionIDs', 'minVotes'); + } + $optionIDs = []; foreach ($this->poll->getOptions() as $option) { $optionIDs[] = $option->optionID; diff --git a/wcfsetup/install/files/lib/data/poll/PollBuilder.class.php b/wcfsetup/install/files/lib/data/poll/PollBuilder.class.php index 74056b7a0e4..6e3abd0ec92 100644 --- a/wcfsetup/install/files/lib/data/poll/PollBuilder.class.php +++ b/wcfsetup/install/files/lib/data/poll/PollBuilder.class.php @@ -183,6 +183,13 @@ public function setResultsRequireEnd(bool $resultsRequireEnd): static return $this; } + public function setMinVotes(int $minVotes): static + { + $this->properties['minVotes'] = \max($minVotes, 1); + + return $this; + } + public function setMaxVotes(int $maxVotes): static { $this->properties['maxVotes'] = \max($maxVotes, 1); diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/polls/Vote.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/polls/Vote.class.php index 2c592231d83..8c5b2224b75 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/polls/Vote.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/polls/Vote.class.php @@ -48,6 +48,10 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res throw new UserInputException('optionIDs', 'maxVotes'); } + if (\count($optionIDs) < $poll->getRequiredVotes()) { + throw new UserInputException('optionIDs', 'minVotes'); + } + $options = $poll->getOptions(); foreach ($optionIDs as $optionID) { if (!isset($options[$optionID])) { diff --git a/wcfsetup/install/files/lib/system/importer/AbstractPollImporter.class.php b/wcfsetup/install/files/lib/system/importer/AbstractPollImporter.class.php index 6405cea88b5..4c704193027 100644 --- a/wcfsetup/install/files/lib/system/importer/AbstractPollImporter.class.php +++ b/wcfsetup/install/files/lib/system/importer/AbstractPollImporter.class.php @@ -53,6 +53,7 @@ public function import(mixed $oldID, array $data, array $additionalData = []) 'sortByVotes' => $builder->setSortByVotes((bool)$value), 'resultsRequireVote' => $builder->setResultsRequireVote((bool)$value), 'resultsRequireEnd' => $builder->setResultsRequireEnd((bool)$value), + 'minVotes' => $builder->setMinVotes($value), 'maxVotes' => $builder->setMaxVotes($value), 'votes' => $builder->setVotes($value), default => $builder->setCustomProperty($key, $value), diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 047ffff5aec..e76bd454bb0 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -4475,6 +4475,9 @@ Dateianhänge: + + + maxVotes} Antwortmöglichkeiten wählen.]]> @@ -4502,6 +4505,8 @@ Dateianhänge: votes} Stimme{if $poll->votes != 1}n{/if}]]> maxVotes} Antworten.]]> + maxVotes} Antworten.]]> + getRequiredVotes()} bis {#$poll->maxVotes} Antworten.]]> diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 5769129ccc8..abd32de1460 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -4474,6 +4474,9 @@ Attachments: + + + maxVotes} options.]]> @@ -4501,6 +4504,8 @@ Attachments: votes} Vote{if $poll->votes != 1}s{/if}]]> maxVotes} answers.]]> + maxVotes} answers.]]> + getRequiredVotes()} to {#$poll->maxVotes} answers.]]> diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index 98b12f33ee3..aa7f4efa3fb 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -3171,6 +3171,8 @@ DefaultFalseBooleanDatabaseTableColumn::create('sortByVotes'), DefaultFalseBooleanDatabaseTableColumn::create('resultsRequireVote'), DefaultFalseBooleanDatabaseTableColumn::create('resultsRequireEnd'), + NotNullInt10DatabaseTableColumn::create('minVotes') + ->defaultValue(1), NotNullInt10DatabaseTableColumn::create('maxVotes') ->defaultValue(1), NotNullInt10DatabaseTableColumn::create('votes') From 6221e1000c2dedfb9f54663b6a0b3baf70e9e48d Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 23 Oct 2026 15:52:58 +0200 Subject: [PATCH 10/19] Deprecate the legacy content-bound poll stack in favor of standalone `[poll]` embeds --- com.woltlab.wcf/objectTypeDefinition.xml | 1 + com.woltlab.wcf/option.xml | 1 + com.woltlab.wcf/templates/__messageFormPoll.tpl | 1 + com.woltlab.wcf/templates/__messageFormPollInline.tpl | 1 + com.woltlab.wcf/templates/poll.tpl | 1 + com.woltlab.wcf/templates/pollResult.tpl | 1 + com.woltlab.wcf/templates/pollVote.tpl | 1 + com.woltlab.wcf/templates/shared_pollOptionsFormField.tpl | 1 + ts/WoltLabSuite/Core/Api/Polls/GetResults.ts | 1 + ts/WoltLabSuite/Core/Api/Polls/GetVoteForm.ts | 1 + ts/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Poll.ts | 1 + ts/WoltLabSuite/Core/Ui/Poll/Editor.ts | 1 + ts/WoltLabSuite/Core/Ui/Poll/Poll.ts | 1 + ts/WoltLabSuite/Core/Ui/Poll/View/Participants.ts | 1 + ts/WoltLabSuite/Core/Ui/Poll/View/Results.ts | 1 + ts/WoltLabSuite/Core/Ui/Poll/View/Vote.ts | 1 + ts/WoltLabSuite/Core/Ui/Poll/Vote.ts | 1 + .../files/js/WoltLabSuite/Core/Api/Polls/GetResults.js | 1 + .../files/js/WoltLabSuite/Core/Api/Polls/GetVoteForm.js | 1 + .../js/WoltLabSuite/Core/Form/Builder/Field/Wysiwyg/Poll.js | 1 + wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Poll/Editor.js | 1 + wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Poll/Poll.js | 1 + .../files/js/WoltLabSuite/Core/Ui/Poll/View/Participants.js | 1 + .../files/js/WoltLabSuite/Core/Ui/Poll/View/Results.js | 1 + .../install/files/js/WoltLabSuite/Core/Ui/Poll/View/Vote.js | 1 + wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Poll/Vote.js | 1 + wcfsetup/install/files/lib/command/poll/CopyPoll.class.php | 1 + wcfsetup/install/files/lib/data/IPollContainer.class.php | 1 + wcfsetup/install/files/lib/data/IPollObject.class.php | 1 + wcfsetup/install/files/lib/data/poll/Poll.class.php | 3 +++ wcfsetup/install/files/lib/data/poll/PollBuilder.class.php | 4 ++++ .../endpoint/controller/core/polls/GetResults.class.php | 1 + .../endpoint/controller/core/polls/GetVoteForm.class.php | 1 + .../builder/container/wysiwyg/WysiwygFormContainer.class.php | 2 ++ .../container/wysiwyg/WysiwygPollFormContainer.class.php | 1 + .../form/builder/field/poll/PollOptionsFormField.class.php | 1 + .../files/lib/system/importer/AbstractPollImporter.class.php | 1 + .../lib/system/importer/AbstractPollOptionImporter.class.php | 1 + .../files/lib/system/poll/AbstractPollHandler.class.php | 1 + wcfsetup/install/files/lib/system/poll/IPollHandler.class.php | 1 + wcfsetup/install/files/lib/system/poll/PollManager.class.php | 1 + wcfsetup/install/files/style/ui/poll.scss | 4 ++-- 42 files changed, 49 insertions(+), 2 deletions(-) diff --git a/com.woltlab.wcf/objectTypeDefinition.xml b/com.woltlab.wcf/objectTypeDefinition.xml index 247fa14e993..13deccedc08 100644 --- a/com.woltlab.wcf/objectTypeDefinition.xml +++ b/com.woltlab.wcf/objectTypeDefinition.xml @@ -93,6 +93,7 @@ com.woltlab.wcf.searchableObjectType + com.woltlab.wcf.poll wcf\system\poll\IPollHandler diff --git a/com.woltlab.wcf/option.xml b/com.woltlab.wcf/option.xml index 2d7887f5191..adb6300c1af 100644 --- a/com.woltlab.wcf/option.xml +++ b/com.woltlab.wcf/option.xml @@ -1442,6 +1442,7 @@ DESC:wcf.global.sortOrder.descending 2 100 +