packages feed

devforms-0.3.0.0: src/DevForms.hs

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : DevForms
Description : A builder DSL for creating HTML survey forms

DevForms lets you define multi-page survey forms using a concise monadic
builder DSL. Forms are served via a built-in web server (Scotty), and
submissions are stored as JSONL files.

Each question type comes in two variants:

* A plain version (e.g. 'questionLikert') that uses default options.
* A @With@ version (e.g. 'questionLikertWith') that accepts a builder block
  for configuring options like 'setOptional' or bounds.

=== Example

@
main :: IO ()
main = devFormServer 9000 $ do
    form "Animal Survey" "animals" $ do
        questionLikert "I enjoy seeing animals"
        questionChoice "Favourite animal"
            ["Alpaca", "Bumblebee", "Camel", "Duck"]
        questionMultiChoiceWith "Which animals have you seen?" ["Alpaca", "Bumblebee", "Camel", "Duck"] $ do
            setMinSelections 1
            setMaxSelections 3
        questionDateWith "When would you like to visit the zoo?" $ setOptional
        questionIntegerWith "How many tickets?" $ do
            setLowerBoundInclusive 1
            setUpperBoundInclusive 10
@
-}
module DevForms (
    ServerBuilder,
    FormBuilder,
    QuestionBuilder,
    NumberQuestionBuilder,
    MultiChoiceQuestionBuilder,
    HasOptional (..),
    HasBounds (..),
    HasSelectionBounds (..),
    devFormServer,
    form,
    questionCheckbox,
    questionCheckboxWith,
    questionLikert,
    questionLikertWith,
    questionChoice,
    questionChoiceWith,
    questionMultiChoice,
    questionMultiChoiceWith,
    questionDate,
    questionDateWith,
    questionTime,
    questionTimeWith,
    questionInteger,
    questionIntegerWith,
    questionFloat,
    questionFloatWith,
    questionFreeText,
    questionFreeTextWith,
    questionRegexText,
    questionRegexTextWith,
) where

import Control.Monad.Writer
import Form (Form (..), FormBuilder)
import Question (
    HasBounds (..),
    HasOptional (..),
    HasSelectionBounds (..),
    MultiChoiceQuestionBuilder,
    NumberQuestionBuilder,
    NumberType (..),
    Question (..),
    QuestionBuilder,
    QuestionType (..),
    runIntegerQuestionBuilder,
    runMultiChoiceQuestionBuilder,
    runQuestionBuilder,
 )
import Server (Server (..), ServerBuilder, runServer)

{- | Start the devforms web server on the given port.

This is the top-level entry point for a devforms application. The second
argument is a 'ServerBuilder' block in which you define one or more forms
using 'form'. The server provides:

* Individual form pages with client-side validation
* A submission endpoint that stores answers in @answers-\<formId\>.jsonl@
-}
devFormServer :: Int -> ServerBuilder () -> IO ()
devFormServer = runServer

{- | Define a survey form.

The first argument is the human-readable title displayed at the top of the
form page. The second argument is a form identifier used for:

* URL routing — the form is served at @/form/\<formId\>@
* Persistent storage — submissions are appended to @answers-\<formId\>.jsonl@

The third argument is a 'FormBuilder' block where you add questions using
the @question*@ functions.
-}
form :: Text -> Text -> FormBuilder () -> ServerBuilder ()
form title formId formBuilder = do
    let f = appEndo (execWriter formBuilder) $ Form{title = title, formId = formId, questions = []}
    tell $ Endo $ \server@Server{forms} -> server{forms = forms <> [f]}

addQuestion :: (MonadWriter (Endo Form) m) => Question -> m ()
addQuestion question =
    tell $ Endo $ \f@Form{questions} -> f{questions = questions <> [question]}

{- | Add a yes\/no checkbox question with default options. Renders as a single
checkbox that the respondent can tick or leave unticked.
-}
questionCheckbox :: Text -> FormBuilder ()
questionCheckbox label = questionCheckboxWith label (pure ())

{- | Add a yes\/no checkbox question with custom options. Renders as a single
checkbox that the respondent can tick or leave unticked.

The second argument is a 'QuestionBuilder' block where you can configure
shared options such as 'setOptional'.
-}
questionCheckboxWith :: Text -> QuestionBuilder () -> FormBuilder ()
questionCheckboxWith questionText builder =
    addQuestion $ Question questionText QuestionCheckbox (runQuestionBuilder builder)

{- | Add a Likert-scale question with default options. Renders as a 5-point
agreement scale (Strongly disagree … Strongly agree) plus a \"Cannot say\"
option.
-}
questionLikert :: Text -> FormBuilder ()
questionLikert label = questionLikertWith label (pure ())

{- | Add a Likert-scale question with custom options. Renders as a 5-point
agreement scale (Strongly disagree … Strongly agree) plus a \"Cannot say\"
option.

The second argument is a 'QuestionBuilder' block where you can configure
shared options such as 'setOptional'.
-}
questionLikertWith :: Text -> QuestionBuilder () -> FormBuilder ()
questionLikertWith questionText builder =
    addQuestion $ Question questionText QuestionLikert (runQuestionBuilder builder)

{- | Add a multiple-choice question with default options. Renders as a group of
radio buttons — the respondent must select exactly one of the provided
options.

The first argument is the question label; the second is the list of choices.
-}
questionChoice :: Text -> [Text] -> FormBuilder ()
questionChoice label options = questionChoiceWith label options (pure ())

{- | Add a multiple-choice question with custom options. Renders as a group of
radio buttons — the respondent must select exactly one of the provided
options.

The first argument is the question label; the second is the list of choices.
The third argument is a 'QuestionBuilder' block where you can configure
shared options such as 'setOptional'.
-}
questionChoiceWith :: Text -> [Text] -> QuestionBuilder () -> FormBuilder ()
questionChoiceWith title qOptions builder =
    addQuestion $ Question title (QuestionChoice qOptions) (runQuestionBuilder builder)

{- | Add a multiple-choice question with default options. Renders as a group of
checkboxes — the respondent can select one or more of the provided options.

The first argument is the question label; the second is the list of choices.
By default, at least one selection is required. Use 'questionMultiChoiceWith'
to configure selection bounds or mark as optional.
-}
questionMultiChoice :: Text -> [Text] -> FormBuilder ()
questionMultiChoice label options = questionMultiChoiceWith label options (pure ())

{- | Add a multiple-choice question with custom options. Renders as a group of
checkboxes — the respondent can select one or more of the provided options.

The first argument is the question label; the second is the list of choices.
The third argument is a 'MultiChoiceQuestionBuilder' block where you can
configure selection bounds using 'setMinSelections' and 'setMaxSelections',
as well as shared options like 'setOptional'.

Selection bounds are enforced both client-side (via _hyperscript) and
server-side on submission. If the question is optional, the respondent may
skip it entirely (zero selections), but if they select any options, the
min\/max constraints must be satisfied.

=== Example

@
questionMultiChoiceWith "Pick your toppings" ["Cheese", "Mushrooms", "Peppers", "Olives"] $ do
    setMinSelections 1
    setMaxSelections 3
@
-}
questionMultiChoiceWith :: Text -> [Text] -> MultiChoiceQuestionBuilder () -> FormBuilder ()
questionMultiChoiceWith title qOptions builder =
    addQuestion $ Question title (QuestionMultiChoice qOptions) (runMultiChoiceQuestionBuilder builder)

{- | Add a date-picker question with default options. Renders as an HTML date
input and stores the answer in @YYYY-MM-DD@ format.
-}
questionDate :: Text -> FormBuilder ()
questionDate label = questionDateWith label (pure ())

{- | Add a date-picker question with custom options. Renders as an HTML date
input and stores the answer in @YYYY-MM-DD@ format.

The second argument is a 'QuestionBuilder' block where you can configure
shared options such as 'setOptional'.
-}
questionDateWith :: Text -> QuestionBuilder () -> FormBuilder ()
questionDateWith questionText builder =
    addQuestion $ Question questionText QuestionDate (runQuestionBuilder builder)

{- | Add a time-picker question with default options. Renders as an HTML time
input and stores the answer in @HH:MM@ format.
-}
questionTime :: Text -> FormBuilder ()
questionTime label = questionTimeWith label (pure ())

{- | Add a time-picker question with custom options. Renders as an HTML time
input and stores the answer in @HH:MM@ format.

The second argument is a 'QuestionBuilder' block where you can configure
shared options such as 'setOptional'.
-}
questionTimeWith :: Text -> QuestionBuilder () -> FormBuilder ()
questionTimeWith questionText builder =
    addQuestion $ Question questionText QuestionTime (runQuestionBuilder builder)

-- | Add an integer input question with default options (no bounds).
questionInteger :: Text -> FormBuilder ()
questionInteger label = questionIntegerWith label (pure ())

{- | Add an integer input question with custom options. The second argument is
a 'NumberQuestionBuilder' block where you can configure bounds using
'setLowerBoundInclusive' and 'setUpperBoundInclusive', as well as shared
options like 'setOptional'. Bounds are enforced both client-side (via HTML
attributes) and server-side on submission.

=== Example

@
questionIntegerWith "How many pets do you have?" $ do
    setLowerBoundInclusive 0
    setUpperBoundInclusive 50
    setOptional
@
-}
questionIntegerWith :: Text -> NumberQuestionBuilder () -> FormBuilder ()
questionIntegerWith = questionNumberWith NumberInteger

-- | Add a float input question with default options (no bounds).
questionFloat :: Text -> FormBuilder ()
questionFloat label = questionFloatWith label (pure ())

{- | Add a float input question with custom options. The second argument is
a 'NumberQuestionBuilder' block where you can configure bounds using
'setLowerBoundInclusive' and 'setUpperBoundInclusive', as well as shared
options like 'setOptional'. Bounds are enforced both client-side (via HTML
attributes) and server-side on submission.

=== Example

@
questionFloatWith "How many pets do you have?" $ do
    setLowerBoundInclusive 0
    setUpperBoundInclusive 50
    setOptional
@
-}
questionFloatWith :: Text -> NumberQuestionBuilder () -> FormBuilder ()
questionFloatWith = questionNumberWith NumberFloat

questionNumberWith :: NumberType -> Text -> NumberQuestionBuilder () -> FormBuilder ()
questionNumberWith numberType title builder =
    addQuestion $ Question title (QuestionNumber numberType) (runIntegerQuestionBuilder builder)

{- | Add a free-text textarea question with default options. The respondent can
enter arbitrary text.
-}
questionFreeText :: Text -> FormBuilder ()
questionFreeText label = questionFreeTextWith label (pure ())

{- | Add a free-text textarea question with custom options. The respondent can
enter arbitrary text.

The second argument is a 'QuestionBuilder' block where you can configure
shared options such as 'setOptional'.
-}
questionFreeTextWith :: Text -> QuestionBuilder () -> FormBuilder ()
questionFreeTextWith questionText builder =
    addQuestion $ Question questionText QuestionFreeText (runQuestionBuilder builder)

{- | Add a regex-validated text input question with default options. The
respondent's answer must match the given POSIX extended regex pattern.

The first argument is the question label; the second is the regex pattern.
-}
questionRegexText :: Text -> Text -> FormBuilder ()
questionRegexText label regexPattern = questionRegexTextWith label regexPattern (pure ())

{- | Add a regex-validated text input question with custom options. The
respondent's answer must match the given POSIX extended regex pattern. The
pattern is also set as the HTML @pattern@ attribute for client-side
validation.

The first argument is the question label; the second is the regex pattern.
The third argument is a 'QuestionBuilder' block where you can configure
shared options such as 'setOptional'.

=== Example

@
questionRegexTextWith "SemVer number" "^v\\d+.\\d+.\\d+$" $ setOptional
@
-}
questionRegexTextWith :: Text -> Text -> QuestionBuilder () -> FormBuilder ()
questionRegexTextWith questionText regexPattern builder =
    addQuestion $ Question questionText (QuestionRegexText regexPattern) (runQuestionBuilder builder)