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.
- checksums.yaml +4 -4
- data/.rspec +2 -0
- data/CHANGELOG.md +13 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +145 -106
- data/README.md +12 -1
- data/Rakefile +10 -0
- data/TODO.md +2 -1
- data/VERSION +1 -1
- data/compony.gemspec +5 -3
- data/config/locales/es.yml +46 -0
- data/doc/ComponentGenerator.html +1 -1
- data/doc/Components.html +1 -1
- data/doc/ComponentsGenerator.html +1 -1
- data/doc/Compony/Component.html +1 -1
- data/doc/Compony/ComponentMixins/Default/Labelling.html +1 -1
- data/doc/Compony/ComponentMixins/Default/Standalone/ResourcefulVerbDsl.html +1 -1
- data/doc/Compony/ComponentMixins/Default/Standalone/StandaloneDsl.html +1 -1
- data/doc/Compony/ComponentMixins/Default/Standalone/VerbDsl.html +1 -1
- data/doc/Compony/ComponentMixins/Default/Standalone.html +1 -1
- data/doc/Compony/ComponentMixins/Default.html +1 -1
- data/doc/Compony/ComponentMixins/Resourceful.html +1 -1
- data/doc/Compony/ComponentMixins.html +1 -1
- data/doc/Compony/Components/Buttons/CssButton.html +1 -1
- data/doc/Compony/Components/Buttons/Link.html +1 -1
- data/doc/Compony/Components/Buttons.html +1 -1
- data/doc/Compony/Components/Destroy.html +1 -1
- data/doc/Compony/Components/Edit.html +1 -1
- data/doc/Compony/Components/Form.html +1 -1
- data/doc/Compony/Components/Index.html +1 -1
- data/doc/Compony/Components/List.html +1 -1
- data/doc/Compony/Components/New.html +1 -1
- data/doc/Compony/Components/Show.html +1 -1
- data/doc/Compony/Components/WithForm.html +1 -1
- data/doc/Compony/Components.html +1 -1
- data/doc/Compony/ControllerMixin.html +1 -1
- data/doc/Compony/Engine.html +1 -1
- data/doc/Compony/Intent.html +1 -1
- data/doc/Compony/ManageIntentsDsl.html +2 -2
- data/doc/Compony/MethodAccessibleHash.html +1 -1
- data/doc/Compony/ModelFields/Anchormodel.html +1 -1
- data/doc/Compony/ModelFields/Association.html +1 -1
- data/doc/Compony/ModelFields/Attachment.html +1 -1
- data/doc/Compony/ModelFields/Base.html +8 -2
- data/doc/Compony/ModelFields/Boolean.html +1 -1
- data/doc/Compony/ModelFields/Color.html +1 -1
- data/doc/Compony/ModelFields/Currency.html +1 -1
- data/doc/Compony/ModelFields/Date.html +1 -1
- data/doc/Compony/ModelFields/Datetime.html +1 -1
- data/doc/Compony/ModelFields/Decimal.html +1 -1
- data/doc/Compony/ModelFields/Email.html +1 -1
- data/doc/Compony/ModelFields/Float.html +1 -1
- data/doc/Compony/ModelFields/Integer.html +1 -1
- data/doc/Compony/ModelFields/Percentage.html +1 -1
- data/doc/Compony/ModelFields/Phone.html +1 -1
- data/doc/Compony/ModelFields/RichText.html +1 -1
- data/doc/Compony/ModelFields/String.html +1 -1
- data/doc/Compony/ModelFields/Text.html +1 -1
- data/doc/Compony/ModelFields/Time.html +1 -1
- data/doc/Compony/ModelFields/Url.html +1 -1
- data/doc/Compony/ModelFields.html +1 -1
- data/doc/Compony/ModelMixin.html +1 -1
- data/doc/Compony/NaturalOrdering.html +1 -1
- data/doc/Compony/RequestContext.html +1 -1
- data/doc/Compony/Version.html +1 -1
- data/doc/Compony/ViewHelpers.html +1 -1
- data/doc/Compony/VirtualModel.html +1 -1
- data/doc/Compony.html +1 -1
- data/doc/ComponyController.html +1 -1
- data/doc/_index.html +1 -1
- data/doc/file.CHANGELOG.html +88 -76
- data/doc/file.README.html +21 -11
- data/doc/file.basic_component.html +14 -14
- data/doc/file.cookbook.html +27 -27
- data/doc/file.destroy.html +2 -2
- data/doc/file.dsl_reference.html +199 -199
- data/doc/file.edit.html +3 -3
- data/doc/file.example.html +13 -13
- data/doc/file.example_advanced.html +10 -10
- data/doc/file.feasibility.html +2 -2
- data/doc/file.form.html +7 -7
- data/doc/file.generators.html +3 -3
- data/doc/file.glossary.html +44 -44
- data/doc/file.gotchas.html +35 -35
- data/doc/file.index.html +10 -10
- data/doc/file.inheritance.html +5 -5
- data/doc/file.installation.html +9 -9
- data/doc/file.integrations.html +46 -46
- data/doc/file.intents.html +11 -11
- data/doc/file.internal_datastructures.html +6 -6
- data/doc/file.list.html +35 -35
- data/doc/file.maintaining.html +21 -21
- data/doc/file.model_fields.html +3 -3
- data/doc/file.nesting.html +5 -5
- data/doc/file.new.html +3 -3
- data/doc/file.ownership.html +2 -2
- data/doc/file.patterns.html +50 -50
- data/doc/file.pre_built_components.html +2 -2
- data/doc/file.resourceful.html +5 -5
- data/doc/file.show.html +17 -17
- data/doc/file.standalone.html +12 -12
- data/doc/file.virtual_models.html +2 -2
- data/doc/file.with_form.html +23 -23
- data/doc/index.html +21 -11
- data/doc/top-level-namespace.html +1 -1
- data/lib/compony/model_fields/base.rb +3 -0
- data/spec/README.md +22 -0
- data/spec/compony/component_spec.rb +80 -0
- data/spec/compony/compony_spec.rb +102 -0
- data/spec/compony/intent_spec.rb +94 -0
- data/spec/compony/method_accessible_hash_spec.rb +32 -0
- data/spec/compony/model_fields_spec.rb +70 -0
- data/spec/compony/model_mixin_spec.rb +81 -0
- data/spec/compony/natural_ordering_spec.rb +61 -0
- data/spec/dummy/app/components/statics/home.rb +17 -0
- data/spec/dummy/app/components/statics/secret.rb +15 -0
- data/spec/dummy/app/components/users/destroy.rb +2 -0
- data/spec/dummy/app/components/users/edit.rb +2 -0
- data/spec/dummy/app/components/users/form.rb +14 -0
- data/spec/dummy/app/components/users/index.rb +29 -0
- data/spec/dummy/app/components/users/new.rb +2 -0
- data/spec/dummy/app/components/users/show.rb +26 -0
- data/spec/dummy/app/controllers/application_controller.rb +3 -0
- data/spec/dummy/app/models/ability.rb +7 -0
- data/spec/dummy/app/models/application_record.rb +4 -0
- data/spec/dummy/app/models/user.rb +22 -0
- data/spec/dummy/app/views/layouts/application.html.erb +7 -0
- data/spec/dummy/config/application.rb +20 -0
- data/spec/dummy/config/database.yml +3 -0
- data/spec/dummy/config/environment.rb +3 -0
- data/spec/dummy/config/initializers/simple_form.rb +9 -0
- data/spec/dummy/config/routes.rb +3 -0
- data/spec/dummy/db/schema.rb +14 -0
- data/spec/rails_helper.rb +23 -0
- data/spec/requests/authorization_spec.rb +16 -0
- data/spec/requests/plain_component_spec.rb +9 -0
- data/spec/requests/routing_spec.rb +20 -0
- data/spec/requests/users_crud_spec.rb +99 -0
- metadata +63 -1
data/doc/file.with_form.html
CHANGED
|
@@ -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="
|
|
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="
|
|
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
|
|
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
|
|
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><form></code> posts back to <code>submit_path</code> using <code>submit_verb</code>; that same component
|
|
78
|
+
<p>The form’s <code><form></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="
|
|
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
|
|
94
|
-
<td
|
|
95
|
-
<td>HTTP verb the twinned form submits with. Mandatory (New sets
|
|
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
|
|
99
|
-
<td
|
|
100
|
-
<td>Use a specific Form class instead of the same-family
|
|
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
|
|
104
|
-
<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
|
|
109
|
-
<td
|
|
110
|
-
<td>CanCanCan action used by the Form for per-field
|
|
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
|
|
114
|
-
<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="
|
|
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'><</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
|
|
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><img src=“logo.svg” height=250 alt=“Compony logo”/></p>
|
|
62
62
|
|
|
63
|
-
<h1 id="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
-
<
|
|
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="
|
|
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
|
|
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
|
|
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
|