compony 0.11.12 → 0.11.14

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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +2 -0
  3. data/CHANGELOG.md +16 -0
  4. data/CLAUDE.md +1 -0
  5. data/Gemfile +3 -0
  6. data/Gemfile.lock +145 -106
  7. data/README.md +12 -1
  8. data/Rakefile +10 -0
  9. data/TODO.md +2 -1
  10. data/VERSION +1 -1
  11. data/compony.gemspec +5 -3
  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 +41 -41
  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 +13 -13
  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 +276 -92
  69. data/doc/ComponyController.html +1 -1
  70. data/doc/_index.html +1 -1
  71. data/doc/file.CHANGELOG.html +89 -77
  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 +20 -8
  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/guide/pre_built_components/form.md +21 -2
  105. data/doc/index.html +21 -11
  106. data/doc/method_list.html +16 -0
  107. data/doc/top-level-namespace.html +1 -1
  108. data/lib/compony/components/form.rb +5 -4
  109. data/lib/compony/model_fields/base.rb +3 -0
  110. data/lib/compony/model_mixin.rb +3 -2
  111. data/lib/compony.rb +19 -0
  112. data/spec/README.md +22 -0
  113. data/spec/compony/component_spec.rb +80 -0
  114. data/spec/compony/components/form_spec.rb +31 -0
  115. data/spec/compony/compony_spec.rb +130 -0
  116. data/spec/compony/intent_spec.rb +94 -0
  117. data/spec/compony/method_accessible_hash_spec.rb +32 -0
  118. data/spec/compony/model_fields_spec.rb +70 -0
  119. data/spec/compony/model_mixin_spec.rb +109 -0
  120. data/spec/compony/natural_ordering_spec.rb +61 -0
  121. data/spec/dummy/app/components/statics/home.rb +17 -0
  122. data/spec/dummy/app/components/statics/secret.rb +15 -0
  123. data/spec/dummy/app/components/users/destroy.rb +2 -0
  124. data/spec/dummy/app/components/users/edit.rb +2 -0
  125. data/spec/dummy/app/components/users/form.rb +14 -0
  126. data/spec/dummy/app/components/users/index.rb +29 -0
  127. data/spec/dummy/app/components/users/new.rb +2 -0
  128. data/spec/dummy/app/components/users/show.rb +26 -0
  129. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  130. data/spec/dummy/app/models/ability.rb +7 -0
  131. data/spec/dummy/app/models/application_record.rb +4 -0
  132. data/spec/dummy/app/models/user.rb +22 -0
  133. data/spec/dummy/app/views/layouts/application.html.erb +7 -0
  134. data/spec/dummy/config/application.rb +20 -0
  135. data/spec/dummy/config/database.yml +3 -0
  136. data/spec/dummy/config/environment.rb +3 -0
  137. data/spec/dummy/config/initializers/simple_form.rb +9 -0
  138. data/spec/dummy/config/routes.rb +3 -0
  139. data/spec/dummy/db/schema.rb +14 -0
  140. data/spec/rails_helper.rb +23 -0
  141. data/spec/requests/authorization_spec.rb +16 -0
  142. data/spec/requests/plain_component_spec.rb +9 -0
  143. data/spec/requests/routing_spec.rb +20 -0
  144. data/spec/requests/users_crud_spec.rb +99 -0
  145. 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 Tue Jun 30 22:41:19 2026 by
150
+ Generated on Wed Oct 7 10:48:18 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>
@@ -91,7 +91,7 @@ In the model:
91
91
  class Foo < ApplicationRecord
92
92
  # No need to write:
93
93
  field :label, :string, virtual: true
94
- I18n.available_locales.each do |locale|
94
+ Compony.content_locales.each do |locale|
95
95
  field :"label_#{locale}", :string
96
96
  end
97
97
 
@@ -114,4 +114,23 @@ class Components::Foos::Form < Compony::Components::Form
114
114
  schema_field :label, multilang: true
115
115
  end
116
116
  end
117
- ```
117
+ ```
118
+
119
+ ### Content locales
120
+
121
+ By default, `multilang: true` generates one field per locale in `I18n.available_locales`. If your app's locales differ
122
+ from the languages your content is stored in, for instance because it uses regional locales such as `:"de-CH"` and
123
+ `:"de-DE"` while content is stored per language, configure the content locales in an initializer:
124
+
125
+ ```ruby
126
+ # config/initializers/compony.rb
127
+ Compony.content_locales = %i[de en fr]
128
+ ```
129
+
130
+ With `I18n.available_locales = %i[de de-CH en fr]`, `field :label, :string, multilang: true` then generates `label`
131
+ (virtual), `label_de`, `label_en` and `label_fr`, without a `label_de-CH`. The form's `field` and `schema_field` follow
132
+ the same setting.
133
+
134
+ Model fields are generated when the model class loads, so the setting must be in place before any model loads. An
135
+ initializer guarantees that. If the locales are only known later, pass a callable instead, which is evaluated every time
136
+ the content locales are needed: `Compony.content_locales = -> { MyApp.content_locales }`.
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 Tue Jun 30 22:41:17 2026 by
233
+ Generated on Wed Oct 7 10:48:16 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>
data/doc/method_list.html CHANGED
@@ -364,6 +364,22 @@
364
364
  </li>
365
365
 
366
366
 
367
+ <li class="odd ">
368
+ <div class="item">
369
+ <span class='object_link'><a href="Compony.html#content_locales-class_method" title="Compony.content_locales (method)">content_locales</a></span>
370
+ <small>Compony</small>
371
+ </div>
372
+ </li>
373
+
374
+
375
+ <li class="even ">
376
+ <div class="item">
377
+ <span class='object_link'><a href="Compony.html#content_locales=-class_method" title="Compony.content_locales= (method)">content_locales=</a></span>
378
+ <small>Compony</small>
379
+ </div>
380
+ </li>
381
+
382
+
367
383
  <li class="odd ">
368
384
  <div class="item">
369
385
  <span class='object_link'><a href="Compony/RequestContext.html#controller-instance_method" title="Compony::RequestContext#controller (method)">#controller</a></span>
@@ -102,7 +102,7 @@
102
102
  </div>
103
103
 
104
104
  <div id="footer">
105
- Generated on Tue Jun 30 22:41:20 2026 by
105
+ Generated on Wed Oct 7 10:48:19 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>
@@ -106,7 +106,8 @@ module Compony
106
106
  # DSL method (inside `form_fields`). Renders a simple_form input inferred from the model field `name`.
107
107
  # Respects per-field CanCanCan authorization; skipped fields render nothing.
108
108
  # @param name [Symbol,String] The model field (use the association name, not the `_id`, for associations).
109
- # @param multilang [Boolean] If true, generates one suffixed input per available locale and returns the array (useful with the "mobility" gem).
109
+ # @param multilang [Boolean] If true, generates one suffixed input per content locale and returns the array (useful with the "mobility" gem).
110
+ # See {Compony.content_locales}.
110
111
  # @param input_opts [Hash] Passed to simple_form. Notable keys: `as:` (input type), `hidden: true`, `autofocus:`.
111
112
  # @return [String,Array<String>] The input HTML (array when `multilang`).
112
113
  # @api public
@@ -114,7 +115,7 @@ module Compony
114
115
  fail("The `field` method may only be called inside `form_fields` for #{inspect}.") unless @simpleform
115
116
 
116
117
  if multilang
117
- I18n.available_locales.map { |locale| field("#{name}_#{locale}", **input_opts) }
118
+ Compony.content_locales.map { |locale| field("#{name}_#{locale}", **input_opts) }
118
119
  else
119
120
  name = name.to_sym
120
121
 
@@ -215,12 +216,12 @@ module Compony
215
216
  # DSL method, whitelists a single field of `data_class` in the param schema, auto-generating the correct schema line.
216
217
  # Respects per-field CanCanCan authorization.
217
218
  # @param field_name [Symbol,String] The model field (association name, not `_id`, for associations).
218
- # @param multilang [Boolean] If true, whitelists one suffixed field per available locale (useful with the "mobility" gem).
219
+ # @param multilang [Boolean] If true, whitelists one suffixed field per content locale, see {Compony.content_locales} (useful with the "mobility" gem).
219
220
  # @return [void]
220
221
  # @api public
221
222
  def schema_field(field_name, multilang: false)
222
223
  if multilang
223
- I18n.available_locales.each { |locale| schema_field("#{field_name}_#{locale}") }
224
+ Compony.content_locales.each { |locale| schema_field("#{field_name}_#{locale}") }
224
225
  else
225
226
  # This runs upon component setup.
226
227
  @schema_lines_for_data << proc do |data, controller|
@@ -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
@@ -19,11 +19,12 @@ module Compony
19
19
  end
20
20
 
21
21
  # DSL method, defines a new field which will be translated and can be added to field groups
22
- # If multilang is true, a suffixed field is generated for every available locale, along with a non-suffixed virtual field (useful with gem "mobility")
22
+ # If multilang is true, a suffixed field is generated for every content locale, along with a non-suffixed virtual field (useful with gem "mobility").
23
+ # See Compony.content_locales.
23
24
  def field(name, type, multilang: false, **extra_attrs)
24
25
  if multilang
25
26
  field(name, type, virtual: true, **extra_attrs)
26
- I18n.available_locales.each { |locale| field("#{name}_#{locale}", type, **extra_attrs) }
27
+ Compony.content_locales.each { |locale| field("#{name}_#{locale}", type, **extra_attrs) }
27
28
  else
28
29
  name = name.to_sym
29
30
  self.fields = fields.dup
data/lib/compony.rb CHANGED
@@ -35,6 +35,17 @@ module Compony
35
35
  @model_field_namespaces = model_field_namespaces
36
36
  end
37
37
 
38
+ # Setter for the content locales, i.e. the languages in which `multilang: true` fields exist (useful with gem "mobility").
39
+ # Set this when your app's `I18n.available_locales` differ from the languages your content is stored in, e.g. when
40
+ # using regional locales such as `:"de-CH"` while content is stored per language (`label_de`, `label_en`, ...).
41
+ # Model fields are generated when the model class loads, so set this in an initializer, before any model is loaded.
42
+ # Alternatively, pass a callable, which is evaluated every time the content locales are needed.
43
+ # @param content_locales [Array<Symbol,String>,#call] The content locales, or a callable returning them. Defaults to `I18n.available_locales`.
44
+ # @see {Compony#content_locales}
45
+ def self.content_locales=(content_locales)
46
+ @content_locales = content_locales
47
+ end
48
+
38
49
  # Setter for the name of the Rails `before_action` that should be called to
39
50
  # ensure that users are authenticated before accessing the component. For
40
51
  # instance, implement a method `def enforce_authentication` in your
@@ -99,6 +110,14 @@ module Compony
99
110
  return @model_field_namespaces ||= ['Compony::ModelFields']
100
111
  end
101
112
 
113
+ # Getter for the content locales, defaults to `I18n.available_locales`.
114
+ # @return [Array<Symbol>] The locales for which `multilang: true` fields generate a suffixed field.
115
+ # @see Compony#content_locales= Explanation of content_locales (documented in the corresponding setter)
116
+ def self.content_locales
117
+ content_locales = @content_locales.respond_to?(:call) ? @content_locales.call : @content_locales
118
+ return (content_locales || I18n.available_locales).map(&:to_sym)
119
+ end
120
+
102
121
  # Getter for the name of the Rails `before_action` that enforces authentication.
103
122
  # @see Compony#authentication_before_action= Explanation of authentication_before_action (documented in the corresponding setter)
104
123
  def self.authentication_before_action
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,31 @@
1
+ RSpec.describe Compony::Components::Form do
2
+ describe 'multilang' do
3
+ around do |example|
4
+ original_available_locales = I18n.available_locales
5
+ I18n.available_locales = %i[en de de-CH fr es]
6
+ Compony.content_locales = %i[de en fr es]
7
+ example.run
8
+ ensure
9
+ I18n.available_locales = original_available_locales
10
+ Compony.content_locales = nil
11
+ end
12
+
13
+ let(:form) { described_class.new }
14
+
15
+ it 'renders one field per content locale' do
16
+ form.instance_variable_set(:@simpleform, Object.new) # `field` refuses to run outside `form_fields`
17
+ allow(form).to receive(:field).and_call_original
18
+ allow(form).to receive(:field).with(a_string_starting_with('label_')) { |name| name }
19
+ expect(form.field(:label, multilang: true)).to eq(%w[label_de label_en label_fr label_es])
20
+ end
21
+
22
+ it 'whitelists one schema field per content locale' do
23
+ allow(form).to receive(:schema_field).and_call_original
24
+ form.send(:schema_field, :label, multilang: true) # DSL method, protected
25
+ %w[label_de label_en label_fr label_es].each do |name|
26
+ expect(form).to have_received(:schema_field).with(name)
27
+ end
28
+ expect(form).not_to have_received(:schema_field).with('label_de-CH')
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,130 @@
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
+
103
+ describe '.content_locales' do
104
+ around do |example|
105
+ original_available_locales = I18n.available_locales
106
+ example.run
107
+ ensure
108
+ I18n.available_locales = original_available_locales
109
+ described_class.content_locales = nil
110
+ end
111
+
112
+ it 'defaults to I18n.available_locales' do
113
+ I18n.available_locales = %i[en de]
114
+ expect(described_class.content_locales).to eq(%i[en de])
115
+ end
116
+
117
+ it 'returns the configured locales as symbols' do
118
+ described_class.content_locales = %w[de en]
119
+ expect(described_class.content_locales).to eq(%i[de en])
120
+ end
121
+
122
+ it 'evaluates a callable every time' do
123
+ locales = %i[de]
124
+ described_class.content_locales = -> { locales }
125
+ expect(described_class.content_locales).to eq(%i[de])
126
+ locales = %i[de fr]
127
+ expect(described_class.content_locales).to eq(%i[de fr])
128
+ end
129
+ end
130
+ end