compony 0.7.1 → 0.8.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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +10 -14
  5. data/VERSION +1 -1
  6. data/compony.gemspec +4 -4
  7. data/doc/ComponentGenerator.html +1 -1
  8. data/doc/Components.html +1 -1
  9. data/doc/ComponentsGenerator.html +1 -1
  10. data/doc/Compony/Component.html +193 -457
  11. data/doc/Compony/ComponentMixins/Default/Labelling.html +1 -1
  12. data/doc/Compony/ComponentMixins/Default/Standalone/ResourcefulVerbDsl.html +1 -1
  13. data/doc/Compony/ComponentMixins/Default/Standalone/StandaloneDsl.html +3 -3
  14. data/doc/Compony/ComponentMixins/Default/Standalone/VerbDsl.html +1 -1
  15. data/doc/Compony/ComponentMixins/Default/Standalone.html +187 -1
  16. data/doc/Compony/ComponentMixins/Default.html +1 -1
  17. data/doc/Compony/ComponentMixins/Resourceful.html +2 -2
  18. data/doc/Compony/ComponentMixins.html +1 -1
  19. data/doc/Compony/Components/Button.html +2 -2
  20. data/doc/Compony/Components/Buttons/CssButton.html +282 -0
  21. data/doc/Compony/Components/Buttons/Link.html +252 -0
  22. data/doc/Compony/Components/Buttons.html +126 -0
  23. data/doc/Compony/Components/Destroy.html +11 -11
  24. data/doc/Compony/Components/Edit.html +14 -14
  25. data/doc/Compony/Components/Form.html +100 -100
  26. data/doc/Compony/Components/Index.html +2 -2
  27. data/doc/Compony/Components/List.html +3 -3
  28. data/doc/Compony/Components/New.html +2 -2
  29. data/doc/Compony/Components/Show.html +24 -24
  30. data/doc/Compony/Components/WithForm.html +3 -3
  31. data/doc/Compony/Components.html +5 -3
  32. data/doc/Compony/ControllerMixin.html +2 -2
  33. data/doc/Compony/Engine.html +1 -1
  34. data/doc/Compony/ExposedIntentsDsl.html +403 -0
  35. data/doc/Compony/Intent.html +1503 -0
  36. data/doc/Compony/MethodAccessibleHash.html +1 -1
  37. data/doc/Compony/ModelFields/Anchormodel.html +1 -1
  38. data/doc/Compony/ModelFields/Association.html +2 -2
  39. data/doc/Compony/ModelFields/Attachment.html +1 -1
  40. data/doc/Compony/ModelFields/Base.html +1 -1
  41. data/doc/Compony/ModelFields/Boolean.html +1 -1
  42. data/doc/Compony/ModelFields/Color.html +1 -1
  43. data/doc/Compony/ModelFields/Currency.html +1 -1
  44. data/doc/Compony/ModelFields/Date.html +1 -1
  45. data/doc/Compony/ModelFields/Datetime.html +1 -1
  46. data/doc/Compony/ModelFields/Decimal.html +1 -1
  47. data/doc/Compony/ModelFields/Email.html +1 -1
  48. data/doc/Compony/ModelFields/Float.html +1 -1
  49. data/doc/Compony/ModelFields/Integer.html +1 -1
  50. data/doc/Compony/ModelFields/Percentage.html +1 -1
  51. data/doc/Compony/ModelFields/Phone.html +1 -1
  52. data/doc/Compony/ModelFields/RichText.html +1 -1
  53. data/doc/Compony/ModelFields/String.html +1 -1
  54. data/doc/Compony/ModelFields/Text.html +1 -1
  55. data/doc/Compony/ModelFields/Time.html +1 -1
  56. data/doc/Compony/ModelFields/Url.html +1 -1
  57. data/doc/Compony/ModelFields.html +1 -1
  58. data/doc/Compony/ModelMixin.html +1 -1
  59. data/doc/Compony/NaturalOrdering.html +1 -1
  60. data/doc/Compony/RequestContext.html +177 -14
  61. data/doc/Compony/Version.html +1 -1
  62. data/doc/Compony/ViewHelpers.html +15 -272
  63. data/doc/Compony/VirtualModel.html +1 -1
  64. data/doc/Compony.html +303 -837
  65. data/doc/ComponyController.html +1 -1
  66. data/doc/_index.html +30 -2
  67. data/doc/class_list.html +1 -1
  68. data/doc/file.README.html +11 -18
  69. data/doc/guide/basic_component.md +12 -8
  70. data/doc/guide/example.md +17 -17
  71. data/doc/guide/feasibility.md +4 -2
  72. data/doc/guide/generators.md +4 -2
  73. data/doc/guide/inheritance.md +4 -2
  74. data/doc/guide/installation.md +4 -2
  75. data/doc/guide/intents.md +167 -0
  76. data/doc/guide/internal_datastructures.md +4 -2
  77. data/doc/guide/model_fields.md +4 -2
  78. data/doc/guide/nesting.md +5 -3
  79. data/doc/guide/ownership.md +5 -3
  80. data/doc/guide/pre_built_components/destroy.md +3 -3
  81. data/doc/guide/pre_built_components/edit.md +1 -1
  82. data/doc/guide/pre_built_components/form.md +1 -1
  83. data/doc/guide/pre_built_components/index.md +1 -1
  84. data/doc/guide/pre_built_components/list.md +1 -1
  85. data/doc/guide/pre_built_components/new.md +2 -2
  86. data/doc/guide/pre_built_components/show.md +1 -1
  87. data/doc/guide/pre_built_components/with_form.md +1 -1
  88. data/doc/guide/pre_built_components.md +4 -3
  89. data/doc/guide/resourceful.md +5 -3
  90. data/doc/guide/standalone.md +10 -2
  91. data/doc/guide/virtual_models.md +4 -2
  92. data/doc/index.html +11 -18
  93. data/doc/method_list.html +273 -161
  94. data/doc/top-level-namespace.html +1 -1
  95. data/lib/compony/component.rb +19 -48
  96. data/lib/compony/component_mixins/default/standalone/standalone_dsl.rb +2 -2
  97. data/lib/compony/component_mixins/default/standalone.rb +16 -0
  98. data/lib/compony/component_mixins/resourceful.rb +1 -1
  99. data/lib/compony/components/buttons/css_button.rb +32 -0
  100. data/lib/compony/components/buttons/link.rb +31 -0
  101. data/lib/compony/components/destroy.rb +9 -8
  102. data/lib/compony/components/edit.rb +5 -4
  103. data/lib/compony/components/form.rb +7 -1
  104. data/lib/compony/components/index.rb +2 -2
  105. data/lib/compony/components/list.rb +4 -4
  106. data/lib/compony/components/new.rb +1 -1
  107. data/lib/compony/components/show.rb +8 -11
  108. data/lib/compony/components/with_form.rb +1 -1
  109. data/lib/compony/exposed_intents_dsl.rb +29 -0
  110. data/lib/compony/intent.rb +145 -0
  111. data/lib/compony/model_fields/association.rb +1 -1
  112. data/lib/compony/request_context.rb +21 -0
  113. data/lib/compony/view_helpers.rb +5 -48
  114. data/lib/compony.rb +63 -149
  115. metadata +12 -6
  116. data/doc/guide/helpers.md +0 -156
  117. data/doc/guide/pre_built_components/button.md +0 -8
  118. data/doc/guide/root_actions.md +0 -67
  119. data/lib/compony/components/button.rb +0 -61
