crud_components 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/Gemfile +23 -0
- data/LICENSE +21 -0
- data/README.md +511 -0
- data/RELEASING.md +44 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/crud_components.css +35 -0
- data/app/views/crud_components/_action_button.html.erb +11 -0
- data/app/views/crud_components/_actions.html.erb +12 -0
- data/app/views/crud_components/_column_header.html.erb +24 -0
- data/app/views/crud_components/_column_picker.html.erb +66 -0
- data/app/views/crud_components/_filter.html.erb +34 -0
- data/app/views/crud_components/_form.html.erb +30 -0
- data/app/views/crud_components/_pager.html.erb +41 -0
- data/app/views/crud_components/_record.html.erb +15 -0
- data/app/views/crud_components/_row.html.erb +26 -0
- data/app/views/crud_components/_selection_action.html.erb +14 -0
- data/app/views/crud_components/_sort_link.html.erb +17 -0
- data/app/views/crud_components/_toolbar.html.erb +50 -0
- data/app/views/crud_components/fields/_asciidoc.html.erb +8 -0
- data/app/views/crud_components/fields/_association.html.erb +13 -0
- data/app/views/crud_components/fields/_association_list.html.erb +24 -0
- data/app/views/crud_components/fields/_attachment.html.erb +16 -0
- data/app/views/crud_components/fields/_attachment_thumb.html.erb +17 -0
- data/app/views/crud_components/fields/_boolean.html.erb +13 -0
- data/app/views/crud_components/fields/_date.html.erb +6 -0
- data/app/views/crud_components/fields/_datetime.html.erb +6 -0
- data/app/views/crud_components/fields/_email.html.erb +7 -0
- data/app/views/crud_components/fields/_enum.html.erb +14 -0
- data/app/views/crud_components/fields/_json.html.erb +10 -0
- data/app/views/crud_components/fields/_markdown.html.erb +9 -0
- data/app/views/crud_components/fields/_number.html.erb +8 -0
- data/app/views/crud_components/fields/_string.html.erb +8 -0
- data/app/views/crud_components/fields/_text.html.erb +9 -0
- data/app/views/crud_components/fields/_url.html.erb +11 -0
- data/app/views/crud_components/filters/_boolean.html.erb +12 -0
- data/app/views/crud_components/filters/_date_range.html.erb +11 -0
- data/app/views/crud_components/filters/_number_range.html.erb +13 -0
- data/app/views/crud_components/filters/_select.html.erb +8 -0
- data/app/views/crud_components/filters/_text.html.erb +5 -0
- data/app/views/crud_components/form_fields/_belongs_to.html.erb +3 -0
- data/app/views/crud_components/form_fields/_boolean.html.erb +12 -0
- data/app/views/crud_components/form_fields/_date.html.erb +2 -0
- data/app/views/crud_components/form_fields/_datetime.html.erb +2 -0
- data/app/views/crud_components/form_fields/_enum.html.erb +8 -0
- data/app/views/crud_components/form_fields/_file.html.erb +47 -0
- data/app/views/crud_components/form_fields/_habtm.html.erb +5 -0
- data/app/views/crud_components/form_fields/_number.html.erb +2 -0
- data/app/views/crud_components/form_fields/_string.html.erb +3 -0
- data/app/views/crud_components/form_fields/_text.html.erb +2 -0
- data/app/views/crud_components/layouts/_table.html.erb +143 -0
- data/config/locales/crud_components.de.yml +39 -0
- data/config/locales/crud_components.en.yml +40 -0
- data/crud_components.gemspec +48 -0
- data/docs/extending.md +308 -0
- data/docs/fields.md +442 -0
- data/docs/forms.md +253 -0
- data/docs/performance.md +90 -0
- data/docs/security.md +139 -0
- data/docs/views.md +405 -0
- data/lib/crud_components/action.rb +85 -0
- data/lib/crud_components/builder.rb +246 -0
- data/lib/crud_components/config.rb +128 -0
- data/lib/crud_components/dynamic_column.rb +68 -0
- data/lib/crud_components/engine.rb +25 -0
- data/lib/crud_components/errors.rb +9 -0
- data/lib/crud_components/fields/attachment_field.rb +22 -0
- data/lib/crud_components/fields/base.rb +260 -0
- data/lib/crud_components/fields/belongs_to_field.rb +91 -0
- data/lib/crud_components/fields/boolean_field.rb +31 -0
- data/lib/crud_components/fields/computed_field.rb +34 -0
- data/lib/crud_components/fields/date_field.rb +51 -0
- data/lib/crud_components/fields/dynamic_field.rb +44 -0
- data/lib/crud_components/fields/enum_field.rb +40 -0
- data/lib/crud_components/fields/has_many_field.rb +50 -0
- data/lib/crud_components/fields/json_field.rb +10 -0
- data/lib/crud_components/fields/numeric_field.rb +31 -0
- data/lib/crud_components/fields/path_field.rb +327 -0
- data/lib/crud_components/fields/string_field.rb +41 -0
- data/lib/crud_components/fields/text_field.rb +9 -0
- data/lib/crud_components/fieldset.rb +38 -0
- data/lib/crud_components/helpers.rb +259 -0
- data/lib/crud_components/like_spec.rb +113 -0
- data/lib/crud_components/markup.rb +36 -0
- data/lib/crud_components/model.rb +33 -0
- data/lib/crud_components/permission_context.rb +62 -0
- data/lib/crud_components/presenters/actions.rb +51 -0
- data/lib/crud_components/presenters/base.rb +95 -0
- data/lib/crud_components/presenters/cell_context.rb +28 -0
- data/lib/crud_components/presenters/cells.rb +160 -0
- data/lib/crud_components/presenters/collection.rb +498 -0
- data/lib/crud_components/presenters/column_selection.rb +91 -0
- data/lib/crud_components/presenters/filter.rb +38 -0
- data/lib/crud_components/presenters/form.rb +57 -0
- data/lib/crud_components/presenters/record.rb +57 -0
- data/lib/crud_components/query.rb +110 -0
- data/lib/crud_components/route_resolver.rb +123 -0
- data/lib/crud_components/structure.rb +343 -0
- data/lib/crud_components/version.rb +3 -0
- data/lib/crud_components/where_like.rb +13 -0
- data/lib/crud_components.rb +160 -0
- data/lib/generators/crud_components/install/install_generator.rb +43 -0
- data/lib/generators/crud_components/install/templates/crud_columns_controller.js +76 -0
- data/lib/generators/crud_components/install/templates/crud_filter_controller.js +32 -0
- data/lib/generators/crud_components/install/templates/crud_multiselect_controller.js +70 -0
- data/lib/generators/crud_components/install/templates/crud_select_controller.js +35 -0
- data/lib/generators/crud_components/install/templates/initializer.rb +56 -0
- data/lib/generators/crud_components/views/views_generator.rb +14 -0
- metadata +209 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/* crud_components — the only CSS the gem needs the host to load: it makes the
|
|
2
|
+
* column picker float as a dropdown overlay instead of pushing the layout.
|
|
3
|
+
* Everything else is plain Bootstrap 5 classes (see Config::DEFAULT_CSS).
|
|
4
|
+
*
|
|
5
|
+
* Load it whichever way fits your asset setup:
|
|
6
|
+
* - asset pipeline (propshaft / sprockets): <%= stylesheet_link_tag "crud_components" %>
|
|
7
|
+
* - pipeline-agnostic (cssbundling / importmap / none): <%= crud_components_styles %>
|
|
8
|
+
* (inlines this file as a <style> tag — no compilation, works everywhere)
|
|
9
|
+
*/
|
|
10
|
+
.crud-column-picker { position: relative; display: inline-block; }
|
|
11
|
+
.crud-column-picker-toggle {
|
|
12
|
+
list-style: none; cursor: pointer; color: var(--bs-secondary);
|
|
13
|
+
padding: .15rem .3rem; border-radius: .25rem;
|
|
14
|
+
}
|
|
15
|
+
.crud-column-picker-toggle::-webkit-details-marker { display: none; }
|
|
16
|
+
.crud-column-picker-toggle:hover { color: var(--bs-body-color); background: var(--bs-secondary-bg); }
|
|
17
|
+
.crud-column-picker[open] .crud-column-picker-toggle { color: var(--bs-body-color); }
|
|
18
|
+
.crud-column-picker-menu {
|
|
19
|
+
position: absolute; right: 0; z-index: 1050; margin-top: .25rem; min-width: 14rem;
|
|
20
|
+
background: var(--bs-body-bg, #fff); border: 1px solid var(--bs-border-color);
|
|
21
|
+
border-radius: .375rem; box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .15); padding: .75rem;
|
|
22
|
+
}
|
|
23
|
+
.crud-column-picker-list { margin: .25rem 0 .5rem; max-height: 28rem; overflow-y: auto; }
|
|
24
|
+
.crud-column-picker-item {
|
|
25
|
+
display: flex; align-items: center; gap: .5rem; padding: .15rem .25rem; border-radius: .25rem;
|
|
26
|
+
}
|
|
27
|
+
.crud-column-picker-item.is-dragging { opacity: .5; background: var(--bs-secondary-bg); }
|
|
28
|
+
.crud-column-picker-handle { cursor: grab; color: var(--bs-secondary); }
|
|
29
|
+
.crud-column-picker-group {
|
|
30
|
+
margin: .5rem 0 .15rem; padding: 0 .25rem; font-size: .7rem; font-weight: 600;
|
|
31
|
+
text-transform: uppercase; letter-spacing: .03em; color: var(--bs-secondary);
|
|
32
|
+
}
|
|
33
|
+
.crud-column-picker-group:first-child { margin-top: 0; }
|
|
34
|
+
/* the model tag at the right of each picker row (Pipedrive-style) */
|
|
35
|
+
.crud-column-picker-model { margin-left: auto; font-size: .7rem; white-space: nowrap; }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%# One action rendered as a button: a GET action is a turbo link, anything else
|
|
2
|
+
a CSRF-safe button_to form. Locals: action (CrudComponents::Action), path, css. %>
|
|
3
|
+
<% content = action.icon ? tag.i('', class: "#{css.icon_prefix}#{action.icon}", aria: { hidden: true }) : action.title %>
|
|
4
|
+
<% if action.http_method == :get %>
|
|
5
|
+
<%= link_to content, path, class: action.css_class, title: action.title,
|
|
6
|
+
data: { turbo_action: 'advance' } %>
|
|
7
|
+
<% else %>
|
|
8
|
+
<%= button_to content, path, method: action.http_method, class: action.css_class,
|
|
9
|
+
title: action.title,
|
|
10
|
+
form: { class: 'd-inline', data: { turbo_confirm: action.confirm_message }.compact } %>
|
|
11
|
+
<% end %>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<%# A group of action buttons. Local: `actions`
|
|
2
|
+
(CrudComponents::Presenters::Actions). Joined (btn-group) only when every
|
|
3
|
+
action is a GET link; a destroy form would break the group's edges, so
|
|
4
|
+
those render as evenly-spaced buttons instead. Each item is rendered by the
|
|
5
|
+
shared _action_button partial. %>
|
|
6
|
+
<% if actions.any? %>
|
|
7
|
+
<div class="<%= actions.joinable? ? actions.css.button_group : 'd-inline-flex gap-1' %>" role="group">
|
|
8
|
+
<% actions.items.each do |item| %>
|
|
9
|
+
<%= render 'crud_components/action_button', action: item.action, path: item.path, css: actions.css %>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
<% end %>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%# Every column's <th> content. The title is collection.column_header(field) — a
|
|
2
|
+
custom header: replaces only the human_name; the column is still wrapped in a
|
|
3
|
+
sort link when sortable. Then any header actions: an `on: :selection` action
|
|
4
|
+
submits the shared select-form (acting on the ticked rows × this column's
|
|
5
|
+
object); any other renders as a plain link/button. Locals: collection, field. %>
|
|
6
|
+
<% css = collection.css %>
|
|
7
|
+
<span class="crud-column-header d-inline-flex align-items-center gap-1">
|
|
8
|
+
<% if collection.sortable_field?(field) %>
|
|
9
|
+
<%= render 'crud_components/sort_link', collection: collection, field: field, css: css,
|
|
10
|
+
label: collection.column_header(field) %>
|
|
11
|
+
<% else %>
|
|
12
|
+
<%= collection.column_header(field) %>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% if (actions = collection.column_header_actions(field)) %>
|
|
15
|
+
<% actions.items.each do |item| %>
|
|
16
|
+
<% if item.action.selection? %>
|
|
17
|
+
<%= render 'crud_components/selection_action', action: item.action, path: item.path,
|
|
18
|
+
form_id: collection.select_form_id, css: css %>
|
|
19
|
+
<% else %>
|
|
20
|
+
<%= render 'crud_components/action_button', action: item.action, path: item.path, css: css %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% end %>
|
|
23
|
+
<% end %>
|
|
24
|
+
</span>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<%# The column picker: a gear that opens a checklist of the columns this user may
|
|
2
|
+
see. Native <details> disclosure — opening needs no JavaScript; the optional
|
|
3
|
+
crud-columns controller only adds drag-to-reorder. The form is a plain GET that
|
|
4
|
+
submits `cols[]` to `url` (this page by default), exactly like the sort links
|
|
5
|
+
and filter row, so it composes with every other param and works no-JS.
|
|
6
|
+
Locals: `collection` (a Collection presenter), `url`. %>
|
|
7
|
+
<% css = collection.css %>
|
|
8
|
+
<details class="crud-column-picker dropdown" data-controller="crud-columns">
|
|
9
|
+
<summary class="crud-column-picker-toggle" role="button"
|
|
10
|
+
title="<%= t('crud_components.columns.edit', default: 'Columns') %>"
|
|
11
|
+
aria-label="<%= t('crud_components.columns.edit', default: 'Columns') %>">
|
|
12
|
+
<i class="<%= css.icon_prefix %>gear" aria-hidden="true"></i>
|
|
13
|
+
</summary>
|
|
14
|
+
<div class="crud-column-picker-menu">
|
|
15
|
+
<%# crud-columns#clean (optional JS) collapses cols[]=… into a single
|
|
16
|
+
cols=a,b on submit — a prettier URL; without JS the cols[] array submits. %>
|
|
17
|
+
<%= form_with url: url, method: :get, data: { turbo_action: 'advance',
|
|
18
|
+
action: 'submit->crud-columns#clean' } do %>
|
|
19
|
+
<% collection.picker_preserved_params.each do |name, value| %>
|
|
20
|
+
<%= hidden_field_tag name, value, id: nil %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<fieldset>
|
|
23
|
+
<legend class="<%= css.muted %> small"><%= t('crud_components.columns.legend', default: 'Show columns') %></legend>
|
|
24
|
+
<ul class="crud-column-picker-list list-unstyled" data-crud-columns-target="list">
|
|
25
|
+
<%# Pipedrive-style: every column is grouped under its source model — the
|
|
26
|
+
collection's own model first, then each associated model. A group's
|
|
27
|
+
header shows the model's icon + name; each row also tags the model on
|
|
28
|
+
the right. So `publisher`, `publisher.name` and `publisher.founded_on`
|
|
29
|
+
cluster under "Publisher". %>
|
|
30
|
+
<% collection.field_groups.each do |group_model, fields| %>
|
|
31
|
+
<% heading = collection.group_heading(group_model) %>
|
|
32
|
+
<% icon = collection.group_icon(group_model) %>
|
|
33
|
+
<li class="crud-column-picker-group">
|
|
34
|
+
<% if icon %><i class="<%= css.icon_prefix %><%= icon %>" aria-hidden="true"></i> <% end %><%= heading %>
|
|
35
|
+
</li>
|
|
36
|
+
<% fields.each do |field| %>
|
|
37
|
+
<%# Pure-HTML behaviour: a ticked checkbox submits cols[]=<name> in DOM
|
|
38
|
+
order, an unticked one submits nothing. crud-columns reorders these
|
|
39
|
+
<li>s on drag, which reorders the submitted columns. %>
|
|
40
|
+
<li class="crud-column-picker-item" draggable="true" data-crud-columns-target="item">
|
|
41
|
+
<span class="crud-column-picker-handle" aria-hidden="true" data-crud-columns-target="handle">
|
|
42
|
+
<i class="<%= css.icon_prefix %>grip-vertical"></i>
|
|
43
|
+
</span>
|
|
44
|
+
<label class="d-inline-flex align-items-center gap-2 flex-grow-1">
|
|
45
|
+
<input type="checkbox" class="form-check-input" name="<%= collection.column_param_name %>"
|
|
46
|
+
value="<%= field.name %>" <%= 'checked' if collection.column_visible?(field) %>
|
|
47
|
+
data-action="change->crud-columns#toggle">
|
|
48
|
+
<span><%= field.picker_label %></span>
|
|
49
|
+
<span class="crud-column-picker-model <%= css.muted %>"><%= heading %></span>
|
|
50
|
+
</label>
|
|
51
|
+
</li>
|
|
52
|
+
<% end %>
|
|
53
|
+
<% end %>
|
|
54
|
+
</ul>
|
|
55
|
+
</fieldset>
|
|
56
|
+
<div class="<%= css.button_group %>">
|
|
57
|
+
<button type="submit" class="<%= css.button %>">
|
|
58
|
+
<%= t('crud_components.columns.apply', default: 'Apply') %>
|
|
59
|
+
</button>
|
|
60
|
+
<%= link_to t('crud_components.columns.reset', default: 'Reset'),
|
|
61
|
+
collection.picker_preserved_params.any? ? "#{url}?#{collection.picker_preserved_params.to_query}" : url,
|
|
62
|
+
class: css.button, data: { turbo_action: 'advance' } %>
|
|
63
|
+
</div>
|
|
64
|
+
<% end %>
|
|
65
|
+
</div>
|
|
66
|
+
</details>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%# Standalone labelled filter form (modal / sidebar). Local: `filter`.
|
|
2
|
+
Never auto-submits — users compose several filters here. %>
|
|
3
|
+
<% css = filter.css %>
|
|
4
|
+
<form method="get" action="<%= filter.form_path %>" class="crud-filter-form"
|
|
5
|
+
data-controller="crud-filter" data-action="submit->crud-filter#clean"
|
|
6
|
+
data-turbo-action="advance">
|
|
7
|
+
<% filter.preserved_params.each do |name, value| %>
|
|
8
|
+
<%= hidden_field_tag name, value, id: nil %>
|
|
9
|
+
<% end %>
|
|
10
|
+
<div class="<%= css.filter_grid %>">
|
|
11
|
+
<% if filter.searchable? %>
|
|
12
|
+
<div class="col">
|
|
13
|
+
<label class="<%= css.form_label %>">
|
|
14
|
+
<%= t('crud_components.filter.search', default: 'Search') %>
|
|
15
|
+
<%= tag.input type: 'search', name: filter.param_name('q'), value: filter.value('q'),
|
|
16
|
+
class: css.input %>
|
|
17
|
+
</label>
|
|
18
|
+
</div>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% filter.fields.each do |field| %>
|
|
21
|
+
<div class="col">
|
|
22
|
+
<span class="<%= css.form_label %>"><%= field.human_name %></span>
|
|
23
|
+
<%= filter.render_filter_control(field, filter.query) %>
|
|
24
|
+
</div>
|
|
25
|
+
<% end %>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="mt-3 d-flex gap-2">
|
|
28
|
+
<button type="submit" class="<%= css.button_primary %>">
|
|
29
|
+
<%= t('crud_components.filter.apply', default: 'Apply') %>
|
|
30
|
+
</button>
|
|
31
|
+
<%= link_to t('crud_components.filter.reset', default: 'Reset'), filter.reset_path,
|
|
32
|
+
class: css.button %>
|
|
33
|
+
</div>
|
|
34
|
+
</form>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<%# Derived create/edit form. Local: `form` (Presenters::Form).
|
|
2
|
+
Rendered with simple_form, so it inherits your app's wrappers (Bootstrap by
|
|
3
|
+
default; the community ships Tailwind/Bulma/Foundation configs). The gem
|
|
4
|
+
decides *which* fields, their types and the permit list, and renders each
|
|
5
|
+
input through a per-type partial (crud_components/form_fields/_<type>);
|
|
6
|
+
simple_form does the markup. Override one of those partials to restyle a
|
|
7
|
+
whole type, set `form_as:` on an attribute to point one field at a different
|
|
8
|
+
partial, or override this file to take over form rendering entirely. %>
|
|
9
|
+
<%= simple_form_for(form.record, **form.form_options) do |f| %>
|
|
10
|
+
<% if form.summary_errors.any? %>
|
|
11
|
+
<%# base / non-field errors — per-field errors are simple_form's job %>
|
|
12
|
+
<div class="<%= form.css.form_summary %>">
|
|
13
|
+
<ul class="mb-0"><% form.summary_errors.each do |message| %><li><%= message %></li><% end %></ul>
|
|
14
|
+
</div>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
<% form.fields.each do |field| %>
|
|
18
|
+
<% if form.editable?(field) %>
|
|
19
|
+
<%= render "crud_components/form_fields/#{field.form_partial}", f: f, field: field, form: form %>
|
|
20
|
+
<% else %>
|
|
21
|
+
<%# visible but not editable: shown read-only for context, not submitted %>
|
|
22
|
+
<div class="mb-2 small d-flex gap-2 crud-form-readonly">
|
|
23
|
+
<span class="<%= form.css.muted %>"><%= field.human_name %>:</span>
|
|
24
|
+
<span><%= form.display(field) %></span>
|
|
25
|
+
</div>
|
|
26
|
+
<% end %>
|
|
27
|
+
<% end %>
|
|
28
|
+
|
|
29
|
+
<%= f.button :submit, class: form.css.button_primary %>
|
|
30
|
+
<% end %>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<%# Footer pager — lives in the table's <tfoot> (a colspan cell), or any layout
|
|
2
|
+
that renders it. One local: `collection`. Override this file in your app to
|
|
3
|
+
restyle. Page links keep all other params, so filters/sort/other collections
|
|
4
|
+
survive turning the page. %>
|
|
5
|
+
<% if collection.show_pager? %>
|
|
6
|
+
<% css = collection.css %>
|
|
7
|
+
<nav class="crud-pager d-flex flex-wrap justify-content-between align-items-center gap-2"
|
|
8
|
+
aria-label="<%= t('crud_components.pager.label', default: 'Pagination') %>">
|
|
9
|
+
<small class="<%= css.muted %>">
|
|
10
|
+
<%= t('crud_components.pager.summary', default: 'Page %{page} of %{total} · %{count} total',
|
|
11
|
+
page: collection.current_page, total: collection.total_pages, count: collection.total_count) %>
|
|
12
|
+
</small>
|
|
13
|
+
<ul class="<%= css.pagination %> mb-0">
|
|
14
|
+
<% prev_off = collection.current_page <= 1 %>
|
|
15
|
+
<li class="page-item <%= 'disabled' if prev_off %>">
|
|
16
|
+
<%= link_to t('crud_components.pager.previous', default: '‹'),
|
|
17
|
+
(prev_off ? '#' : collection.page_url(collection.current_page - 1)),
|
|
18
|
+
class: 'page-link', data: { turbo_action: 'advance' },
|
|
19
|
+
aria: { label: t('crud_components.pager.previous_label', default: 'Previous') },
|
|
20
|
+
tabindex: ('-1' if prev_off) %>
|
|
21
|
+
</li>
|
|
22
|
+
<% collection.pager_pages.each do |p| %>
|
|
23
|
+
<% if p == :gap %>
|
|
24
|
+
<li class="page-item disabled"><span class="page-link">…</span></li>
|
|
25
|
+
<% else %>
|
|
26
|
+
<li class="page-item <%= 'active' if p == collection.current_page %>">
|
|
27
|
+
<%= link_to p, collection.page_url(p), class: 'page-link', data: { turbo_action: 'advance' } %>
|
|
28
|
+
</li>
|
|
29
|
+
<% end %>
|
|
30
|
+
<% end %>
|
|
31
|
+
<% next_off = collection.current_page >= collection.total_pages %>
|
|
32
|
+
<li class="page-item <%= 'disabled' if next_off %>">
|
|
33
|
+
<%= link_to t('crud_components.pager.next', default: '›'),
|
|
34
|
+
(next_off ? '#' : collection.page_url(collection.current_page + 1)),
|
|
35
|
+
class: 'page-link', data: { turbo_action: 'advance' },
|
|
36
|
+
aria: { label: t('crud_components.pager.next_label', default: 'Next') },
|
|
37
|
+
tabindex: ('-1' if next_off) %>
|
|
38
|
+
</li>
|
|
39
|
+
</ul>
|
|
40
|
+
</nav>
|
|
41
|
+
<% end %>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%# One record as a definition list. Local: `record_presenter`. %>
|
|
2
|
+
<% css = record_presenter.css %>
|
|
3
|
+
<div class="crud-record">
|
|
4
|
+
<% if record_presenter.actions&.any? %>
|
|
5
|
+
<div class="mb-3">
|
|
6
|
+
<%= render 'crud_components/actions', actions: record_presenter.actions %>
|
|
7
|
+
</div>
|
|
8
|
+
<% end %>
|
|
9
|
+
<dl class="<%= css.dl %>">
|
|
10
|
+
<% record_presenter.fields.each do |field| %>
|
|
11
|
+
<dt class="<%= css.dt %>"><%= field.human_name %></dt>
|
|
12
|
+
<dd class="<%= css.dd %>"><%= record_presenter.value_html(field) %></dd>
|
|
13
|
+
<% end %>
|
|
14
|
+
</dl>
|
|
15
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<%# One collection table row. Locals: collection, record, css; optional group. %>
|
|
2
|
+
<% group = local_assigns[:group] %>
|
|
3
|
+
<tr id="<%= dom_id(record) %>">
|
|
4
|
+
<% if collection.selectable? %>
|
|
5
|
+
<td class="crud-select-cell">
|
|
6
|
+
<%= check_box_tag collection.select_param_name, collection.select_value(record), false,
|
|
7
|
+
id: nil, form: collection.select_form_id, class: 'form-check-input',
|
|
8
|
+
aria: { label: 'Select row' },
|
|
9
|
+
data: { crud_select_target: 'row', action: 'change->crud-select#update', group: group&.key } %>
|
|
10
|
+
</td>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% collection.fields.each do |field| %>
|
|
13
|
+
<td><%= collection.cell(field, record) %></td>
|
|
14
|
+
<% end %>
|
|
15
|
+
<% if collection.trailing_column? %>
|
|
16
|
+
<td class="<%= css.actions_cell %>">
|
|
17
|
+
<% if collection.actions_column? %>
|
|
18
|
+
<% if collection.custom_actions_partial %>
|
|
19
|
+
<%= render collection.custom_actions_partial, record: record %>
|
|
20
|
+
<% else %>
|
|
21
|
+
<%= render 'crud_components/actions', actions: collection.row_actions(record) %>
|
|
22
|
+
<% end %>
|
|
23
|
+
<% end %>
|
|
24
|
+
</td>
|
|
25
|
+
<% end %>
|
|
26
|
+
</tr>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%# One bulk action bound to the shared select-form: submitting it carries the
|
|
2
|
+
ticked `selected[]` slugs to `path`. A GET action submits as GET (filter-like);
|
|
3
|
+
others POST with a `_method` override. The crud-select controller disables it
|
|
4
|
+
while nothing is ticked; without JS it stays enabled and acts on whatever is
|
|
5
|
+
ticked. Shared by the toolbar's selection cluster and a column header.
|
|
6
|
+
Locals: action (CrudComponents::Action), path, form_id, css. %>
|
|
7
|
+
<% get = action.http_method == :get %>
|
|
8
|
+
<%= button_tag type: 'submit', form: form_id, formaction: path,
|
|
9
|
+
formmethod: get ? 'get' : 'post',
|
|
10
|
+
name: get ? nil : '_method', value: get ? nil : action.http_method,
|
|
11
|
+
class: action.css_class, title: action.title,
|
|
12
|
+
data: { turbo_confirm: action.confirm_message, crud_select_target: 'button' }.compact do %>
|
|
13
|
+
<% if action.icon %><i class="<%= css.icon_prefix %><%= action.icon %>" aria-hidden="true"></i> <% end %><%= action.title %>
|
|
14
|
+
<% end %>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%# The sortable column title: a link that toggles ?sort=/?dir= with a tri-state
|
|
2
|
+
glyph (unsorted / asc / desc), picking a numeric vs alphabetic icon.
|
|
3
|
+
Locals: collection, field, css; optional label (the link text, defaulting to
|
|
4
|
+
the field's human_name — a custom header: passes its own content here). %>
|
|
5
|
+
<% label = local_assigns.fetch(:label, field.human_name) %>
|
|
6
|
+
<%= link_to collection.sort_url(field),
|
|
7
|
+
class: "#{css.sort_link} d-inline-flex align-items-center gap-1",
|
|
8
|
+
title: t('crud_components.sort', default: 'Sort'),
|
|
9
|
+
data: { turbo_action: 'advance' } do %>
|
|
10
|
+
<%= label %>
|
|
11
|
+
<% if (dir = collection.sort_direction(field)) %>
|
|
12
|
+
<% family = collection.sort_numeric?(field) ? 'sort-numeric' : 'sort-alpha' %>
|
|
13
|
+
<i class="<%= css.icon_prefix %><%= family %>-<%= dir == :desc ? 'up' : 'down' %>"></i>
|
|
14
|
+
<% else %>
|
|
15
|
+
<i class="<%= css.icon_prefix %>arrow-down-up text-muted opacity-25"></i>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% end %>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<%# Collection toolbar: global ?q= search (+ reset) on the left, collection and
|
|
2
|
+
selection actions on the right. Layout-agnostic — any layout can render it,
|
|
3
|
+
so swapping the table shell doesn't mean reimplementing search/actions.
|
|
4
|
+
Local: `collection`. %>
|
|
5
|
+
<% css = collection.css %>
|
|
6
|
+
<% if collection.searchable? || collection.collection_actions&.any? || collection.selection_actions&.any? %>
|
|
7
|
+
<div class="<%= css.toolbar %>">
|
|
8
|
+
<div class="d-flex align-items-center gap-2">
|
|
9
|
+
<% if collection.searchable? %>
|
|
10
|
+
<%# plain GET form — works without JavaScript %>
|
|
11
|
+
<form method="get" action="<%= request.path %>" class="<%= css.search_form %>"
|
|
12
|
+
data-controller="crud-filter" data-action="submit->crud-filter#clean"
|
|
13
|
+
data-turbo-action="advance">
|
|
14
|
+
<% collection.preserved_params.each do |name, value| %>
|
|
15
|
+
<%= hidden_field_tag name, value, id: nil %>
|
|
16
|
+
<% end %>
|
|
17
|
+
<%= tag.input type: 'search', name: collection.search_param_name,
|
|
18
|
+
value: collection.search_value, class: css.input_sm,
|
|
19
|
+
placeholder: t('crud_components.filter.search', default: 'Search'),
|
|
20
|
+
aria: { label: t('crud_components.filter.search', default: 'Search') } %>
|
|
21
|
+
<button type="submit" class="<%= css.button %>">
|
|
22
|
+
<%= t('crud_components.filter.search', default: 'Search') %>
|
|
23
|
+
</button>
|
|
24
|
+
<% if collection.filtered? %>
|
|
25
|
+
<%= link_to t('crud_components.filter.reset', default: 'Reset'), collection.reset_url,
|
|
26
|
+
class: css.button, data: { turbo_action: 'advance' } %>
|
|
27
|
+
<% end %>
|
|
28
|
+
</form>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="d-flex align-items-center gap-2">
|
|
32
|
+
<% if collection.selection_actions&.any? %>
|
|
33
|
+
<%# The bulk-action cluster: a count + the actions that operate on the
|
|
34
|
+
ticked rows, kept together so it's clear they act on the selection.
|
|
35
|
+
crud-select disables the buttons while nothing is selected (no-JS:
|
|
36
|
+
they stay enabled and act on whatever you ticked). %>
|
|
37
|
+
<div class="d-flex align-items-center gap-2 crud-selection-bar">
|
|
38
|
+
<span class="<%= css.muted %> small" data-crud-select-target="count"></span>
|
|
39
|
+
<% collection.selection_actions.items.each do |item| %>
|
|
40
|
+
<%= render 'crud_components/selection_action', action: item.action, path: item.path,
|
|
41
|
+
form_id: collection.select_form_id, css: css %>
|
|
42
|
+
<% end %>
|
|
43
|
+
</div>
|
|
44
|
+
<% end %>
|
|
45
|
+
<% if collection.collection_actions&.any? %>
|
|
46
|
+
<%= render 'crud_components/actions', actions: collection.collection_actions %>
|
|
47
|
+
<% end %>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<% end %>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%# Locals: value, record, field, surface — needs asciidoctor in the host app %>
|
|
2
|
+
<%- if value.nil? || value == '' -%>
|
|
3
|
+
<span class="<%= CrudComponents.config.css.muted %>">—</span>
|
|
4
|
+
<%- elsif surface == :collection -%>
|
|
5
|
+
<%= truncate(strip_tags(CrudComponents::Markup.asciidoc(value)), length: 120) %>
|
|
6
|
+
<%- else -%>
|
|
7
|
+
<div class="crud-asciidoc"><%= sanitize CrudComponents::Markup.asciidoc(value) %></div>
|
|
8
|
+
<%- end -%>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%# Locals: value (the associated record or nil), record, field, surface —
|
|
2
|
+
nil-safe link via the target's label %>
|
|
3
|
+
<%- if value.nil? -%>
|
|
4
|
+
<span class="<%= CrudComponents.config.css.muted %>">—</span>
|
|
5
|
+
<%- else -%>
|
|
6
|
+
<% path = crud_record_path(value, owner: record) %>
|
|
7
|
+
<% label = crud_association_label(field, value) %>
|
|
8
|
+
<%- if path -%>
|
|
9
|
+
<%= link_to label, path, data: { turbo_action: 'advance' } %>
|
|
10
|
+
<%- else -%>
|
|
11
|
+
<%= label %>
|
|
12
|
+
<%- end -%>
|
|
13
|
+
<%- end -%>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%# Locals: value (a collection), record, field, surface, cell_context —
|
|
2
|
+
truncated list of links: "a, b +3 more" (the "more" link goes to the
|
|
3
|
+
nested/filtered index when one resolves) %>
|
|
4
|
+
<% items = value.to_a %>
|
|
5
|
+
<% css = CrudComponents.config.css %>
|
|
6
|
+
<%- if items.empty? -%>
|
|
7
|
+
<span class="<%= css.muted %>">—</span>
|
|
8
|
+
<%- else -%>
|
|
9
|
+
<% shown = surface == :collection ? items.first(3) : items %>
|
|
10
|
+
<%= safe_join(shown.map { |item|
|
|
11
|
+
path = crud_record_path(item, owner: record)
|
|
12
|
+
label = crud_association_label(field, item)
|
|
13
|
+
path ? link_to(label, path, data: { turbo_action: 'advance' }) : label.to_s
|
|
14
|
+
}, ', ') %>
|
|
15
|
+
<%- if items.size > shown.size -%>
|
|
16
|
+
<% more = t('crud_components.more', count: items.size - shown.size, default: '+%{count} more') %>
|
|
17
|
+
<% index_path = crud_association_index_path(record, field) %>
|
|
18
|
+
<%- if index_path -%>
|
|
19
|
+
<%= link_to more, index_path, class: css.muted, data: { turbo_action: 'advance' } %>
|
|
20
|
+
<%- else -%>
|
|
21
|
+
<span class="<%= css.muted %>"><%= more %></span>
|
|
22
|
+
<%- end -%>
|
|
23
|
+
<%- end -%>
|
|
24
|
+
<%- end -%>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# Default renderer for Active Storage attachments. Locals: value (one or
|
|
2
|
+
many attachments), record, field, surface, cell_context. Each attachment is
|
|
3
|
+
drawn by content type via _attachment_thumb (image / preview / icon). %>
|
|
4
|
+
<%- if value.respond_to?(:attached?) && value.attached? -%>
|
|
5
|
+
<%- if field.respond_to?(:many?) && field.many? -%>
|
|
6
|
+
<span class="d-inline-flex flex-wrap gap-1">
|
|
7
|
+
<%- value.each do |attachment| -%>
|
|
8
|
+
<%= render 'crud_components/fields/attachment_thumb', attachment: attachment, surface: surface %>
|
|
9
|
+
<%- end -%>
|
|
10
|
+
</span>
|
|
11
|
+
<%- else -%>
|
|
12
|
+
<%= render 'crud_components/fields/attachment_thumb', attachment: value, surface: surface %>
|
|
13
|
+
<%- end -%>
|
|
14
|
+
<%- else -%>
|
|
15
|
+
<span class="<%= CrudComponents.config.css.muted %>">—</span>
|
|
16
|
+
<%- end -%>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%# One Active Storage attachment, rendered by content type. Locals:
|
|
2
|
+
attachment, surface. image → inline; previewable (PDF, video, …) → a
|
|
3
|
+
preview image; anything else → an icon + filename download link.
|
|
4
|
+
(Previews need a previewer backend, e.g. poppler, + image_processing;
|
|
5
|
+
without them `previewable?` is false and it falls back to the icon.) %>
|
|
6
|
+
<% css = CrudComponents.config.css %>
|
|
7
|
+
<% style = surface == :record ? 'max-height: 16rem' : 'max-height: 2.5rem' %>
|
|
8
|
+
<%- if attachment.image? -%>
|
|
9
|
+
<%= image_tag attachment, class: 'rounded crud-image', style: style %>
|
|
10
|
+
<%- elsif attachment.previewable? && CrudComponents.previews_available? -%>
|
|
11
|
+
<%= image_tag attachment.preview(resize_to_limit: [600, 600]), class: 'rounded crud-image', style: style %>
|
|
12
|
+
<%- else -%>
|
|
13
|
+
<%= link_to rails_blob_path(attachment, disposition: 'attachment'),
|
|
14
|
+
class: 'd-inline-flex align-items-center gap-1 text-decoration-none' do %>
|
|
15
|
+
<i class="<%= css.icon_prefix %><%= crud_file_icon(attachment.filename) %>"></i><span><%= attachment.filename %></span>
|
|
16
|
+
<% end %>
|
|
17
|
+
<%- end -%>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%# Locals: value, record, field, surface, cell_context %>
|
|
2
|
+
<% css = CrudComponents.config.css %>
|
|
3
|
+
<%- if value.nil? -%>
|
|
4
|
+
<span class="<%= css.muted %>">—</span>
|
|
5
|
+
<%- else -%>
|
|
6
|
+
<% icon = tag.span(value ? '✓' : '✗', class: value ? css.boolean_true : css.boolean_false) %>
|
|
7
|
+
<%- if cell_context&.filterable?(field) -%>
|
|
8
|
+
<%= link_to icon, cell_context.filter_url(field, value), class: css.filter_link,
|
|
9
|
+
data: { turbo_action: 'advance' } %>
|
|
10
|
+
<%- else -%>
|
|
11
|
+
<%= icon %>
|
|
12
|
+
<%- end -%>
|
|
13
|
+
<%- end -%>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<%# Renders an email value as a mailto: link. Locals: value, record, field,
|
|
2
|
+
surface, cell_context. Used automatically for columns named email / *_email. %>
|
|
3
|
+
<%- if value.nil? || value == '' -%>
|
|
4
|
+
<span class="<%= CrudComponents.config.css.muted %>">—</span>
|
|
5
|
+
<%- else -%>
|
|
6
|
+
<%= mail_to value.to_s %>
|
|
7
|
+
<%- end -%>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%# Locals: value, record, field, surface, cell_context %>
|
|
2
|
+
<% css = CrudComponents.config.css %>
|
|
3
|
+
<%- if value.nil? -%>
|
|
4
|
+
<span class="<%= css.muted %>">—</span>
|
|
5
|
+
<%- else -%>
|
|
6
|
+
<% label = field.respond_to?(:human_value) ? field.human_value(value) : value %>
|
|
7
|
+
<% badge = tag.span(label, class: css.badge) %>
|
|
8
|
+
<%- if cell_context&.filterable?(field) -%>
|
|
9
|
+
<%= link_to badge, cell_context.filter_url(field, value), class: css.filter_link,
|
|
10
|
+
data: { turbo_action: 'advance' }, title: t('crud_components.filter_by', name: label, default: "Filter by #{label}") %>
|
|
11
|
+
<%- else -%>
|
|
12
|
+
<%= badge %>
|
|
13
|
+
<%- end -%>
|
|
14
|
+
<%- end -%>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%# Locals: value, record, field, surface — pretty-printed; highlighted via
|
|
2
|
+
rouge when the host app has it %>
|
|
3
|
+
<%- if value.nil? -%>
|
|
4
|
+
<span class="<%= CrudComponents.config.css.muted %>">—</span>
|
|
5
|
+
<%- else -%>
|
|
6
|
+
<% pretty = value.is_a?(String) ? value : JSON.pretty_generate(value) %>
|
|
7
|
+
<% pretty = truncate(pretty, length: 120) if surface == :collection %>
|
|
8
|
+
<% highlighted = CrudComponents::Markup.highlight_json(pretty) %>
|
|
9
|
+
<pre class="crud-json mb-0"><code><%= highlighted ? raw(highlighted) : pretty %></code></pre>
|
|
10
|
+
<%- end -%>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<%# Locals: value, record, field, surface — needs commonmarker, redcarpet or
|
|
2
|
+
kramdown in the host app (validated at structure build) %>
|
|
3
|
+
<%- if value.nil? || value == '' -%>
|
|
4
|
+
<span class="<%= CrudComponents.config.css.muted %>">—</span>
|
|
5
|
+
<%- elsif surface == :collection -%>
|
|
6
|
+
<%= truncate(strip_tags(CrudComponents::Markup.markdown(value)), length: 120) %>
|
|
7
|
+
<%- else -%>
|
|
8
|
+
<div class="crud-markdown"><%= sanitize CrudComponents::Markup.markdown(value) %></div>
|
|
9
|
+
<%- end -%>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%# Locals: value, record, field, surface — options: unit:, digits: %>
|
|
2
|
+
<%- if value.nil? -%>
|
|
3
|
+
<span class="<%= CrudComponents.config.css.muted %>">—</span>
|
|
4
|
+
<%- else -%>
|
|
5
|
+
<% options = field.renderer_options %>
|
|
6
|
+
<% formatted = options[:digits] ? number_with_precision(value, precision: options[:digits], delimiter: ',') : number_with_delimiter(value) %>
|
|
7
|
+
<%= formatted %><%= " #{options[:unit]}" if options[:unit] %>
|
|
8
|
+
<%- end -%>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%# Locals: value, record, field, surface %>
|
|
2
|
+
<%- if value.nil? || value == '' -%>
|
|
3
|
+
<span class="<%= CrudComponents.config.css.muted %>">—</span>
|
|
4
|
+
<%- elsif surface == :collection -%>
|
|
5
|
+
<%= truncate(value.to_s, length: 120) %>
|
|
6
|
+
<%- else -%>
|
|
7
|
+
<%= value %>
|
|
8
|
+
<%- end -%>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<%# Locals: value, record, field, surface — truncated in collections,
|
|
2
|
+
line breaks preserved on record pages %>
|
|
3
|
+
<%- if value.nil? || value == '' -%>
|
|
4
|
+
<span class="<%= CrudComponents.config.css.muted %>">—</span>
|
|
5
|
+
<%- elsif surface == :collection -%>
|
|
6
|
+
<%= truncate(value.to_s, length: 120) %>
|
|
7
|
+
<%- else -%>
|
|
8
|
+
<div class="crud-text" style="white-space: pre-line"><%= value %></div>
|
|
9
|
+
<%- end -%>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%# Renders an http(s) URL as a link; anything else stays plain text (gated on
|
|
2
|
+
the value, so a non-URL never becomes a link). Locals: value, record, field,
|
|
3
|
+
surface, cell_context. Used automatically for columns named url/website/link. %>
|
|
4
|
+
<%- if value.nil? || value == '' -%>
|
|
5
|
+
<span class="<%= CrudComponents.config.css.muted %>">—</span>
|
|
6
|
+
<%- elsif value.to_s.match?(%r{\Ahttps?://}i) -%>
|
|
7
|
+
<%= link_to(surface == :collection ? truncate(value.to_s, length: 60) : value.to_s,
|
|
8
|
+
value.to_s, rel: 'noopener', target: '_blank') %>
|
|
9
|
+
<%- else -%>
|
|
10
|
+
<%= value %>
|
|
11
|
+
<%- end -%>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<%# Locals: field, query, form_id, compact, autosubmit, param_name, css %>
|
|
2
|
+
<% choices = [
|
|
3
|
+
[t('crud_components.filter.any', default: '–'), ''],
|
|
4
|
+
[t('crud_components.filter.yes', default: 'Yes'), 'true'],
|
|
5
|
+
[t('crud_components.filter.no', default: 'No'), 'false']
|
|
6
|
+
] %>
|
|
7
|
+
<% choices << [t('crud_components.filter.not_set', default: 'Not set'), CrudComponents::NULL_FILTER_VALUE] if field.filter_includes_null? %>
|
|
8
|
+
<%= select_tag param_name,
|
|
9
|
+
options_for_select(choices, query.value(field.name.to_s)),
|
|
10
|
+
class: compact ? css.select_input_sm : css.select_input, form: form_id, id: nil,
|
|
11
|
+
data: (autosubmit ? { action: 'change->crud-filter#submit' } : {}),
|
|
12
|
+
aria: { label: field.human_name } %>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<%# Locals: field, query, form_id, compact, autosubmit, param_name, css %>
|
|
2
|
+
<div class="<%= compact ? 'd-flex flex-column gap-1' : css.input_group %>">
|
|
3
|
+
<%= tag.input type: 'date', name: "#{param_name}_geq",
|
|
4
|
+
value: query.value("#{field.name}_geq"),
|
|
5
|
+
class: compact ? css.input_sm : css.input, form: form_id,
|
|
6
|
+
aria: { label: "#{field.human_name} ≥" } %>
|
|
7
|
+
<%= tag.input type: 'date', name: "#{param_name}_leq",
|
|
8
|
+
value: query.value("#{field.name}_leq"),
|
|
9
|
+
class: compact ? css.input_sm : css.input, form: form_id,
|
|
10
|
+
aria: { label: "#{field.human_name} ≤" } %>
|
|
11
|
+
</div>
|