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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/Gemfile +23 -0
  4. data/LICENSE +21 -0
  5. data/README.md +511 -0
  6. data/RELEASING.md +44 -0
  7. data/Rakefile +12 -0
  8. data/app/assets/stylesheets/crud_components.css +35 -0
  9. data/app/views/crud_components/_action_button.html.erb +11 -0
  10. data/app/views/crud_components/_actions.html.erb +12 -0
  11. data/app/views/crud_components/_column_header.html.erb +24 -0
  12. data/app/views/crud_components/_column_picker.html.erb +66 -0
  13. data/app/views/crud_components/_filter.html.erb +34 -0
  14. data/app/views/crud_components/_form.html.erb +30 -0
  15. data/app/views/crud_components/_pager.html.erb +41 -0
  16. data/app/views/crud_components/_record.html.erb +15 -0
  17. data/app/views/crud_components/_row.html.erb +26 -0
  18. data/app/views/crud_components/_selection_action.html.erb +14 -0
  19. data/app/views/crud_components/_sort_link.html.erb +17 -0
  20. data/app/views/crud_components/_toolbar.html.erb +50 -0
  21. data/app/views/crud_components/fields/_asciidoc.html.erb +8 -0
  22. data/app/views/crud_components/fields/_association.html.erb +13 -0
  23. data/app/views/crud_components/fields/_association_list.html.erb +24 -0
  24. data/app/views/crud_components/fields/_attachment.html.erb +16 -0
  25. data/app/views/crud_components/fields/_attachment_thumb.html.erb +17 -0
  26. data/app/views/crud_components/fields/_boolean.html.erb +13 -0
  27. data/app/views/crud_components/fields/_date.html.erb +6 -0
  28. data/app/views/crud_components/fields/_datetime.html.erb +6 -0
  29. data/app/views/crud_components/fields/_email.html.erb +7 -0
  30. data/app/views/crud_components/fields/_enum.html.erb +14 -0
  31. data/app/views/crud_components/fields/_json.html.erb +10 -0
  32. data/app/views/crud_components/fields/_markdown.html.erb +9 -0
  33. data/app/views/crud_components/fields/_number.html.erb +8 -0
  34. data/app/views/crud_components/fields/_string.html.erb +8 -0
  35. data/app/views/crud_components/fields/_text.html.erb +9 -0
  36. data/app/views/crud_components/fields/_url.html.erb +11 -0
  37. data/app/views/crud_components/filters/_boolean.html.erb +12 -0
  38. data/app/views/crud_components/filters/_date_range.html.erb +11 -0
  39. data/app/views/crud_components/filters/_number_range.html.erb +13 -0
  40. data/app/views/crud_components/filters/_select.html.erb +8 -0
  41. data/app/views/crud_components/filters/_text.html.erb +5 -0
  42. data/app/views/crud_components/form_fields/_belongs_to.html.erb +3 -0
  43. data/app/views/crud_components/form_fields/_boolean.html.erb +12 -0
  44. data/app/views/crud_components/form_fields/_date.html.erb +2 -0
  45. data/app/views/crud_components/form_fields/_datetime.html.erb +2 -0
  46. data/app/views/crud_components/form_fields/_enum.html.erb +8 -0
  47. data/app/views/crud_components/form_fields/_file.html.erb +47 -0
  48. data/app/views/crud_components/form_fields/_habtm.html.erb +5 -0
  49. data/app/views/crud_components/form_fields/_number.html.erb +2 -0
  50. data/app/views/crud_components/form_fields/_string.html.erb +3 -0
  51. data/app/views/crud_components/form_fields/_text.html.erb +2 -0
  52. data/app/views/crud_components/layouts/_table.html.erb +143 -0
  53. data/config/locales/crud_components.de.yml +39 -0
  54. data/config/locales/crud_components.en.yml +40 -0
  55. data/crud_components.gemspec +48 -0
  56. data/docs/extending.md +308 -0
  57. data/docs/fields.md +442 -0
  58. data/docs/forms.md +253 -0
  59. data/docs/performance.md +90 -0
  60. data/docs/security.md +139 -0
  61. data/docs/views.md +405 -0
  62. data/lib/crud_components/action.rb +85 -0
  63. data/lib/crud_components/builder.rb +246 -0
  64. data/lib/crud_components/config.rb +128 -0
  65. data/lib/crud_components/dynamic_column.rb +68 -0
  66. data/lib/crud_components/engine.rb +25 -0
  67. data/lib/crud_components/errors.rb +9 -0
  68. data/lib/crud_components/fields/attachment_field.rb +22 -0
  69. data/lib/crud_components/fields/base.rb +260 -0
  70. data/lib/crud_components/fields/belongs_to_field.rb +91 -0
  71. data/lib/crud_components/fields/boolean_field.rb +31 -0
  72. data/lib/crud_components/fields/computed_field.rb +34 -0
  73. data/lib/crud_components/fields/date_field.rb +51 -0
  74. data/lib/crud_components/fields/dynamic_field.rb +44 -0
  75. data/lib/crud_components/fields/enum_field.rb +40 -0
  76. data/lib/crud_components/fields/has_many_field.rb +50 -0
  77. data/lib/crud_components/fields/json_field.rb +10 -0
  78. data/lib/crud_components/fields/numeric_field.rb +31 -0
  79. data/lib/crud_components/fields/path_field.rb +327 -0
  80. data/lib/crud_components/fields/string_field.rb +41 -0
  81. data/lib/crud_components/fields/text_field.rb +9 -0
  82. data/lib/crud_components/fieldset.rb +38 -0
  83. data/lib/crud_components/helpers.rb +259 -0
  84. data/lib/crud_components/like_spec.rb +113 -0
  85. data/lib/crud_components/markup.rb +36 -0
  86. data/lib/crud_components/model.rb +33 -0
  87. data/lib/crud_components/permission_context.rb +62 -0
  88. data/lib/crud_components/presenters/actions.rb +51 -0
  89. data/lib/crud_components/presenters/base.rb +95 -0
  90. data/lib/crud_components/presenters/cell_context.rb +28 -0
  91. data/lib/crud_components/presenters/cells.rb +160 -0
  92. data/lib/crud_components/presenters/collection.rb +498 -0
  93. data/lib/crud_components/presenters/column_selection.rb +91 -0
  94. data/lib/crud_components/presenters/filter.rb +38 -0
  95. data/lib/crud_components/presenters/form.rb +57 -0
  96. data/lib/crud_components/presenters/record.rb +57 -0
  97. data/lib/crud_components/query.rb +110 -0
  98. data/lib/crud_components/route_resolver.rb +123 -0
  99. data/lib/crud_components/structure.rb +343 -0
  100. data/lib/crud_components/version.rb +3 -0
  101. data/lib/crud_components/where_like.rb +13 -0
  102. data/lib/crud_components.rb +160 -0
  103. data/lib/generators/crud_components/install/install_generator.rb +43 -0
  104. data/lib/generators/crud_components/install/templates/crud_columns_controller.js +76 -0
  105. data/lib/generators/crud_components/install/templates/crud_filter_controller.js +32 -0
  106. data/lib/generators/crud_components/install/templates/crud_multiselect_controller.js +70 -0
  107. data/lib/generators/crud_components/install/templates/crud_select_controller.js +35 -0
  108. data/lib/generators/crud_components/install/templates/initializer.rb +56 -0
  109. data/lib/generators/crud_components/views/views_generator.rb +14 -0
  110. 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