@@ -102,7 +102,7 @@
102
102
  </div>
103
103
 
104
104
  <div id="footer">
105
- Generated on Thu Nov 20 12:44:23 2025 by
105
+ Generated on Thu Nov 27 16:02:21 2025 by
106
106
  <a href="https://yardoc.org" title="Yay! A Ruby Documentation Tool" target="_parent">yard</a>
107
107
  0.9.34 (ruby-3.3.5).
108
108
  </div>
@@ -20,24 +20,14 @@ module Compony
20
20
  setup_blocks << block
21
21
  end
22
22
 
23
- # Returns the name of the module constant (=family) of this component. Do not override.
24
- def self.family_cst
25
- module_parent.to_s.demodulize.to_sym
26
- end
27
-
28
23
  # Returns the family name
29
24
  def self.family_name
30
- family_cst.to_s.underscore
31
- end
32
-
33
- # Returns the name of the class constant of this component. Do not override.
34
- def self.comp_cst
35
- name.demodulize.to_sym
25
+ module_parent.to_s.demodulize.underscore
36
26
  end
37
27
 
38
28
  # Returns the component name
39
29
  def self.comp_name
40
- comp_cst.to_s.underscore
30
+ name.demodulize.underscore
41
31
  end
42
32
 
43
33
  def initialize(parent_comp = nil, index: 0, **comp_opts)
