How to implement multi-step/multi-page form?

I am creating a form for a test/exam. Each question in the test has a separate page. For context:

I have a StudentTestController and the index.html.heex file contains a list of all tests.

Each test has a “Take Test” button which links to the first question:

<%= link "Take Test", to: Routes.student_test_path(@conn, :show, test, %{"question" => 1}) %>

This link targets the :show function as seen above.

It would take the user to /my-tests/1?question=1. 1 is the test_id and ?question=1 represents the question number.

I am able to get all the question params (such as title, options and correct_answer) by querying test_id and question_number. For reference here’s an example of the questions table

id question_number question_title options correct_answer test_id
1 1 What is an integer? [“a”, “b” “2”] “2” 1

Now my show function will take these question_params and pass them into show.html.heex

The options field is the only user input that will be in the form. It will be radio buttons of each item from the array, and the user will have to try and select the correct_answer.

My show.html.heex file has Next and Back buttons to cycle through questions. For example, the Next button looks like this:

<%= link "Next", to: Routes.student_test_path(@conn, :show, @test, %{"question" => @question_number + 1}) %>

It takes the current question number and adds 1 to it, which will run :show again but this time it will be the next question. The Back button does the same thing but subtracts from the current question number.

Now here’s my issue:

Every time the Next or Back buttons are clicked, the page refreshes as it goes to the next query. How would I implement a form that has radio buttons of all the options (@question.options) and once the user clicks Next or Back, it will remember their input for that radio. So that when they get to the final page, the Next button will be replaced with “Save” that will submit the entire form?

There are multiple ways how you could achieve this.

  1. Build it as a LiveView! Like that, you maintain the form state on the server and the pages never needs to be fully reloaded. @dbern wrote an article on that a while back: The article is from 2020, so not everything is up-to-date any more, but the principle still stands.
  2. Use session cookies. You can save the answers in a session cookie and examine them once the final question is submitted
  3. Use JavaScript to break up the form. With this approach, you send out all questions to the user at once but you use JavaScript (e.g. sprinkle in some AlpineJS) to hide all but the current question. When the user clicks on next, the current question is hidden and the next one revealed. Only the final click after the last question submits the form to your server.

… and probably many more ways :wink:

I suggest you save progress on every navigation - answers provided by student in a row and keep the status of the row like “in progress”. When the last question is answered finalise the status to complete and calculate result.

Your navigation button next can be form submit to simplify things

I am assuming your are using regular views (not live views)

1 Like

Yes I am using regular views.

But I don’t actually want to save anything until the user clicks “Save”/“Submit Test” on the final page. If they navigate to a page outside of /my-tests/:id directory I want to make sure all changes are discarded.

I guess I could take your approach as long as I delete all entries when the user cancels a test that has a status of: in_progress.

Does that sound like a good approach?

1 Like

You will have to save data - otherwise progress will be lost. Saving to db is the easiest way for now in this case.

Optimisations can be done at the end of project. - don’t worry about rows accumulating in database.

Explicit actions are better for destructive actions like you mentioned - cancel buttton.

This is true. The other option though, particularly if using normal views, is to save a value in a cookie.

This is preferable. I don’t care about saving anything in the DB unless the test is submitted. How would I achieve this though?

The summary version is that you take the params given to the controller action, and then put those params under a key in the cookie using Plug.Conn — Plug v1.12.1. Then when people do a GET request to the page, your controller action should look to see if a value is on the cookie with get_session and if so, pre-populate the form with its values.

Give it a shot, and let us know if you run into issues.

My current issue is that I am trying to create a form for student_answer but I don’t want to save it in the database. However, a changeset is needed for the form and as a result there needs to be a schema that will accept the field student_answer.

I can’t find a way to create the form without making the given field pass through a changeset.

You may want to explore an embedded schema to represent the the form, and then make a changeset of that. An embedded changeset is basically just an “in memory” changeset that doesn’t need to be attached to a database table.

1 Like

Running into some issues with embedded schemas.

