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,160 @@
|
|
|
1
|
+
require 'bigdecimal'
|
|
2
|
+
require 'active_support'
|
|
3
|
+
require 'active_support/core_ext/object/blank'
|
|
4
|
+
require 'active_support/core_ext/array/wrap'
|
|
5
|
+
require 'active_support/core_ext/string/inflections'
|
|
6
|
+
require 'active_support/core_ext/date/calculations'
|
|
7
|
+
require 'active_support/core_ext/date_and_time/calculations'
|
|
8
|
+
require 'active_support/core_ext/integer/time'
|
|
9
|
+
|
|
10
|
+
module CrudComponents
|
|
11
|
+
# The query params the gem owns (filters are top-level params named after the
|
|
12
|
+
# field, so a field can't share these names). Declaring such an attribute
|
|
13
|
+
# raises in the Builder rather than silently colliding with sort/pagination.
|
|
14
|
+
RESERVED_PARAMS = %w[q sort dir page per cols].freeze
|
|
15
|
+
|
|
16
|
+
# Sentinel filter value meaning "the column is NULL" (boolean/enum filters on
|
|
17
|
+
# nullable columns offer it as a "not set" choice). Improbable as a real
|
|
18
|
+
# value, so it never collides with a genuine enum key or boolean string.
|
|
19
|
+
NULL_FILTER_VALUE = '__null__'.freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
require_relative 'crud_components/version'
|
|
23
|
+
require_relative 'crud_components/errors'
|
|
24
|
+
require_relative 'crud_components/config'
|
|
25
|
+
require_relative 'crud_components/permission_context'
|
|
26
|
+
require_relative 'crud_components/like_spec'
|
|
27
|
+
require_relative 'crud_components/where_like'
|
|
28
|
+
require_relative 'crud_components/fields/base'
|
|
29
|
+
require_relative 'crud_components/fields/string_field'
|
|
30
|
+
require_relative 'crud_components/fields/text_field'
|
|
31
|
+
require_relative 'crud_components/fields/numeric_field'
|
|
32
|
+
require_relative 'crud_components/fields/date_field'
|
|
33
|
+
require_relative 'crud_components/fields/boolean_field'
|
|
34
|
+
require_relative 'crud_components/fields/enum_field'
|
|
35
|
+
require_relative 'crud_components/fields/json_field'
|
|
36
|
+
require_relative 'crud_components/fields/attachment_field'
|
|
37
|
+
require_relative 'crud_components/fields/belongs_to_field'
|
|
38
|
+
require_relative 'crud_components/fields/has_many_field'
|
|
39
|
+
require_relative 'crud_components/fields/computed_field'
|
|
40
|
+
require_relative 'crud_components/fields/path_field'
|
|
41
|
+
require_relative 'crud_components/fields/dynamic_field'
|
|
42
|
+
require_relative 'crud_components/dynamic_column'
|
|
43
|
+
require_relative 'crud_components/action'
|
|
44
|
+
require_relative 'crud_components/fieldset'
|
|
45
|
+
require_relative 'crud_components/builder'
|
|
46
|
+
require_relative 'crud_components/structure'
|
|
47
|
+
require_relative 'crud_components/model'
|
|
48
|
+
require_relative 'crud_components/query'
|
|
49
|
+
|
|
50
|
+
module CrudComponents
|
|
51
|
+
class << self
|
|
52
|
+
def config
|
|
53
|
+
@config ||= Config.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def configure
|
|
57
|
+
yield config
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def structure_for(model)
|
|
61
|
+
Structure.for(model)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Safe case-insensitive contains-match on any relation, using the same
|
|
65
|
+
# escaped-ILIKE machinery as `filter like:` / `search_in` — so you never
|
|
66
|
+
# hand-write `where("col LIKE ?", "%#{value}%")` (which forgets to escape the
|
|
67
|
+
# user's `%`/`_`). The scope handed to a filter/search block already carries
|
|
68
|
+
# `#where_like`; this module function is for the relations you build yourself,
|
|
69
|
+
# e.g. a subquery on another model:
|
|
70
|
+
#
|
|
71
|
+
# filter: ->(scope, value) {
|
|
72
|
+
# ids = CrudComponents.where_like(PropertyValue.where(definition: prop), :value, value)
|
|
73
|
+
# scope.where(id: ids.select(:subject_id))
|
|
74
|
+
# }
|
|
75
|
+
#
|
|
76
|
+
# `spec` is a {LikeSpec} spec (`:value`, `%i[a b]`, `{ assoc: :col }`).
|
|
77
|
+
def where_like(relation, spec, value)
|
|
78
|
+
LikeSpec.apply(relation, spec, value)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The column-picker selection from a request's params: the ordered list of
|
|
82
|
+
# column names the user ticked, or nil when the picker wasn't submitted.
|
|
83
|
+
# Honors `param_prefix:` (match it to the picker's). Persist it however you
|
|
84
|
+
# like, then feed it back via `picked_columns:`.
|
|
85
|
+
#
|
|
86
|
+
# cols = CrudComponents.selected_columns(params)
|
|
87
|
+
# current_user.update!(book_columns: cols) if cols
|
|
88
|
+
#
|
|
89
|
+
# A block runs only when a selection was submitted, and receives the list:
|
|
90
|
+
#
|
|
91
|
+
# CrudComponents.selected_columns(params) { |cols| current_user.update!(book_columns: cols) }
|
|
92
|
+
#
|
|
93
|
+
# Accepts both the no-JS `cols[]=a&cols[]=b` array and the comma-joined
|
|
94
|
+
# `cols=a,b` the crud-columns controller submits.
|
|
95
|
+
def selected_columns(params, param_prefix: nil)
|
|
96
|
+
key = param_prefix ? "#{param_prefix}_cols" : 'cols'
|
|
97
|
+
raw = params[key] || params[key.to_sym]
|
|
98
|
+
list = raw.is_a?(Array) ? raw : raw.is_a?(String) ? raw.split(',') : nil
|
|
99
|
+
names = list&.map { |n| n.to_s.strip }&.reject(&:blank?)
|
|
100
|
+
names = nil if names.nil? || names.empty?
|
|
101
|
+
yield names if block_given? && names
|
|
102
|
+
names
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Whether non-image attachment previews (e.g. a PDF's first page) can
|
|
106
|
+
# actually be generated here. Beyond a previewer binary (poppler/ffmpeg,
|
|
107
|
+
# which `previewable?` already checks), processing needs `image_processing`
|
|
108
|
+
# plus the configured variant backend's gem (ruby-vips or mini_magick).
|
|
109
|
+
# When any is missing, the renderer shows an icon + filename rather than a
|
|
110
|
+
# preview that would 500 at processing time.
|
|
111
|
+
# @api private
|
|
112
|
+
def previews_available?
|
|
113
|
+
return @previews_available if defined?(@previews_available)
|
|
114
|
+
|
|
115
|
+
@previews_available = begin
|
|
116
|
+
require 'image_processing'
|
|
117
|
+
processor = defined?(ActiveStorage) ? ActiveStorage.variant_processor : :vips
|
|
118
|
+
require(processor.to_s == 'mini_magick' ? 'mini_magick' : 'vips')
|
|
119
|
+
true
|
|
120
|
+
rescue LoadError
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The strong-params permit list for a model's derived form — the same
|
|
126
|
+
# field metadata the form renders from, so the two can't drift. Use in a
|
|
127
|
+
# controller:
|
|
128
|
+
# params.require(:book)
|
|
129
|
+
# .permit(*CrudComponents.permitted_attributes(Book, action: :update,
|
|
130
|
+
# ability: current_ability))
|
|
131
|
+
def permitted_attributes(model, action: :update, ability: nil)
|
|
132
|
+
Structure.for(model).permitted_params(action, PermissionContext.new(ability))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Resolve a bulk-action selection from request params into a relation. The
|
|
136
|
+
# row checkboxes submit `selected[]=<identify_by>` (a slug array; a comma
|
|
137
|
+
# string is also accepted).
|
|
138
|
+
#
|
|
139
|
+
# Pass the same authorized scope you'd render — selection narrows *within*
|
|
140
|
+
# it, so a tampered slug can never reach a row outside it:
|
|
141
|
+
# CrudComponents.selected(@books, params).destroy_all # @books already scoped
|
|
142
|
+
# A model class also works when you don't scope (acts on the whole table):
|
|
143
|
+
# CrudComponents.selected(Book, params)
|
|
144
|
+
def selected(scope, params, param: :selected)
|
|
145
|
+
model = scope.respond_to?(:klass) ? scope.klass : scope
|
|
146
|
+
values = Array(params[param]).flat_map { |v| v.to_s.split(',') }.map(&:strip).reject(&:blank?)
|
|
147
|
+
scope.where(Structure.for(model).identify_by => values)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The gem's stylesheet (the column-picker float styles), read once from the
|
|
151
|
+
# packaged file. Backs the `crud_components_styles` helper, which inlines it;
|
|
152
|
+
# the same file is also linkable via `stylesheet_link_tag "crud_components"`
|
|
153
|
+
# on hosts whose asset pipeline serves engine assets.
|
|
154
|
+
def bundled_css
|
|
155
|
+
@bundled_css ||= File.read(File.expand_path('../app/assets/stylesheets/crud_components.css', __dir__))
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
require_relative 'crud_components/engine' if defined?(Rails::Engine)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Generators
|
|
3
|
+
class InstallGenerator < Rails::Generators::Base
|
|
4
|
+
source_root File.expand_path('templates', __dir__)
|
|
5
|
+
|
|
6
|
+
desc 'Creates the CrudComponents initializer and copies the optional ' \
|
|
7
|
+
'Stimulus controllers (filter niceties + habtm token/chip picker).'
|
|
8
|
+
|
|
9
|
+
def create_initializer
|
|
10
|
+
copy_file 'initializer.rb', 'config/initializers/crud_components.rb'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def copy_stimulus_controllers
|
|
14
|
+
copy_file 'crud_filter_controller.js', 'app/javascript/controllers/crud_filter_controller.js'
|
|
15
|
+
copy_file 'crud_multiselect_controller.js', 'app/javascript/controllers/crud_multiselect_controller.js'
|
|
16
|
+
copy_file 'crud_select_controller.js', 'app/javascript/controllers/crud_select_controller.js'
|
|
17
|
+
copy_file 'crud_columns_controller.js', 'app/javascript/controllers/crud_columns_controller.js'
|
|
18
|
+
say <<~NOTE
|
|
19
|
+
|
|
20
|
+
Load the gem's stylesheet (it makes the column picker float; everything
|
|
21
|
+
else is plain Bootstrap). Either is fine:
|
|
22
|
+
- layout <head>: <%= crud_components_styles %> (works under any
|
|
23
|
+
asset setup — cssbundling/sass, importmap, sprockets, propshaft)
|
|
24
|
+
- or, asset-pipeline hosts: <%= stylesheet_link_tag "crud_components" %>
|
|
25
|
+
|
|
26
|
+
The Stimulus controllers are optional — everything works without them.
|
|
27
|
+
- crud-filter: strip empty params on submit + auto-submit inline selects.
|
|
28
|
+
- crud-multiselect: turn a habtm `<select multiple>` into a chips + add picker.
|
|
29
|
+
- crud-select: "select all" / per-group checkboxes + a live count for bulk actions.
|
|
30
|
+
- crud-columns: drag-to-reorder the column picker (ticking columns works without it).
|
|
31
|
+
Register them (stimulus-rails with importmap does this automatically via
|
|
32
|
+
controllers/index.js; otherwise):
|
|
33
|
+
|
|
34
|
+
application.register("crud-filter", CrudFilterController)
|
|
35
|
+
application.register("crud-multiselect", CrudMultiselectController)
|
|
36
|
+
application.register("crud-select", CrudSelectController)
|
|
37
|
+
application.register("crud-columns", CrudColumnsController)
|
|
38
|
+
|
|
39
|
+
NOTE
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Optional progressive enhancement for the CrudComponents column picker:
|
|
4
|
+
// - drag the rows to reorder columns
|
|
5
|
+
// - on submit, collapse the `cols[]=a&cols[]=b` array into a single `cols=a,b`
|
|
6
|
+
// for a prettier, shareable URL (the server reads both forms)
|
|
7
|
+
// Without this controller the picker still works — ticked checkboxes submit
|
|
8
|
+
// `cols[]` in their listed order; you just can't reorder by dragging, and the
|
|
9
|
+
// URL keeps the repeated `cols[]` form. Submission order = DOM order of the <li>s.
|
|
10
|
+
export default class extends Controller {
|
|
11
|
+
static targets = ["list", "item"]
|
|
12
|
+
|
|
13
|
+
// Rewrite the checked cols[] boxes into one comma-joined hidden field per param
|
|
14
|
+
// group (handles param_prefix). Mirrors crud-filter#clean: mutate during the
|
|
15
|
+
// submit event so the browser serializes the rewritten form, then restore
|
|
16
|
+
// shortly after in case the navigation was cancelled.
|
|
17
|
+
clean(event) {
|
|
18
|
+
const form = event.target
|
|
19
|
+
this.injected = []
|
|
20
|
+
this.disabledBoxes = []
|
|
21
|
+
const groups = new Map() // base name (without []) -> [values] in DOM order
|
|
22
|
+
|
|
23
|
+
for (const box of form.querySelectorAll('input[type=checkbox][name$="[]"]')) {
|
|
24
|
+
const base = box.name.slice(0, -2)
|
|
25
|
+
this.disabledBoxes.push(box)
|
|
26
|
+
box.disabled = true // drop the array form from this submit
|
|
27
|
+
if (box.checked) (groups.get(base) || groups.set(base, []).get(base)).push(box.value)
|
|
28
|
+
}
|
|
29
|
+
for (const [base, values] of groups) {
|
|
30
|
+
const hidden = document.createElement("input")
|
|
31
|
+
hidden.type = "hidden"
|
|
32
|
+
hidden.name = base
|
|
33
|
+
hidden.value = values.join(",")
|
|
34
|
+
form.appendChild(hidden)
|
|
35
|
+
this.injected.push(hidden)
|
|
36
|
+
}
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
this.injected.forEach((n) => n.remove())
|
|
39
|
+
this.disabledBoxes.forEach((b) => (b.disabled = false))
|
|
40
|
+
this.injected = this.disabledBoxes = []
|
|
41
|
+
}, 500)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
connect() {
|
|
45
|
+
this.dragging = null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
itemTargetConnected(item) {
|
|
49
|
+
item.addEventListener("dragstart", (e) => this.start(e, item))
|
|
50
|
+
item.addEventListener("dragover", (e) => this.over(e, item))
|
|
51
|
+
item.addEventListener("dragend", () => this.end(item))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
start(event, item) {
|
|
55
|
+
this.dragging = item
|
|
56
|
+
item.classList.add("is-dragging")
|
|
57
|
+
event.dataTransfer.effectAllowed = "move"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
over(event, item) {
|
|
61
|
+
event.preventDefault()
|
|
62
|
+
if (!this.dragging || this.dragging === item) return
|
|
63
|
+
const rect = item.getBoundingClientRect()
|
|
64
|
+
const after = event.clientY - rect.top > rect.height / 2
|
|
65
|
+
item.parentNode.insertBefore(this.dragging, after ? item.nextSibling : item)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
end(item) {
|
|
69
|
+
item.classList.remove("is-dragging")
|
|
70
|
+
this.dragging = null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A checkbox toggled with nothing else to do — kept so the markup's
|
|
74
|
+
// data-action has a handler and future enhancements have a hook.
|
|
75
|
+
toggle() {}
|
|
76
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Optional progressive enhancement for CrudComponents filter forms:
|
|
4
|
+
// - strips empty inputs on submit (clean, shareable URLs)
|
|
5
|
+
// - auto-submits selects in the inline filter row (wired via data-action)
|
|
6
|
+
// Everything works without this controller — plain GET forms.
|
|
7
|
+
export default class extends Controller {
|
|
8
|
+
static targets = ["form"]
|
|
9
|
+
|
|
10
|
+
clean() {
|
|
11
|
+
this.cleaned = []
|
|
12
|
+
for (const element of this.form.elements) {
|
|
13
|
+
if (element.name && element.value === "" && !element.disabled) {
|
|
14
|
+
element.disabled = true
|
|
15
|
+
this.cleaned.push(element)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// Re-enable shortly after, in case the navigation was cancelled.
|
|
19
|
+
setTimeout(() => {
|
|
20
|
+
this.cleaned.forEach((element) => (element.disabled = false))
|
|
21
|
+
this.cleaned = []
|
|
22
|
+
}, 500)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
submit() {
|
|
26
|
+
this.form.requestSubmit()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get form() {
|
|
30
|
+
return this.hasFormTarget ? this.formTarget : this.element
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Optional progressive enhancement for habtm/has_many form fields
|
|
4
|
+
// (data-controller="crud-multiselect"): replaces a `<select multiple>` with a
|
|
5
|
+
// chips-list (each removable with ×) + an "add" dropdown. The select stays in
|
|
6
|
+
// the DOM as the hidden source of truth, so the form submits identically with
|
|
7
|
+
// or without JavaScript.
|
|
8
|
+
//
|
|
9
|
+
// Good for sets up to a few hundred (all options live client-side). For very
|
|
10
|
+
// large sets, render an autocomplete against your own endpoint instead — see
|
|
11
|
+
// the docs (Forms / Extending).
|
|
12
|
+
export default class extends Controller {
|
|
13
|
+
connect() {
|
|
14
|
+
this.select = this.element.matches("select[multiple]")
|
|
15
|
+
? this.element
|
|
16
|
+
: this.element.querySelector("select[multiple]")
|
|
17
|
+
if (!this.select) return
|
|
18
|
+
|
|
19
|
+
this.select.style.display = "none"
|
|
20
|
+
this.chips = document.createElement("div")
|
|
21
|
+
this.chips.className = "d-flex flex-wrap gap-1 mb-1"
|
|
22
|
+
this.adder = document.createElement("select")
|
|
23
|
+
this.adder.className = "form-select"
|
|
24
|
+
this.adder.addEventListener("change", () => this.toggle(this.adder.value, true))
|
|
25
|
+
this.select.insertAdjacentElement("beforebegin", this.chips)
|
|
26
|
+
this.select.insertAdjacentElement("beforebegin", this.adder)
|
|
27
|
+
this.render()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get options() {
|
|
31
|
+
return Array.from(this.select.options)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
render() {
|
|
35
|
+
this.chips.replaceChildren(
|
|
36
|
+
...this.options.filter((o) => o.selected).map((o) => this.chip(o))
|
|
37
|
+
)
|
|
38
|
+
this.adder.replaceChildren(this.option("", "+ add…"))
|
|
39
|
+
this.options
|
|
40
|
+
.filter((o) => !o.selected)
|
|
41
|
+
.forEach((o) => this.adder.appendChild(this.option(o.value, o.text)))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
chip(option) {
|
|
45
|
+
const chip = document.createElement("span")
|
|
46
|
+
chip.className = "badge text-bg-primary d-inline-flex align-items-center gap-1"
|
|
47
|
+
chip.textContent = option.text
|
|
48
|
+
const close = document.createElement("button")
|
|
49
|
+
close.type = "button"
|
|
50
|
+
close.className = "btn-close btn-close-white"
|
|
51
|
+
close.style.fontSize = ".6rem"
|
|
52
|
+
close.addEventListener("click", () => this.toggle(option.value, false))
|
|
53
|
+
chip.appendChild(close)
|
|
54
|
+
return chip
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
option(value, text) {
|
|
58
|
+
const opt = document.createElement("option")
|
|
59
|
+
opt.value = value
|
|
60
|
+
opt.text = text
|
|
61
|
+
return opt
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
toggle(value, selected) {
|
|
65
|
+
const option = this.options.find((o) => o.value === value)
|
|
66
|
+
if (!option) return
|
|
67
|
+
option.selected = selected
|
|
68
|
+
this.render()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Optional progressive enhancement for selectable collections: a "select all"
|
|
4
|
+
// (visible rows) and per-group master checkbox, plus a live "N selected" count.
|
|
5
|
+
// The row checkboxes submit selected[] without this — it just spares the
|
|
6
|
+
// clicking. The bulk-action buttons post the form regardless.
|
|
7
|
+
export default class extends Controller {
|
|
8
|
+
static targets = ["row", "all", "group", "count", "button"]
|
|
9
|
+
|
|
10
|
+
connect() { this.update() }
|
|
11
|
+
|
|
12
|
+
toggleAll() {
|
|
13
|
+
this.rowTargets.forEach((row) => (row.checked = this.allTarget.checked))
|
|
14
|
+
this.update()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
toggleGroup(event) {
|
|
18
|
+
const key = event.target.dataset.group
|
|
19
|
+
this.rowTargets.forEach((row) => {
|
|
20
|
+
if (row.dataset.group === key) row.checked = event.target.checked
|
|
21
|
+
})
|
|
22
|
+
this.update()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
update() {
|
|
26
|
+
const count = this.rowTargets.filter((row) => row.checked).length
|
|
27
|
+
if (this.hasAllTarget) {
|
|
28
|
+
this.allTarget.checked = count > 0 && count === this.rowTargets.length
|
|
29
|
+
this.allTarget.indeterminate = count > 0 && count < this.rowTargets.length
|
|
30
|
+
}
|
|
31
|
+
if (this.hasCountTarget) this.countTarget.textContent = `${count} selected`
|
|
32
|
+
// disable the bulk-action buttons while nothing is selected
|
|
33
|
+
this.buttonTargets.forEach((button) => (button.disabled = count === 0))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# CrudComponents — everything has a working default; uncomment to deviate.
|
|
2
|
+
CrudComponents.configure do |config|
|
|
3
|
+
# belongs_to filter selects switch to a text input over the target's
|
|
4
|
+
# search_in beyond this many records:
|
|
5
|
+
# config.select_limit = 250
|
|
6
|
+
|
|
7
|
+
# Grouped collections (`group_by:`) open all groups below this row count, the
|
|
8
|
+
# first only above it:
|
|
9
|
+
# config.group_collapse_threshold = 50
|
|
10
|
+
|
|
11
|
+
# Icon names (no library prefix — paired with css.icon_prefix). Action glyphs
|
|
12
|
+
# are a name=>icon map (nil = no icon); file glyphs map an extension to a full
|
|
13
|
+
# icon name, with a fallback. Full key lists: Config::DEFAULT_ACTION_ICONS /
|
|
14
|
+
# Config::DEFAULT_FILE_ICONS.
|
|
15
|
+
# config.action_icons[:destroy] = 'trash-fill'
|
|
16
|
+
# config.file_icons['zip'] = 'file-earmark-zip'
|
|
17
|
+
# config.file_fallback_icon = 'file-earmark-text'
|
|
18
|
+
|
|
19
|
+
# Per-model icon (column-picker groups, association links, path-column cells),
|
|
20
|
+
# guessed from the model name unless a model declares `icon '…'`. Add your own
|
|
21
|
+
# mapping (key = singular underscored model name) or badge every model with a
|
|
22
|
+
# fallback. Full key list: Config::DEFAULT_MODEL_ICONS.
|
|
23
|
+
# config.model_icons['widget'] = 'box-seam'
|
|
24
|
+
# config.model_fallback_icon = 'box' # default nil = no icon when unmapped
|
|
25
|
+
|
|
26
|
+
# CSS class map (Bootstrap 5 defaults). The full key list:
|
|
27
|
+
# CrudComponents::Config::DEFAULT_CSS
|
|
28
|
+
# config.css.table = 'table align-middle'
|
|
29
|
+
# config.css.thead = ''
|
|
30
|
+
# config.css.filter_row = 'crud-filter-row'
|
|
31
|
+
# config.css.sort_link = 'text-reset text-decoration-none'
|
|
32
|
+
# config.css.actions_cell = 'text-end'
|
|
33
|
+
# config.css.button_group = 'btn-group btn-group-sm'
|
|
34
|
+
# config.css.button = 'btn btn-sm btn-outline-secondary'
|
|
35
|
+
# config.css.button_primary = 'btn btn-sm btn-primary'
|
|
36
|
+
# config.css.button_danger = 'btn btn-sm btn-outline-danger'
|
|
37
|
+
# config.css.pagination = 'pagination pagination-sm' # footer pager (paginated relations)
|
|
38
|
+
# config.css.badge = 'badge text-bg-secondary'
|
|
39
|
+
# config.css.badge_muted = 'badge text-bg-light'
|
|
40
|
+
# config.css.input = 'form-control'
|
|
41
|
+
# config.css.input_sm = 'form-control form-control-sm'
|
|
42
|
+
# # named *_input to avoid OrderedOptions#select (Hash#select) collisions
|
|
43
|
+
# config.css.select_input = 'form-select'
|
|
44
|
+
# config.css.select_input_sm = 'form-select form-select-sm'
|
|
45
|
+
# config.css.form_label = 'form-label'
|
|
46
|
+
# config.css.filter_grid = 'row row-cols-1 g-2'
|
|
47
|
+
# config.css.input_group = 'input-group flex-nowrap'
|
|
48
|
+
# config.css.boolean_true = 'text-success'
|
|
49
|
+
# config.css.boolean_false = 'text-danger'
|
|
50
|
+
# config.css.muted = 'text-muted'
|
|
51
|
+
# icon font base + name prefix; 'fa fa-' for Font Awesome (icon names differ)
|
|
52
|
+
# config.css.icon_prefix = 'bi bi-'
|
|
53
|
+
# config.css.dl = 'row'
|
|
54
|
+
# config.css.dt = 'col-sm-3'
|
|
55
|
+
# config.css.dd = 'col-sm-9'
|
|
56
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
module Generators
|
|
3
|
+
class ViewsGenerator < Rails::Generators::Base
|
|
4
|
+
source_root File.expand_path('../../../../app/views/crud_components', __dir__)
|
|
5
|
+
|
|
6
|
+
desc 'Copies all CrudComponents partials into your app for editing — ' \
|
|
7
|
+
'a file at the same path wins over the gem version.'
|
|
8
|
+
|
|
9
|
+
def copy_views
|
|
10
|
+
directory '.', 'app/views/crud_components'
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|