@@ -48,11 +38,12 @@ module Compony
48
38
  @before_render_blocks = NaturalOrdering.new
49
39
  @content_blocks = NaturalOrdering.new
50
40
  @actions = NaturalOrdering.new
41
+ @exposed_intent_blocks = []
51
42
  @skipped_actions = Set.new
52
43
  @path_block = proc do |model = nil, *args_for_path_helper, standalone_name: nil, **kwargs_for_path_helper|
53
44
  kwargs_for_path_helper.merge!(id: model.id) if model
54
45
  next Rails.application.routes.url_helpers.send(
55
- "#{Compony.path_helper_name(comp_cst, family_cst, standalone_name&.to_sym)}_path",
46
+ "#{path_helper_name(standalone_name)}_path",
56
47
  *args_for_path_helper,
57
48
  **kwargs_for_path_helper
58
49
  )
@@ -114,21 +105,16 @@ module Compony
114
105
  end
115
106
 
116
107
  # Instanciate a component with `self` as a parent
117
- def sub_comp(component_class, **comp_opts)
118
- sub = component_class.new(self, index: @sub_comps.count, **comp_opts)
108
+ def sub_comp(*, **comp_opts)
109
+ intent = Compony.intent(*)
110
+ sub = intent.comp(self, index: @sub_comps.count, **comp_opts)
119
111
  @sub_comps << sub
120
112
  return sub
121
113
  end
122
114
 
123
- # Returns the name of the module constant (=family) of this component. Do not override.
124
- delegate :family_cst, to: :class
125
-
126
115
  # Returns the family name
127
116
  delegate :family_name, to: :class
128
117
 
129
- # Returns the name of the class constant of this component. Do not override.
130
- delegate :comp_cst, to: :class
131
-
132
118
  # Returns the component name
133
119
  delegate :comp_name, to: :class
134
120
 
@@ -227,33 +213,18 @@ module Compony
227
213
  end
228
214
 
229
215
  # DSL method
230
- # Adds or replaces an action (for action buttons)
231
- # If before: is specified, will insert the action before the named action. When replacing, an element keeps its position unless before: is specified.
232
- def action(action_name, before: nil, &block)
233
- @actions.natural_push(action_name, block, before:)
234
- end
235
-
236
- # DSL method
237
- # Marks an action for skip
238
- def skip_action(action_name)
239
- @skipped_actions << action_name.to_sym
240
- end
241
-
242
- # Used to render all actions of this component, each button wrapped in a div with the specified class
243
- def render_actions(controller, wrapper_class: '', action_class: '')
244
- h = controller.helpers
245
- h.content_tag(:div, class: wrapper_class) do
246
- button_htmls = @actions.map do |action|
247
- next if @skipped_actions.include?(action.name)
248
- Compony.with_button_defaults(feasibility_action: action.name.to_sym) do
249
- action_button = action.payload.call(controller)
250
- next unless action_button
251
- button_html = action_button.render(controller)
252
- next if button_html.blank?
253
- h.content_tag(:div, button_html, class: action_class)
254
- end
255
- end
256
- next h.safe_join button_htmls
216
+ # If a block is given: Enters the DSL where exposed intents can be added or removed (use from {Component#setup} within the component).
217
+ # If no block is given: Builds the declared intents and returns them (use from a {RequestContest} outside the component).
218
+ def exposed_intents(&block)
219
+ if block_given?
220
+ # Enter DSL
221
+ @exposed_intent_blocks << block
222
+ else
223
+ # Build the declared intents
224
+ return @exposed_intents if @exposed_intents
225
+ @exposed_intents = NaturalOrdering.new
226
+ @exposed_intent_blocks.each { |block| ExposedIntentsDsl.new(@exposed_intents).evaluate(&block) } # alters @exposed_intents
227
+ return @exposed_intents.map!(&:payload)
257
228
  end
258
229
  end
259
230
 
@@ -32,8 +32,8 @@ module Compony
32
32
  scope: @scope,
33
33
  scope_args: @scope_args,
34
34
  verbs: @verbs,
