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
@@ -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
@@ -0,0 +1,109 @@
1
+ RSpec.describe Compony::ModelMixin do
2
+ describe '.field' do
3
+ it 'registers fields with the correct types' do
4
+ expect(User.fields[:name]).to be_a(Compony::ModelFields::String)
5
+ expect(User.fields[:age]).to be_a(Compony::ModelFields::Integer)
6
+ expect(User.fields[:comment]).to be_a(Compony::ModelFields::Text)
7
+ expect(User.fields[:email]).to be_a(Compony::ModelFields::Email)
8
+ expect(User.fields[:admin]).to be_a(Compony::ModelFields::Boolean)
9
+ expect(User.fields[:born_on]).to be_a(Compony::ModelFields::Date)
10
+ expect(User.fields[:created_at]).to be_a(Compony::ModelFields::Datetime)
11
+ end
12
+
13
+ it 'keeps the field name and model class' do
14
+ field = User.fields[:name]
15
+ expect(field.name).to eq(:name)
16
+ expect(field.model_class).to eq(User)
17
+ end
18
+
19
+ it 'rebinds fields to the subclass on inheritance' do
20
+ subclass = Class.new(User)
21
+ expect(subclass.fields[:name].model_class).to eq(subclass)
22
+ expect(User.fields[:name].model_class).to eq(User) # parent untouched
23
+ end
24
+
25
+ it 'does not leak fields defined on a subclass into the parent' do
26
+ subclass = Class.new(User) do
27
+ def self.name = 'SpecialUser'
28
+ field :extra, :string
29
+ end
30
+ expect(subclass.fields).to have_key(:extra)
31
+ expect(User.fields).not_to have_key(:extra)
32
+ end
33
+
34
+ context 'with multilang: true' do
35
+ around do |example|
36
+ original_available_locales = I18n.available_locales
37
+ I18n.available_locales = %i[en de de-CH fr es]
38
+ example.run
39
+ ensure
40
+ I18n.available_locales = original_available_locales
41
+ Compony.content_locales = nil
42
+ end
43
+
44
+ def multilang_model
45
+ Class.new(User) do
46
+ def self.name = 'MultilangUser'
47
+ field :label, :string, multilang: true
48
+ end
49
+ end
50
+
51
+ it 'generates a virtual field plus one suffixed field per available locale by default' do
52
+ expect(multilang_model.fields.keys - User.fields.keys).to eq(%i[label label_en label_de label_de-CH label_fr label_es])
53
+ expect(multilang_model.fields[:label].extra_attrs).to eq(virtual: true)
54
+ end
55
+
56
+ it 'generates one suffixed field per content locale when configured' do
57
+ Compony.content_locales = %i[de en fr es]
58
+ expect(multilang_model.fields.keys - User.fields.keys).to eq(%i[label label_de label_en label_fr label_es])
59
+ end
60
+ end
61
+ end
62
+
63
+ describe 'feasibility' do
64
+ it 'is feasible when no prevention triggers' do
65
+ user = User.create!(name: 'Alice', admin: false)
66
+ expect(user.feasible?(:destroy)).to be(true)
67
+ expect(user.feasibility_messages(:destroy)).to eq([])
68
+ expect(user.full_feasibility_messages(:destroy)).to eq('')
69
+ end
70
+
71
+ it 'is infeasible when a prevention triggers' do
72
+ admin = User.create!(name: 'Root', admin: true)
73
+ expect(admin.feasible?(:destroy)).to be(false)
74
+ expect(admin.feasibility_messages(:destroy)).to eq(['Cannot destroy admins'])
75
+ expect(admin.full_feasibility_messages(:destroy)).to eq('Cannot destroy admins.')
76
+ end
77
+
78
+ it 'caches the result until recompute is requested' do
79
+ user = User.create!(name: 'Alice', admin: false)
80
+ expect(user.feasible?(:destroy)).to be(true)
81
+ user.admin = true
82
+ expect(user.feasible?(:destroy)).to be(true) # cached
83
+ expect(user.feasible?(:destroy, recompute: true)).to be(false)
84
+ end
85
+
86
+ it 'returns true for unknown actions' do
87
+ user = User.create!(name: 'Alice')
88
+ expect(user.feasible?(:frobnicate)).to be(true)
89
+ end
90
+ end
91
+
92
+ describe '.owned_by' do
93
+ it 'stores the owner attribute' do
94
+ klass = Class.new(User) do
95
+ def self.name = 'OwnedUser'
96
+ owned_by :parent
97
+ end
98
+ expect(klass.owner_model_attr).to eq(:parent)
99
+ expect(User.owner_model_attr).to be_nil
100
+ end
101
+ end
102
+
103
+ describe '#field' do
104
+ it 'returns the formatted value for a field' do
105
+ user = User.create!(name: 'Alice', age: 30)
106
+ expect(user.field(:name, nil)).to eq('Alice')
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,61 @@
1
+ RSpec.describe Compony::NaturalOrdering do
2
+ subject(:collection) { described_class.new }
3
+
4
+ it 'appends elements in order' do
5
+ collection.natural_push(:a, 1)
6
+ collection.natural_push(:b, 2)
7
+ expect(collection.map(&:name)).to eq(%i[a b])
8
+ expect(collection.map(&:payload)).to eq([1, 2])
9
+ end
10
+
11
+ it 'inserts before a given element' do
12
+ collection.natural_push(:a, 1)
13
+ collection.natural_push(:c, 3)
14
+ collection.natural_push(:b, 2, before: :c)
15
+ expect(collection.map(&:name)).to eq(%i[a b c])
16
+ end
17
+
18
+ it 'overwrites an existing element in place' do
19
+ collection.natural_push(:a, 1)
20
+ collection.natural_push(:b, 2)
21
+ collection.natural_push(:a, 99)
22
+ expect(collection.map(&:name)).to eq(%i[a b])
23
+ expect(collection.first.payload).to eq(99)
24
+ end
25
+
26
+ it 'keeps old kwargs when overwriting without specifying them' do
27
+ collection.natural_push(:a, 1, hidden: true)
28
+ collection.natural_push(:a, 2)
29
+ expect(collection.first.hidden).to be(true)
30
+ expect(collection.first.payload).to eq(2)
31
+ end
32
+
33
+ it 'allows omitting the payload when overwriting' do
34
+ collection.natural_push(:a, 1)
35
+ collection.natural_push(:a, hidden: true)
36
+ expect(collection.first.payload).to eq(1)
37
+ expect(collection.first.hidden).to be(true)
38
+ end
39
+
40
+ it 'moves an existing element when overwriting with before:' do
41
+ collection.natural_push(:a, 1)
42
+ collection.natural_push(:b, 2)
43
+ collection.natural_push(:c, 3)
44
+ collection.natural_push(:c, 3, before: :a)
45
+ expect(collection.map(&:name)).to eq(%i[c a b])
46
+ end
47
+
48
+ it 'fails when inserting a new element without payload' do
49
+ expect { collection.natural_push(:a) }.to raise_error(RuntimeError, /without a payload/)
50
+ end
51
+
52
+ it 'fails when before: refers to a missing element' do
53
+ collection.natural_push(:a, 1)
54
+ expect { collection.natural_push(:b, 2, before: :nope) }.to raise_error(RuntimeError, /not found/)
55
+ end
56
+
57
+ it 'stores extra kwargs on the element' do
58
+ collection.natural_push(:a, 1, hidden: true)
59
+ expect(collection.select(&:hidden).map(&:name)).to eq([:a])
60
+ end
61
+ end
@@ -0,0 +1,17 @@
1
+ class Components::Statics::Home < Compony::Component
2
+ setup do
3
+ label(:all) { 'Home' }
4
+
5
+ standalone path: 'home' do
6
+ skip_authentication!
7
+ verb :get do
8
+ authorize { true }
9
+ end
10
+ end
11
+
12
+ content do
13
+ h1 'Welcome home'
14
+ para 'This is a plain component.'
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ class Components::Statics::Secret < Compony::Component
2
+ setup do
3
+ label(:all) { 'Secret' }
4
+
5
+ standalone path: 'secret' do
6
+ verb :get do
7
+ authorize { false }
8
+ end
9
+ end
10
+
11
+ content do
12
+ h1 'You should never see this'
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,2 @@
1
+ class Components::Users::Destroy < Compony::Components::Destroy
2
+ end
@@ -0,0 +1,2 @@
1
+ class Components::Users::Edit < Compony::Components::Edit
2
+ end
@@ -0,0 +1,14 @@
1
+ class Components::Users::Form < Compony::Components::Form
2
+ setup do
3
+ form_fields do
4
+ concat field(:name)
5
+ concat field(:age)
6
+ concat field(:comment)
7
+ concat field(:email)
8
+ concat field(:admin)
9
+ concat field(:born_on)
10
+ end
11
+
12
+ schema_fields :name, :age, :comment, :email, :admin, :born_on
13
+ end
14
+ end
@@ -0,0 +1,29 @@
1
+ class Components::Users::Index < Compony::Component
2
+ include Compony::ComponentMixins::Resourceful
3
+
4
+ setup do
5
+ label(:all) { 'Users' }
6
+ standalone path: 'users' do
7
+ verb :get do
8
+ authorize { true }
9
+ end
10
+ end
11
+
12
+ load_data { @data = User.order(:id) }
13
+
14
+ content do
15
+ h4 'Users:'
16
+ concat render_intent(:new, :users)
17
+ div class: 'users' do
18
+ @data.each do |user|
19
+ div class: 'user' do
20
+ span user.name
21
+ concat render_intent(:show, user, button: { label: { format: :short } })
22
+ concat render_intent(:edit, user, button: { label: { format: :short } })
23
+ concat render_intent(:destroy, user, button: { label: { format: :short } })
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,2 @@
1
+ class Components::Users::New < Compony::Components::New
2
+ end
@@ -0,0 +1,26 @@
1
+ class Components::Users::Show < Compony::Component
2
+ include Compony::ComponentMixins::Resourceful
3
+
4
+ setup do
5
+ label(:short) { |_u| 'Show' }
6
+ label(:long) { |u| "Show user #{u.label}" }
7
+
8
+ standalone path: 'users/show/:id' do
9
+ verb :get do
10
+ authorize { true }
11
+ end
12
+ end
13
+
14
+ content do
15
+ h3 @data.label
16
+ table do
17
+ tr do
18
+ @data.fields.each_value { |field| th field.label }
19
+ end
20
+ tr do
21
+ @data.fields.each_value { |field| td field.value_for(@data, controller:) }
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,3 @@
1
+ class ApplicationController < ActionController::Base
2
+ attr_accessor :current_user
3
+ end
@@ -0,0 +1,7 @@
1
+ class Ability
2
+ include CanCan::Ability
3
+
4
+ def initialize(_user)
5
+ can :manage, :all
6
+ end
7
+ end
@@ -0,0 +1,4 @@
1
+ class ApplicationRecord < ActiveRecord::Base
2
+ primary_abstract_class
3
+ include Compony::ModelMixin
4
+ end
@@ -0,0 +1,22 @@
1
+ class User < ApplicationRecord
2
+ field :name, :string
3
+ field :age, :integer
4
+ field :comment, :text
5
+ field :email, :email
6
+ field :admin, :boolean
7
+ field :born_on, :date
8
+ field :margin_relative, :percentage
9
+ field :salary, :currency
10
+ field :homepage, :url
11
+ field :created_at, :datetime
12
+
13
+ validates :name, presence: true
14
+
15
+ prevent :destroy, 'Cannot destroy admins' do
16
+ admin?
17
+ end
18
+
19
+ def label
20
+ name
21
+ end
22
+ end
@@ -0,0 +1,7 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head><title>Dummy</title></head>
4
+ <body data-layout="application">
5
+ <%= yield %>
6
+ </body>
7
+ </html>
@@ -0,0 +1,20 @@
1
+ require 'rails'
2
+ require 'active_record/railtie'
3
+ require 'action_controller/railtie'
4
+ require 'action_view/railtie'
5
+
6
+ require 'compony'
7
+
8
+ module Dummy
9
+ class Application < Rails::Application
10
+ config.root = File.expand_path('..', __dir__)
11
+ config.load_defaults Rails::VERSION::STRING.to_f
12
+ config.eager_load = false
13
+ config.hosts.clear
14
+ config.secret_key_base = 'dummy-secret-key-base-for-tests-only'
15
+ config.logger = Logger.new(nil)
16
+ config.active_support.deprecation = :stderr
17
+ config.action_dispatch.show_exceptions = :none
18
+ config.action_controller.allow_forgery_protection = false
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ test:
2
+ adapter: sqlite3
3
+ database: ':memory:'
@@ -0,0 +1,3 @@
1
+ require_relative 'application'
2
+
3
+ Rails.application.initialize!
@@ -0,0 +1,9 @@
1
+ SimpleForm.setup do |config|
2
+ config.wrappers :default, class: :input do |b|
3
+ b.use :html5
4
+ b.use :label_input
5
+ b.use :error, wrap_with: { tag: :span, class: :error }
6
+ end
7
+ config.default_wrapper = :default
8
+ config.button_class = 'btn'
9
+ end
@@ -0,0 +1,3 @@
1
+ # Compony draws its routes via the gem's own config/routes.rb (loaded by the engine).
2
+ Rails.application.routes.draw do # rubocop:disable Lint/EmptyBlock
3
+ end
@@ -0,0 +1,14 @@
1
+ ActiveRecord::Schema.define do
2
+ create_table :users, force: true do |t|
3
+ t.string :name
4
+ t.integer :age
5
+ t.text :comment
6
+ t.string :email
7
+ t.boolean :admin, default: false, null: false
8
+ t.date :born_on
9
+ t.decimal :margin_relative
10
+ t.decimal :salary
11
+ t.string :homepage
12
+ t.timestamps
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ ENV['RAILS_ENV'] = 'test'
2
+
3
+ require_relative 'dummy/config/environment'
4
+ require 'rspec/rails'
5
+
6
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
7
+ load File.expand_path('dummy/db/schema.rb', __dir__)
8
+
9
+ RSpec.configure do |config|
10
+ config.use_transactional_fixtures = true
11
+ config.infer_spec_type_from_file_location!
12
+
13
+ config.expect_with :rspec do |expectations|
14
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
15
+ end
16
+
17
+ config.mock_with :rspec do |mocks|
18
+ mocks.verify_partial_doubles = true
19
+ end
20
+
21
+ config.order = :random
22
+ Kernel.srand config.seed
23
+ end
@@ -0,0 +1,16 @@
1
+ RSpec.describe 'Authorization', type: :request do
2
+ it 'denies access when the authorize block returns false' do
3
+ expect { get '/secret' }.to raise_error(CanCan::AccessDenied)
4
+ end
5
+
6
+ it 'grants access when the authorize block returns true' do
7
+ get '/home'
8
+ expect(response).to have_http_status(:ok)
9
+ end
10
+
11
+ it 'hides buttons pointing to unauthorized components' do
12
+ comp = Components::Statics::Secret.new
13
+ controller = ApplicationController.new
14
+ expect(comp.standalone_access_permitted_for?(controller, verb: :get)).to be(false)
15
+ end
16
+ end
@@ -0,0 +1,9 @@
1
+ RSpec.describe 'Plain standalone component', type: :request do
2
+ it 'renders content within the application layout' do
3
+ get '/home'
4
+ expect(response).to have_http_status(:ok)
5
+ expect(response.body).to include('data-layout="application"')
6
+ expect(response.body).to include('<h1>Welcome home</h1>')
7
+ expect(response.body).to include('<p>This is a plain component.</p>')
8
+ end
9
+ end
@@ -0,0 +1,20 @@
1
+ RSpec.describe 'Compony routing', type: :request do
2
+ it 'generates a route per standalone component' do
3
+ routes = Rails.application.routes.routes.map { |r| r.path.spec.to_s }
4
+ expect(routes).to include('/users(.:format)')
5
+ expect(routes).to include('/users/show/:id(.:format)')
6
+ expect(routes).to include('/home(.:format)')
7
+ end
8
+
9
+ it 'creates named path helpers' do
10
+ expect(Rails.application.routes.url_helpers.index_users_comp_path).to eq('/users')
11
+ rescue NoMethodError
12
+ # Fallback: assert helper name from standalone config
13
+ helper = Components::Users::Index.new.standalone_configs.values.first.path_helper_name
14
+ expect(Rails.application.routes.url_helpers.send(:"#{helper}_path")).to eq('/users')
15
+ end
16
+
17
+ it 'routes only the configured verbs' do
18
+ expect { post '/users/show/1' }.to raise_error(ActionController::RoutingError)
19
+ end
20
+ end