unmagic-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.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "action_view_helpers"
4
+
5
+ module Unmagic
6
+ module Components
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace Unmagic::Components
9
+
10
+ initializer "unmagic_components.helpers" do
11
+ ActiveSupport.on_load(:action_view) do
12
+ include Unmagic::Components::ActionViewHelpers
13
+ end
14
+ end
15
+
16
+ # The components' stylesheet is a plain CSS file, deliberately not part of any
17
+ # Tailwind build: Tailwind only generates classes it can see, and it does not
18
+ # scan installed gems. Serving it through the asset pipeline keeps the gem's
19
+ # look self-contained and themeable through --unmagic-* custom properties.
20
+ initializer "unmagic_components.assets" do |app|
21
+ next unless app.config.respond_to?(:assets)
22
+
23
+ app.config.assets.paths << Engine.root.join("app/assets/stylesheets")
24
+ app.config.assets.paths << Engine.root.join("app/assets/javascripts")
25
+ end
26
+
27
+ # The one piece of JavaScript here: the `upsert` Turbo Stream action a live
28
+ # table's broadcasts use. Pinned rather than served so the host imports it by
29
+ # name; importmap-rails is optional, and an app without it simply never sees
30
+ # the pin (and cannot broadcast into a table).
31
+ initializer "unmagic_components.importmap", before: "importmap" do |app|
32
+ next unless app.config.respond_to?(:importmap)
33
+
34
+ app.config.importmap.paths << Engine.root.join("config/importmap.rb")
35
+ app.config.importmap.cache_sweepers << Engine.root.join("app/assets/javascripts")
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+ require "action_view/helpers"
5
+
6
+ module Unmagic
7
+ module Components
8
+ # The chrome around a form control: the wrapper, the label and its required
9
+ # marker, the hint, and the error line — the part every app writes the same way
10
+ # and then repeats in every view.
11
+ #
12
+ # <%= form_with model: @label, builder: Unmagic::Components::FormBuilder do |form| %>
13
+ # <%= form.errors_summary %>
14
+ # <%= form.field :name, "Name", required: true, hint: "Shown in the sidebar." %>
15
+ # <%= form.submit "Add label" %>
16
+ # <% end %>
17
+ #
18
+ # What the control itself looks like is deliberately not decided here. Apps
19
+ # style inputs in incompatible ways — a class on every input, or a bare-element
20
+ # rule — and a component library that picked one would be wrong in the other. So
21
+ # the builder emits structure and leaves the control's own appearance alone.
22
+ class FormBuilder < ::ActionView::Helpers::FormBuilder
23
+ # Verbs the plain "drop a trailing -e, add -ing" rule gets wrong (consonant
24
+ # doubling). Everything else the rule handles: Save -> Saving, Create ->
25
+ # Creating, Add -> Adding, Continue -> Continuing.
26
+ SUBMIT_GERUNDS = {
27
+ "submit" => "Submitting",
28
+ "set" => "Setting",
29
+ "get" => "Getting",
30
+ "log" => "Logging",
31
+ "run" => "Running"
32
+ }.freeze
33
+
34
+ # Label + control + hint + error, wrapped consistently. Pass a block to supply
35
+ # a control the builder doesn't know how to make (a select, a file picker, two
36
+ # inputs side by side); otherwise it is built from `as:`.
37
+ #
38
+ # <%= form.field :email, "Email", required: true, as: :email_field %>
39
+ # <%= form.field :role, "Role" do %>
40
+ # <%= form.select :role, Role.all %>
41
+ # <% end %>
42
+ def field(method, label_text = nil, required: false, hint: nil, as: :text_field, **options, &block)
43
+ options = options.merge(required: true) if required && !block
44
+
45
+ errors = errors_for(method)
46
+ options = options.merge("aria-invalid" => "true") if errors.any? && !block
47
+
48
+ control = block ? @template.capture(&block) : public_send(as, method, options)
49
+
50
+ @template.content_tag(:div, class: field_classes, data: { field: "" }) do
51
+ @template.safe_join [
52
+ (field_label(method, label_text, required) if label_text),
53
+ control,
54
+ (@template.content_tag(:p, hint, class: "UnmagicHint") if hint),
55
+ (@template.content_tag(:p, errors.to_sentence, class: "UnmagicError") if errors.any?)
56
+ ].compact
57
+ end
58
+ end
59
+
60
+ def label(method, text = nil, options = {}, &block)
61
+ options = options.dup
62
+ options[:class] = @template.class_names("UnmagicLabel", options[:class])
63
+ super
64
+ end
65
+
66
+ # Lay the contained fields out in a row — a first-name / last-name pair above
67
+ # other stacked fields. With inline: true they render compact and auto-width,
68
+ # for a filter toolbar. Groups can't nest.
69
+ def group(inline: false, **options, &block)
70
+ @group = inline ? :inline : :row
71
+ content = @template.capture(&block)
72
+ @group = nil
73
+
74
+ classes = @template.class_names(inline ? "UnmagicFieldGroup--inline" : "UnmagicFieldGroup", options[:class])
75
+ @template.content_tag(:div, content, class: classes)
76
+ end
77
+
78
+ # The record's whole-object errors, read as a sentence. Attribute errors show
79
+ # under their own field; these have nowhere else to go.
80
+ def errors_summary
81
+ errors = errors_for(:base)
82
+ return if errors.none?
83
+
84
+ @template.content_tag(:div, errors.to_sentence, class: "UnmagicFormErrors", role: "alert")
85
+ end
86
+
87
+ # A checkbox with its label beside it, and an optional hint under that.
88
+ def check_box_field(method, label_text, hint: nil, **options)
89
+ @template.content_tag(:label, class: "UnmagicCheckField") do
90
+ text = [ @template.content_tag(:span, label_text, class: "UnmagicCheckField__label") ]
91
+ text << @template.content_tag(:span, hint, class: "UnmagicHint") if hint
92
+
93
+ @template.safe_join [
94
+ check_box(method, options),
95
+ @template.content_tag(:span, @template.safe_join(text), class: "UnmagicCheckField__text")
96
+ ]
97
+ end
98
+ end
99
+
100
+ # A vertical list of checkboxes from a collection — Rails' collection_check_boxes
101
+ # with the labelling and spacing baked in. inline: true flows them in a row.
102
+ def check_box_collection(method, collection, value_method, text_method, inline: false, **options)
103
+ body = collection_check_boxes(method, collection, value_method, text_method) do |check_box|
104
+ @template.content_tag(:label, class: "UnmagicCheckField") do
105
+ @template.safe_join [
106
+ check_box.check_box(options),
107
+ @template.content_tag(:span, check_box.text, class: "UnmagicCheckField__label")
108
+ ]
109
+ end
110
+ end
111
+
112
+ @template.content_tag(:div, body, class: inline ? "UnmagicCheckList--inline" : "UnmagicCheckList")
113
+ end
114
+
115
+ # A submit button that says what it is doing while it does it: Turbo swaps the
116
+ # label for the conjugated verb ("Save" -> "Saving…") for the length of the
117
+ # submit. Pass submitting: false to leave the label alone, or a string to
118
+ # choose it.
119
+ def submit(value = nil, options = {}, &block)
120
+ options = options.dup
121
+ variant = options.delete(:variant) || :primary
122
+ submitting = options.delete(:submitting)
123
+ value ||= "Save"
124
+
125
+ options[:type] = "submit"
126
+ options[:class] = @template.class_names(
127
+ Components.configuration.submit_class.call(@template, variant), options[:class]
128
+ )
129
+ options[:data] = with_submitting_text(options[:data], value, submitting)
130
+
131
+ # A block is the caller's own button content — an icon beside the label —
132
+ # while `value` stays the label the submitting text is conjugated from.
133
+ @template.content_tag(:button, options) { block ? @template.capture(&block) : value }
134
+ end
135
+
136
+ # The value to show for a field, whether the object is a model or something
137
+ # hash-ish (a JSON Schema instance, a params object). Reads what the user
138
+ # actually typed when the object tracks that, so a rejected cast still shows
139
+ # their input rather than nil.
140
+ def form_value_for(method)
141
+ if hash_value_object?
142
+ @object.key?(method.to_s) ? @object[method.to_s] : @object[method.to_sym]
143
+ elsif @object.respond_to?("#{method}_before_type_cast") && form_value_came_from_user?(method)
144
+ @object.public_send("#{method}_before_type_cast")
145
+ elsif @object.respond_to?(method)
146
+ @object.public_send(method)
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ def field_classes
153
+ case @group
154
+ when :row then "UnmagicField UnmagicField--in-row"
155
+ when :inline then "UnmagicField UnmagicField--inline"
156
+ else "UnmagicField"
157
+ end
158
+ end
159
+
160
+ def field_label(method, text, required)
161
+ return label(method, text) unless required
162
+
163
+ label(method) do
164
+ @template.safe_join [ text, " ", @template.content_tag(:span, "*", class: "UnmagicLabel__required") ]
165
+ end
166
+ end
167
+
168
+ def errors_for(method)
169
+ return [] unless @object.respond_to?(:errors)
170
+
171
+ @object.errors[method]
172
+ end
173
+
174
+ def hash_value_object?
175
+ @object.respond_to?(:key?) && @object.respond_to?(:[]) && @object.respond_to?(:to_h)
176
+ end
177
+
178
+ def form_value_came_from_user?(method)
179
+ came_from_user = "#{method}_came_from_user?"
180
+ !@object.respond_to?(came_from_user) || @object.public_send(came_from_user)
181
+ end
182
+
183
+ # data-turbo-submits-with, unless the caller opted out or set their own.
184
+ def with_submitting_text(data, value, submitting)
185
+ return data if submitting == false
186
+
187
+ text = submitting.is_a?(String) ? submitting : submitting_text(value)
188
+ return data unless text
189
+
190
+ data = (data || {}).dup
191
+ data[:turbo_submits_with] = text unless data.key?(:turbo_submits_with)
192
+ data
193
+ end
194
+
195
+ # "Save" -> "Saving…", "Add label" -> "Adding label…".
196
+ def submitting_text(value)
197
+ verb, *rest = value.to_s.split
198
+ return unless verb
199
+
200
+ gerund = SUBMIT_GERUNDS[verb.downcase] || "#{verb.sub(/e\z/i, "")}ing"
201
+ "#{[ gerund, *rest ].join(" ")}…"
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module Renderers
6
+ # The blank slate a table falls back to when its collection is empty.
7
+ module EmptyState
8
+ class << self
9
+ def default
10
+ method(:render).to_proc
11
+ end
12
+
13
+ # Extra options from `table.empty` are for a host's own renderer; the
14
+ # built-in one takes only a class.
15
+ def render(view, content, **options)
16
+ view.tag.div(content, class: view.class_names("UnmagicEmptyState", options[:class]))
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ module Renderers
6
+ # A prev/next pager over a Pagy object. Pagy is an optional dependency: the
7
+ # default resolver looks for the controller's @pagy and the renderer bails
8
+ # out unless the object it gets speaks the parts of Pagy's API it needs, so
9
+ # an app without Pagy simply never renders a pager.
10
+ module Pagination
11
+ class << self
12
+ def default
13
+ ->(view, pagy:, turbo_frame: nil) { render(view, pagy: pagy, turbo_frame: turbo_frame) }
14
+ end
15
+
16
+ def default_pagy_for
17
+ method(:resolve).to_proc
18
+ end
19
+
20
+ # A count-aware collection (search results reporting #found) can build its
21
+ # own pagy, which forces a lazy collection only on the render that shows
22
+ # it. Anything else falls back to whatever the action assigned.
23
+ def resolve(view, collection)
24
+ if defined?(::Pagy) && collection.respond_to?(:found) && view.respond_to?(:pagy, true)
25
+ view.send(:pagy, :offset, collection, count: collection.found, limit: collection.per_page).first
26
+ else
27
+ view.instance_variable_get(:@pagy)
28
+ end
29
+ end
30
+
31
+ def render(view, pagy:, turbo_frame: nil)
32
+ return unless pageable?(pagy)
33
+ return if pagy.previous.nil? && pagy.next.nil?
34
+
35
+ data = ({ turbo_frame: turbo_frame, turbo_action: "advance" } if turbo_frame)
36
+
37
+ view.tag.nav(class: "UnmagicPagination", "aria-label": "Pagination") do
38
+ view.safe_join [
39
+ link(view, pagy, :previous, "Previous", data),
40
+ link(view, pagy, :next, "Next", data)
41
+ ]
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def pageable?(pagy)
48
+ pagy.respond_to?(:previous) && pagy.respond_to?(:next) && pagy.respond_to?(:page_url)
49
+ end
50
+
51
+ def link(view, pagy, direction, label, data)
52
+ if pagy.public_send(direction)
53
+ view.link_to(label, pagy.page_url(direction), class: "UnmagicPagination__link", data: data)
54
+ else
55
+ view.tag.span(label, class: "UnmagicPagination__link", "aria-disabled": "true")
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ class Table
6
+ class Column
7
+ attr_reader :title, :attribute, :sort, :direction, :block, :width
8
+
9
+ def initialize(title:, attribute:, block:, sort: nil, direction: :asc, align: nil,
10
+ numeric: false, width: nil, **options)
11
+ @title = title
12
+ @attribute = attribute
13
+ @block = block
14
+ @sort = sort
15
+ @direction = direction.to_sym
16
+ @align = align
17
+ @numeric = numeric
18
+ @width = width
19
+ @classes = options[:class]
20
+ end
21
+
22
+ def right_aligned? = @align == :right || @numeric
23
+
24
+ def centered? = @align == :center
25
+
26
+ def header_classes
27
+ alignment
28
+ end
29
+
30
+ def cell_classes
31
+ [ alignment, ("is-numeric" if @numeric), @classes ].compact.presence&.join(" ")
32
+ end
33
+
34
+ private
35
+
36
+ def alignment
37
+ if right_aligned?
38
+ "is-right"
39
+ elsif centered?
40
+ "is-center"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Components
5
+ # Collects the column definitions a `table_for` block declares, then renders
6
+ # them as a TableTag. See ActionViewHelpers#table_for for the public API.
7
+ class Table
8
+ SKELETON_ROWS = 8
9
+
10
+ # A pinned column sizes its bar as a fraction of itself; a content-sized one
11
+ # has no width to take a fraction of, so it falls back to a fixed bar.
12
+ SKELETON_WIDTHS = %w[8rem 5rem 7rem 4rem 6rem].freeze
13
+ SKELETON_FRACTIONS = %w[75% 50% 66% 40% 80%].freeze
14
+
15
+ def initialize(view, collection, headers: true, sorted_by: nil, sort_direction: nil,
16
+ sort_url: nil, row_class: nil, rows_id: nil, row_id: nil, **attributes)
17
+ @view = view
18
+ @collection = collection
19
+ @headers = headers
20
+ @attributes = attributes
21
+ @rows_id = rows_id
22
+ @row_id = row_id
23
+ @sort_url = sort_url
24
+ @row_class = row_class
25
+ @sorted_by = (sorted_by || view.params[:sort]).presence&.to_s
26
+ @sort_direction = (sort_direction || view.params[:direction]).presence&.to_sym
27
+ @columns = []
28
+ end
29
+
30
+ def column(title = nil, attribute = nil, **options, &block)
31
+ @columns << Column.new(title: title, attribute: attribute, block: block, **options)
32
+ nil
33
+ end
34
+
35
+ # A full-width companion row rendered under a record's own. The block runs
36
+ # per record inside one colspan cell; rendering nothing skips the row, so
37
+ # other records stay single-line. The record row drops its bottom border so
38
+ # the pair reads as one row.
39
+ def details(&block)
40
+ @details_block = block
41
+ nil
42
+ end
43
+
44
+ # The blank slate for a genuinely empty dataset (no search/filter applied) —
45
+ # typically a prompt to create the first record.
46
+ # Any extra options are handed to the configured empty_state seam, so an
47
+ # app whose blank slate takes more than text (an icon, say) can ask for it
48
+ # per table.
49
+ def empty(text = nil, **options, &block)
50
+ @empty_text = text
51
+ @empty_block = block
52
+ @empty_options = options
53
+ nil
54
+ end
55
+
56
+ # The blank slate shown when a search/filter matched nothing, so a
57
+ # filtered-to-zero table doesn't read as "create your first one". Only
58
+ # reached when the collection reports filtered?.
59
+ def no_results(text = nil, **options, &block)
60
+ @no_results_text = text
61
+ @no_results_block = block
62
+ @no_results_options = options
63
+ nil
64
+ end
65
+
66
+ def render(pagy: nil, turbo_frame: nil)
67
+ @turbo_frame = turbo_frame
68
+
69
+ if @collection.blank?
70
+ empty_slate
71
+ else
72
+ safe_join [ table, pagination(pagy) ].compact
73
+ end
74
+ end
75
+
76
+ # The same header cells and the same column widths the loaded table renders —
77
+ # only the body is stand-in bars — so the frame swaps rows in without
78
+ # shifting the columns.
79
+ def skeleton
80
+ view.table_tag (skeleton_header_cells if @headers), skeleton_rows,
81
+ **table_attributes, widths: widths, caption: "Loading…", role: "status", "aria-busy": "true"
82
+ end
83
+
84
+ # The <tr> this table would render for one record, on its own — what a
85
+ # broadcast upserts into the tbody. The companion details row is left out:
86
+ # a stream action carries one element, and the pair is a page-render concern.
87
+ def row(record)
88
+ TableTag.new(view, nil, []).render_row(row_hash(record))
89
+ end
90
+
91
+ private
92
+
93
+ attr_reader :view
94
+
95
+ delegate :tag, :safe_join, :link_to, :class_names, to: :view, private: true
96
+
97
+ def table
98
+ view.table_tag((header_cells if @headers), row_data, widths: widths, rows_id: @rows_id, **table_attributes)
99
+ end
100
+
101
+ # Anything the caller put on table_for beyond the builder's own options rides
102
+ # on the <table>, so a view can space, identify or annotate it without
103
+ # wrapping it in a div.
104
+ def table_attributes = @attributes
105
+
106
+ # A record collection's columns/rows recast as the plain cells table_tag
107
+ # renders: the header carries its sort link and aria-sort, each row its dom
108
+ # id and row_class.
109
+ def header_cells
110
+ @columns.map do |column|
111
+ { content: header_label(column), class: column.header_classes, "aria-sort": aria_sort(column) }
112
+ end
113
+ end
114
+
115
+ def widths = @columns.map(&:width)
116
+
117
+ def row_data
118
+ @collection.flat_map do |record|
119
+ details = details_content(record)
120
+ row = row_hash(record, has_details: details.present?)
121
+ details.present? ? [ row, details_row(record, details) ] : [ row ]
122
+ end
123
+ end
124
+
125
+ def row_hash(record, has_details: false)
126
+ {
127
+ cells: @columns.map { |column| { content: cell_content(column, record), class: column.cell_classes } },
128
+ id: row_id(record),
129
+ class: class_names(@row_class&.call(record), "has-details" => has_details).presence
130
+ }
131
+ end
132
+
133
+ def details_content(record)
134
+ view.capture(record, &@details_block) if @details_block
135
+ end
136
+
137
+ def details_row(record, content)
138
+ {
139
+ cells: [ { content: content, colspan: @columns.size } ],
140
+ class: class_names("UnmagicTable__details", @row_class&.call(record))
141
+ }
142
+ end
143
+
144
+ def header_label(column)
145
+ column.sort ? sort_link(column) : column.title
146
+ end
147
+
148
+ def aria_sort(column)
149
+ if column.sort && sorted_by?(column)
150
+ sorted_direction(column) == :asc ? "ascending" : "descending"
151
+ end
152
+ end
153
+
154
+ def sort_link(column)
155
+ next_direction = sorted_by?(column) ? opposite(sorted_direction(column)) : column.direction
156
+
157
+ link_to sort_url(column.sort, next_direction), class: "UnmagicTable__sort", data: frame_data do
158
+ safe_join [ column.title, sort_arrow(column) ].compact, " "
159
+ end
160
+ end
161
+
162
+ def sort_arrow(column)
163
+ tag.span(sorted_direction(column) == :asc ? "↑" : "↓", "aria-hidden": "true") if sorted_by?(column)
164
+ end
165
+
166
+ def sorted_by?(column) = @sorted_by == column.sort.to_s
167
+
168
+ def sorted_direction(column)
169
+ if sorted_by?(column)
170
+ @sort_direction || column.direction
171
+ else
172
+ column.direction
173
+ end
174
+ end
175
+
176
+ def opposite(direction) = direction == :asc ? :desc : :asc
177
+
178
+ def sort_url(key, direction)
179
+ if @sort_url
180
+ @sort_url.call(key, direction)
181
+ else
182
+ query = view.request.query_parameters.except("page").merge("sort" => key, "direction" => direction)
183
+ "#{view.request.path}?#{query.to_query}"
184
+ end
185
+ end
186
+
187
+ # dom_id by default. A live table over a mixed collection needs to say
188
+ # otherwise: dom_id names the record's own class, so an STI table's ids carry
189
+ # different prefixes and sort by type rather than by id — which is the order
190
+ # the upsert action inserts on.
191
+ def row_id(record)
192
+ if @row_id
193
+ @row_id.call(record)
194
+ elsif record.respond_to?(:to_key)
195
+ view.dom_id(record)
196
+ end
197
+ end
198
+
199
+ def cell_content(column, record)
200
+ if column.block
201
+ view.capture(record, &column.block)
202
+ elsif column.attribute
203
+ record.public_send(column.attribute)
204
+ end
205
+ end
206
+
207
+ def empty_slate
208
+ content, options = filtered? ? no_results_slate : empty_dataset_slate
209
+ Components.configuration.empty_state.call(view, content, **options)
210
+ end
211
+
212
+ # A search result set knows whether a query/filter was applied; a plain
213
+ # relation or array doesn't, so it's treated as an unfiltered dataset.
214
+ def filtered? = @collection.respond_to?(:filtered?) && @collection.filtered?
215
+
216
+ def empty_dataset_slate
217
+ content = @empty_block ? view.capture(&@empty_block) : (@empty_text || "Nothing here yet.")
218
+ [ content, @empty_options || {} ]
219
+ end
220
+
221
+ def no_results_slate
222
+ content = @no_results_block ? view.capture(&@no_results_block) : (@no_results_text || "No matching results.")
223
+ [ content, @no_results_options || {} ]
224
+ end
225
+
226
+ def pagination(pagy)
227
+ return unless pagy
228
+
229
+ Components.configuration.pagination.call(view, pagy: pagy, turbo_frame: @turbo_frame)
230
+ end
231
+
232
+ def frame_data
233
+ { turbo_frame: @turbo_frame, turbo_action: "advance" } if @turbo_frame
234
+ end
235
+
236
+ def skeleton_header_cells
237
+ @columns.map { |column| { content: column.title, class: column.header_classes } }
238
+ end
239
+
240
+ def skeleton_rows
241
+ Array.new(SKELETON_ROWS) do |index|
242
+ @columns.map.with_index { |column, column_index| skeleton_bar(column, index + column_index) }
243
+ end
244
+ end
245
+
246
+ def skeleton_bar(column, seed)
247
+ scale = column.width.present? ? SKELETON_FRACTIONS : SKELETON_WIDTHS
248
+ classes = class_names("UnmagicSkeleton", "is-right" => column.right_aligned?)
249
+
250
+ tag.div class: classes, style: "width: #{scale[seed % scale.size]}"
251
+ end
252
+ end
253
+ end
254
+ end