Skip to content

Commit 7208697

Browse files
Expand l_ui_form field types and add multipart override (#86)
1 parent cd515d7 commit 7208697

6 files changed

Lines changed: 144 additions & 8 deletions

File tree

.claude/skills/layered-ui-rails/references/HELPERS.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,20 +208,23 @@ Formats a date/time value as `"%-d %b %Y, %H:%M"` (e.g. "15 Apr 2026, 10:30"). R
208208
## Form
209209

210210
```ruby
211-
l_ui_form(record, fields:, url:, method: nil, submit: nil)
211+
l_ui_form(record, fields:, url:, method: nil, submit: nil, multipart: nil)
212212
```
213213

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

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

222225
Field options:
223226
- `attribute` (Symbol) - model attribute
224-
- `as` (Symbol, optional) - field type; auto-detected from column type. Supported: `:string`, `:text`, `:email`, `:password`, `:number`, `:date`, `:datetime`, `:select`, `:checkbox`, `:hidden`
227+
- `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`.
225228
- `label` (String, optional) - custom label text; defaults to humanised attribute
226229
- `required` (Boolean, optional) - marks field as required; default false
227230
- `hint` (String, optional) - help text below the field

app/helpers/layered/ui/form_helper.rb

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
module Layered
22
module Ui
33
module FormHelper
4-
FIELD_TYPES = %i[string text email password number date datetime select checkbox hidden].freeze
4+
FIELD_TYPES = %i[
5+
string text email password number tel url search
6+
date datetime time month week
7+
color range file
8+
select checkbox hidden
9+
].freeze
510

611
# Renders a complete form with all fields, error summary,
712
# and submit button.
@@ -10,9 +15,10 @@ module FormHelper
1015
# fields: Post.l_managed_resource_fields,
1116
# url: managed_posts_path)
1217
#
13-
def l_ui_form(record, fields:, url:, method: nil, submit: nil)
18+
def l_ui_form(record, fields:, url:, method: nil, submit: nil, multipart: nil)
1419
render partial: "layered/ui/managed_resource/form",
15-
locals: { record: record, fields: fields, url: url, method: method, submit: submit }
20+
locals: { record: record, fields: fields, url: url, method: method,
21+
submit: submit, multipart: multipart }
1622
end
1723

1824
# Normalises a raw field config hash into a canonical form.

app/views/layered/ui/managed_resource/_field_input.html.erb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@
4242
<%= form.label(attribute, config[:label], class: "l-ui-checkbox-container__label") %>
4343
</div>
4444
<% else %>
45-
<% method_name = config[:as] == :string ? :text_field : :"#{config[:as]}_field" %>
45+
<%
46+
method_name = case config[:as]
47+
when :string then :text_field
48+
when :tel then :telephone_field
49+
else :"#{config[:as]}_field"
50+
end
51+
%>
4652
<%= form.public_send(method_name, attribute,
4753
class: field_class.call("l-ui-form__field"), **base_opts, **extras) %>
4854
<% end %>

app/views/layered/ui/managed_resource/_form.html.erb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
<% multipart = local_assigns.fetch(:multipart, nil) %>
12
<% form_opts = { url: url, class: "l-ui-form" } %>
23
<% form_opts[:method] = method if method %>
4+
<% form_opts[:multipart] = multipart.nil? ? fields.any? { |f| f[:as] == :file } : multipart %>
35

46
<%= form_with(model: record, **form_opts) do |f| %>
57
<%= render "layered_ui/shared/form_errors", item: record %>

test/dummy/app/views/pages/forms_helper.html.erb

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,30 @@
3939

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

42-
<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>
42+
<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>
4343

4444
<%= l_ui_form(@post, fields: [
4545
{ attribute: :title, as: :string, required: true, placeholder: "Enter a title" },
4646
{ attribute: :body, as: :text, hint: "A longer description" },
4747
{ attribute: :user_id, as: :select, label: "Author",
4848
prompt: "Choose an author",
4949
collection: @users.map { |u| [u.name, u.id] } },
50+
{ attribute: :title, as: :email, label: "Email" },
51+
{ attribute: :title, as: :password, label: "Password" },
52+
{ attribute: :user_id, as: :number, label: "Number" },
53+
{ attribute: :title, as: :tel, label: "Telephone" },
54+
{ attribute: :title, as: :url, label: "URL" },
55+
{ attribute: :title, as: :search, label: "Search" },
56+
{ attribute: :created_at, as: :date, label: "Date" },
57+
{ attribute: :created_at, as: :datetime, label: "Datetime" },
58+
{ attribute: :created_at, as: :time, label: "Time" },
59+
{ attribute: :created_at, as: :month, label: "Month" },
60+
{ attribute: :created_at, as: :week, label: "Week" },
61+
{ attribute: :title, as: :color, label: "Color" },
62+
{ attribute: :user_id, as: :range, label: "Range" },
63+
{ attribute: :title, as: :file, label: "File" },
64+
{ attribute: :user_id, as: :checkbox, label: "Checkbox" },
65+
{ attribute: :user_id, as: :hidden },
5066
],
5167
url: "#") %>
5268

@@ -68,6 +84,22 @@
6884
{ attribute: :user_id, as: :select, label: "Author",
6985
prompt: "Choose an author",
7086
collection: User.pluck(:name, :id) },
87+
{ attribute: :email, as: :email },
88+
{ attribute: :password, as: :password },
89+
{ attribute: :age, as: :number },
90+
{ attribute: :phone, as: :tel },
91+
{ attribute: :website, as: :url },
92+
{ attribute: :query, as: :search },
93+
{ attribute: :starts_on, as: :date },
94+
{ attribute: :starts_at, as: :datetime },
95+
{ attribute: :starts_time, as: :time },
96+
{ attribute: :starts_month, as: :month },
97+
{ attribute: :starts_week, as: :week },
98+
{ attribute: :tint, as: :color },
99+
{ attribute: :volume, as: :range },
100+
{ attribute: :avatar, as: :file },
101+
{ attribute: :accepted, as: :checkbox },
102+
{ attribute: :secret_id, as: :hidden },
71103
],
72104
url: posts_path) %&gt;</code></pre>
73105
</div>
@@ -112,10 +144,17 @@
112144
<td class="l-ui-table__cell">String</td>
113145
<td class="l-ui-table__cell">Submit button text (defaults to "Create" for new records, "Save" for persisted records)</td>
114146
</tr>
147+
<tr>
148+
<th class="l-ui-table__cell l-ui-table__cell--primary" scope="row"><code>multipart:</code></th>
149+
<td class="l-ui-table__cell">Boolean</td>
150+
<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>
151+
</tr>
115152
</tbody>
116153
</table>
117154
</div>
118155

