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,57 @@
1
+ module CrudComponents
2
+ module Presenters
3
+ # The single `record_presenter` local of the record partial.
4
+ class Record < Base
5
+ include ColumnSelection
6
+
7
+ attr_reader :record, :model, :structure, :fieldset, :param_prefix
8
+
9
+ def initialize(view:, record:, fieldset: nil, actions: true, picked_columns: :auto,
10
+ param_prefix: nil, extra_columns: nil)
11
+ super(view: view)
12
+ @record = record
13
+ @model = record.class
14
+ @structure = Structure.for(@model)
15
+ @fieldset = @structure.fieldset(fieldset || :show)
16
+ @actions_enabled = actions
17
+ @param_prefix = param_prefix
18
+ # Dynamic columns work on a detail view too — user-defined properties
19
+ # whose data lives outside the model's table, shown as extra rows.
20
+ @dynamic_fields = Array(extra_columns).map { |c| c.to_field(@model).preload!([record]) }
21
+ # A column picker can narrow/order this dl too, but a detail view has no
22
+ # inline gear of its own (no `picker:` knob) — the gear is a standalone
23
+ # `crud_column_picker` on the page. So pass `picked_columns:` an Array you
24
+ # resolved (e.g. via `CrudComponents.selected_columns(params)`); `:auto`
25
+ # here means "don't narrow" (no gear → a stray `?cols=` is ignored).
26
+ # (`fields`, `column_visible?` and the picker logic come from ColumnSelection.)
27
+ @picker = false
28
+ @picked_columns = normalize_picked_columns(picked_columns)
29
+ end
30
+
31
+ def title
32
+ structure.label_for(record, view)
33
+ end
34
+
35
+ # Every field this user may see on this record — declared fields plus the
36
+ # dynamic columns; the picker's universe.
37
+ def available_fields
38
+ @available_fields ||= (structure.fieldset_fields(fieldset) + @dynamic_fields)
39
+ .select { |f| f.permitted?(permission_context, record) }
40
+ end
41
+
42
+ def value_html(field)
43
+ render_cell(field, record, surface: :record)
44
+ end
45
+
46
+ # Row actions for this record; a derived :show button to the page we
47
+ # are already on would be noise.
48
+ def actions
49
+ return nil unless @actions_enabled
50
+
51
+ @actions ||= Actions.new(view: view, subject: record, structure: structure,
52
+ actions: structure.fieldset_actions(fieldset, on: :row),
53
+ suppress_show: true)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,110 @@
1
+ module CrudComponents
2
+ # Applies URL params to a relation: filtering, global search, sorting.
3
+ #
4
+ # The uniform rule: a param is applied iff it names a filterable field of
5
+ # the fieldset in play that the current user may see (or one of the
6
+ # reserved params q/sort/dir/page/per). Everything else never reaches SQL.
7
+ class Query
8
+ SORT_DIRECTIONS = %w[asc desc].freeze
9
+
10
+ attr_reader :model, :structure, :fieldset, :param_prefix
11
+
12
+ def initialize(model, params, fieldset: nil, ability: nil, param_prefix: nil, extra_fields: [])
13
+ @model = model
14
+ @structure = Structure.for(model)
15
+ @fieldset = fieldset.is_a?(Fieldset) ? fieldset : @structure.fieldset(fieldset)
16
+ @params = extract(params)
17
+ @permission = PermissionContext.new(ability)
18
+ @param_prefix = param_prefix
19
+ @extra_fields = extra_fields
20
+ end
21
+
22
+ def apply(scope)
23
+ scope = apply_filters(scope)
24
+ scope = apply_search(scope)
25
+ apply_sort(scope)
26
+ end
27
+
28
+ def fieldset_name = fieldset.name
29
+
30
+ def filter_fields
31
+ (structure.fieldset_filter_fields(fieldset) + @extra_fields.select(&:filterable?))
32
+ .select { |f| f.permitted?(@permission) }
33
+ end
34
+
35
+ def sortable_fields
36
+ (structure.fieldset_sortable_fields(fieldset) + @extra_fields.select(&:sortable?))
37
+ .select { |f| f.permitted?(@permission) }
38
+ end
39
+
40
+ def searchable? = structure.searchable?
41
+
42
+ # Current value of a (logical, unprefixed) param — for filter controls.
43
+ def value(key) = param(key)
44
+
45
+ def active?
46
+ keys = filter_fields.flat_map { |f| [f.name.to_s, "#{f.name}_geq", "#{f.name}_leq"] }
47
+ (keys + ['q']).any? { |key| param(key) }
48
+ end
49
+
50
+ # [field_name_string, direction_string] or nil.
51
+ def sort_state
52
+ field = current_sort_field
53
+ field && [field.name.to_s, direction]
54
+ end
55
+
56
+ def param_name(key) = "#{prefix}#{key}"
57
+
58
+ private
59
+
60
+ def prefix = param_prefix ? "#{param_prefix}_" : ''
61
+
62
+ def param(key)
63
+ raw = @params[param_name(key)]
64
+ raw.is_a?(String) && raw.present? ? raw : nil
65
+ end
66
+
67
+ def extract(params)
68
+ hash = if params.respond_to?(:to_unsafe_h)
69
+ params.to_unsafe_h
70
+ else
71
+ params.to_h
72
+ end
73
+ hash.transform_keys(&:to_s)
74
+ end
75
+
76
+ def apply_filters(scope)
77
+ filter_fields.reduce(scope) do |current, field|
78
+ exact = param(field.name.to_s)
79
+ geq = param("#{field.name}_geq")
80
+ leq = param("#{field.name}_leq")
81
+ next current unless exact || geq || leq
82
+
83
+ field.apply_filter(current, exact: exact, geq: geq, leq: leq)
84
+ end
85
+ end
86
+
87
+ def apply_search(scope)
88
+ q = param('q')
89
+ return scope unless q && structure.searchable?
90
+
91
+ structure.apply_search(scope, q, permission: @permission)
92
+ end
93
+
94
+ def current_sort_field
95
+ sort = param('sort')
96
+ sort && sortable_fields.find { |f| f.name.to_s == sort }
97
+ end
98
+
99
+ def direction
100
+ SORT_DIRECTIONS.include?(param('dir')) ? param('dir') : 'asc'
101
+ end
102
+
103
+ def apply_sort(scope)
104
+ field = current_sort_field
105
+ return scope unless field
106
+
107
+ field.apply_sort(scope, direction.to_sym)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,123 @@
1
+ module CrudComponents
2
+ # Resolves derived actions and record links to routes: the most specific
3
+ # conventional route first (association-scoped when the collection came
4
+ # from an association), then the top-level route, then nil — a nil means
5
+ # the button/link is omitted, never broken.
6
+ module RouteResolver
7
+ module_function
8
+
9
+ def action_path(view, action, record: nil, model: nil, owner: nil)
10
+ if action.path_block
11
+ subject = record || model
12
+ return view.instance_exec(subject, &action.path_block)
13
+ end
14
+
15
+ if action.collection?
16
+ collection_path(view, action, model, owner)
17
+ else
18
+ member_path(view, action, record, owner)
19
+ end
20
+ end
21
+
22
+ # The plain link to a record (label cells, association cells):
23
+ # show route, then edit. Returns [path, kind] or nil.
24
+ def record_path(view, record, owner: nil)
25
+ path = try_helpers(view, member_candidates(nil, record, owner))
26
+ return [path, :show] if path
27
+
28
+ path = try_helpers(view, member_candidates('edit_', record, owner))
29
+ path ? [path, :edit] : nil
30
+ end
31
+
32
+ # Whether the record has a plain (show) route — feeds the
33
+ # ":show button only without a label link" rule.
34
+ def show_path(view, record, owner: nil)
35
+ try_helpers(view, member_candidates(nil, record, owner))
36
+ end
37
+
38
+ # The index a has_many "+n more" link points at:
39
+ # 1. the nested index under the owner (publisher_books_path(publisher)),
40
+ # 2. else the target's index filtered by the owner — but ONLY when the
41
+ # target actually has a filterable belongs_to back to the owner
42
+ # (publisher→books works; a habtm like author↔books does not, so we
43
+ # do not emit a link that would silently show everything),
44
+ # 3. else nil (the renderer shows "+n more" as plain text).
45
+ # `assoc_name` is the owner's reflection name (e.g. :books).
46
+ def collection_index_path(view, target, owner, assoc_name)
47
+ return nil unless owner
48
+
49
+ key = target.model_name.route_key
50
+ owner_key = owner.model_name.singular_route_key
51
+ nested = "#{owner_key}_#{key}_path"
52
+ return safe_url(view, nested, owner) if view.respond_to?(nested)
53
+
54
+ flat = "#{key}_path"
55
+ return nil unless view.respond_to?(flat)
56
+
57
+ filter = inverse_filter(target, owner, assoc_name)
58
+ return nil unless filter
59
+
60
+ safe_url(view, flat, **{ filter[:param] => filter[:value] })
61
+ end
62
+
63
+ # The target's belongs_to that mirrors the owner's collection (matched by
64
+ # foreign key), if it is filterable — with the owner's identify_by value.
65
+ def inverse_filter(target, owner, assoc_name)
66
+ owner_reflection = owner.class.reflect_on_association(assoc_name)
67
+ return nil unless owner_reflection&.foreign_key
68
+
69
+ fk = owner_reflection.foreign_key.to_s
70
+ inverse = target.reflect_on_all_associations(:belongs_to).find { |r| r.foreign_key.to_s == fk }
71
+ return nil unless inverse
72
+
73
+ field = Structure.for(target).field(inverse.name)
74
+ return nil unless field.filterable?
75
+
76
+ identify = Structure.for(owner.class).identify_by
77
+ { param: inverse.name, value: owner.public_send(identify) }
78
+ rescue CrudComponents::DefinitionError
79
+ nil
80
+ end
81
+
82
+ def safe_url(view, helper, *args, **kwargs)
83
+ kwargs.empty? ? view.public_send(helper, *args) : view.public_send(helper, *args, **kwargs)
84
+ rescue ActionController::UrlGenerationError, NoMethodError
85
+ nil
86
+ end
87
+
88
+ def member_path(view, action, record, owner)
89
+ prefix = { show: nil, destroy: nil, edit: 'edit_' }.fetch(action.name, "#{action.name}_")
90
+ try_helpers(view, member_candidates(prefix, record, owner))
91
+ end
92
+
93
+ def collection_path(view, action, model, owner)
94
+ prefix = action.name == :new ? 'new_' : "#{action.name}_"
95
+ key = action.name == :new ? model.model_name.singular_route_key : model.model_name.route_key
96
+ candidates = []
97
+ candidates << ["#{prefix}#{owner.model_name.singular_route_key}_#{key}_path", [owner]] if owner
98
+ candidates << ["#{prefix}#{key}_path", []]
99
+ try_helpers(view, candidates)
100
+ end
101
+
102
+ def member_candidates(prefix, record, owner)
103
+ key = record.model_name.singular_route_key
104
+ candidates = []
105
+ candidates << ["#{prefix}#{owner.model_name.singular_route_key}_#{key}_path", [owner, record]] if owner
106
+ candidates << ["#{prefix}#{key}_path", [record]]
107
+ candidates
108
+ end
109
+
110
+ def try_helpers(view, candidates)
111
+ candidates.each do |helper, args|
112
+ next unless view.respond_to?(helper)
113
+
114
+ begin
115
+ return view.public_send(helper, *args)
116
+ rescue ActionController::UrlGenerationError, NoMethodError
117
+ next
118
+ end
119
+ end
120
+ nil
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,343 @@
1
+ module CrudComponents
2
+ # The resolved, validated description of how a model appears in the UI.
3
+ # Built lazily per model class and memoized; works for models without any
4
+ # declaration (rule zero: everything is derived from what Rails knows).
5
+ class Structure
6
+ RENDERER_GEMS = {
7
+ markdown: %w[commonmarker redcarpet kramdown],
8
+ asciidoc: %w[asciidoctor]
9
+ }.freeze
10
+
11
+ class << self
12
+ def for(model)
13
+ unless model.respond_to?(:columns_hash)
14
+ raise ArgumentError, "#{model.inspect} is not an ActiveRecord model class"
15
+ end
16
+
17
+ cached = model.instance_variable_get(:@_crud_structure) if model.instance_variable_defined?(:@_crud_structure)
18
+ return cached if cached
19
+
20
+ structure = new(model, find_builder(model))
21
+ model.instance_variable_set(:@_crud_structure, structure)
22
+ structure
23
+ end
24
+
25
+ private
26
+
27
+ # Class-level ivars are not inherited; walk up for STI subclasses.
28
+ def find_builder(model)
29
+ klass = model
30
+ while klass.respond_to?(:instance_variable_defined?)
31
+ if klass.instance_variable_defined?(:@_crud_structure_block)
32
+ block = klass.instance_variable_get(:@_crud_structure_block)
33
+ return Builder.new(model, &block) if block
34
+ end
35
+ klass = klass.superclass
36
+ break if klass.nil? || klass == ActiveRecord::Base
37
+ end
38
+ nil
39
+ end
40
+ end
41
+
42
+ # identity_preloads: associations to eager-load whenever this model is shown
43
+ # as another model's association cell (its label/render dependencies), from
44
+ # `label …, preload:` and the standalone `preload` declaration.
45
+ attr_reader :model, :identify_by, :identity_preloads
46
+
47
+ def initialize(model, builder = nil)
48
+ @model = model
49
+ @declarations = builder&.declarations || {}
50
+ @label_decl = builder&.label_decl
51
+ @identify_by = builder&.identify_by_decl || :id
52
+ @icon_decl = builder&.icon_decl
53
+ @search_decl = builder&.search_decl
54
+ @identity_preloads = ((builder&.label_preload_decl || []) + (builder&.preload_decl || [])).uniq
55
+ @declared_actions = builder&.actions || {}
56
+ @declared_fieldsets = builder&.fieldsets || {}
57
+ @fields = {}
58
+ validate!
59
+ end
60
+
61
+ # ── fields ───────────────────────────────────────────────────────────────
62
+ def field(name)
63
+ @fields[name.to_sym] ||= resolve_field(name.to_sym)
64
+ end
65
+
66
+ # The :all set: every column (foreign keys swapped for their belongs_to),
67
+ # then Active Storage attachments, then non-belongs_to associations
68
+ # (has_many / habtm / has_one), then any declared computed fields — all
69
+ # derived, in a stable order.
70
+ def default_field_names
71
+ @default_field_names ||= begin
72
+ base = column_field_names + attachment_field_names + association_field_names
73
+ base + (@declarations.keys - base)
74
+ end
75
+ end
76
+
77
+ # Active Storage attachments (has_one_attached / has_many_attached),
78
+ # surfaced as image fields — derived, no declaration needed.
79
+ def attachment_field_names
80
+ @attachment_field_names ||=
81
+ model.respond_to?(:reflect_on_all_attachments) ? model.reflect_on_all_attachments.map(&:name) : []
82
+ end
83
+
84
+ # has_many / habtm / has_one — belongs_to already arrive via the FK swap.
85
+ # Active Storage's generated join associations (images_attachments/_blobs)
86
+ # and ActionText's rich-text associations are not user-facing columns.
87
+ def association_field_names
88
+ @association_field_names ||=
89
+ model.reflect_on_all_associations.reject(&:belongs_to?).map(&:name)
90
+ .reject { |n| n.to_s.start_with?('rich_text_', 'with_attached_') }
91
+ .reject { |n| attachment_support_names.include?(n) }
92
+ end
93
+
94
+ # The join associations behind each attachment — excluded from the field
95
+ # universe, since the attachment itself is the field.
96
+ def attachment_support_names
97
+ @attachment_support_names ||=
98
+ attachment_field_names.flat_map do |att|
99
+ %W[#{att}_attachment #{att}_attachments #{att}_blob #{att}_blobs].map(&:to_sym)
100
+ end
101
+ end
102
+
103
+ # ── fieldsets ────────────────────────────────────────────────────────────
104
+ # :default always exists; :index and :show fall back to :default when not
105
+ # declared; any other name must be declared (typo protection).
106
+ def fieldset(name = :default)
107
+ name = (name || :default).to_sym
108
+ return @declared_fieldsets[name] if @declared_fieldsets.key?(name)
109
+ return default_fieldset if %i[default index show].include?(name)
110
+
111
+ known = (@declared_fieldsets.keys + [:default]).uniq
112
+ raise UnknownFieldsetError, "#{model} has no fieldset :#{name} — " \
113
+ "available: #{known.map(&:inspect).join(', ')}"
114
+ end
115
+
116
+ def default_fieldset
117
+ @default_fieldset ||= Fieldset.new(:default, :all)
118
+ end
119
+
120
+ def fieldset_fields(fieldset)
121
+ names = fieldset.all_fields? ? default_field_names : fieldset.field_names
122
+ names.map { |name| field(name) }
123
+ end
124
+
125
+ def fieldset_filter_fields(fieldset)
126
+ (fieldset_fields(fieldset) + fieldset.filter_names.map { |name| field(name) })
127
+ .uniq.select(&:filterable?)
128
+ end
129
+
130
+ def fieldset_sortable_fields(fieldset)
131
+ fieldset_fields(fieldset).select(&:sortable?)
132
+ end
133
+
134
+ # ── forms ──────────────────────────────────────────────────────────────
135
+ # Form field selection falls back most-specific-first:
136
+ # the action's own fieldset → :form → :default.
137
+ def form_fieldset(action = nil)
138
+ names = [action, :form, :default].compact
139
+ names.each do |name|
140
+ return @declared_fieldsets[name] if @declared_fieldsets.key?(name)
141
+ end
142
+ default_fieldset
143
+ end
144
+
145
+ # Editable, permitted fields of the form fieldset, as a strong-params
146
+ # permit list (symbols and nested hashes) — the controller's single
147
+ # source of truth, so form and params can never drift.
148
+ def permitted_params(action, context)
149
+ fields = fieldset_fields(form_fieldset(action))
150
+ fields.select { |f| f.permitted?(context) && f.editable? && f.editable_permitted?(context) && f.form_control }
151
+ .map(&:permit_param)
152
+ end
153
+
154
+ # ── identity ─────────────────────────────────────────────────────────────
155
+ def label_source
156
+ return @label_decl if @label_decl
157
+
158
+ @label_source ||= %i[name title].find { |attr| model.columns_hash.key?(attr.to_s) } ||
159
+ model.columns.find { |col| col.type == :string }&.name&.to_sym
160
+ end
161
+
162
+ def label_for(record, context = nil)
163
+ case (source = label_source)
164
+ when Proc then context ? context.instance_exec(record, &source) : source.call(record)
165
+ when Symbol then record.public_send(source)
166
+ else "#{model.model_name.human} ##{record.id}"
167
+ end
168
+ end
169
+
170
+ # The field whose cell carries the record link (nil for block labels).
171
+ def label_field_name
172
+ label_source.is_a?(Symbol) ? label_source : nil
173
+ end
174
+
175
+ # The icon name (no library prefix) badging this model: the declared `icon`,
176
+ # else the name-based guess in `config.model_icons` (keyed by the singular
177
+ # underscored model name), else `config.model_fallback_icon` (nil = none).
178
+ # Resolved per call so a host's config changes apply without rebuilding.
179
+ def icon
180
+ @icon_decl || CrudComponents.config.model_icons[model.model_name.element] ||
181
+ CrudComponents.config.model_fallback_icon
182
+ end
183
+
184
+ # ── search ───────────────────────────────────────────────────────────────
185
+ # nil when search_in is a custom block (delegation is then impossible).
186
+ def search_in_spec
187
+ return nil if @search_decl.is_a?(Proc)
188
+
189
+ @search_in_spec ||= (@search_decl.presence || default_search_spec)
190
+ end
191
+
192
+ def default_search_spec
193
+ model.columns.select { |col| %i[string text].include?(col.type) }
194
+ .map { |col| col.name.to_sym }
195
+ end
196
+
197
+ def searchable?
198
+ @search_decl.is_a?(Proc) || search_in_spec.any?
199
+ end
200
+
201
+ def apply_search(scope, query_string, permission: nil)
202
+ if @search_decl.is_a?(Proc)
203
+ @search_decl.call(scope.extending(WhereLike), query_string)
204
+ else
205
+ spec = permission ? permitted_search_spec(permission) : search_in_spec
206
+ spec.any? ? LikeSpec.apply(scope, spec, query_string) : scope
207
+ end
208
+ end
209
+
210
+ # A declared, permission-gated column (`attribute :x, if: :manage`) is
211
+ # hidden everywhere including ?q= — so drop it from the search spec for a
212
+ # user who may not see it. Undeclared columns in the default spec are
213
+ # model-global search by design and stay.
214
+ def permitted_search_spec(permission)
215
+ search_in_spec.reject do |entry|
216
+ entry.is_a?(Symbol) && model.columns_hash.key?(entry.to_s) &&
217
+ @declarations.key?(entry) && !field(entry).permitted?(permission)
218
+ end
219
+ end
220
+
221
+ # ── actions ──────────────────────────────────────────────────────────────
222
+ def actions
223
+ @actions ||= begin
224
+ derived = %i[new show edit destroy].to_h { |name| [name, Action.new(name, derived: true)] }
225
+ merged = derived.merge(@declared_actions)
226
+ custom = @declared_actions.keys - derived.keys
227
+ order = [:new, :show, :edit, *custom, :destroy]
228
+ order.to_h { |name| [name, merged.fetch(name)] }
229
+ end
230
+ end
231
+
232
+ def action(name)
233
+ actions[name.to_sym] || raise(DefinitionError, "#{model} has no action :#{name} — " \
234
+ "available: #{actions.keys.map(&:inspect).join(', ')}")
235
+ end
236
+
237
+ # A fieldset's actions: list is authoritative per kind: listing only row
238
+ # actions curates the row buttons without losing the derived :new button
239
+ # (and vice versa). An empty list hides everything.
240
+ def fieldset_actions(fieldset, on:)
241
+ of_kind = ->(a) { a.public_send("#{on}?") }
242
+ names = fieldset.action_names
243
+ return actions.values.select(&of_kind) if names.nil?
244
+ return [] if names.empty?
245
+
246
+ listed = names.map { |name| action(name) }.select(&of_kind)
247
+ listed.any? ? listed : actions.values.select(&of_kind)
248
+ end
249
+
250
+ # ── validation (DefinitionError with a way out, at first build) ──────────
251
+ private
252
+
253
+ def validate!
254
+ @declarations.each_key { |name| field(name) }
255
+ validate_renderer_gems!
256
+ validate_fieldsets!
257
+ end
258
+
259
+ def validate_renderer_gems!
260
+ @declarations.each do |name, decl|
261
+ gems = RENDERER_GEMS[decl[:options][:as]]
262
+ next unless gems
263
+ next if gems.any? { |gem_name| try_require(gem_name) }
264
+
265
+ raise DefinitionError, "#{model}.#{name}: as: :#{decl[:options][:as]} needs one of these gems " \
266
+ "in your bundle: #{gems.join(', ')}"
267
+ end
268
+ end
269
+
270
+ def try_require(gem_name)
271
+ require gem_name
272
+ true
273
+ rescue LoadError
274
+ false
275
+ end
276
+
277
+ def validate_fieldsets!
278
+ @declared_fieldsets.each_value do |fs|
279
+ fs.field_names.each { |name| field(name) } unless fs.all_fields?
280
+ fs.filter_names.each do |name|
281
+ next if field(name).filterable?
282
+
283
+ raise DefinitionError, "#{model}: fieldset :#{fs.name} lists :#{name} under filters:, " \
284
+ 'but that field is not filterable — give it a filter facet first'
285
+ end
286
+ fs.action_names&.each { |name| action(name) }
287
+ end
288
+ end
289
+
290
+ def resolve_field(name)
291
+ decl = @declarations[name] || {}
292
+ field_class_for(name, decl[:facets] || {})
293
+ .new(name, model, decl[:options] || {}, decl[:facets] || {})
294
+ end
295
+
296
+ def field_class_for(name, facets)
297
+ if name.to_s.include?('.')
298
+ Fields::PathField
299
+ elsif model.defined_enums.key?(name.to_s)
300
+ Fields::EnumField
301
+ elsif (reflection = model.reflect_on_association(name))
302
+ reflection.collection? ? Fields::HasManyField : Fields::BelongsToField
303
+ elsif model.respond_to?(:reflect_on_attachment) && model.reflect_on_attachment(name)
304
+ Fields::AttachmentField
305
+ elsif (column = model.columns_hash[name.to_s])
306
+ column_field_class(column)
307
+ elsif facets[:render] || model.method_defined?(name)
308
+ Fields::ComputedField
309
+ else
310
+ raise DefinitionError, "#{model} has no column, enum, association or public method '#{name}'. " \
311
+ "Computed fields need a render facet: attribute(:#{name}) { |record| ... }"
312
+ end
313
+ end
314
+
315
+ def column_field_class(column)
316
+ case column.type
317
+ when :text then Fields::TextField
318
+ when :integer, :float, :decimal then Fields::NumericField
319
+ when :date, :datetime, :timestamp, :timestamptz then Fields::DateField
320
+ when :boolean then Fields::BooleanField
321
+ when :json, :jsonb then Fields::JsonField
322
+ else Fields::StringField
323
+ end
324
+ end
325
+
326
+ def column_field_names
327
+ @column_field_names ||= begin
328
+ by_foreign_key = {}
329
+ polymorphic_type_columns = []
330
+ model.reflect_on_all_associations(:belongs_to).each do |ref|
331
+ by_foreign_key[ref.foreign_key.to_s] = ref.name
332
+ polymorphic_type_columns << ref.foreign_type.to_s if ref.polymorphic?
333
+ end
334
+
335
+ model.columns.filter_map do |col|
336
+ next nil if polymorphic_type_columns.include?(col.name)
337
+
338
+ by_foreign_key[col.name] || col.name.to_sym
339
+ end.uniq
340
+ end
341
+ end
342
+ end
343
+ end
@@ -0,0 +1,3 @@
1
+ module CrudComponents
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -0,0 +1,13 @@
1
+ module CrudComponents
2
+ # Extended onto every scope handed to filter/search blocks, so custom query
3
+ # logic keeps the safe ILIKE machinery without hand-written SQL:
4
+ #
5
+ # filter do |scope, value|
6
+ # scope.where(active: true).where_like({ authors: :name }, value)
7
+ # end
8
+ module WhereLike
9
+ def where_like(spec, value)
10
+ CrudComponents::LikeSpec.apply(self, spec, value)
11
+ end
12
+ end
13
+ end