35
- rails_action_name: Compony.rails_action_name(comp_name, family_name, @name),
36
- path_helper_name: Compony.path_helper_name(comp_name, family_name, @name),
35
+ rails_action_name: rails_action_name(@name),
36
+ path_helper_name: path_helper_name(@name),
37
37
  skip_authentication: @skip_authentication,
38
38
  skip_forgery_protection: @skip_forgery_protection,
39
39
  layout: @layout
@@ -123,6 +123,22 @@ module Compony
123
123
  @standalone_configs = {}
124
124
  end
125
125
 
126
+ # Returns the name of the ComponyController action for this component.<br>
127
+ # Optionally can pass a name for extra standalone configs.
128
+ # @param name [String,Symbol] If referring to an extra standalone entrypoint, specify its name using this param.
129
+ # @see Compony#path
130
+ def rails_action_name(name = nil)
131
+ [name.presence, comp_name, family_name].compact.join('_')
132
+ end
133
+
134
+ # Returns the name of the Rails URL helper returning the path to this component.<br>
135
+ # Optionally can pass a name for extra standalone configs.
136
+ # @param name [String,Symbol] If referring to an extra standalone entrypoint, specify its name using this param.
137
+ # @see Compony#path
138
+ def path_helper_name(...)
139
+ "#{rails_action_name(...)}_comp"
140
+ end
141
+
126
142
  private
127
143
 
128
144
  def init_standalone
@@ -28,7 +28,7 @@ module Compony
28
28
  # DSL method
29
29
  # Sets or calculates the model class based on the component's family name
30
30
  def data_class(new_data_class = nil)
31
- @data_class ||= new_data_class || family_cst.to_s.singularize.constantize
31
+ @data_class ||= new_data_class || family_name.singularize.camelize.constantize
32
32
  end
33
33
 
34
34
  # Instanciate a component with `self` as a parent and render it, having it inherit the resource
@@ -0,0 +1,32 @@
1
+ module Compony
2
+ module Components
3
+ module Buttons
4
+ class CssButton < Link
5
+ def prepare_opts!
6
+ super
7
+ css = [
8
+ 'display:inline-block',
9
+ 'padding:.15rem .35rem',
10
+ 'text-decoration:none',
11
+ 'border-radius:6px',
12
+ 'font-size: 13.333px'
13
+ ]
14
+ if @comp_opts[:class]&.split&.include?('disabled')
15
+ css += [
16
+ 'background:#e9ecef',
17
+ 'color:#6c757d',
18
+ 'cursor: default'
19
+ ]
20
+ else
21
+ css += [
22
+ 'background:#eaeaee',
23
+ 'color:#000000',
24
+ 'border:1px solid #90909e'
25
+ ]
26
+ end
27
+ @comp_opts[:style] = "#{css.join(';')};"
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ module Compony
2
+ module Components
3
+ module Buttons
4
+ class Link < Component
5
+ setup do
6
+ before_render do
7
+ prepare_opts!
8
+ end
9
+
10
+ content do
11
+ concat link_to(@label, @href, **@comp_opts)
12
+ end
13
+ end
14
+
15
+ protected
16
+
17
+ def prepare_opts!
18
+ @label = @comp_opts.delete(:label).presence
19
+ @href = @comp_opts.delete(:href).presence || 'javascript:void(0)'
20
+ @method = @comp_opts.delete(:method).presence
21
+ if @method && @method.to_sym != :get
22
+ @comp_opts[:data] = { turbo_method: @method }.merge(@comp_opts[:data] || {})
23
+ end
24
+ if @comp_opts[:class]&.split&.include?('disabled')
25
+ @comp_opts[:style] = 'text-decoration:line-through;cursor: default;color: #6c757d;'
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -32,10 +32,10 @@ module Compony
32
32
 
33
33
  content :confirm_button, hidden: true do
34
34
  div do
35
- concat compony_button(comp_cst,
36
- @data,
37
- label: I18n.t('compony.components.destroy.confirm_button'),
38
- method: :delete)
35
+ concat render_intent(comp_name,
36
+ @data,
37
+ label: I18n.t('compony.components.destroy.confirm_button'),
38
+ method: :delete)
39
39
  end
40
40
  end
41
41
 
@@ -44,9 +44,10 @@ module Compony
44
44
  content :confirm_button
45
45
  end
46
46
 
47
- action :back_to_owner do
48
- next if data_class.owner_model_attr.blank?
49
- Compony.button(:show, @data.send(data_class.owner_model_attr), icon: :xmark, color: :secondary, label: I18n.t('compony.cancel'))
47
+ exposed_intents do
48
+ if data_class.owner_model_attr
49
+ add :show, @data.send(data_class.owner_model_attr), icon: :xmark, color: :secondary, label: I18n.t('compony.cancel'), name: :back_to_owner
50
+ end
50
51
  end
51
52
 
52
53
  store_data do
@@ -72,7 +73,7 @@ module Compony
72
73
  if data_class.owner_model_attr.present?
73
74
  Compony.path(:show, @data.send(data_class.owner_model_attr))
74
75
  else
75
- Compony.path(:index, family_cst)
76
+ Compony.path(:index, family_name)
76
77
  end
77
78
  end
78
79
  end
@@ -33,9 +33,10 @@ module Compony
33
33
 
34
34
  form_cancancan_action :edit
35
35
 
36
- action :back_to_owner do
37
- next if data_class.owner_model_attr.blank?
38
- Compony.button(:show, @data.send(data_class.owner_model_attr), icon: :xmark, color: :secondary, label: I18n.t('compony.cancel'))
36
+ exposed_intents do
37
+ if data_class.owner_model_attr
38
+ add :show, @data.send(data_class.owner_model_attr), icon: :xmark, color: :secondary, label: I18n.t('compony.cancel'), name: :back_to_owner
39
+ end
39
40
  end
40
41
 
41
42
  content :label do
@@ -78,7 +79,7 @@ module Compony
78
79
  elsif data_class.owner_model_attr.present?
79
80
  Compony.path(:show, @data.send(data_class.owner_model_attr))
80
81
  else
81
- Compony.path(:index, family_cst)
82
+ Compony.path(:index, family_name)
82
83
  end
83
84
  end
84
85
 
@@ -25,9 +25,15 @@ module Compony
25
25
 
26
26
  # Override this to provide a custom submit button
27
27
  content :submit_button, hidden: true do
28
+ # Fake submit button rendered by a button component and submitting the form via JS:
28
29
  concat Compony.button_component_class.new(
29
- label: @submit_label || I18n.t('compony.components.form.submit'), icon: 'arrow-right', type: :submit
30
+ label: @submit_label || I18n.t('compony.components.form.submit'),
31
+ icon: 'arrow-right',
32
+ href: '#',
33
+ onclick: "this.closest('form').requestSubmit(); return false;"
30
34
  ).render(controller)
35
+ # Real (but hidden) submit button to allow Return to submit:
36
+ button type: :submit, hidden: true
31
37
  end
32
38
 
33
39
  # Override this to provide additional submit buttons.
@@ -18,9 +18,9 @@ module Compony
18
18
  @data = data_class.accessible_by(controller.current_ability)
19
19
  end
20
20
 
21
- action :new do
21
+ exposed_intents do
22
22
  if Compony.comp_class_for(:new, data_class)
23
- Compony.button(:new, data_class.model_name.plural)
23
+ add :new, data_class.model_name.plural, name: :new
24
24
  end
25
25
  end
26
26
 
@@ -168,7 +168,7 @@ module Compony
168
168
  unless block_given?
169
169
  block = proc do |record|
170
170
  next if Compony.comp_class_for(name, record).nil?
171
- compony_button(name, record, **button_opts)
171
+ render_intent(name, record, **{ label: { format: :short } }.deep_merge(button_opts))
172
172
  end
173
173
  end
174
174
  @row_actions.natural_push(name, block, **)
@@ -275,9 +275,9 @@ module Compony
275
275
  end
276
276
 
277
277
  # Default row actions (use override or skip_row_action to prevent)
278
- row_action(:show)
279
- row_action(:edit)
280
- row_action(:destroy)
278
+ row_action(:show) if Compony.comp_class_for(:show, data_class)
279
+ row_action(:edit) if Compony.comp_class_for(:edit, data_class)
280
+ row_action(:destroy) if Compony.comp_class_for(:destroy, data_class)
281
281
 
282
282
  before_render do
283
283
  process_data!(controller)
@@ -69,7 +69,7 @@ module Compony
69
69
  elsif data_class.owner_model_attr.present?
70
70
  Compony.path(:show, @data.send(data_class.owner_model_attr))
71
71
  else
72
- Compony.path(:index, family_cst)
72
+ Compony.path(:index, family_name)
73
73
  end
