Add word cloud poll type - #242
Open
junkerderprovinz wants to merge 8 commits into
Open
Conversation
Adds a "Word Cloud" option next to "Choice" when creating a poll. Attendees type their own word or short phrase instead of picking from choices the moderator set up ahead of time -- there's nothing to configure beyond the title, so the whole options list, the "Add" button and "Multiple answers" are hidden for this type in the creation form. Deliberately reuses the existing poll_opts/poll_votes tables rather than adding a new schema: Polls.submit_word/4 looks for a poll_opt on this poll whose content matches the submitted text case-insensitively (trimmed), increments its vote_count if found or creates a new poll_opt at vote_count 1 if not, then records a PollVote exactly like Polls.vote/4 does -- so "have you already answered this poll" (Polls.get_poll_vote/2), percentages (Polls.set_percentages/1) and the :poll_updated PubSub broadcast all work unchanged for both poll types, and both the attendee's own screen and the presenter's shared display render the live cloud, because they already share PollComponent. The cloud itself is plain CSS (font-size scaled by each word's share of total votes) -- no new JS dependency for what's fundamentally a sized-text layout. New migration adds `type` (string, default "choice") to polls; every existing poll implicitly stays type :choice, no behaviour change for polls that already exist. Verified end-to-end against a real running instance: created a word cloud poll as the moderator (confirmed the options UI disappears when the type is switched), enabled it, submitted a word as an anonymous attendee, watched it render as the cloud immediately. Added 3 tests for Polls.submit_word/4 (new word, case-insensitive merge of a repeated word, blank word rejected) -- full existing test suite still passes (the one failure in the suite is a pre-existing, unrelated ImageMagick-availability test). Fixes ClaperCo#79
Claper.Polls.calculate_percentage/2 returns a binary (via
:erlang.float_to_binary), not a number -- same as every other
opt.percentage use in this file, which all interpolate it directly
into a string ("{opt.percentage}%"). word_size/1 guarded on
is_number(percentage), which never matched, so every word silently
fell through to the 14px fallback regardless of vote count. Add a
is_binary(percentage) clause that parses it first.
Also: mix format (find_matching_poll_opt's where clause needed
wrapping to fit the 98-column default, and poll_component.ex had
inconsistent indentation on a few lines the wrapping ~H sigil
didn't auto-fix).
Verified: submitted the same word from 4 attendees and a different
word from 1, checked the actual rendered inline style attributes --
the 4-vote word (80% share) renders at font-size: 41px, the 1-vote
word (20% share) at 21px, matching the word_size/1 formula.
mix test: 339 tests, 0 failures.
Word-cloud polls only rendered as a cloud in the moderator's own PollComponent panel; presenter.html.heex had its own separate markup that showed a plain percentage-bar list instead, so the projected screen never actually displayed a cloud. Extract word_size/1 into ClaperWeb.Helpers (now word_size/3 with a base/range so callers can pick their own scale) and reuse it in both PollComponent and the presenter template.
`Claper.Polls.submit_word/4` tripped two Credo checks: its body nested
three levels deep (max 2) and its cyclomatic complexity reached 10
(max 9).
Replace the nested `case`/`if` pyramid inside the transaction with a
`with` chain and move the two concerns into private helpers:
`upsert_word_poll_opt/2` for the match-or-create of the poll option and
`create_word_poll_vote/3` (plus a guarded `word_poll_vote_attrs/3`) for
recording the vote. Behaviour is unchanged: the same rollback on either
failure, the same `{:ok, poll}` / `{:error, changeset}` return.
The "submit-word" event is client-triggered, but the handler passed
socket.assigns.current_interaction.id to Polls.submit_word/4 without
checking what that interaction actually was. Any attendee could emit the
event whatever was on screen:
- with a choice poll active, upsert_word_poll_opt/2 inserted the
submitted text as a brand new PollOpt on that live poll, so arbitrary
text could be injected into a running presentation;
- with a form, quiz or embed active, the id belonged to another table
entirely and the insert hit the poll_opts_poll_id_fkey constraint.
PollOpt.changeset/2 declares no foreign_key_constraint, so that raised
Ecto.ConstraintError and took the LiveView process down.
Match %Polls.Poll{type: :word_cloud, enabled: true} in the handler heads
and ignore the event otherwise, the way this module already guards
"save" on chat_enabled and "react"/"unreact" on message_reaction_enabled.
The cloud rendered whenever the attendee had submitted, ignoring the moderator's "show results" toggle, so anyone who submitted saw the live cloud even while results were meant to stay hidden. It also rendered directly underneath the "thanks, submitted" message, showing both at once. Gate it on @show_results alone, which is how the choice branch of this component already behaves (bar width zeroed and the percentages hidden unless @show_results).
Ran `mix gettext.extract --merge`, so default.pot and every locale's default.po pick up the seven strings this feature adds. They land untranslated and fall back to the English source until a translation pass, matching how strings arrived in previous feature PRs. The run also refreshes `#:` source references across the catalogues, which had drifted from the current source tree before this branch.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A minimal take on #79's word cloud request.
What this adds
A "Word Cloud" option next to "Choice" when creating a poll. Attendees type their own word or short phrase instead of picking from choices the moderator set up ahead of time. There's nothing to configure beyond the title, so the options list, "Add" button and "Multiple answers" are hidden for this type in the creation form.
Design choice: reuses poll_opts/poll_votes, no new schema
Polls.submit_word/4looks for apoll_opton the poll whose content matches the submitted text case-insensitively (trimmed), increments itsvote_countif found or creates a newpoll_optatvote_count1 if not, then records aPollVoteexactly likePolls.vote/4does. That means "have you already answered this poll" (Polls.get_poll_vote/2), percentages (Polls.set_percentages/1) and the:poll_updatedbroadcast all work unchanged for both poll types.The attendee's own screen renders the cloud through
PollComponent. The shared/projected presenter display (/e/:code/presenter) has its own separate markup that doesn't usePollComponent, so it needed its own word-cloud branch; both now compute font size through a sharedClaperWeb.Helpers.word_size/3(extracted from what was aPollComponent-private function, now takes an optional base/range so the bigger presenter screen can size its words differently than the moderator's small panel).The cloud itself is plain CSS (font-size scaled by each word's share of total votes). Didn't bring in a word-cloud JS library for what's fundamentally a sized-text layout; happy to swap in something fancier if you'd rather have real cloud packing and rotation, and can point me at a preferred library.
New migration adds
type(string, default"choice") topolls; every existing poll implicitly stays:choice, no behavior change for polls that already exist.Defects found and fixed on review
A second pass over my own branch turned up two real bugs, both now fixed with regression tests.
submit-wordaccepted a submission against whatever interaction was on screen. The handler passedsocket.assigns.current_interaction.idstraight intoPolls.submit_word/4without checking what that interaction actually was, and the event is client-triggered, so an attendee could fire it regardless of what was being shown. With a choice poll active,upsert_word_poll_opt/2inserted the submitted text as a brand newPollOpton that live poll, which is arbitrary text injection into a running presentation. With a form, quiz or embed active, the id belonged to a different table entirely, and becausePollOpt.changeset/2declares noforeign_key_constraint, the insert hitpoll_opts_poll_id_fkey, raisedEcto.ConstraintErrorand took the LiveView process down. The handler heads now match%Polls.Poll{type: :word_cloud, enabled: true}and the event is ignored otherwise, the same shape this module already uses to guardsaveonchat_enabledandreact/unreactonmessage_reaction_enabled.The word cloud ignored the "show results" toggle. The cloud rendered as soon as an attendee had submitted, whatever the moderator's setting, so a submitting attendee saw the live cloud even while results were meant to stay hidden. It also rendered directly beneath the "thanks, submitted" message, showing both at once. It is now gated on
@show_resultsalone, which is what the choice branch of the same component already does (bar width zeroed and percentages hidden unless@show_results).Translations
Ran
mix gettext.extract --merge, sodefault.potand every locale'sdefault.popick up the seven new strings. They land untranslated and fall back to the English source until a translation pass, matching how strings arrived in previous feature PRs such as #214. Note that the same run also refreshes#:source references across the catalogues, which had drifted from the current source tree before this branch, so the diff on those files is larger than the seven new strings alone.Not included
Kept this to the poll mechanics themselves. A few things deliberately left out:
poll_opts, so stale choice options would resurface as pre-existing "words"; worth a follow-up if that path matters to youpoll_optsinstead of merging into oneHappy to pick any of these up if you'd rather have them in scope here instead of as follow-ups.
Verification
mix testgives 347 tests, 0 failures, run just now against this branch at its current head, in a container matching.github/workflows/elixir.yml(Elixir 1.18.4 / OTP 28, postgres:15, same env).mix format --check-formattedandmix credo diff --from-git-merge-base origin/mainare both clean. Earlier commit messages on this branch quoted different counts (336, 339, 341); those were snapshots from different points as tests were added, and 347 is the current, current-head number.Both fixes above have tests that genuinely fail without them. Applying only the new test files to this branch, with no source fix, reproduces all three symptoms:
submit-wordagainst a live choice poll leaves["some option 1", "some option 2", "injected"]on that poll,submit-wordwith a form on screen raisesEcto.ConstraintErroronpoll_opts_poll_id_fkeyand terminates the LiveView, and the cloud renders its words whileshow_resultsis false. With the fixes in place all of them pass.End to end against a real running instance: created a word cloud poll as the moderator (confirmed the options UI disappears when switching the type), enabled it, submitted a word as an anonymous attendee, watched it render as the cloud immediately. The size scaling matches
word_size/3on real numbers: with 4 attendees on one word and 1 on another, the 80% word renders atfont-size: 41pxand the 20% word atfont-size: 21px.On CI
For transparency, since this PR previously quoted test numbers that upstream CI never confirmed: the Elixir CI run on this PR from 2026-08-16 failed at "Check Credo Warnings" and its "Run tests" step was skipped, so the suite never ran upstream at all.
08425e1addressed those Credo findings. The run queued against the current head is sitting inaction_required, GitHub's approval gate for workflow runs on pull requests from forks, so it needs a maintainer to approve it before it will execute. Every number above comes from my own container run, not from upstream CI.Fixes #79