compony 0.11.11 → 0.11.13

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 (139) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +2 -0
  3. data/CHANGELOG.md +13 -0
  4. data/Gemfile +3 -0
  5. data/Gemfile.lock +145 -106
  6. data/README.md +12 -1
  7. data/Rakefile +10 -0
  8. data/TODO.md +2 -1
  9. data/VERSION +1 -1
  10. data/compony.gemspec +5 -3
  11. data/config/locales/es.yml +46 -0
  12. data/doc/ComponentGenerator.html +1 -1
  13. data/doc/Components.html +1 -1
  14. data/doc/ComponentsGenerator.html +1 -1
  15. data/doc/Compony/Component.html +1 -1
  16. data/doc/Compony/ComponentMixins/Default/Labelling.html +1 -1
  17. data/doc/Compony/ComponentMixins/Default/Standalone/ResourcefulVerbDsl.html +1 -1
  18. data/doc/Compony/ComponentMixins/Default/Standalone/StandaloneDsl.html +1 -1
  19. data/doc/Compony/ComponentMixins/Default/Standalone/VerbDsl.html +1 -1
  20. data/doc/Compony/ComponentMixins/Default/Standalone.html +1 -1
  21. data/doc/Compony/ComponentMixins/Default.html +1 -1
  22. data/doc/Compony/ComponentMixins/Resourceful.html +1 -1
  23. data/doc/Compony/ComponentMixins.html +1 -1
  24. data/doc/Compony/Components/Buttons/CssButton.html +1 -1
  25. data/doc/Compony/Components/Buttons/Link.html +1 -1
  26. data/doc/Compony/Components/Buttons.html +1 -1
  27. data/doc/Compony/Components/Destroy.html +1 -1
  28. data/doc/Compony/Components/Edit.html +1 -1
  29. data/doc/Compony/Components/Form.html +1 -1
  30. data/doc/Compony/Components/Index.html +1 -1
  31. data/doc/Compony/Components/List.html +1 -1
  32. data/doc/Compony/Components/New.html +1 -1
  33. data/doc/Compony/Components/Show.html +1 -1
  34. data/doc/Compony/Components/WithForm.html +1 -1
  35. data/doc/Compony/Components.html +1 -1
  36. data/doc/Compony/ControllerMixin.html +1 -1
  37. data/doc/Compony/Engine.html +1 -1
  38. data/doc/Compony/Intent.html +1 -1
  39. data/doc/Compony/ManageIntentsDsl.html +2 -2
  40. data/doc/Compony/MethodAccessibleHash.html +1 -1
  41. data/doc/Compony/ModelFields/Anchormodel.html +1 -1
  42. data/doc/Compony/ModelFields/Association.html +1 -1
  43. data/doc/Compony/ModelFields/Attachment.html +1 -1
  44. data/doc/Compony/ModelFields/Base.html +8 -2
  45. data/doc/Compony/ModelFields/Boolean.html +1 -1
  46. data/doc/Compony/ModelFields/Color.html +1 -1
  47. data/doc/Compony/ModelFields/Currency.html +1 -1
  48. data/doc/Compony/ModelFields/Date.html +1 -1
  49. data/doc/Compony/ModelFields/Datetime.html +1 -1
  50. data/doc/Compony/ModelFields/Decimal.html +1 -1
  51. data/doc/Compony/ModelFields/Email.html +1 -1
  52. data/doc/Compony/ModelFields/Float.html +1 -1
  53. data/doc/Compony/ModelFields/Integer.html +1 -1
  54. data/doc/Compony/ModelFields/Percentage.html +1 -1
  55. data/doc/Compony/ModelFields/Phone.html +1 -1
  56. data/doc/Compony/ModelFields/RichText.html +1 -1
  57. data/doc/Compony/ModelFields/String.html +1 -1
  58. data/doc/Compony/ModelFields/Text.html +1 -1
  59. data/doc/Compony/ModelFields/Time.html +1 -1
  60. data/doc/Compony/ModelFields/Url.html +1 -1
  61. data/doc/Compony/ModelFields.html +1 -1
  62. data/doc/Compony/ModelMixin.html +1 -1
  63. data/doc/Compony/NaturalOrdering.html +1 -1
  64. data/doc/Compony/RequestContext.html +1 -1
  65. data/doc/Compony/Version.html +1 -1
  66. data/doc/Compony/ViewHelpers.html +1 -1
  67. data/doc/Compony/VirtualModel.html +1 -1
  68. data/doc/Compony.html +1 -1
  69. data/doc/ComponyController.html +1 -1
  70. data/doc/_index.html +1 -1
  71. data/doc/file.CHANGELOG.html +88 -76
  72. data/doc/file.README.html +21 -11
  73. data/doc/file.basic_component.html +14 -14
  74. data/doc/file.cookbook.html +27 -27
  75. data/doc/file.destroy.html +2 -2
  76. data/doc/file.dsl_reference.html +199 -199
  77. data/doc/file.edit.html +3 -3
  78. data/doc/file.example.html +13 -13
  79. data/doc/file.example_advanced.html +10 -10
  80. data/doc/file.feasibility.html +2 -2
  81. data/doc/file.form.html +7 -7
  82. data/doc/file.generators.html +3 -3
  83. data/doc/file.glossary.html +44 -44
  84. data/doc/file.gotchas.html +35 -35
  85. data/doc/file.index.html +10 -10
  86. data/doc/file.inheritance.html +5 -5
  87. data/doc/file.installation.html +9 -9
  88. data/doc/file.integrations.html +46 -46
  89. data/doc/file.intents.html +11 -11
  90. data/doc/file.internal_datastructures.html +6 -6
  91. data/doc/file.list.html +35 -35
  92. data/doc/file.maintaining.html +21 -21
  93. data/doc/file.model_fields.html +3 -3
  94. data/doc/file.nesting.html +5 -5
  95. data/doc/file.new.html +3 -3
  96. data/doc/file.ownership.html +2 -2
  97. data/doc/file.patterns.html +50 -50
  98. data/doc/file.pre_built_components.html +2 -2
  99. data/doc/file.resourceful.html +5 -5
  100. data/doc/file.show.html +17 -17
  101. data/doc/file.standalone.html +12 -12
  102. data/doc/file.virtual_models.html +2 -2
  103. data/doc/file.with_form.html +23 -23
  104. data/doc/index.html +21 -11
  105. data/doc/top-level-namespace.html +1 -1
  106. data/lib/compony/model_fields/base.rb +3 -0
  107. data/spec/README.md +22 -0
  108. data/spec/compony/component_spec.rb +80 -0
  109. data/spec/compony/compony_spec.rb +102 -0
  110. data/spec/compony/intent_spec.rb +94 -0
  111. data/spec/compony/method_accessible_hash_spec.rb +32 -0
  112. data/spec/compony/model_fields_spec.rb +70 -0
  113. data/spec/compony/model_mixin_spec.rb +81 -0
  114. data/spec/compony/natural_ordering_spec.rb +61 -0
  115. data/spec/dummy/app/components/statics/home.rb +17 -0
  116. data/spec/dummy/app/components/statics/secret.rb +15 -0
  117. data/spec/dummy/app/components/users/destroy.rb +2 -0
  118. data/spec/dummy/app/components/users/edit.rb +2 -0
  119. data/spec/dummy/app/components/users/form.rb +14 -0
  120. data/spec/dummy/app/components/users/index.rb +29 -0
  121. data/spec/dummy/app/components/users/new.rb +2 -0
  122. data/spec/dummy/app/components/users/show.rb +26 -0
  123. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  124. data/spec/dummy/app/models/ability.rb +7 -0
  125. data/spec/dummy/app/models/application_record.rb +4 -0
  126. data/spec/dummy/app/models/user.rb +22 -0
  127. data/spec/dummy/app/views/layouts/application.html.erb +7 -0
  128. data/spec/dummy/config/application.rb +20 -0
  129. data/spec/dummy/config/database.yml +3 -0
  130. data/spec/dummy/config/environment.rb +3 -0
  131. data/spec/dummy/config/initializers/simple_form.rb +9 -0
  132. data/spec/dummy/config/routes.rb +3 -0
  133. data/spec/dummy/db/schema.rb +14 -0
  134. data/spec/rails_helper.rb +23 -0
  135. data/spec/requests/authorization_spec.rb +16 -0
  136. data/spec/requests/plain_component_spec.rb +9 -0
  137. data/spec/requests/routing_spec.rb +20 -0
  138. data/spec/requests/users_crud_spec.rb +99 -0
  139. metadata +63 -1