74
74
  end
75
75
 
@@ -16,20 +16,17 @@ module Compony
16
16
  label(:short) { |_| I18n.t('compony.components.show.label.short') }
17
17
  icon { :eye }
18
18
 
19
- action :back_to_owner do
20
- next if data_class.owner_model_attr.blank?
21
- Compony.button(:show, @data.send(data_class.owner_model_attr), icon: :'arrow-left', color: :secondary, label: I18n.t('compony.back'))
22
- end
19
+ exposed_intents do
20
+ if data_class.owner_model_attr
21
+ add :show, @data.send(data_class.owner_model_attr), icon: :xmark, color: :secondary, label: I18n.t('compony.back'), name: :back_to_owner
22
+ end
23
23
 
24
- if Compony.comp_class_for(:edit, family_cst)
25
- action :edit do
26
- Compony.button(:edit, @data, label_opts: { format: :short })
24
+ if Compony.comp_class_for(:edit, family_name)
25
+ add :edit, @data, label: { format: :short }, name: :edit
27
26
  end
28
- end
29
27
 
30
- if Compony.comp_class_for(:destroy, family_cst)
31
- action :destroy do
32
- Compony.button(:destroy, @data, label_opts: { format: :short })
28
+ if Compony.comp_class_for(:destroy, family_name)
29
+ add :destroy, @data, label: { format: :short }, name: :destroy
33
30
  end
34
31
  end
35
32
 
@@ -13,7 +13,7 @@ module Compony
13
13
  # Returns an instance of the form component responsible for rendering the form.
14
14
  # Feel free to override this in subclasses.
15
15
  def form_comp