156+
<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>
157+
119158
<h2 class="mt-8">Field options</h2>
120159

121160
<div class="l-ui-table-container mt-4">
@@ -137,7 +176,7 @@
137176
<tr>
138177
<th class="l-ui-table__cell l-ui-table__cell--primary" scope="row"><code>as:</code></th>
139178
<td class="l-ui-table__cell">Symbol</td>
140-
<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>
179+
<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>
141180
</tr>
142181
<tr>
143182
<th class="l-ui-table__cell l-ui-table__cell--primary" scope="row"><code>label:</code></th>

test/helpers/form_helper_test.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,58 @@ class FormHelperTest < ActionView::TestCase
7777
assert_includes result, 'datetime'
7878
end
7979

80+
test "renders password field" do
81+
result = render_field({ attribute: :title, as: :password })
82+
assert_includes result, 'type="password"'
83+
assert_includes result, 'class="l-ui-form__field"'
84+
end
85+
86+
test "renders tel field (maps to telephone_field)" do
87+
result = render_field({ attribute: :title, as: :tel })
88+
assert_includes result, 'type="tel"'
89+
assert_includes result, 'class="l-ui-form__field"'
90+
end
91+
92+
test "renders url field" do
93+
result = render_field({ attribute: :title, as: :url })
94+
assert_includes result, 'type="url"'
95+
end
96+
97+
test "renders search field" do
98+
result = render_field({ attribute: :title, as: :search })
99+
assert_includes result, 'type="search"'
100+
end
101+
102+
test "renders time field" do
103+
result = render_field({ attribute: :created_at, as: :time })
104+
assert_includes result, 'type="time"'
105+
end
106+
107+
test "renders month field" do
108+
result = render_field({ attribute: :created_at, as: :month })
109+
assert_includes result, 'type="month"'
110+
end
111+
112+
test "renders week field" do
113+
result = render_field({ attribute: :created_at, as: :week })
114+
assert_includes result, 'type="week"'
115+
end
116+
117+
test "renders color field" do
118+
result = render_field({ attribute: :title, as: :color })
119+
assert_includes result, 'type="color"'
120+
end
121+
122+
test "renders range field" do
123+
result = render_field({ attribute: :user_id, as: :range })
124+
assert_includes result, 'type="range"'
125+
end
126+
127+
test "renders file field" do
128+
result = render_field({ attribute: :title, as: :file })
129+
assert_includes result, 'type="file"'
130+
end
131+
80132
test "renders hidden field without group wrapper" do
81133
result = render_field({ attribute: :user_id, as: :hidden })
82134
assert_includes result, 'type="hidden"'
@@ -193,6 +245,26 @@ class FormHelperTest < ActionView::TestCase
193245
assert_includes result, 'rows="8"'
194246
end
195247

248+
# -- multipart auto-detection --
249+
250+
test "form auto-enables multipart when any field is :file" do
251+
result = render_form(fields: [
252+
{ attribute: :title },
253+
{ attribute: :body, as: :file }
254+
])
255+
assert_includes result, 'enctype="multipart/form-data"'
256+
end
257+
258+
test "form omits multipart when no :file fields" do
259+
result = render_form(fields: [{ attribute: :title }, { attribute: :body, as: :text }])
260+
assert_not_includes result, 'enctype="multipart/form-data"'
261+
end
262+
263+
test "multipart: true forces multipart without any :file field" do
264+
result = render_form(fields: [{ attribute: :title }], multipart: true)
265+
assert_includes result, 'enctype="multipart/form-data"'
266+
end
267+
196268
# -- type validation --
197269

198270
test "raises ArgumentError for unsupported field type" do
@@ -223,4 +295,12 @@ def render_field(field_config)
223295
locals: { form: builder, record: record, config: config }
224296
rendered
225297
end
298+
299+
def render_form(fields:, multipart: nil)
300+
record = Post.new
301+
render partial: "layered/ui/managed_resource/form",
302+
locals: { record: record, fields: fields, url: "/posts",
303+
method: nil, submit: nil, multipart: multipart }
304+
rendered
305+
end
226306
end

0 commit comments

Comments
 (0)