schema "test_submissions" do
    field :test_name, :string
    field :score, :string
    embeds_many(:student_answers, StudentAnswer, on_replace: :delete)

    belongs_to :student, TaskApp.Students.Student
    belongs_to :test, TaskApp.Tests.Test

  def question_changeset(user_or_changeset, attrs \\ %{}) do
    |> Ecto.Changeset.cast(attrs, [:student_answers])
    |> Ecto.Changeset.validate_required([:student_answers])

Created this new schema for test_submissions and then one for student_answers

schema "student_answers" do
    field :student_answer, :string

Currently getting this Runtime error, but I’m not even sure if I’m on the right track anymore:
casting embeds with cast/4 for :student_answers field is not supported, use cast_embed/3 instead

I’m not saying you should embed the answers in a table, I’m saying you should just have a thing in memory. See: Use Ecto Embedded Schemas to Back Phoenix Forms | Matt Pruitt

Okay I looked at the article and made a new schema:

  embedded_schema do
    field :student_answer, :string

  @spec form :: Ecto.Changeset.t()
  def form, do: cast(%__MODULE__{}, %{}, [:student_answer])

Changed the changeset: changeset = StudentAnswer.form()

My form looks like this (select field currently filled with hardcoded values rather than options)

<.form let={f} for={@changeset} action={@action}>
  <%= label f, :student_answer, "Options" %>
  <%= select f, :student_answer, ["A": "a", "B": "b"], prompt: "Select an answer" %>
  <%= error_tag f, :student_answer %>

  <%= submit "Next" %>

Now when I submit the form it runs the create function in my controller with these params:

def create(conn, %{
        "id" => id,
        "question_number" => question_number,
        "student_answer" => student_answer
      }) do

Not really sure what to do from here though. Main blockers are:

  1. How would I save the current student answer in memory and how do I retrieve it when needed
  2. How would I redirect to the next question when form is submitted?
  3. How do I ensure user’s answer remains in the select field when they cycle back and forth through questions?

According to path you were taking of using cookies:

  1. answers were not to be stored in memory - they were supposed to be stored in User’s browser as cookie using session

  2. when the form is submitted - data was supposed to be updated into cookie

  3. when user revisits a question, supposed to read from session cookie parse data of question and populate in the form.

I had a look at the documentation for Conn

Is put_resp_cookie/4 the function I’m looking for?

Since the form output looks like this:

   "id" => 1,
   "question_number" => 4,
   "student_answer" => %{"student_answer" => "example answer"}

I guess once I’ve got this stored as a cookie, if a user cycles through questions I will look for the particular question_number they are going to and then pull the student_answer from there, then store it as the value for the select field.

Once all questions are completed and the user clicks “Complete test”, should this run a different function in the controller that will take all student_answers, calculate what the user’s test score is and submit the score into test_submissions table? Or should that functionality all fit into the create function.

I think once I am able to store the forms in the cookies it’ll be a big step forward. How do I actually store it though?

You can store question_id as key and answer as value.

Start with this and you can switch to json as you make progress ?

I currently am trying out a possible workaround, but I’m not sure if it’s valid.

Wondering if it would be possible and simpler to fit all questions in one page using a for statement.

Instead of bringing in one question every time, just get all questions and then iterate over each of them like so:

<.form let={f} for={@changeset} action={@action}>

<%= for question <- @questions do %>

    <%= question.title %>
    <%= label f, :student_answer, "Options" %>
    <%= select f, :student_answer, ["A": "a", "B": "b"], prompt: "Select an answer" %>
    <%= error_tag f, :student_answer %>

<% end %>

<%= submit "Complete Test" %>

This would be not exactly what I wanted, but if it’s possible it would work as a workaround for now.
Then I could just get the full form with all questions answered in one go which would simplify things.

The only thing I’m unsure about is how I could make :student_answer a unique name every loop. Is that even doable, or is my logic out because it’s impossible?

It might be simpler to do it as a LiveView if you can make that change.

I made a guide too on how to achieve the multi-step form in a LV and you can see it here and the revised version for heex.

Then, since you don’t want to save anything to the DB you can follow one of the above suggestions like embedded schemas from @benwilson512.

Anyway, just another option out there that I hope helps you. :heart: