Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nested errors do not propagate #453

Closed
honungsburk opened this issue Nov 8, 2024 · 8 comments
Closed

Nested errors do not propagate #453

honungsburk opened this issue Nov 8, 2024 · 8 comments

Comments

@honungsburk
Copy link

I'm encountering an issue with the access behavior in nested Ecto schemas that produce nested changesets. While errors in the top-level struct propagate as expected, errors within nested structs do not appear to propagate correctly, even though the values do.

defmodule Settings do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key false
  embedded_schema do
    field :field_one, :string
    embeds_one :field_two, NestedSetting
  end

  def changeset(settings, attrs \\ %{}) do
    settings
    |> cast(attrs, [:field_one])
    |> cast_embed(:field_two)
    |> validate_length(:field_one, min: 2)
  end
end

defmodule NestedSetting do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key false
  embedded_schema do
    field :nested_field, :string
  end

  def changeset(nested_setting, attrs \\ %{}) do
    nested_setting
    |> cast(attrs, [:nested_field])
    |> validate_required([:nested_field])
  end
end

Accessed like so

<.form 
  id="settings-form"
  :let={form}
  for={@settings_form}
  as={:settings}
  phx-change="validate"
  phx-submit="save"
>
  <div class="flex flex-col gap-6 mt-8">
    <%!-- Settings Information --%>
    <.section title="Settings Information" description="Main settings fields">
      <.input_list>
        <:item for="field_one" title="Field One" description="Main field in settings">
          <.input
            id="field-one"
            field={form[:field_one]}
            type="text"
            autocomplete="off"
            phx-debounce="blur"
          />
        </:item>
      </.input_list>
    </.section>

    <%!-- Nested Settings --%>
    <.inputs_for :let={nested_form} field={form[:field_two]}>
      <.section title="Nested Settings" description="Additional nested settings fields">
        <.input_list>
          <:item for="nested_field" title="Nested Field" description="Field inside nested settings">
            <.input
              id="nested-field"
              field={nested_form[:nested_field]}
              type="text"
              autocomplete="off"
              phx-debounce="blur"
            />
          </:item>
        </.input_list>
      </.section>
    </.inputs_for>
  </div>

  <div class="mt-4">
    <.button type="submit">Save Settings</.button>
  </div>
</.form>

How I validate:

@impl true
def handle_event("validate", %{"settings" => settings_params}, socket) do
  form =
    %Settings{}
    |> Settings.changeset(settings_params)
    |> to_form(as: :settings, action: :validate)

  {:noreply,
   socket
   |> assign(:settings_form, form)
  }
end

When I inspect the values it seems that the access behavior doesn't propagate the errors correctly

@honungsburk
Copy link
Author

Here is a concrete example of the output from the first the top level form, then a subfield called company_settings and then inactive_connect_session_timeout_hours.

form:

%Phoenix.HTML.Form{
  source: #Ecto.Changeset<
    action: :validate,
    changes: %{
      group: "Indeez",
      language: "fr_FR",
      display_name: "Test Company 22",
      interface_settings: "{\"we\":\"jy\"}",
      company_name: "Test Company",
      company_settings: #Ecto.Changeset<
        action: :insert,
        changes: %{
          freemium: false,
          valuation_parameters: #Ecto.Changeset<
            action: :insert,
            changes: %{
              preferred_store_price: "median",
              all_refurbished_stores: false
            },
            errors: [],
            data: #Runar.CompanySettings.ValuationParameters<>,
            valid?: true,
            ...
          >,
          single_sign_on: #Ecto.Changeset<
            action: :insert,
            changes: %{
              type: "saml",
              parameters: #Ecto.Changeset<action: :insert, changes: %{},
               errors: [], data: #Runar.CompanySettings.SingleSignOnParams<>,
               valid?: true, ...>
            },
            errors: [],
            data: #Runar.CompanySettings.SingleSignOn<>,
            valid?: true,
            ...
          >,
          inactive_connect_session_timeout_hours: -1
        },
        errors: [
          inactive_connect_session_timeout_hours: {"must be greater than %{number}",
           [validation: :number, kind: :greater_than, number: 0]}
        ],
        data: #Runar.CompanySettings<>,
        valid?: false,
        ...
      >,
      market: "FR",
      timezone: "Europe/Paris"
    },
    errors: [],
    data: #Runar.CompanyForm<>,
    valid?: false,
    ...
  >,
  impl: Phoenix.HTML.FormData.Ecto.Changeset,
  id: "company-form",
  name: "form",
  data: %Runar.CompanyForm{
    active: true,
    display_name: nil,
    company_name: nil,
    language: nil,
    market: nil,
    group: nil,
    statistics_enabled: false,
    timezone: nil,
    interface_settings: "{}",
    company_settings: nil
  },
  action: nil,
  hidden: [],
  params: %{
    "_unused_company_name" => "",
    "_unused_display_name" => "",
    "_unused_group" => "",
    "_unused_interface_settings" => "",
    "_unused_language" => "",
    "_unused_market" => "",
    "_unused_statistics_enabled" => "",
    "_unused_timezone" => "",
    "company_name" => "Test Company",
    "company_settings" => %{
      "_persistent_id" => "0",
      "_unused_force_connect_logout_session_hours" => "",
      "_unused_freemium" => "",
      "_unused_max_concurrent_connect_logins" => "",
      "force_connect_logout_session_hours" => "",
      "freemium" => "false",
      "inactive_connect_session_timeout_hours" => "-1",
      "max_concurrent_connect_logins" => "",
      "single_sign_on" => %{
        "_persistent_id" => "0",
        "_unused_connected_domains" => "",
        "_unused_type" => "",
        "_unused_version" => "",
        "connected_domains" => "",
        "parameters" => %{
          "_persistent_id" => "0",
          "_unused_button_text" => "",
          "_unused_idp" => "",
          "button_text" => "",
          "idp" => ""
        },
        "type" => "saml",
        "version" => ""
      },
      "valuation_parameters" => %{
        "_persistent_id" => "0",
        "_unused_all_refurbished_stores" => "",
        "_unused_preferred_store_price" => "",
        "all_refurbished_stores" => "false",
        "preferred_store_price" => "median"
      }
    },
    "display_name" => "Test Company 22",
    "group" => "Indeez",
    "interface_settings" => "{\"we\":\"jy\"}",
    "language" => "fr_FR",
    "market" => "FR",
    "statistics_enabled" => "false",
    "timezone" => "Europe/Paris"
  },
  errors: [],
  options: [
    method: "post",
    id: "company-form",
    multipart: false,
    "phx-submit": "save",
    "phx-change": "validate"
  ],
  index: nil
}

company_settings:

%Phoenix.HTML.Form{
  source: #Ecto.Changeset<
    action: nil,
    changes: %{
      freemium: false,
      valuation_parameters: #Ecto.Changeset<
        action: :insert,
        changes: %{
          preferred_store_price: "median",
          all_refurbished_stores: false
        },
        errors: [],
        data: #Runar.CompanySettings.ValuationParameters<>,
        valid?: true,
        ...
      >,
      single_sign_on: #Ecto.Changeset<
        action: :insert,
        changes: %{
          type: "saml",
          parameters: #Ecto.Changeset<action: :insert, changes: %{}, errors: [],
           data: #Runar.CompanySettings.SingleSignOnParams<>, valid?: true, ...>
        },
        errors: [],
        data: #Runar.CompanySettings.SingleSignOn<>,
        valid?: true,
        ...
      >,
      inactive_connect_session_timeout_hours: -1
    },
    errors: [
      inactive_connect_session_timeout_hours: {"must be greater than %{number}",
       [validation: :number, kind: :greater_than, number: 0]}
    ],
    data: #Runar.CompanySettings<>,
    valid?: false,
    ...
  >,
  impl: Phoenix.HTML.FormData.Ecto.Changeset,
  id: "company-form_company_settings_0",
  name: "form[company_settings]",
  data: %Runar.CompanySettings{
    claim_link_ai_group_suggestion: nil,
    claim_link_default_expiration: nil,
    claim_link_direct_enabled: nil,
    claim_link_disabled_email_notifications: nil,
    claim_link_enabled: nil,
    claim_link_default_notifier: nil,
    claim_link_notifiers_current_user_disabled: nil,
    claim_link_current_market_value_enabled: nil,
    claim_link_notifiers: [],
    open_link_email_template: nil,
    connect_favorite_product_groups: nil,
    connect_manual_object_depreciation: nil,
    connect_use_ai_bot: nil,
    connect_news: nil,
    connect_survey: nil,
    white_label_mode: nil,
    settlement_recipients: [],
    dialogue_current_market_value_enabled: nil,
    post_message_to_iframe: nil,
    enabled_item_tags: [],
    printout_languages: nil,
    max_concurrent_connect_logins: nil,
    force_connect_logout_session_hours: nil,
    inactive_connect_session_timeout_hours: nil,
    freemium: nil,
    valuation_parameters: nil,
    single_sign_on: nil
  },
  action: nil,
  hidden: [{"_persistent_id", "0"}],
  params: %{
    "_persistent_id" => "0",
    "_unused_force_connect_logout_session_hours" => "",
    "_unused_freemium" => "",
    "_unused_max_concurrent_connect_logins" => "",
    "force_connect_logout_session_hours" => "",
    "freemium" => "false",
    "inactive_connect_session_timeout_hours" => "-1",
    "max_concurrent_connect_logins" => "",
    "single_sign_on" => %{
      "_persistent_id" => "0",
      "_unused_connected_domains" => "",
      "_unused_type" => "",
      "_unused_version" => "",
      "connected_domains" => "",
      "parameters" => %{
        "_persistent_id" => "0",
        "_unused_button_text" => "",
        "_unused_idp" => "",
        "button_text" => "",
        "idp" => ""
      },
      "type" => "saml",
      "version" => ""
    },
    "valuation_parameters" => %{
      "_persistent_id" => "0",
      "_unused_all_refurbished_stores" => "",
      "_unused_preferred_store_price" => "",
      "all_refurbished_stores" => "false",
      "preferred_store_price" => "median"
    }
  },
  errors: [],
  options: [multipart: false],
  index: 0
}

inactive_connect_session_timeout_hours:

%Phoenix.HTML.FormField{
  id: "company-form_company_settings_0_inactive_connect_session_timeout_hours",
  name: "form[company_settings][inactive_connect_session_timeout_hours]",
  errors: [],
  field: :inactive_connect_session_timeout_hours,
  value: -1
  form: %Phoenix.HTML.Form{
    source: #Ecto.Changeset<
      action: nil,
      changes: %{
        freemium: false,
        valuation_parameters: #Ecto.Changeset<
          action: :insert,
          changes: %{
            preferred_store_price: "median",
            all_refurbished_stores: false
          },
          errors: [],
          data: #Runar.CompanySettings.ValuationParameters<>,
          valid?: true,
          ...
        >,
        single_sign_on: #Ecto.Changeset<
          action: :insert,
          changes: %{
            type: "saml",
            parameters: #Ecto.Changeset<action: :insert, changes: %{},
             errors: [], data: #Runar.CompanySettings.SingleSignOnParams<>,
             valid?: true, ...>
          },
          errors: [],
          data: #Runar.CompanySettings.SingleSignOn<>,
          valid?: true,
          ...
        >,
        inactive_connect_session_timeout_hours: -1
      },
      errors: [
        inactive_connect_session_timeout_hours: {"must be greater than %{number}",
         [validation: :number, kind: :greater_than, number: 0]}
      ],
      data: #Runar.CompanySettings<>,
      valid?: false,
      ...
    >,
    impl: Phoenix.HTML.FormData.Ecto.Changeset,
    id: "company-form_company_settings_0",
    name: "form[company_settings]",
    data: %Runar.CompanySettings{
      claim_link_ai_group_suggestion: nil,
      claim_link_default_expiration: nil,
      claim_link_direct_enabled: nil,
      claim_link_disabled_email_notifications: nil,
      claim_link_enabled: nil,
      claim_link_default_notifier: nil,
      claim_link_notifiers_current_user_disabled: nil,
      claim_link_current_market_value_enabled: nil,
      claim_link_notifiers: [],
      open_link_email_template: nil,
      connect_favorite_product_groups: nil,
      connect_manual_object_depreciation: nil,
      connect_use_ai_bot: nil,
      connect_news: nil,
      connect_survey: nil,
      white_label_mode: nil,
      settlement_recipients: [],
      dialogue_current_market_value_enabled: nil,
      post_message_to_iframe: nil,
      enabled_item_tags: [],
      printout_languages: nil,
      max_concurrent_connect_logins: nil,
      force_connect_logout_session_hours: nil,
      inactive_connect_session_timeout_hours: nil,
      freemium: nil,
      valuation_parameters: nil,
      single_sign_on: nil
    },
    action: nil,
    hidden: [{"_persistent_id", "0"}],
    params: %{
      "_persistent_id" => "0",
      "_unused_force_connect_logout_session_hours" => "",
      "_unused_freemium" => "",
      "_unused_max_concurrent_connect_logins" => "",
      "force_connect_logout_session_hours" => "",
      "freemium" => "false",
      "inactive_connect_session_timeout_hours" => "-1",
      "max_concurrent_connect_logins" => "",
      "single_sign_on" => %{
        "_persistent_id" => "0",
        "_unused_connected_domains" => "",
        "_unused_type" => "",
        "_unused_version" => "",
        "connected_domains" => "",
        "parameters" => %{
          "_persistent_id" => "0",
          "_unused_button_text" => "",
          "_unused_idp" => "",
          "button_text" => "",
          "idp" => ""
        },
        "type" => "saml",
        "version" => ""
      },
      "valuation_parameters" => %{
        "_persistent_id" => "0",
        "_unused_all_refurbished_stores" => "",
        "_unused_preferred_store_price" => "",
        "all_refurbished_stores" => "false",
        "preferred_store_price" => "median"
      }
    },
    errors: [],
    options: [multipart: false],
    index: 0
  },
}

@josevalim
Copy link
Member

Can you please file a report in the LiveView repository and include a single file application that reproduces the error? Thank you.

@honungsburk
Copy link
Author

There is quite a bit of boilerplate to make an example but all the interesting parts are in a single file: https://github.com/honungsburk/phoenix_nested_form_validation

@honungsburk
Copy link
Author

I found the single file template. I'll create another repo using that.

@honungsburk
Copy link
Author

https://github.com/honungsburk/phoenix_single_file_nested_form_validation

I get this error when I just ran the template with elixir main.exs:

** (ErlangError) Erlang error: :enoent
    (elixir 1.17.3) lib/system.ex:1114: System.cmd("npm", ["install"], [cd: "/Users/frankhampusweslien/Library/Caches/mix/installs/elixir-1.17.3-erts-15.1.1/28114fb80ebc96d816737c5c55048e3c/deps/phoenix_live_view/lib/.././assets", into: %IO.Stream{device: :standard_io, raw: true, line_or_bytes: :line}])
    main.exs:19: (file)

I'll create a reproduction if you can tell me what I need to do to fix the error.

@SteffenDE
Copy link
Contributor

Hi @honungsburk,

in order to always use the latest JS assets, the single file scripts tries to build them and therefore fetch the dependencies using npm. So you either need nodejs on your system or remove those lines from the script. Currently the included assets should work fine as well.

@honungsburk
Copy link
Author

honungsburk commented Nov 9, 2024

@SteffenDE
Copy link
Contributor

Closing in favor of phoenixframework/phoenix_live_view#3493.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants