Documentation
¶
Overview ¶
Package polls asks visitors a question with a few options and shows the results. Every visitor has one vote per poll. Add an instance with:
app.Use(polls.Module("survey", "Polls"))
Editors write polls in the admin: a question, one option per line, an optional closing time and whether visitors see the results before they vote. Results per poll, and a button to reset them, are under Results.
Templates ¶
Polls can be shown on any page with template functions; they need no page of their own:
{{poll "survey" "favourite-colour"}} *Poll by slug or ID, or nil
{{polls "survey" 3}} []Poll: the first published polls, newest first; 0 for all
{{pollURL "survey"}} the address votes are posted to
{{pollBox "survey" "colour"}} the box component, rendered (below)
When the module is mounted on a page, that page renders polls/list.gohtml (or <key>/list.gohtml) with a ListView: all published polls with their results. App.WriteTemplates(dir, key) writes a starting point that shows each poll with the box component.
Component ¶
The box component (see webthings.Module.Components) shows one poll with plain, accessible default markup: the vote form, the results with bars and the result of the visitor's vote.
{{pollBox "survey" "favourite-colour"}} the poll with that slug or ID
{{pollBox "survey" ""}} the newest poll, for example on the home page
It gets a Poll and renders nothing when there is no such poll. A theme replaces it with <key>/box.gohtml, or polls/box.gohtml for every instance; App.WriteTemplates(dir, key) writes the default there to start from. The default uses the classes wt-poll, wt-poll__question, wt-poll__results, wt-poll__result, wt-poll__result--chosen, wt-poll__label, wt-poll__percent, wt-poll__bar (a <meter> of the percentage, without a style attribute) and wt-poll__total, and the shared wt-notice, wt-button and wt-meta, styled by the optional {{asset "webthings.css"}}; go-form renders the options with the site's form theme (webthings.Config.FormTheme), by default with wt-form, wt-form__field, wt-form__options and wt-form__option. Its texts go through tl, so the site's translations translate them.
Or write the markup yourself; a complete poll:
{{with poll "survey" "favourite-colour"}}
<section id="{{.Anchor}}">
<h2>{{.Question}}</h2>
{{if eq .Result "voted"}}<p>Thanks for voting.</p>{{end}}
{{if .CanVote}}
{{form_open .Form}}
{{form_field .Form "poll" nil}}
{{form_field .Form "option" nil}}
<button>Vote</button>
{{form_close}}
{{end}}
{{if .ShowResults}}
{{range .Options}}<p>{{.Label}}: {{.Percent}}% ({{.Votes}}){{if .Chosen}} ✓{{end}}</p>{{end}}
<p>{{.Total}} votes</p>
{{end}}
</section>
{{end}}
Voting ¶
The form posts to /_/<key>/vote with the fields poll (the poll ID) and option (an option ID), VoteInput; form_open adds the spam trap and the way back, to the element with the id Poll.Anchor. The visitor returns to the page with ?<key>=<result>&<key>_poll=<poll ID>; the result is Poll.Result of that poll only, so other polls on the page show none:
voted the vote was counted already this visitor already voted on this poll closed the poll is closed or no longer published invalid no option, or an option of another poll, was chosen throttled the visitor sent more than 20 votes in a minute; nothing was counted
A visitor is recognised by the wt_poll cookie, which the first vote sets. A visitor without it is recognised by their IP address (frontend.ClientIP, so list a reverse proxy in Config.TrustedProxies), so scripts that drop cookies get one vote per address; when someone else on that address already voted, the visitor gets a cookie of their own and can vote again. The address is keyed with a secret of the instance (webthings.Env.Secret) and only the SHA-256 of the visitor's id is stored, so votes cannot be traced back to addresses.
Index ¶
- Constants
- Variables
- func Module(key, title string) webthings.Module
- type Fields
- type Item
- type Lines
- type ListView
- type Option
- type Poll
- type ResultRow
- type ResultsView
- type Service
- func (s *Service) Find(ctx context.Context, r *http.Request, locale, slugOrID string) (Poll, error)
- func (s *Service) Latest(ctx context.Context, r *http.Request, locale string, limit int) ([]Poll, error)
- func (s *Service) ResetVotes(ctx context.Context, pollID string) error
- func (s *Service) Store() content.Store[Fields, Text]
- func (s *Service) Vote(ctx context.Context, pollID, optionID, visitor string) error
- type Text
- type VoteInput
Constants ¶
const ( // ResultVoted is a vote that was counted. ResultVoted = "voted" // ResultAlready is a visitor who voted on this poll before. ResultAlready = "already" // ResultClosed is a poll that no longer takes votes, or is gone. ResultClosed = "closed" // ResultInvalid is a vote for an option the poll does not have. ResultInvalid = "invalid" // ResultThrottled is sent to a visitor who posted too many votes in a // short time; nothing was counted. ResultThrottled = frontend.ResultThrottled )
Results of a vote, added to the page address as ?<key>=<result>.
Variables ¶
var ( // ErrClosed is a poll that is not open for votes at this time. ErrClosed = errors.New("polls_closed") // ErrAlreadyVoted is a visitor who voted on the poll before. ErrAlreadyVoted = errors.New("polls_already_voted") // ErrInvalidOption is an option the poll does not have. ErrInvalidOption = errors.New("polls_invalid_option") )
Errors of Vote.
Functions ¶
Types ¶
type Fields ¶
type Fields struct {
Options Lines `db:"option_ids"`
// ClosesAt is when voting ends, in UTC; nil keeps the poll open.
ClosesAt *time.Time `db:"closes_at"`
ShowResults bool `db:"show_results"`
}
Fields are the language-independent parts of a poll. Options are the option IDs in their order; votes refer to them. The editor makes UUIDs; code that writes polls through Store may use any IDs that are unique in the poll, not empty and at most 36 bytes, and must keep them once there are votes.
type ListView ¶
type ListView struct {
// Title is the title of the page the module is mounted on.
Title string
// Key is the instance key, for {{pollBox .Key .ID}}.
Key string
Polls []Poll
}
ListView is the data of polls/list.gohtml.
type Option ¶
type Option struct {
ID string
Label string
Votes int
// Percent is the share of all votes, rounded.
Percent int
Chosen bool
}
Option is one answer of a poll.
type Poll ¶
type Poll struct {
ID string
Slug string
Question string
Options []Option
// Total is the number of votes.
Total int
// ClosesAt is when voting ends, in the site's timezone; zero when the poll
// stays open.
ClosesAt time.Time
Closed bool
// Voted is set when the current visitor voted; Option.Chosen marks their choice.
Voted bool
// CanVote is set when the poll is open and the visitor has not voted.
CanVote bool
// ShowResults is set when results may be shown: after voting, when the
// poll is closed, or always when editors chose so.
ShowResults bool
// Action is the address the vote form posts to.
Action string
// Anchor is the id of the element the vote form returns the visitor
// to, <key>-<ID>, for the element around the form and its result.
Anchor string
// Form is the vote form's model (VoteInput, with the poll's ID) for
// go-form's template functions, such as {{form_open .Form}}; the ids
// of its fields start with Anchor.
Form form.RenderModel
// Result is the outcome of the visitor's last vote on this poll:
// "voted", "already", "closed", "invalid", "throttled" or empty.
Result string
}
Poll is a poll as templates see it, with the results and what the current visitor did.
type ResultRow ¶
type ResultRow struct {
Question string
EditURL string
// Status is "Draft", "Open" or "Closed".
Status string
Options []Option
Total int
// Reset removes the votes; nil without the Delete permission.
Reset *form.RenderModel
}
ResultRow is one poll with its options and their votes.
type ResultsView ¶
ResultsView is the data of templates/admin/results.gohtml: the votes of every poll.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is one Polls instance, for the website's own code.
func (*Service) Find ¶
Find returns a published poll by its slug in locale or by its ID, or content.ErrNotFound. r is the visitor's request, or nil.
func (*Service) Latest ¶
func (s *Service) Latest(ctx context.Context, r *http.Request, locale string, limit int) ([]Poll, error)
Latest returns up to limit published polls in locale, in their order (new polls first); a limit of 0 or less returns all. r is the visitor's request, or nil.
func (*Service) ResetVotes ¶
ResetVotes removes all votes of a poll.
func (*Service) Store ¶
Store gives write access to this instance's polls, for imports and seeds. Admin edits go through the same store; new polls are put first.
type Text ¶
type Text struct {
Question string `db:"question"`
Slug string `db:"slug"`
Options Lines `db:"option_labels"`
}
Text is a poll in one language. Options are the labels of Fields.Options, in the same order.
type VoteInput ¶
type VoteInput struct {
Poll string `form:"input,hidden" name:"poll" maxLength:"64" line:"true"`
Option string `form:"radios" name:"option" required:"true"`
}
VoteInput is the vote a visitor posts to /_/<key>/vote: the poll's ID and the chosen option's ID. Poll.Form is its model for a poll, with the poll's options (RenderModel.Options) and its question as the label of the options: form_open, {{form_field .Form "poll" nil}} and {{form_field .Form "option" nil}} render the form, the options as radio buttons. A poll that does not exist answers "closed"; a vote without an option, or with one the poll does not have, answers "invalid", and the poll shows the vote again with the message at its options (frontend.KeepForm).