@@ -63,22 +63,22 @@
63
63
  <p><a href="/doc/guide/pre_built_components_md.html">List of pre-built components</a></p>
64
64
  </li></ul>
65
65
 
66
- <h1 id="label-Pre-built+components-3A+WithForm">Pre-built components: WithForm</h1>
66
+ <h1 id="pre-built-components-withform">Pre-built components: WithForm</h1>
67
67
 
68
- <p><code>Compony::Components::WithForm</code> is the abstract base for components that render and submit a form. It is <strong>twinned</strong> with a href="./form_md.html"></a> component: WithForm provides the route, authorization and resource handling; the Form provides the inputs and param schema. href="./new_md.html"></a> and href="./edit_md.html"></a> both inherit from WithForm — you rarely subclass it directly, but understanding the twinning explains how they work.</p>
68
+ <p><code>Compony::Components::WithForm</code> is the abstract base for components that render and submit a form. It is <strong>twinned</strong> with a <a href="./form_md.html"><code>Form</code></a> component: WithForm provides the route, authorization and resource handling; the Form provides the inputs and param schema. <a href="./new_md.html"><code>New</code></a> and <a href="./edit_md.html"><code>Edit</code></a> both inherit from WithForm — you rarely subclass it directly, but understanding the twinning explains how they work.</p>
69
69
 
70
70
  <p>A WithForm component may be resourceful (New/Edit are) but does not have to be.</p>
71
71
 
72
- <h2 id="label-How+the+twinning+works">How the twinning works</h2>
72
+ <h2 id="how-the-twinning-works">How the twinning works</h2>
73
73
  <ul><li>
74
- <p><code>form_comp</code> returns the Form instance, built lazily. It defaults to the component named <code>Form</code> in the <strong>same family</strong>, instantiated with this component as <code>parent_comp</code> and passed <code>submit_verb</code>, <code>submit_path</code> and <code>cancancan_action</code>.</p>
74
+ <p><code>form_comp</code> returns the Form instance, built lazily. It defaults to the component named <code>Form</code> in the <strong>same family</strong>, instantiated with this component as <code>parent_comp</code> and passed <code>submit_verb</code>, <code>submit_path</code> and <code>cancancan_action</code>.</p>
75
75
  </li><li>
76
- <p>The Form renders inside this component’s <code>content</code> (e.g. New/Edit do <code>concat form_comp.render(controller, data: @data)</code>).</p>
76
+ <p>The Form renders inside this component’s <code>content</code> (e.g. New/Edit do <code>concat form_comp.render(controller, data: @data)</code>).</p>
77
77
  </li><li>
78
- <p>The form’s <code>&lt;form&gt;</code> posts back to <code>submit_path</code> using <code>submit_verb</code>; that same component handles the submit verb (POST for New, PATCH for Edit) and runs the resourceful lifecycle (<code>assign_attributes</code> → <code>store_data</code> → <code>respond</code>).</p>
78
+ <p>The form’s <code>&lt;form&gt;</code> posts back to <code>submit_path</code> using <code>submit_verb</code>; that same component handles the submit verb (POST for New, PATCH for Edit) and runs the resourceful lifecycle (<code>assign_attributes</code> → <code>store_data</code> → <code>respond</code>).</p>
79
79
  </li></ul>
80
80
 
81
- <h2 id="label-DSL+methods">DSL methods</h2>
81
+ <h2 id="dsl-methods">DSL methods</h2>
82
82
 
83
83
  <table role="table">
84
84
  <thead>
@@ -90,34 +90,34 @@
90
90
  </thead>
91
91
  <tbody>
92
92
  <tr>
93
- <td>‘submit_verb`</td>
94
- <td>‘submit_verb(:patch)`</td>
95
- <td>HTTP verb the twinned form submits with. Mandatory (New sets ‘:post`, Edit `:patch`).</td>
93
+ <td><code>submit_verb</code></td>
94
+ <td><code>submit_verb(:patch)</code></td>
95
+ <td>HTTP verb the twinned form submits with. Mandatory (New sets <code>:post</code>, Edit <code>:patch</code>).</td>
96
96
  </tr>
97
97
  <tr>
98
- <td>‘form_comp_class`</td>
99
- <td>‘form_comp_class(Components::Users::SignupForm)`</td>
100
- <td>Use a specific Form class instead of the same-family Form`.</td>
98
+ <td><code>form_comp_class</code></td>
99
+ <td><code>form_comp_class(Components::Users::SignupForm)</code></td>
100
+ <td>Use a specific Form class instead of the same-family <code>Form</code>.</td>
101
101
  </tr>
102
102
  <tr>
103
- <td>‘submit_path`</td>
104
- <td>‘submit_path { Compony.path(:create, :users) }`</td>
103
+ <td><code>submit_path</code></td>
104
+ <td><code>submit_path { Compony.path(:create, :users) }</code></td>
105
105
  <td>Override where the form submits. Block is given the controller; defaults to this component’s own path.</td>
106
106
  </tr>
107
107
  <tr>
108
- <td>‘form_cancancan_action`</td>
109
- <td>‘form_cancancan_action(:edit)`</td>
110
- <td>CanCanCan action used by the Form for per-field permitted_attributes` (New sets `:new`, Edit `:edit`). Pass `nil` to disable per-field auth.</td>
108
+ <td><code>form_cancancan_action</code></td>
109
+ <td><code>form_cancancan_action(:edit)</code></td>
110
+ <td>CanCanCan action used by the Form for per-field <code>permitted_attributes</code> (New sets <code>:new</code>, Edit <code>:edit</code>). Pass <code>nil</code> to disable per-field auth.</td>
111
111
  </tr>
112
112
  <tr>
113
- <td>‘form_comp`</td>
114
- <td>‘form_comp`</td>
113
+ <td><code>form_comp</code></td>
114
+ <td><code>form_comp</code></td>
115
115
  <td>(Not DSL — a reader.) The Form instance; override in a subclass for full control.</td>
116
116
  </tr>
117
117
  </tbody>
118
118
  </table>
119
119
 
120
- <h2 id="label-Example-3A+a+custom+non-default+form">Example: a custom non-default form</h2>
120
+ <h2 id="example-a-custom-non-default-form">Example: a custom non-default form</h2>
121
121
 
122
122
  <pre class="code ruby"><code class="ruby"><span class='kw'>class</span> <span class='const'><span class='object_link'><a href="Components.html" title="Components (module)">Components</a></span></span><span class='op'>::</span><span class='const'>Users</span><span class='op'>::</span><span class='const'>Signup</span> <span class='op'>&lt;</span> <span class='const'><span class='object_link'><a href="Compony.html" title="Compony (module)">Compony</a></span></span><span class='op'>::</span><span class='const'><span class='object_link'><a href="Compony/Components.html" title="Compony::Components (module)">Components</a></span></span><span class='op'>::</span><span class='const'><span class='object_link'><a href="Compony/Components/New.html" title="Compony::Components::New (class)">New</a></span></span>
123
123
  <span class='id identifier rubyid_setup'>setup</span> <span class='kw'>do</span>
@@ -143,11 +143,11 @@
143
143
  <span class='kw'>end</span>
144
144
  </code></pre>
145
145
 
146
- <p>For the full submit/redirect behavior see href="./new_md.html"></a> and href="./edit_md.html"></a>; for the form input/schema DSL see href="./form_md.html"></a>; for the lifecycle hooks see <a href="/doc/guide/resourceful_md.html">Resourceful</a>. Multi-step and clone flows that reuse this machinery are in <a href="../patterns_md.html#11-non-crud-job-dispatch-toggles-clone">Real-world patterns</a>.</p>
146
+ <p>For the full submit/redirect behavior see <a href="./new_md.html"><code>New</code></a> and <a href="./edit_md.html"><code>Edit</code></a>; for the form input/schema DSL see <a href="./form_md.html"><code>Form</code></a>; for the lifecycle hooks see <a href="/doc/guide/resourceful_md.html">Resourceful</a>. Multi-step and clone flows that reuse this machinery are in <a href="../patterns_md.html#11-non-crud-job-dispatch-toggles-clone">Real-world patterns</a>.</p>
147
147
  </div></div>
148
148
 
149
149
  <div id="footer">
150
- Generated on Sat Jun 6 09:14:47 2026 by
150
+ Generated on Tue Aug 18 14:36:34 2026 by
151
151
  <a href="https://yardoc.org" title="Yay! A Ruby Documentation Tool" target="_parent">yard</a>
152
152
  0.9.34 (ruby-3.4.9).
153
153
  </div>
data/doc/index.html CHANGED
@@ -60,7 +60,7 @@
60
60
  <div id="content"><div id='filecontents'>
61
61
  <p>&lt;img src=“logo.svg” height=250 alt=“Compony logo”/&gt;</p>
62
62
 
63
- <h1 id="label-Where+to+read+what">Where to read what</h1>
63
+ <h1 id="where-to-read-what">Where to read what</h1>
64
64
  <ul><li>
65
65
  <p>Get to know what Compony is and which concepts it uses in the <a href="https://github.com/kalsan/compony">README on GitHub</a>.</p>
66
66
  </li><li>
@@ -69,7 +69,7 @@
69
69
  <p>There is also a <a href="https://www.rubydoc.info/github/kalsan/compony.git">RubyDoc page for Compony</a> available.</p>
70
70
  </li></ul>
71
71
 
72
- <h1 id="label-About+Compony">About Compony</h1>
72
+ <h1 id="about-compony">About Compony</h1>
73
73
 
74
74
  <p>Compony is a Gem that allows you to write your Rails application in <strong>component-style</strong> fashion. It combines a controller action and route along with its view into a single Ruby class. Along with the DSL approach and a powerful model mixin, Compony <strong>makes your application’s code more semantic</strong>, allows writing <strong>much DRYer code</strong>, using inheritance even in views and <strong>much easier refactoring</strong> for your Rails applications, helping you to <strong>keep the code clean as the application evolves</strong>.</p>
75
75
 
@@ -95,15 +95,15 @@
95
95
  <p>Compony uses <a href="https://github.com/CanCanCommunity/cancancan">CanCanCan</a> for authorization but does not provide an authentication mechanism. You can easily build your own by creating login/logout components that manage cookies, and configure Compony to enforce authentication using the <code>Compony.authentication_before_action</code> setter. I have also successfully tested Compony to work with <a href="https://github.com/heartcombo/devise">Devise</a>.</p>
96
96
  </li></ul>
97
97
 
98
- <h2 id="label-State+of+the+project">State of the project</h2>
98
+ <h2 id="state-of-the-project">State of the project</h2>
99
99
 
100
- <p>I am actively using this framework in various applications and both performance and reliability are good. However, the project is experimental and lacking peer reviews and especially automatic testing, such as unit and integration tests. Also, expect there to be (<a href="/CHANGELOG_md.html">documented</a>) breaking changes in the future, as the API will likely be further refined, resulting in renamings and deprecation of various methods.</p>
100
+ <p>I am actively using this framework in various applications and both performance and reliability are good. However, the project is experimental and lacking peer reviews and especially extensive automatic testing, such as unit and integration tests. Also, expect there to be (<a href="/CHANGELOG_md.html">documented</a>) breaking changes in the future, as the API will likely be further refined, resulting in renamings and deprecation of various methods.</p>
101
101
 
102
- <h2 id="label-Other+projects+exploring+similar+concepts">Other projects exploring similar concepts</h2>
102
+ <h2 id="other-projects-exploring-similar-concepts">Other projects exploring similar concepts</h2>
103
103
 
104
104
  <p>A project with a similar aim, but a different approach, is <a href="https://github.com/phlex-ruby/phlex">Phlex</a>.</p>
105
105
 
106
- <h1 id="label-Guide+-2F+Documentation">Guide / Documentation</h1>
106
+ <h1 id="guide--documentation">Guide / Documentation</h1>
107
107
 
108
108
  <p>The following topics should help you get started. Please note that the links will not work in yard doc - read this on <a href="https://github.com/kalsan/compony">Compony’s GitHub repo</a>.</p>
109
109
  <ul><li>
@@ -181,18 +181,28 @@
181
181
  </li></ul>
182
182
  </li></ul>
183
183
 
184
- <h1 id="label-Contributing">Contributing</h1>
184
+ <h1 id="contributing">Contributing</h1>
185
185
 
186
186
  <p>Compony is Free Software under the LGPLv3 and you are most welcome to contribute to it.</p>
187
187
  <ul><li>
188
- <p>If you spotted a security vulnerability, <strong>do not open an issue</strong> but instead use the contact form at <a href="https://kalsan.ch/#contact">kalsan.ch/#contact</a> instead (English is just fine, even if the website is in German).</p>
188
+ <p>If you spotted a security vulnerability, <strong>do not open an issue</strong> but instead use the contact form at <a href="https://kalsan.ch/#contact">https://kalsan.ch/#contact</a> instead (English is just fine, even if the website is in German).</p>
189
189
  </li><li>
190
190
  <p>If you’d like to contribute feedback or discuss something, please open an issue.</p>
191
191
  </li><li>
192
192
  <p>If you have an idea that is worth implementing, please fork the repo, implement your changes in your own fork, and open a pull request.</p>
193
193
  </li></ul>
194
194
 
195
- <h1 id="label-Caveats">Caveats</h1>
195
+ <h2 id="running-the-tests">Running the tests</h2>
196
+
197
+ <p>The gem ships an RSpec suite backed by a minimal dummy Rails app (in-memory SQLite, no setup needed):</p>
198
+
199
+ <pre class="code ruby"><code class="ruby"><span class='id identifier rubyid_bundle'>bundle</span> <span class='id identifier rubyid_install'>install</span>
200
+ <span class='id identifier rubyid_bundle'>bundle</span> <span class='id identifier rubyid_exec'>exec</span> <span class='id identifier rubyid_rspec'>rspec</span> <span class='comment'># or: bundle exec rake spec (also the default rake task)
201
+ </span></code></pre>
202
+
203
+ <p>See <a href="./spec/README_md.html">spec/README.md</a> for the suite’s layout and the reasons behind the Gemfile’s <code>rails</code> and <code>rack</code> version pins.</p>
204
+
205
+ <h1 id="caveats">Caveats</h1>
196
206
  <ul><li>
197
207
  <p>The API is not yet as consistent as I’d like it. Examples:</p>
198
208
  </li><li>
@@ -212,7 +222,7 @@
212
222
  <p>Compony is not compatible with <code>tailwindcss-rails</code>. This is likely due to Tailwind automatically removing any CSS that is not used by the application and the usage detection not picking up Compony components, as their content is not provided in views.</p>
213
223
  </li></ul>
214
224
 
215
- <h1 id="label-Acknowledgements">Acknowledgements</h1>
225
+ <h1 id="acknowledgements">Acknowledgements</h1>
216
226
 
217
227
  <p>A big thank you to Alex and Koni who have patiently listened to my weird ideas and helped me developing them further, resulting in a few of the key concepts of Compony, such as <code>param_name</code>, or the way forms are structured.</p>
218
228
 
@@ -220,7 +230,7 @@
220
230
  </div></div>
221
231
 
222
232
  <div id="footer">
223
- Generated on Sat Jun 6 09:14:45 2026 by
233
+ Generated on Tue Aug 18 14:36:32 2026 by
224
234
  <a href="https://yardoc.org" title="Yay! A Ruby Documentation Tool" target="_parent">yard</a>
225
235
  0.9.34 (ruby-3.4.9).
226
236
  </div>
@@ -102,7 +102,7 @@
102
102
  </div>
103
103
 
104
104
  <div id="footer">
105
- Generated on Sat Jun 6 09:14:48 2026 by
105
+ Generated on Tue Aug 18 14:36:35 2026 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.4.9).
108
108
  </div>
@@ -72,6 +72,9 @@ module Compony
72
72
  data = data.compact.map(&transform_block) if block_given?
73
73
  return controller.helpers.safe_join(data.compact, ', ')
74
74
  else
75
+ # A nil value has nothing to transform, and the transform blocks are written for actual values, thus they are
76
+ # not required to be nil safe. This mirrors the compact applied to enumerables above.
77
+ return nil if data.nil?
75
78
  data = transform_block.call(data) if block_given?
76
79
  return data
77
80
  end
data/spec/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # Compony test suite
2
+
3
+ Run with `bundle exec rspec` (or `rake spec`).
4
+
5
+ ## Layout
6
+
7
+ - `spec/dummy` — a minimal Rails app (in-memory SQLite) hosting the gem, with a `User`
8
+ model, CRUD components under `app/components/users`, and plain components under
9
+ `app/components/statics`. The schema lives in `spec/dummy/db/schema.rb` and is loaded
10
+ fresh on boot by `spec/rails_helper.rb`.
11
+ - `spec/compony` — unit specs for the plain-Ruby parts (module API, intents, model mixin,
12
+ model fields, `MethodAccessibleHash`, `NaturalOrdering`, component DSL basics).
13
+ - `spec/requests` — request specs exercising routing, rendering, the pre-built CRUD
14
+ components (Index/Show/New/Edit/Destroy/Form), feasibility and authorization end-to-end.
15
+
16
+ ## Version pins (Gemfile)
17
+
18
+ - `rack >= 3.1`: the pre-built components respond with the `:unprocessable_content`
19
+ status, which only exists from Rack 3.1.
20
+ - `rails ~> 7.2.1`: Rails 8.1 renamed the form helper `text_area` to `textarea`, which
21
+ collides with Dyny 0.0.3's `textarea` HTML tag helper on the view context and breaks
22
+ every simple_form textarea. Remove the pin once Dyny is fixed for Rails >= 8.1.
@@ -0,0 +1,80 @@
1
+ RSpec.describe Compony::Component do
2
+ describe 'naming' do
3
+ it 'knows its comp and family name' do
4
+ expect(Components::Users::Show.comp_name).to eq('show')
5
+ expect(Components::Users::Show.family_name).to eq('users')
6
+ end
7
+ end
8
+
9
+ describe 'labelling' do
10
+ let(:user) { User.create!(name: 'Alice') }
11
+
12
+ it 'returns the long label by default' do
13
+ expect(Components::Users::Show.new.label(user)).to eq('Show user Alice')
14
+ end
15
+
16
+ it 'returns the short label on demand' do
17
+ expect(Components::Users::Show.new.label(user, format: :short)).to eq('Show')
18
+ end
19
+
20
+ it 'applies label(:all) to both formats' do
21
+ comp = Components::Users::Index.new
22
+ expect(comp.label(format: :long)).to eq('Users')
23
+ expect(comp.label(format: :short)).to eq('Users')
24
+ end
25
+ end
26
+
27
+ describe 'setup inheritance' do
28
+ it 'runs the parent setup before the child setup, letting children override' do
29
+ parent = Class.new(Compony::Component) do
30
+ setup do
31
+ label(:all) { 'Parent' }
32
+ end
33
+ end
34
+ child = Class.new(parent) do
35
+ setup do
36
+ label(:all) { 'Child' }
37
+ end
38
+ end
39
+ stub_const('Components::Tests::Parent', parent)
40
+ stub_const('Components::Tests::Child', child)
41
+ expect(child.new.label).to eq('Child')
42
+ expect(parent.new.label).to eq('Parent')
43
+ end
44
+ end
45
+
46
+ describe 'standalone config' do
47
+ it 'stores path and verbs' do
48
+ config = Components::Users::Show.new.standalone_configs[nil] || Components::Users::Show.new.standalone_configs.values.first
49
+ expect(config[:path]).to eq('users/show/:id')
50
+ expect(config[:verbs].keys).to eq([:get])
51
+ end
52
+
53
+ it 'marks components without standalone as not standalone-routed' do
54
+ comp_class = Class.new(Compony::Component) do
55
+ setup do
56
+ label(:all) { 'Nested' }
57
+ content { div 'hi' }
58
+ end
59
+ end
60
+ stub_const('Components::Tests::Nested', comp_class)
61
+ expect(comp_class.new.standalone_configs.values.pluck(:path).compact).to be_empty
62
+ end
63
+
64
+ it 'records skip_authentication!' do
65
+ config = Components::Statics::Home.new.standalone_configs.values.first
66
+ expect(config[:skip_authentication]).to be(true)
67
+ end
68
+ end
69
+
70
+ describe 'resourceful' do
71
+ it 'marks resourceful components' do
72
+ expect(Components::Users::Show.new.resourceful?).to be(true)
73
+ expect(Components::Statics::Home.new.resourceful?).to be(false)
74
+ end
75
+
76
+ it 'infers the data class from the family name' do
77
+ expect(Components::Users::Show.new.data_class).to eq(User)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,102 @@
1
+ RSpec.describe Compony do
2
+ describe '.family_name_for' do
3
+ it 'underscores strings and symbols' do
4
+ expect(described_class.family_name_for('Users')).to eq('users')
5
+ expect(described_class.family_name_for(:Users)).to eq('users')
6
+ expect(described_class.family_name_for(:users)).to eq('users')
7
+ end
8
+
9
+ it 'derives the family from a model instance' do
10
+ expect(described_class.family_name_for(User.new)).to eq('users')
11
+ end
12
+
13
+ it 'derives the family from a model class' do
14
+ expect(described_class.family_name_for(User)).to eq('users')
15
+ end
16
+ end
17
+
18
+ describe '.comp_class_for' do
19
+ it 'resolves a component from comp and family symbols' do
20
+ expect(described_class.comp_class_for(:show, :users)).to eq(Components::Users::Show)
21
+ end
22
+
23
+ it 'resolves a component from a model instance' do
24
+ expect(described_class.comp_class_for(:show, User.new)).to eq(Components::Users::Show)
25
+ end
26
+
27
+ it 'returns nil for a missing component' do
28
+ expect(described_class.comp_class_for(:missing, :users)).to be_nil
29
+ end
30
+ end
31
+
32
+ describe '.comp_class_for!' do
33
+ it 'fails for a missing component' do
34
+ expect { described_class.comp_class_for!(:missing, :users) }.to raise_error(RuntimeError, /No component found/)
35
+ end
36
+
37
+ it 'resolves an existing component' do
38
+ expect(described_class.comp_class_for!(:edit, :users)).to eq(Components::Users::Edit)
39
+ end
40
+ end
41
+
42
+ describe '.intent' do
43
+ it 'builds an intent' do
44
+ intent = described_class.intent(:show, :users)
45
+ expect(intent).to be_a(Compony::Intent)
46
+ expect(intent.comp_class).to eq(Components::Users::Show)
47
+ end
48
+
49
+ it 'passes through an existing intent' do
50
+ intent = described_class.intent(:show, :users)
51
+ expect(described_class.intent(intent)).to be(intent)
52
+ end
53
+ end
54
+
55
+ describe '.path' do
56
+ it 'generates the path for a component with a model' do
57
+ user = User.create!(name: 'Alice')
58
+ expect(described_class.path(:show, user)).to eq("/users/show/#{user.id}")
59
+ end
60
+
61
+ it 'generates the path for a component without model' do
62
+ expect(described_class.path(:index, :users)).to eq('/users')
63
+ end
64
+ end
65
+
66
+ describe '.button_component_class' do
67
+ it 'returns the default css button class' do
68
+ expect(described_class.button_component_class).to eq(Compony::Components::Buttons::CssButton)
69
+ end
70
+
71
+ it 'returns the link class for :link' do
72
+ expect(described_class.button_component_class(:link)).to eq(Compony::Components::Buttons::Link)
73
+ end
74
+
75
+ it 'fails for an unknown style' do
76
+ expect { described_class.button_component_class(:nope) }.to raise_error(RuntimeError, /Unknown button style/)
77
+ end
78
+ end
79
+
80
+ describe '.default_button_style' do
81
+ it 'defaults to :css_button' do
82
+ expect(described_class.default_button_style).to eq(:css_button)
83
+ end
84
+ end
85
+
86
+ describe '.model_field_class_for' do
87
+ it 'resolves built-in field classes' do
88
+ expect(described_class.model_field_class_for('String')).to eq(Compony::ModelFields::String)
89
+ expect(described_class.model_field_class_for('Date')).to eq(Compony::ModelFields::Date)
90
+ end
91
+
92
+ it 'fails for unknown field types' do
93
+ expect { described_class.model_field_class_for('Nope') }.to raise_error(RuntimeError, /No `model_field_namespace`/)
94
+ end
95
+ end
96
+
97
+ describe '.model_field_namespaces' do
98
+ it 'defaults to the built-in namespace' do
99
+ expect(described_class.model_field_namespaces).to eq(['Compony::ModelFields'])
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,94 @@
1
+ RSpec.describe Compony::Intent do
2
+ let(:user) { User.create!(name: 'Alice') }
3
+ let(:admin) { User.create!(name: 'Root', admin: true) }
4
+
5
+ describe 'component resolution' do
6
+ it 'resolves from comp and family' do
7
+ expect(described_class.new(:show, :users).comp_class).to eq(Components::Users::Show)
8
+ end
9
+
10
+ it 'resolves from a model' do
11
+ expect(described_class.new(:show, user).comp_class).to eq(Components::Users::Show)
12
+ end
13
+
14
+ it 'accepts a component class directly' do
15
+ expect(described_class.new(Components::Users::Show).comp_class).to eq(Components::Users::Show)
16
+ end
17
+
18
+ it 'fails when neither component nor name/label are given' do
19
+ expect { described_class.new }.to raise_error(RuntimeError, /must be given the kwargs/)
20
+ end
21
+
22
+ it 'allows a component-less intent with name and label' do
23
+ intent = described_class.new(name: :external, label: 'External', path: 'https://example.com')
24
+ expect(intent.comp_class).to be_nil
25
+ expect(intent.name).to eq(:external)
26
+ expect(intent.label).to eq('External')
27
+ expect(intent.path).to eq('https://example.com')
28
+ end
29
+ end
30
+
31
+ describe '#model?' do
32
+ it 'is true for a record' do
33
+ expect(described_class.new(:show, user).model?).to be(true)
34
+ end
35
+
36
+ it 'is false for a family symbol' do
37
+ expect(described_class.new(:index, :users).model?).to be(false)
38
+ end
39
+ end
40
+
41
+ describe '#path' do
42
+ it 'builds the path from the model' do
43
+ expect(described_class.new(:show, user).path).to eq("/users/show/#{user.id}")
44
+ end
45
+
46
+ it 'prefers an explicitly given path string' do
47
+ expect(described_class.new(:show, user, path: '/custom').path).to eq('/custom')
48
+ end
49
+ end
50
+
51
+ describe '#name' do
52
+ it 'joins comp and family name' do
53
+ expect(described_class.new(:show, :users).name).to eq(:show_users)
54
+ end
55
+ end
56
+
57
+ describe '#method' do
58
+ it 'defaults to :get' do
59
+ expect(described_class.new(:show, user).method).to eq(:get)
60
+ end
61
+
62
+ it 'can be overridden' do
63
+ expect(described_class.new(:destroy, user, method: :delete).method).to eq(:delete)
64
+ end
65
+ end
66
+
67
+ describe 'feasibility' do
68
+ it 'is feasible for allowed actions' do
69
+ expect(described_class.new(:destroy, user).feasible?).to be(true)
70
+ end
71
+
72
+ it 'is infeasible when the model prevents the action' do
73
+ expect(described_class.new(:destroy, admin).feasible?).to be(false)
74
+ end
75
+
76
+ it 'is feasible when no model is involved' do
77
+ expect(described_class.new(:index, :users).feasible?).to be(true)
78
+ end
79
+
80
+ it 'disables the button when infeasible' do
81
+ opts = described_class.new(:destroy, admin).button_comp_opts
82
+ expect(opts[:href]).to be_nil
83
+ expect(opts[:class]).to eq('disabled')
84
+ expect(opts[:title]).to eq('Cannot destroy admins.')
85
+ end
86
+
87
+ it 'enables the button when feasible' do
88
+ opts = described_class.new(:destroy, user).button_comp_opts
89
+ expect(opts[:href]).to be_present
90
+ expect(opts[:class]).to be_nil
91
+ expect(opts[:title]).to be_nil
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,32 @@
1
+ RSpec.describe Compony::MethodAccessibleHash do
2
+ it 'symbolizes keys given in the constructor' do
3
+ hash = described_class.new('foo' => 1, bar: 2)
4
+ expect(hash[:foo]).to eq(1)
5
+ expect(hash[:bar]).to eq(2)
6
+ end
7
+
8
+ it 'provides read access via methods' do
9
+ hash = described_class.new(foo: :bar)
10
+ expect(hash.foo).to eq(:bar)
11
+ end
12
+
13
+ it 'returns nil for missing keys' do
14
+ expect(described_class.new.anything).to be_nil
15
+ end
16
+
17
+ it 'provides write access via methods' do
18
+ hash = described_class.new
19
+ hash.foo = 42
20
+ expect(hash[:foo]).to eq(42)
21
+ end
22
+
23
+ it 'responds to any method' do
24
+ expect(described_class.new).to respond_to(:whatever)
25
+ end
26
+
27
+ it 'symbolizes keys on merge' do
28
+ merged = described_class.new(foo: 1).merge('bar' => 2)
29
+ expect(merged[:bar]).to eq(2)
30
+ expect(merged['bar']).to be_nil
31
+ end
32
+ end
@@ -0,0 +1,70 @@
1
+ RSpec.describe 'Compony::ModelFields' do
2
+ let(:user) do
3
+ User.create!(name: 'Alice', age: 30, comment: 'Hi', email: 'alice@example.com', admin: true, born_on: Date.new(1990, 5, 6))
4
+ end
5
+
6
+ let(:controller) do
7
+ ApplicationController.new.tap { |controller| controller.set_request!(ActionDispatch::Request.new({})) }
8
+ end
9
+
10
+ describe 'value_for' do
11
+ it 'returns the raw value for string fields' do
12
+ expect(User.fields[:name].value_for(user)).to eq('Alice')
13
+ end
14
+
15
+ it 'formats booleans via I18n' do
16
+ value = User.fields[:admin].value_for(user)
17
+ expect(value).to be_present
18
+ expect(value).not_to eq(true) # must be a human-readable representation, not the raw boolean
19
+ end
20
+
21
+ it 'localizes dates' do
22
+ expect(User.fields[:born_on].value_for(user)).to eq(I18n.l(Date.new(1990, 5, 6)))
23
+ end
24
+
25
+ it 'renders emails as mailto links when given a controller' do
26
+ controller = ApplicationController.new
27
+ controller.set_request!(ActionDispatch::Request.new({}))
28
+ value = User.fields[:email].value_for(user, controller:)
29
+ expect(value).to include('mailto:alice@example.com')
30
+ end
31
+
32
+ it 'fails for emails without controller' do
33
+ expect { User.fields[:email].value_for(user) }.to raise_error(RuntimeError, /Must pass controller/)
34
+ end
35
+
36
+ it 'formats percentages' do
37
+ user.update!(margin_relative: 0.5)
38
+ expect(User.fields[:margin_relative].value_for(user, controller:)).to eq('50.0%')
39
+ end
40
+
41
+ it 'returns nil-safe values for blank fields' do
42
+ empty = User.create!(name: 'Bob')
43
+ expect(User.fields[:born_on].value_for(empty)).to be_blank
44
+ expect(User.fields[:email].value_for(empty, controller: ApplicationController.new)).to be_blank
45
+ end
46
+
47
+ # A field may legitimately hold no value, e.g. a percentage that relates to zero. Formatting such a value must not
48
+ # raise, no matter which type the field has.
49
+ it 'returns nil for every field type of a record that has no values' do
50
+ empty = User.create!(name: 'Bob')
51
+ User.fields.each_value do |field|
52
+ next unless empty.send(field.name).nil? # e.g. name is given and admin defaults to false
53
+ expect { field.value_for(empty, controller:) }.not_to raise_error, "field #{field.name} raised"
54
+ expect(field.value_for(empty, controller:)).to be_blank, "field #{field.name} did not answer with a blank value"
55
+ end
56
+ end
57
+ end
58
+
59
+ describe 'label' do
60
+ it 'uses human_attribute_name' do
61
+ expect(User.fields[:name].label).to eq(User.human_attribute_name(:name))
62
+ end
63
+ end
64
+
65
+ describe 'schema_line' do
66
+ it 'returns a proc for schemacop integration' do
67
+ expect(User.fields[:name].schema_line).to be_a(Proc)
68
+ end
69
+ end
70
+ end