Best practice of saving & validating changesets in live view

Hey everyone I was wondering how you all handle processing actions that need to take place in a save rather than a validate live view event.

So far I’ve seen passing-in opts such as the one below, but I was wondering if there are better alternatives.

  def update_event(%Event{} = event, attrs) do
    event
    |> Event.changeset(attrs, set_timezone: true)
    |> Repo.update()
  end

Should I make 2 different changesets?

  def update_event(%Event{} = event, attrs) do
    event
    |> Event.save_changeset(attrs, set_timezone: true)
    |> Repo.update()
  end

Should I pass in an action: :save

  def update_event(%Event{} = event, attrs) do
    event
    |> Event.changeset(attrs, action: :save)
    |> Repo.update()
  end

Would love some advice because I feel like there’s a lot of overhead to manage these different actions. Espeically when I have to handle quite a bit of changesets for the event as is since an admin, org_member, or submitter can submit these things.

ie:

 def proposal_changeset(event, attrs, opts \\ []) do
    event
    |> cast(attrs, [
      :title,
      :hosted_by,
      :starts_at,
      :place_name,
      :event_url,
      :is_paid,
      :status
    ])
    |> validate_required([
      :title,
      :hosted_by,
      :starts_at,
      :place_name,
      :event_url,
      :status
    ])
    |> required_manipulations(attrs, event, opts)
  end
 def maybe_change_timezone(changeset, field, location, opts) do
    defp required_manipulations(changeset, attrs, _event, opts) do
    changeset
    |> maybe_put_assoc(:admin, attrs)
    |> maybe_put_assoc(:organization, attrs)
    |> maybe_put_assoc(:org_member, attrs)
    |> maybe_put_assoc(:location, attrs)
    |> maybe_change_timezone(:starts_at, attrs["location_id"], opts)
    |> maybe_change_timezone(:ends_at, attrs["location_id"], opts)
    |> validate_url(:event_url)
    |> validate_length(:title, max: 45)
  end
  def maybe_change_timezone(changeset, field, location, opts) do
    if opts[:change_tz] do
      shifted_time =
        get_change(changeset, field)
        |> DateTime.to_naive()
        |> DateTime.from_naive!(location.timezone)
        |> DateTime.shift_zone!("Etc/UTC")

      put_change(changeset, field, shifted_time)
    else
      changeset
    end
  end

After two years I still feel like an absolute newbie when it comes to handling changesets and associations…

Thanks in advance

A lot of people wouldn’t call Repo functions from the liveview (the web context) and would instead create functions in an Events module, which calls the Event changeset/query functions.

Certainly no problems creating multiple changeset functions for different purposes.

3 Likes

Thanks for responding!
Yes that’s what I’m doing now, but how would I design the changesets? Architecturally

It’s always a case-by-case basis and I’d need more info about how your changeset works and am missing some info on exactly what you’re doing here, so I’m going to make some assumptions that can maybe help:

  1. I’m going to assume that :admin, :organization, and :org_member never change, so these are good candidates to set in a create_changeset, and then you don’t need the maybe_. I would also pass them in as parameters and not through attrs. It’s generally not a good idea to set “ownership” association through attrs as they can be manipulated by the client.

  2. It looks as if timezone is updated if the location changes (which makes sense), so all you need to do is check if the location changed. The outside world doesn’t need to know anything about this. In your changeset:

    defp maybe_update_timezone(changeset) do
      if location = get_change(changeset, :location) do
        starts_at = get_field(changeset, :starts_at)
        ends_at = get_field(changeset, :ends_at)
    
        changeset
        |> put_change(:starts_at, shift_time(starts_at, location.timezone)
        |> put_change(:ends_at, shift_time(ends_at, location.timezone)
    else
      changeset
    end
    
    defp shift_time(time, timezone) do
        time
        |> DateTime.to_naive()
        |> DateTime.from_naive!(timezone)
        |> DateTime.shift_zone!("Etc/UTC")
    end
    

As a side note, you get around managing the timezone stuff at the db level if you store your times as utc_datetime_usec. It’s rather unfortunate that NaiveDateTime is the default in Ecto (apparently it’s for historical purposes) but that is always the very first thing I change when I start a new project! Although I understand if it’s a bit late for your project.

1 Like

Hey thanks for the reply!

Regarding #1, is it best to set :admin, :org, and :org_member when I build the event and not even bother casting/putting the params? Would that be the safest approach?

ie

 defp apply_action(socket, :new, _params) do
    location = socket.assigns.location

    socket
    |> assign(:page_title, "New #{socket.assigns.location.name} Event")
    |> assign(:event, %Event{location: location, admin: socket.assigns.current_admin})
  end

I think if this is the case (or a better alternative if you have one) will actually save a lot of the issues I’m having around the changesets. Is there a way you’d recommend accomplishing #1?

As for #2 thank you, this is superhelpful! This is another weak point of mine haha. My query for ‘recent events’ is pretty bad because of the way I have things

def events_at_date(month, year) do
    timezone = "America/Los_Angeles"

    from(e in Event,
      where:
        fragment(
          "EXTRACT(YEAR FROM (starts_at AT TIME ZONE 'UTC' AT TIME ZONE ?)) = ?",
          ^timezone,
          ^year
        ),
      where:
        fragment(
          "EXTRACT(MONTH FROM (starts_at AT TIME ZONE 'UTC' AT TIME ZONE ?)) = ?",
          ^timezone,
          ^month
        )
    )
  end

Hey, happy to help sorry for the delay there—I had a very sleepless night then took my dog out for a few hours and then came home crashed :sleeping:

I was a little vague on #1 since I don’t know the exact rules you need and there are sort of several different ways to do this and I was worried about giving a rambling answer, and I still am :upside_down_face: Generally speaking I never built schemas like that in the web layer, I push it down to the business layer (context). I would be assigning a changeset only in the web layer (for a form) and then have a context function that looks something like this:

def create_event(%Admin{} = admin, attrs) do
  with :ok <- can_create_events?(admin) do
    %Event{admin: admin}
    |> Event.changeset(attrs)
    |> Repo.insert()
  end
end

I’ve gone back and forth on setting the associations directly like. It all depends on if your changeset is going to care about them.

In terms of your scenario, I have questions like: can only one of :admin, :org, or :org_member be set, or some combination? IE, who is being authorized here? Or can an admin assign an org member to an event? Also, I see location is coming from assigns. Is the event’s location always based on the current user’s location, or can they change it through the form?

2 Likes

No worries! Hope you got the rest you needed!

Ah okay yea that makes sense. What I’ve been doing is passing around a built event (fig 1), when in actuality, I can go borderline stateless and just make the event on the function call of create.

Current setup:

  • Build event and assign to socket in index.ex
# index.ex
defp apply_action(socket, :new, _params) do
    location = socket.assigns.location

    socket
    |> assign(:page_title, "New #{socket.assigns.location.name} Event")
    |> assign(:event, AdminEvent.build_event(socket.assigns.current_admin, location))
  end
  • Pass the event to the form component
# index.html.heex
<.live_component
    module={ClimateCollectiveWeb.Admin.EventLive.FormComponent}
    id={@event.id || :new}
    title={@page_title}
    action={@live_action}
    event={@event}
    location={@location}
    type={:admin}
    patch={~p"/admins/#{@location_string}/events"}
    submit_another_url={~p"/admins/#{@location_string}/events/new"}
  />
  • On save, take the passed event and use that as save
# FormComponent
 defp save_admin_event(socket, :new, event_params) do
    base_event = socket.assigns.event

    case AdminEvents.create_event(socket.assigns.event, event_params) do
      {:ok, event} ->
        notify_parent({:saved, event})

        {:noreply,
         socket
         |> put_flash(:info, "Event created successfully")
         |> push_navigate(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign_form(socket, changeset)}
    end
  end

Instead, just pass in what I need for the form componetn, or push the save to the top level index.ex (maybe the latter is better?)

Now as for your question, no my changeset doesn’t care about them. The logic is as follows

(All of these are using %Event{})
ProposedEvent - always has a pre-defined location
AdminEvent - Always has an admin and location
OrgMemberEvent - Always has an org_member, org, and location

All of these assignments are handled by the backend. The user makes no selection on any of these except for OrgMemberEvent can change the location.