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.
- checksums.yaml +4 -4
- data/.rspec +2 -0
- data/CHANGELOG.md +16 -0
- data/CLAUDE.md +1 -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/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 +41 -41
- 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 +13 -13
- 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 +276 -92
- data/doc/ComponyController.html +1 -1
- data/doc/_index.html +1 -1
- data/doc/file.CHANGELOG.html +89 -77
- 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 +20 -8
- 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/guide/pre_built_components/form.md +21 -2
- data/doc/index.html +21 -11
- data/doc/method_list.html +16 -0
- data/doc/top-level-namespace.html +1 -1
- data/lib/compony/components/form.rb +5 -4
- data/lib/compony/model_fields/base.rb +3 -0
- data/lib/compony/model_mixin.rb +3 -2
- data/lib/compony.rb +19 -0
- data/spec/README.md +22 -0
- data/spec/compony/component_spec.rb +80 -0
- data/spec/compony/components/form_spec.rb +31 -0
- data/spec/compony/compony_spec.rb +130 -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 +109 -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
|
@@ -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,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,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,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,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,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
|