polls

package
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: MIT Imports: 29 Imported by: 0

README

Polls

Ask visitors a question with a few options and show the results. Every visitor has one vote per poll.

Polls appear on any page through a template function, for example on the home page or beside an article. Placing the instance on a page is optional; that page lists every poll.

Add it to the site

app.Use(polls.Module("polls", "Polls"))

The key (polls) is the instance's name in URLs and template functions and must be unique on the site. The title (Polls) is what editors see in the admin navigation and the page editor.

Write a poll

In the admin, open the instance (/admin/polls; /admin is the default admin path) and choose New poll:

  • Question, and an optional Short name, a name for templates such as favourite-colour.
  • Options: one per line, at least two and at most 20.
  • Closes at: when voting ends, in the site's timezone. Leave it empty to keep the poll open.
  • Show results before voting: otherwise visitors see the results after voting or once the poll is closed.

Publish the poll when it is ready. It appears in each language that has a question and at least two options.

Options are matched by line in every language, so translate them in the same order and add new options at the end. Changing the text of a line renames that option and keeps its votes. Options that have votes cannot be removed until the votes are reset.

New polls go to the top of the list, and editors can move them up and down. That order is the order of the polls function, and the top poll is the one {{pollBox "polls" ""}} shows. The list shows the number of votes per poll and which polls are closed.

Move to trash moves a poll to the trash with its votes, where it can be restored or deleted for good. Restoring is refused when another poll has taken its short name meanwhile.

Show a poll on any page

{{pollBox "polls" "favourite-colour"}}
{{pollBox "polls" ""}}

pollBox renders a published poll by short name or ID, or for "" the top poll of the list, the newest one unless editors moved the polls: the question, the vote form, the results with bars when they may be shown, and a notice after voting. Without such a poll it renders nothing.

The box comes with plain, accessible default markup. A theme replaces it with <key>/box.gohtml, or polls/box.gohtml for every instance; task templates -- polls writes the default to start from. It uses stable wt-* classes (wt-poll, wt-poll__results, wt-poll__bar, …), laid out by the optional stylesheet webthings.css, which the layout links with {{styles "webthings.css" "site.css"}} (see The base stylesheet). A result's bar is a <meter class="wt-poll__bar" min="0" max="100" aria-hidden="true"> whose value is the result's percentage, which also shows as text; it has no style attribute. webthings.css draws it as a --wt-bar track filled with --wt-accent. go-form renders the options with the site's form theme, as a fieldset with the question as its legend, by default wt-form__field wt-form__fieldset around wt-form__options with a wt-form__option each. Its texts go through tl, so the site's translations translate them.

For markup of your own, poll returns one poll (or nil), polls the first published polls ({{polls "polls" 0}} for all) and pollURL the address votes are posted to:

{{with poll "polls" "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}}%{{if .Chosen}} (your vote){{end}}</p>
                <meter min="0" max="100" value="{{.Percent}}">{{.Percent}}%</meter>
            {{end}}
            <p>{{.Total}} votes</p>
        {{end}}
    </section>
{{end}}

CanVote is set while the poll is open and the visitor has not voted. Percent is rounded, ClosesAt is in the site's timezone and zero for a poll that stays open.

.Form is the vote form: form_open writes the form tag, posting to .Action, with the spam trap and the page to return to, the element whose id is .Anchor (<key>-<poll ID>), so several polls can share a page. poll is a hidden field with the poll's ID. option renders the poll's options as radio buttons, required, in a fieldset with the question as its legend, in the site's form theme; the ids start with .Anchor. See Template functions. Radio inputs written by hand, named option with an option's .ID as value, work too.

Poll page

In the admin, open a page and choose the instance under Shows on this page. The page renders polls/list.gohtml with a polls.ListView: the page's published title, the instance key and every published poll in the editors' order. task templates -- polls writes a starting point for it too. The box works here:

{{/* @dot github.com/donseba/go-webthings/modules/polls.ListView */}}
<h1>{{.Title}}</h1>

{{range .Polls}}
    {{pollBox $.Key .ID}}
{{end}}

Voting

The vote form posts to /_/<key>/vote ({{pollURL "polls"}}) with the fields of polls.VoteInput, poll (the poll's ID) and option (an option's ID), plus the spam trap and the way back that form_open adds. The visitor returns to the poll's .Anchor on the page with ?<key>=<result>&<key>_poll=<poll ID>. Only that poll gets the result as .Result, so other polls on the page show none; unknown results are ignored:

  • 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: more than 20 votes in a minute from this visitor; nothing was counted.

With invalid, the poll shows the vote form again with the message in the page's language at its options. The box and form_field do this without a change to the template; inputs written by hand do not. See A post with mistakes. A vote that fails for another reason, such as an unreachable database, gets the site's error page, 500.gohtml.

Bots that fill in the spam trap are told their vote counted; nothing is stored.

The first vote sets a wt_poll cookie that recognises the visitor for a year. Visitors without it are recognised by their IP address, so scripts that drop cookies get one vote per address; behind a reverse proxy, list it in Config.TrustedProxies. When someone else on that address already voted, the visitor gets a cookie of their own and can vote next time. Only a hash of the visitor's ID is stored, and addresses are keyed with a secret of the instance first, so votes cannot be traced back to addresses.

Only the cookie marks a poll as voted on a page. The module names it to the site's output cache (Env.PersonalCookie), which keeps pages apart for each value of wt_poll, so a visitor who voted sees their choice and others the vote form; see Modules and the output cache. Only a vote that counted makes the cache render the pages again: every other result tells it that nothing changed (frontend.NothingChanged).

Results

Results on the instance's admin page (/admin/polls/results) shows every poll with its status (draft, open or closed), the votes and percentage per option and the total. Reset votes removes all votes of a poll after confirmation; it needs the delete right.

From Go

polls.Of(app, "polls") returns the instance once the app has started. Latest and Find take the visitor's request, or nil, to mark what that visitor chose; Latest with a limit of 0 or less returns all polls:

survey := polls.Of(app, "polls")

newest, err := survey.Latest(ctx, nil, "en", 1)
poll, err := survey.Find(ctx, r, "en", "favourite-colour")

Vote counts a vote for any visitor ID, such as a member number, and returns ErrAlreadyVoted, ErrClosed or ErrInvalidOption; ResetVotes clears a poll. Store() gives write access for seed data. Votes and resets made from Go, and writes through Store(), show at once, also on pages in the site's output cache; see Modules and the output cache. Code may choose its own option IDs, unique in the poll and at most 36 bytes:

store := survey.Store()

saved, err := store.Save(ctx, content.Draft[polls.Fields, polls.Text]{
    Fields: polls.Fields{Options: polls.Lines{"red", "green", "blue"}},
    Translations: content.SetText(nil, "en", polls.Text{
        Question: "Which colour should the new logo have?",
        Slug:     "logo-colour",
        Options:  polls.Lines{"Red", "Green", "Blue"},
    }),
})

err = store.Publish(ctx, saved.ID, "", "en")
  • Forms asks longer questions, with an inbox for the answers.
  • Comments lets visitors say more than a vote.

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

View Source
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

View Source
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

func Module

func Module(key, title string) webthings.Module

Module adds a Polls instance. key is used in URLs of the admin and in the template functions and must be unique on the site; title is shown in the admin and the page editor.

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 Item

type Item = content.Item[Fields, Text]

Item is a stored poll with its translations.

type Lines

type Lines []string

Lines is a list of strings stored as a JSON array in one column.

func (*Lines) Scan

func (l *Lines) Scan(src any) error

Scan reads lines stored by Value.

func (Lines) Value

func (l Lines) Value() (driver.Value, error)

Value stores the lines as JSON, an empty list as [].

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

type ResultsView struct {
	Title   string
	BackURL string
	Polls   []ResultRow
}

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 Of

func Of(app *webthings.App, key string) *Service

Of returns the Polls instance with key from a started app, or nil.

func (*Service) Find

func (s *Service) Find(ctx context.Context, r *http.Request, locale, slugOrID string) (Poll, error)

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

func (s *Service) ResetVotes(ctx context.Context, pollID string) error

ResetVotes removes all votes of a poll.

func (*Service) Store

func (s *Service) Store() content.Store[Fields, Text]

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.

func (*Service) Vote

func (s *Service) Vote(ctx context.Context, pollID, optionID, visitor string) error

Vote records visitor's choice of option on a poll. visitor identifies the voter; only its hash is stored. It returns content.ErrNotFound for unknown or unpublished polls, ErrClosed, ErrInvalidOption or ErrAlreadyVoted.

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).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL