Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .claude/skills/layered-ui-rails/references/HELPERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,20 +208,23 @@ Formats a date/time value as `"%-d %b %Y, %H:%M"` (e.g. "15 Apr 2026, 10:30"). R
## Form

```ruby
l_ui_form(record, fields:, url:, method: nil, submit: nil)
l_ui_form(record, fields:, url:, method: nil, submit: nil, multipart: nil)
```

- `record` (ActiveRecord) - the model instance
- `fields` (Array<Hash>) - field definitions (see below)
- `url` (String) - form action URL
- `method` (Symbol, optional) - HTTP method override
- `submit` (String, optional) - submit button text; defaults to "Create" for new records and "Save" for persisted records
- `multipart` (Boolean, optional) - override multipart encoding. Defaults to auto-detection: `true` when any field is `:file`, otherwise `false`. Pass `true`/`false` to force.

If you need control beyond these options, override the partial in your host app by creating `app/views/layered/ui/managed_resource/_form.html.erb` (Rails view lookup prefers the host's copy), or write the form directly with `form_with` and reuse `layered_ui/shared/form_errors`, `layered_ui/shared/label`, and `layered_ui/shared/field_error`.

Renders a complete form with all fields, error summary, and submit button via the `layered/ui/managed_resource/form` partial.

Field options:
- `attribute` (Symbol) - model attribute
- `as` (Symbol, optional) - field type; auto-detected from column type. Supported: `:string`, `:text`, `:email`, `:password`, `:number`, `:date`, `:datetime`, `:select`, `:checkbox`, `:hidden`
- `as` (Symbol, optional) - field type; auto-detected from column type. Supported: `:string`, `:text`, `:email`, `:password`, `:number`, `:tel`, `:url`, `:search`, `:date`, `:datetime`, `:time`, `:month`, `:week`, `:color`, `:range`, `:file`, `:select`, `:checkbox`, `:hidden`. Forms automatically become `multipart` when any field is `:file`.
- `label` (String, optional) - custom label text; defaults to humanised attribute
- `required` (Boolean, optional) - marks field as required; default false
- `hint` (String, optional) - help text below the field
Expand Down
12 changes: 9 additions & 3 deletions app/helpers/layered/ui/form_helper.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
module Layered
module Ui
module FormHelper
FIELD_TYPES = %i[string text email password number date datetime select checkbox hidden].freeze
FIELD_TYPES = %i[
string text email password number tel url search
date datetime time month week
color range file
select checkbox hidden
].freeze

# Renders a complete form with all fields, error summary,
# and submit button.
Expand All @@ -10,9 +15,10 @@ module FormHelper
# fields: Post.l_managed_resource_fields,
# url: managed_posts_path)
#
def l_ui_form(record, fields:, url:, method: nil, submit: nil)
def l_ui_form(record, fields:, url:, method: nil, submit: nil, multipart: nil)
render partial: "layered/ui/managed_resource/form",
locals: { record: record, fields: fields, url: url, method: method, submit: submit }
locals: { record: record, fields: fields, url: url, method: method,
submit: submit, multipart: multipart }
end

# Normalises a raw field config hash into a canonical form.
Expand Down
8 changes: 7 additions & 1 deletion app/views/layered/ui/managed_resource/_field_input.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@
<%= form.label(attribute, config[:label], class: "l-ui-checkbox-container__label") %>
</div>
<% else %>
<% method_name = config[:as] == :string ? :text_field : :"#{config[:as]}_field" %>
<%
method_name = case config[:as]
when :string then :text_field
when :tel then :telephone_field
else :"#{config[:as]}_field"
end
%>
<%= form.public_send(method_name, attribute,
class: field_class.call("l-ui-form__field"), **base_opts, **extras) %>
<% end %>
2 changes: 2 additions & 0 deletions app/views/layered/ui/managed_resource/_form.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<% multipart = local_assigns.fetch(:multipart, nil) %>
<% form_opts = { url: url, class: "l-ui-form" } %>
<% form_opts[:method] = method if method %>
<% form_opts[:multipart] = multipart.nil? ? fields.any? { |f| f[:as] == :file } : multipart %>

<%= form_with(model: record, **form_opts) do |f| %>
<%= render "layered_ui/shared/form_errors", item: record %>
Expand Down
43 changes: 41 additions & 2 deletions test/dummy/app/views/pages/forms_helper.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,29 @@

<h2 class="mt-8">All field types</h2>

<p class="mt-4">Use <code>as:</code> to explicitly set the field type. Supported types: <code>:string</code>, <code>:text</code>, <code>:email</code>, <code>:number</code>, <code>:date</code>, <code>:datetime</code>, <code>:select</code>, <code>:checkbox</code>, and <code>:hidden</code>.</p>
<p class="mt-4">Use <code>as:</code> to explicitly set the field type. Supported types: <code>:string</code>, <code>:text</code>, <code>:email</code>, <code>:password</code>, <code>:number</code>, <code>:tel</code>, <code>:url</code>, <code>:search</code>, <code>:date</code>, <code>:datetime</code>, <code>:time</code>, <code>:month</code>, <code>:week</code>, <code>:color</code>, <code>:range</code>, <code>:file</code>, <code>:select</code>, <code>:checkbox</code>, and <code>:hidden</code>. The form is automatically rendered as <code>multipart</code> when any field uses <code>:file</code>.</p>

<%= l_ui_form(@post, fields: [
{ attribute: :title, as: :string, required: true, placeholder: "Enter a title" },
{ attribute: :body, as: :text, hint: "A longer description" },
{ attribute: :user_id, as: :select, label: "Author",
collection: @users.map { |u| [u.name, u.id] } },
{ attribute: :title, as: :email, label: "Email" },
{ attribute: :title, as: :password, label: "Password" },
{ attribute: :user_id, as: :number, label: "Number" },
{ attribute: :title, as: :tel, label: "Telephone" },
{ attribute: :title, as: :url, label: "URL" },
{ attribute: :title, as: :search, label: "Search" },
{ attribute: :created_at, as: :date, label: "Date" },
{ attribute: :created_at, as: :datetime, label: "Datetime" },
{ attribute: :created_at, as: :time, label: "Time" },
{ attribute: :created_at, as: :month, label: "Month" },
{ attribute: :created_at, as: :week, label: "Week" },
{ attribute: :title, as: :color, label: "Color" },
{ attribute: :user_id, as: :range, label: "Range" },
{ attribute: :title, as: :file, label: "File" },
{ attribute: :user_id, as: :checkbox, label: "Checkbox" },
{ attribute: :user_id, as: :hidden },
],
url: "#") %>

Expand All @@ -66,6 +82,22 @@
hint: "A longer description" },
{ attribute: :user_id, as: :select, label: "Author",
collection: User.pluck(:name, :id) },
{ attribute: :email, as: :email },
{ attribute: :password, as: :password },
{ attribute: :age, as: :number },
{ attribute: :phone, as: :tel },
{ attribute: :website, as: :url },
{ attribute: :query, as: :search },
{ attribute: :starts_on, as: :date },
{ attribute: :starts_at, as: :datetime },
{ attribute: :starts_time, as: :time },
{ attribute: :starts_month, as: :month },
{ attribute: :starts_week, as: :week },
{ attribute: :tint, as: :color },
{ attribute: :volume, as: :range },
{ attribute: :avatar, as: :file },
{ attribute: :accepted, as: :checkbox },
{ attribute: :secret_id, as: :hidden },
],
url: posts_path) %&gt;</code></pre>
</div>
Expand Down Expand Up @@ -110,10 +142,17 @@
<td class="l-ui-table__cell">String</td>
<td class="l-ui-table__cell">Submit button text (defaults to "Create" for new records, "Save" for persisted records)</td>
</tr>
<tr>
<th class="l-ui-table__cell l-ui-table__cell--primary" scope="row"><code>multipart:</code></th>
<td class="l-ui-table__cell">Boolean</td>
<td class="l-ui-table__cell">Override multipart encoding. Defaults to auto-detection (true when any field is <code>:file</code>). Pass <code>true</code> or <code>false</code> to force.</td>
</tr>
</tbody>
</table>
</div>

