Questionnaires: questions asked one at a time, with progress, back, skip and next, each answer checked before moving on, and the answers gathered at the end.
gleam run -m howdy/ui add questionnaireCopying it also copies Button, Checkbox, Field, Input, Progress.
Questionnaire
[
questionnaire_module.question(
"role",
"What do you mostly do?",
questionnaire_module.Single([
#("build", "Build software"),
#("design", "Design it"),
#("other", "Something else"),
]),
)
|> questionnaire_module.required,
questionnaire_module.question(
"score",
"How likely are you to recommend us?",
questionnaire_module.Scale(
from: 0,
to: 10,
low: "Not likely",
high: "Very likely",
),
),
questionnaire_module.question(
"more",
"Anything else?",
questionnaire_module.Long,
),
]
|> questionnaire_module.start
|> questionnaire_module.view([attribute.method("post")])Full source of howdy/ui/questionnaire
//// Questionnaires: questions asked one at a time, with progress, back,
//// skip and next, each answer checked before moving on, and the answers
//// gathered at the end.
////
//// ```gleam
//// let questions = [
//// questionnaire.question("role", "What do you do?", questionnaire.Single([
//// #("dev", "I build software"), #("design", "I design it"), #("other", "Something else"),
//// ]))
//// |> questionnaire.required,
//// questionnaire.question("tools", "Which do you use?", questionnaire.Several([
//// #("gleam", "Gleam"), #("elixir", "Elixir"), #("erlang", "Erlang"),
//// ])),
//// questionnaire.question("score", "How likely are you to recommend us?",
//// questionnaire.Scale(from: 0, to: 10, low: "Not likely", high: "Very likely")),
//// questionnaire.question("more", "Anything else?", questionnaire.Long),
//// ]
////
//// // On a page: show the first question, then handle each post.
//// questionnaire.view(questionnaire.start(questions), [attribute.method("post")])
////
//// use fields <- form.read(ctx)
//// let asked = questionnaire.submit(questions, form.fields(fields))
//// case questionnaire.is_complete(asked) {
//// True -> save(questionnaire.answers(asked))
//// False -> render(questionnaire.view(asked, [attribute.method("post")]))
//// }
//// ```
////
//// The form carries the answers so far and the current step in hidden
//// fields, so the server keeps nothing between posts. In a live view, pass
//// `event.on_submit(Submitted)` in the attributes and call `submit` with
//// the fields it gives you; the button pressed comes with them.
////
//// Next checks the current answer: a `required` question needs one, a
//// choice must be one of its options and a scale number on the scale, and
//// a `check` can reject it with a message. Past the last question every
//// answer is checked again, since earlier ones came back from the form,
//// and the first that fails is asked again. Back keeps what was typed, and
//// Skip, offered for questions that are not required, clears it.
import gleam/dict.{type Dict}
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import howdy/ui/button
import howdy/ui/checkbox
import howdy/ui/field
import howdy/ui/input
import howdy/ui/progress
import howdy/ui/style.{class}
import howdy/ui/theme/tokens
import lustre/attribute.{type Attribute}
import lustre/element.{type Element, text}
import lustre/element/html
import sketch/css.{type Class}
import sketch/css/length.{rem}
/// How a question is answered.
pub type Kind {
/// One of these options, as `#(value, label)`.
Single(options: List(#(String, String)))
/// Any of these options.
Several(options: List(#(String, String)))
/// A line of text.
Short
/// A paragraph of text.
Long
/// A number on a scale, with words for its ends.
Scale(from: Int, to: Int, low: String, high: String)
}
pub opaque type Question {
Question(
id: String,
prompt: String,
help: String,
kind: Kind,
required: Bool,
check: fn(List(String)) -> Result(Nil, String),
)
}
/// A question, answered in a field named `id`.
pub fn question(id: String, prompt: String, kind: Kind) -> Question {
Question(id:, prompt:, help: "", kind:, required: False, check: fn(_) {
Ok(Nil)
})
}
/// It must be answered; it cannot be skipped.
pub fn required(question: Question) -> Question {
Question(..question, required: True)
}
/// A line under the question saying more about it.
pub fn help(question: Question, help: String) -> Question {
Question(..question, help:)
}
/// Check an answer before moving on; an `Error` holds the message to show.
pub fn check(
question: Question,
check: fn(List(String)) -> Result(Nil, String),
) -> Question {
Question(..question, check:)
}
/// Where someone has got to.
pub opaque type Questionnaire {
Questionnaire(
questions: List(Question),
step: Int,
answers: Dict(String, List(String)),
error: Option(String),
complete: Bool,
)
}
/// The first question, with nothing answered.
pub fn start(questions: List(Question)) -> Questionnaire {
Questionnaire(
questions:,
step: 0,
answers: dict.new(),
error: None,
complete: False,
)
}
const step_field = "questionnaire-step"
const action_field = "questionnaire-action"
const answer_prefix = "questionnaire-answer:"
/// Take a submitted form: restore the answers so far, read the current
/// answer, and go back, skip, or check it and go on, as the button pressed
/// asks.
pub fn submit(
questions: List(Question),
fields: List(#(String, String)),
) -> Questionnaire {
let count = list.length(questions)
let step =
list.key_find(fields, step_field)
|> result.try(int.parse)
|> result.unwrap(0)
|> int.clamp(0, int.max(count - 1, 0))
let current = list.drop(questions, step) |> list.first
let answers =
list.fold(fields, dict.new(), fn(answers, pair) {
case pair.0 {
"questionnaire-answer:" <> id ->
dict.upsert(answers, id, fn(values) {
list.append(option.unwrap(values, []), [pair.1])
})
_ -> answers
}
})
let given = case current {
Ok(question) ->
fields
|> list.filter(fn(pair) { pair.0 == question.id })
|> list.map(fn(pair) { pair.1 })
|> list.filter(fn(value) { string.trim(value) != "" })
Error(Nil) -> []
}
let answers = case current {
Ok(question) -> dict.insert(answers, question.id, given)
Error(Nil) -> answers
}
let state =
Questionnaire(questions:, step:, answers:, error: None, complete: False)
case list.key_find(fields, action_field), current {
Ok("back"), _ -> Questionnaire(..state, step: int.max(step - 1, 0))
Ok("skip"), Ok(question) if !question.required ->
advance(
Questionnaire(..state, answers: dict.delete(answers, question.id)),
)
_, Ok(question) ->
case validate(question, given) {
Ok(Nil) -> advance(state)
Error(message) -> Questionnaire(..state, error: Some(message))
}
_, Error(Nil) -> state
}
}
/// Whether an answer fits its question: given if required, one of the
/// options or on the scale, and passing the question's own check.
fn validate(question: Question, given: List(String)) -> Result(Nil, String) {
case question.required, given {
True, [] -> Error("Answer this to go on.")
False, [] -> Ok(Nil)
_, _ -> {
use _ <- result.try(fits(question.kind, given))
question.check(given)
}
}
}
fn fits(kind: Kind, given: List(String)) -> Result(Nil, String) {
let offered = fn(options: List(#(String, String)), value) {
list.any(options, fn(option) { option.0 == value })
}
case kind, given {
Single(options), [value] ->
case offered(options, value) {
True -> Ok(Nil)
False -> Error("Choose one of the options.")
}
Single(_), _ -> Error("Choose one of the options.")
Several(options), values ->
case list.all(values, offered(options, _)) {
True -> Ok(Nil)
False -> Error("Choose from the options.")
}
Scale(from:, to:, ..), [value] ->
case int.parse(value) {
Ok(n) if n >= from && n <= to -> Ok(Nil)
_ -> Error("Choose a number on the scale.")
}
Scale(..), _ -> Error("Choose a number on the scale.")
Short, [_] | Long, [_] -> Ok(Nil)
Short, _ | Long, _ -> Error("Give one answer.")
}
}
/// On past the last question, every answer is checked again, since the
/// earlier ones came back from the form and may have been changed or left
/// out. The first that fails is asked again.
fn advance(state: Questionnaire) -> Questionnaire {
case state.step + 1 >= list.length(state.questions) {
False -> Questionnaire(..state, step: state.step + 1)
True -> {
let failed =
state.questions
|> list.index_map(fn(question, index) {
let given = dict.get(state.answers, question.id) |> result.unwrap([])
#(index, validate(question, given))
})
|> list.find_map(fn(checked) {
case checked {
#(index, Error(message)) -> Ok(#(index, message))
_ -> Error(Nil)
}
})
case failed {
Ok(#(step, message)) ->
Questionnaire(..state, step:, error: Some(message))
Error(Nil) -> Questionnaire(..state, complete: True)
}
}
}
}
/// Whether the last question has been answered or skipped.
pub fn is_complete(state: Questionnaire) -> Bool {
state.complete
}
/// Every answer given, in the order the questions were asked. Questions
/// skipped or left empty are left out.
pub fn answers(state: Questionnaire) -> List(#(String, List(String))) {
list.filter_map(state.questions, fn(question) {
case dict.get(state.answers, question.id) {
Ok([_, ..] as values) -> Ok(#(question.id, values))
_ -> Error(Nil)
}
})
}
/// The question being asked, from 1, and how many there are.
pub fn progress(state: Questionnaire) -> #(Int, Int) {
#(state.step + 1, list.length(state.questions))
}
/// The current question as a form. `attributes` go on the `<form>`: a
/// `method` and `action` on a page, or a submit handler in a live view.
pub fn view(
state: Questionnaire,
attributes: List(Attribute(msg)),
) -> Element(msg) {
let #(number, count) = progress(state)
let current = list.drop(state.questions, state.step) |> list.first
let kept =
state.answers
|> dict.to_list
|> list.filter(fn(pair) {
case current {
Ok(question) -> pair.0 != question.id
Error(Nil) -> True
}
})
|> list.flat_map(fn(pair) {
list.map(pair.1, fn(value) {
html.input([
attribute.type_("hidden"),
attribute.name(answer_prefix <> pair.0),
attribute.value(value),
])
})
})
html.form([class(form_class()), ..attributes], [
html.input([
attribute.type_("hidden"),
attribute.name(step_field),
attribute.value(int.to_string(state.step)),
]),
element.fragment(kept),
html.div([class(progress_class())], [
html.span([class(count_class())], [
text(
"Question " <> int.to_string(number) <> " of " <> int.to_string(count),
),
]),
progress.progress(label: "Progress", value: number - 1, max: count),
]),
case current {
Ok(question) -> ask(state, question)
Error(Nil) -> element.none()
},
html.div([class(actions_class())], actions(state, current, number, count)),
])
}
fn actions(
state: Questionnaire,
current: Result(Question, Nil),
number: Int,
count: Int,
) -> List(Element(msg)) {
let act = fn(value) { [attribute.name(action_field), attribute.value(value)] }
let next_label = case number == count {
True -> "Finish"
False -> "Next"
}
// Next comes first, so Enter in a text field presses it; the row is
// reversed to put Back on the left.
list.flatten([
[button.submit(button.Primary, act("next"), [text(next_label)])],
case current {
Ok(question) if !question.required -> [
button.submit(button.Ghost, act("skip"), [text("Skip")]),
]
_ -> []
},
case state.step > 0 {
True -> [
button.submit(
button.Outline,
[attribute.attribute("formnovalidate", ""), ..act("back")],
[text("Back")],
),
]
False -> []
},
])
}
fn ask(state: Questionnaire, question: Question) -> Element(msg) {
let given = dict.get(state.answers, question.id) |> result.unwrap([])
let error_id = question.id <> "-error"
let help_id = question.id <> "-help"
let described =
[
case question.help {
"" -> None
_ -> Some(help_id)
},
option.map(state.error, fn(_) { error_id }),
]
|> option.values
let invalid = case state.error, described {
Some(_), _ -> [
attribute.aria_invalid("true"),
attribute.aria_describedby(string.join(described, " ")),
]
None, [] -> []
None, _ -> [attribute.aria_describedby(string.join(described, " "))]
}
let first = given |> list.first |> result.unwrap("")
let control = case question.kind {
Short ->
input.input([
attribute.id(question.id),
attribute.name(question.id),
attribute.value(first),
attribute.autofocus(True),
..invalid
])
Long ->
input.textarea(
[
attribute.id(question.id),
attribute.name(question.id),
attribute.rows(4),
attribute.autofocus(True),
..invalid
],
first,
)
Single(options) ->
checkbox.radio_group(
invalid,
list.map(options, fn(option) {
checkbox.choice(
checkbox.radio([
attribute.name(question.id),
attribute.value(option.0),
attribute.checked(list.contains(given, option.0)),
]),
[text(option.1)],
)
}),
)
Several(options) ->
html.div(
[class(options_class()), ..invalid],
list.map(options, fn(option) {
checkbox.choice(
checkbox.checkbox([
attribute.name(question.id),
attribute.value(option.0),
attribute.checked(list.contains(given, option.0)),
]),
[text(option.1)],
)
}),
)
Scale(from:, to:, low:, high:) ->
html.div([class(scale_class())], [
html.div(
[class(scale_steps_class()), attribute.role("radiogroup"), ..invalid],
list.map(numbers(from, to), fn(value) {
let value = int.to_string(value)
html.label([class(scale_step_class())], [
html.input([
attribute.type_("radio"),
attribute.name(question.id),
attribute.value(value),
attribute.checked(list.contains(given, value)),
]),
html.span([], [text(value)]),
])
}),
),
html.div([class(scale_ends_class())], [
html.span([], [text(low)]),
html.span([], [text(high)]),
]),
])
}
// One fieldset per question, so its prompt names every option in it.
field.fieldset(
[class(question_class())],
legend: [html.span([class(prompt_class())], [text(question.prompt)])],
children: [
case question.help {
"" -> element.none()
help -> field.description([attribute.id(help_id)], [text(help)])
},
control,
case state.error {
Some(message) ->
field.error([attribute.id(error_id), attribute.role("alert")], [
text(message),
])
None -> element.none()
},
],
)
}
fn numbers(from: Int, to: Int) -> List(Int) {
case from > to {
True -> []
False -> [from, ..numbers(from + 1, to)]
}
}
/// Every class this module uses, for `howdy/ui/export`.
pub fn classes() -> List(Class) {
[
form_class(),
progress_class(),
count_class(),
question_class(),
prompt_class(),
options_class(),
scale_class(),
scale_steps_class(),
scale_step_class(),
scale_ends_class(),
actions_class(),
]
}
pub fn form_class() -> Class {
css.class([
css.display("flex"),
css.flex_direction("column"),
css.gap(rem(1.5)),
css.property("max-width", "36rem"),
])
}
pub fn progress_class() -> Class {
css.class([
css.display("flex"),
css.flex_direction("column"),
css.gap(rem(0.5)),
])
}
pub fn count_class() -> Class {
css.class([css.font_size(rem(0.875)), css.color(tokens.text_muted)])
}
pub fn question_class() -> Class {
css.class([css.gap(rem(1.0))])
}
pub fn prompt_class() -> Class {
css.class([
css.font_size(rem(1.25)),
css.font_weight("600"),
css.line_height("1.3"),
css.color(tokens.text),
])
}
pub fn options_class() -> Class {
css.class([
css.display("flex"),
css.flex_direction("column"),
css.gap(rem(0.75)),
])
}
pub fn scale_class() -> Class {
css.class([
css.display("flex"),
css.flex_direction("column"),
css.gap(rem(0.5)),
])
}
pub fn scale_steps_class() -> Class {
css.class([css.display("flex"), css.flex_wrap("wrap"), css.gap(rem(0.25))])
}
/// A number on the scale: a radio button drawn as a box, filled when
/// chosen.
pub fn scale_step_class() -> Class {
css.class([
css.position("relative"),
css.selector(" > input", [
css.position("absolute"),
css.property("opacity", "0"),
css.property("inset", "0"),
css.margin(rem(0.0)),
css.cursor("pointer"),
]),
css.selector(" > span", [
css.display("inline-flex"),
css.align_items("center"),
css.justify_content("center"),
css.property("min-width", "2.5rem"),
css.property("height", "2.5rem"),
style.bordered(),
style.radius(tokens.radius_medium),
css.background(tokens.surface),
css.color(tokens.text),
css.font_weight("500"),
css.property("font-variant-numeric", "tabular-nums"),
]),
css.selector(" > input:checked + span", [
css.background(tokens.primary),
css.color(tokens.on_primary),
css.property("border-color", tokens.primary),
]),
css.selector(" > input:focus-visible + span", [
style.focus_outline(),
css.property("outline-offset", "2px"),
]),
css.selector(" > input:hover + span", [
css.property("border-color", tokens.primary),
]),
])
}
pub fn scale_ends_class() -> Class {
css.class([
css.display("flex"),
css.justify_content("space-between"),
css.font_size(rem(0.8125)),
css.color(tokens.text_muted),
])
}
pub fn actions_class() -> Class {
css.class([
css.display("flex"),
css.flex_direction("row-reverse"),
css.justify_content("flex-end"),
css.gap(rem(0.5)),
])
}