16
- @form_comp ||= (form_comp_class || Compony.comp_class_for!(:form, family_cst)).new(
16
+ @form_comp ||= (form_comp_class || Compony.comp_class_for!(:form, family_name)).new(
17
17
  self,
18
18
  submit_verb:,
19
19
  # If applicable, Rails adds the route keys automatically, thus, e.g. :id does not need to be passed here, as it comes from the request.
@@ -0,0 +1,29 @@
1
+ module Compony
2
+ class ExposedIntentsDsl < Dslblend::Base
3
+ def initialize(previously_exposed_intents)
4
+ super()
5
+ @exposed_intents = previously_exposed_intents
6
+ end
7
+
8
+ protected
9
+
10
+ # DSL method
11
+ # Adds or replaces an intent to those exposed by the component based on the intent name (override the name if you need to avoid a naming collision).
12
+ # Intents specified this way can be retrieved and rendered by the parent component or by calling `root_intents` in case of standalone access.
13
+ # @param [Symbol] before If specified, will insert the intent before the other. When replacing, an element keeps its position unless `before:`` is passed.
14
+ def add(*, before: nil, **)
15
+ intent = Compony.intent(*, **)
16
+ @exposed_intents.natural_push(intent.name, intent, before:)
17
+ end
18
+
19
+ # DSL method
20
+ # Removes an exposed intent previously added to this component
21
+ # @param [Symbol] intent_name The name of the intent to remove
22
+ def remove(intent_name)
23
+ existing_index = @exposed_intents.find_index { |el| el.name == intent_name.to_sym }
24
+ if existing_index
25
+ @exposed_intents.delete_at(existing_index)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,145 @@
1
+ module Compony
2
+ # @api description
3
+ # An Intent is a a gateway to a component, along with relevant context, such as the comp and family, perhaps a resource, standalone name, feasibility etc.
4
+ # The class provides tooling used by various Compony helpers used to point to other components in some way.
5
+ class Intent
6
+ attr_reader :comp_class
7
+ attr_reader :data
8
+
9
+ # @param comp_name_or_cst_or_class [String,Symbol,Class] The component that should be loaded,
10
+ # for instance `ShowForAll`, `'ShowForAll'` or `:show_for_all`,
11
+ # or can also pass a component class (such as Components::Users::Show)
12
+ # @param model_or_family_name_or_cst [String,Symbol,ApplicationRecord] Either the family that contains the requested component, or an
13
+ # instance implementing `model_name` from which the family name is auto-generated.
14
+ # Examples: `Users`, `'Users'`, `:users`, `User.first`
15
+ # @param standalone_name [Symbol] If given, will override the standalone name for all `path` calls for this intent instance.
16
+ # @param name [Symbol] If given, will override the name of this intent. Defaults to component and family name joined by underscore.
17
+ # @param label [String,Hash] If given, will be used for generating the label. If Hash, is given as options to {Intent#label}.
18
+ # @param path [String,Hash] If given, will be used for generating the path. If Hash, is given as options to {Intent#path}.
19
+ # @param data [ApplicationRecord,Object] If given, the target component will be instanciated with this argument. Omit if your second pos arg is a model.
20
+ # @param data_class [Class] If given, the target component will be instanciated with this argument.
21
+ # @param feasibility_target [ApplicationRecord] If given, will override the feasibility target (prevention framework)
22
+ # @param feasibility_action [ApplicationRecord] If given, will override the feasibility action (prevention framework)
23
+ def initialize(comp_name_or_cst_or_class,
24
+ model_or_family_name_or_cst = nil,
25
+ standalone_name: nil,
26
+ name: nil,
27
+ label: nil,
28
+ path: nil,
29
+ method: nil,
30
+ data: nil,
31
+ data_class: nil,
32
+ feasibility_target: nil,
33
+ feasibility_action: nil,
34
+ **custom_args)
35
+ # Check for model / data
36
+ @data = data
37
+ @data ||= model_or_family_name_or_cst if model_or_family_name_or_cst.respond_to?(:model_name)
38
+ @data_class = data_class
39
+
40
+ # Figure out comp_class
41
+ if comp_name_or_cst_or_class.is_a?(Class) && (comp_name_or_cst_or_class <= Compony::Component)
42
+ # A class was given as the first argument
43
+ @comp_class = comp_name_or_cst_or_class
44
+ else
45
+ # Build the constant from the first two arguments
46
+ family_underscore_str = @data.respond_to?(:model_name) ? @data.model_name.plural : model_or_family_name_or_cst.to_s.underscore
47
+ constant_str = "::Components::#{family_underscore_str.camelize}::#{comp_name_or_cst_or_class.to_s.camelize}"
48
+ @comp_class = constant_str.constantize
49
+ end
50
+
51
+ # Store further arguments
52
+ @name = name&.to_sym
53
+ @standalone_name = standalone_name
54
+ @label = label.is_a?(String) ? label : nil
55
+ @label_opts = label.is_a?(Hash) ? label : {}
56
+ @path = path.is_a?(String) ? path : nil
57
+ @path_opts = path.is_a?(Hash) ? path : {}
58
+ @method = method&.to_sym
59
+ @feasibility_target = feasibility_target
60
+ @feasibility_action = feasibility_action
61
+ @custom_args = custom_args
62
+ end
63
+
64
+ # Returns true for things like User.first, but false for things like :users or User
65
+ def model?
66
+ @model = @data.respond_to?(:model_name) && !@data.is_a?(Class) if @model.nil?
67
+ return @model
68
+ end
69
+
70
+ # Instanciates the component and returns the instance. If `data` and/or `data_class` were specified when instantiating this intent, they are passed.
71
+ # All given arguments will be given to the component's initializer, also overriding `data` and `data_class` if present.
72
+ def comp(*, **)
73
+ return @comp ||= @comp_class.new(*, data: @data, data_class: @data_class, **)
74
+ end
75
+
76
+ # Returns the path to the component.
77
+ # Additional arguments are passed to the component's path block, which typically passes them to the Rails path helper.
78
+ # @param model [ApplicationRecord] If given and non-nil, will override the model passed to the component's path block
79
+ # @param standalone_name [Symbol] If given and non-nil, will override the `standalone_name` passed to the component's path block
80
+ def path(model = nil, *, standalone_name: nil, **path_opt_overrides)
81
+ path_opts = @path_opts.deep_merge(path_opt_overrides)
82
+ comp.path(model || (model? ? @data : nil), standalone_name: standalone_name || @standalone_name, **path_opts)
83
+ end
84
+
85
+ # Returns a name for this intent, consisting of comp and family name. Can be overriden in the constructor.
86
+ # Example: :show_users, :destroy_sessions
87
+ def name
88
+ @name.presence || :"#{comp_class.comp_name}_#{comp_class.family_name}"
89
+ end
90
+
91
+ # Returns the label of buttons produced by this intent.
92
+ def label(model = nil, *, **label_opt_overrides)
93
+ label_opts = @label_opts.deep_merge(label_opt_overrides)
94
+ @label.presence || comp.label(model || (model? ? @data : nil), *, **label_opts)
95
+ end
96
+
97
+ def method
98
+ @method || :get
99
+ end
100
+
101
+ def feasibility_target
102
+ @feasibility_target.presence || model? ? @data : nil
103
+ end
104
+
105
+ def feasibility_action
106
+ @feasibility_action.presence || comp_class.comp_name.to_sym
107
+ end
108
+
109
+ # Returns whether this intent is feasible (no prevention)
110
+ def feasible?
111
+ return true if feasibility_target.blank? || feasibility_action.blank?
112
+ return feasibility_target.feasible?(feasibility_action)
113
+ end
114
+
115
+ # Returns the options that are given to the initializer when creating a button from this intent.
116
+ def button_comp_opts(label: {})
117
+ return @custom_args.deep_merge({
118
+ label: label(**label),
119
+ href: feasible? ? path : nil,
120
+ method:,
121
+ class: feasible? ? nil : 'disabled',
122
+ title: feasible? ? nil : feasibility_target.full_feasibility_messages(feasibility_action).presence
123
+ })
124
+ end
125
+
126
+ # Renders this intent into a button defined by `style`.
127
+ # @param controller [ApplicationController] The controller from the request context, needed to render the button.
128
+ # @param parent_comp [Compony::Component] If called from within a component, pass the component to inform the button that it is nested within.
129
+ # @param style [Symbol] If present, overrides the class of the generated button component, defaults to {Compony#default_button_style}.
130
+ # @param button_comp_opts_overrides [Hash] Any further kwargs are passed to the button component's initializer.
131
+ def render(controller, parent_comp = nil, style: nil, label: {}, **button_comp_opts_overrides)
132
+ # Check if permitted
133
+ return nil unless comp.standalone_access_permitted_for?(controller, standalone_name: @standalone_name, verb: method)
134
+ # Prepare opts
135
+ button_comp_class ||= Compony.button_component_class(*[style].compact)
136
+ button_opts = button_comp_opts(label:).merge(button_comp_opts_overrides)
137
+ # Perform render
138
+ if parent_comp
139
+ return parent_comp.sub_comp(button_comp_class, **button_opts).render(controller)
140
+ else
141
+ button_comp_class.new(**button_opts).render(controller)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -12,7 +12,7 @@ module Compony
12
12
  return transform_and_join(data.send(@name), controller:) do |el|
13
13
  next nil if el.nil?
14
14
  if Compony.comp_class_for(link_to_component, el)
15
- next controller.helpers.compony_link(link_to_component, el, **link_opts)
15
+ next controller.helpers.render_intent(link_to_component, el, **{ button: { style: :link } }.deep_merge(link_opts))
16
16
  else
17
17
  next el.label
18
18
  end
@@ -42,7 +42,12 @@ module Compony
42
42
  return super
43
43
  end
44
44
 
45
+ ################################################
46
+ # View helpers available only inside components:
47
+ ################################################
48
+
45
49
  # Renders a content block from the current component.
50
+ # Does nothing if no such content block exists.
46
51
  def content(name) # rubocop:disable Naming/PredicateMethod
47
52
  name = name.to_sym
48
53
  content_block = component.content_blocks.find { |el| el.name == name }
@@ -59,8 +64,24 @@ module Compony
59
64
  return true
60
65
  end
61
66
 
67
+ # Like {Compony::RequestContext#content}, but fails if no such content block exists.
68
+ # @see Compony::RequestContext#content
62
69
  def content!(name)
63
70
  content(name) || fail("Content block #{name.inspect} not found in #{component.inspect}.")
64
71
  end
72
+
73
+ # View helper that renders an intent through the default button class.
74
+ # All non-mentioned parameters are given to the intent initializer.
75
+ # When inside a request context (`content do...`), this precedes {ViewHelpers#render_intent}.
76
+ # @param button [Hash] Parameters that will be given to the button component initializer.
77
+ def render_intent(*, button: {}, **)
78
+ Compony.intent(*, **).render(controller, component, **button)
79
+ end
80
+
81
+ # View helper that instanciates a sub comp and renders it.
82
+ # Example usage: `concat render_sub_comp(Components::Something::Nested)`
83
+ def render_sub_comp(...)
84
+ sub_comp(...).render(controller)
85
+ end
65
86
  end
66
87
  end