<p class="mt-4">Need more control? Override the partial by creating <code>app/views/layered/ui/managed_resource/_form.html.erb</code> in your host app, or write the form directly with <code>form_with</code> and reuse <code>layered_ui/shared/form_errors</code>, <code>layered_ui/shared/label</code>, and <code>layered_ui/shared/field_error</code>.</p>

<h2 class="mt-8">Field options</h2>

<div class="l-ui-table-container mt-4">
Expand All @@ -135,7 +174,7 @@
<tr>
<th class="l-ui-table__cell l-ui-table__cell--primary" scope="row"><code>as:</code></th>
<td class="l-ui-table__cell">Symbol</td>
<td class="l-ui-table__cell">Field type (<code>:string</code>, <code>:text</code>, <code>:email</code>, <code>:password</code>, <code>:number</code>, <code>:date</code>, <code>:datetime</code>, <code>:select</code>, <code>:checkbox</code>, <code>:hidden</code>). Inferred from column type when omitted.</td>
<td class="l-ui-table__cell">Field type (<code>:string</code>, <code>:text</code>, <code>:email</code>, <code>:password</code>, <code>:number</code>, <code>:tel</code>, <code>:url</code>, <code>:search</code>, <code>:date</code>, <code>:datetime</code>, <code>:time</code>, <code>:month</code>, <code>:week</code>, <code>:color</code>, <code>:range</code>, <code>:file</code>, <code>:select</code>, <code>:checkbox</code>, <code>:hidden</code>). Inferred from column type when omitted.</td>
</tr>
<tr>
<th class="l-ui-table__cell l-ui-table__cell--primary" scope="row"><code>label:</code></th>
Expand Down
80 changes: 80 additions & 0 deletions test/helpers/form_helper_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,58 @@ class FormHelperTest < ActionView::TestCase
assert_includes result, 'datetime'
end

test "renders password field" do
result = render_field({ attribute: :title, as: :password })
assert_includes result, 'type="password"'
assert_includes result, 'class="l-ui-form__field"'
end

test "renders tel field (maps to telephone_field)" do
result = render_field({ attribute: :title, as: :tel })
assert_includes result, 'type="tel"'
assert_includes result, 'class="l-ui-form__field"'
end

test "renders url field" do
result = render_field({ attribute: :title, as: :url })
assert_includes result, 'type="url"'
end

test "renders search field" do
result = render_field({ attribute: :title, as: :search })
assert_includes result, 'type="search"'
end

test "renders time field" do
result = render_field({ attribute: :created_at, as: :time })
assert_includes result, 'type="time"'
end

test "renders month field" do
result = render_field({ attribute: :created_at, as: :month })
assert_includes result, 'type="month"'
end

test "renders week field" do
result = render_field({ attribute: :created_at, as: :week })
assert_includes result, 'type="week"'
end

test "renders color field" do
result = render_field({ attribute: :title, as: :color })
assert_includes result, 'type="color"'
end

test "renders range field" do
result = render_field({ attribute: :user_id, as: :range })
assert_includes result, 'type="range"'
end

test "renders file field" do
result = render_field({ attribute: :title, as: :file })
assert_includes result, 'type="file"'
end

test "renders hidden field without group wrapper" do
result = render_field({ attribute: :user_id, as: :hidden })
assert_includes result, 'type="hidden"'
Expand Down Expand Up @@ -193,6 +245,26 @@ class FormHelperTest < ActionView::TestCase
assert_includes result, 'rows="8"'
end

# -- multipart auto-detection --

test "form auto-enables multipart when any field is :file" do
result = render_form(fields: [
{ attribute: :title },
{ attribute: :body, as: :file }
])
assert_includes result, 'enctype="multipart/form-data"'
end

test "form omits multipart when no :file fields" do
result = render_form(fields: [{ attribute: :title }, { attribute: :body, as: :text }])
assert_not_includes result, 'enctype="multipart/form-data"'
end

test "multipart: true forces multipart without any :file field" do
result = render_form(fields: [{ attribute: :title }], multipart: true)
assert_includes result, 'enctype="multipart/form-data"'
end

# -- type validation --

test "raises ArgumentError for unsupported field type" do
Expand Down Expand Up @@ -223,4 +295,12 @@ def render_field(field_config)
locals: { form: builder, record: record, config: config }
rendered
end

def render_form(fields:, multipart: nil)
record = Post.new
render partial: "layered/ui/managed_resource/form",
locals: { record: record, fields: fields, url: "/posts",
method: nil, submit: nil, multipart: multipart }
rendered
end
end
Loading