gestart-admino 1.0.0

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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +29 -0
  3. data/.gitignore +20 -0
  4. data/.tool-versions +1 -0
  5. data/CHANGELOG.md +91 -0
  6. data/Gemfile +3 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +716 -0
  9. data/Rakefile +6 -0
  10. data/gestart-admino.gemspec +36 -0
  11. data/lib/admino/action_view_extension.rb +35 -0
  12. data/lib/admino/query/base.rb +124 -0
  13. data/lib/admino/query/base_presenter.rb +27 -0
  14. data/lib/admino/query/builder.rb +24 -0
  15. data/lib/admino/query/configuration.rb +100 -0
  16. data/lib/admino/query/dsl.rb +38 -0
  17. data/lib/admino/query/filter_group.rb +73 -0
  18. data/lib/admino/query/filter_group_presenter.rb +85 -0
  19. data/lib/admino/query/scope_presenter.rb +17 -0
  20. data/lib/admino/query/search_field.rb +43 -0
  21. data/lib/admino/query/sorting.rb +74 -0
  22. data/lib/admino/query/sorting_presenter.rb +60 -0
  23. data/lib/admino/query.rb +15 -0
  24. data/lib/admino/table/head_row.rb +86 -0
  25. data/lib/admino/table/presenter.rb +122 -0
  26. data/lib/admino/table/resource_row.rb +145 -0
  27. data/lib/admino/table/row.rb +56 -0
  28. data/lib/admino/table.rb +7 -0
  29. data/lib/admino/version.rb +3 -0
  30. data/lib/admino.rb +14 -0
  31. data/lib/gestart-admino.rb +1 -0
  32. data/spec/admino/integration_spec.rb +63 -0
  33. data/spec/admino/query/base_presenter_spec.rb +59 -0
  34. data/spec/admino/query/base_spec.rb +192 -0
  35. data/spec/admino/query/dsl_spec.rb +52 -0
  36. data/spec/admino/query/filter_group_presenter_spec.rb +182 -0
  37. data/spec/admino/query/filter_group_spec.rb +133 -0
  38. data/spec/admino/query/search_field_spec.rb +66 -0
  39. data/spec/admino/query/sorting_presenter_spec.rb +206 -0
  40. data/spec/admino/query/sorting_spec.rb +137 -0
  41. data/spec/admino/table/head_row_spec.rb +158 -0
  42. data/spec/admino/table/presenter_spec.rb +180 -0
  43. data/spec/admino/table/resource_row_spec.rb +225 -0
  44. data/spec/admino/table/row_spec.rb +76 -0
  45. data/spec/spec_helper.rb +93 -0
  46. metadata +267 -0
@@ -0,0 +1,206 @@
1
+ require 'spec_helper'
2
+ require 'active_support/hash_with_indifferent_access'
3
+
4
+ module Admino
5
+ module Query
6
+ describe SortingPresenter do
7
+ subject(:presenter) { SortingPresenter.new(sorting, view) }
8
+ let(:view) { RailsViewContext.new }
9
+ let(:sorting) do
10
+ double(
11
+ 'Sorting',
12
+ default_scope: 'by_name',
13
+ query_i18n_key: 'query_name'
14
+ )
15
+ end
16
+ let(:request_object) do
17
+ double(
18
+ 'ActionDispatch::Request',
19
+ query_parameters: ActiveSupport::HashWithIndifferentAccess.new(
20
+ 'sorting' => 'by_date', 'other' => 'value'
21
+ ),
22
+ path: '/'
23
+ )
24
+ end
25
+
26
+ before do
27
+ allow(view).to receive(:request).and_return(request_object)
28
+ end
29
+
30
+ describe '#scope_link' do
31
+ subject { presenter.scope_link(:by_title, 'Titolo') }
32
+
33
+ before do
34
+ allow(sorting).to receive(:is_scope_active?).with(:by_title).and_return(false)
35
+ end
36
+
37
+ context 'scope is active' do
38
+ before do
39
+ allow(sorting).to receive(:is_scope_active?).with(:by_title).and_return(true)
40
+ end
41
+
42
+ context 'ascending' do
43
+ before do
44
+ allow(sorting).to receive(:ascending?).and_return(true)
45
+ end
46
+
47
+ it 'adds an is-asc class' do
48
+ is_expected.to have_tag(:a, with: { class: 'is-asc' })
49
+ end
50
+
51
+ context 'if an :asc_class option is specified' do
52
+ subject { presenter.scope_link(:by_title, 'Titolo', asc_class: 'asc') }
53
+
54
+ it 'adds it' do
55
+ is_expected.to have_tag(:a, with: { class: 'asc' })
56
+ end
57
+ end
58
+ end
59
+
60
+ context 'descendent' do
61
+ before do
62
+ allow(sorting).to receive(:ascending?).and_return(false)
63
+ end
64
+
65
+ it 'adds an is-desc class' do
66
+ is_expected.to have_tag(:a, with: { class: 'is-desc' })
67
+ end
68
+
69
+ context 'if a :desc_class option is specified' do
70
+ subject { presenter.scope_link(:by_title, 'Titolo', desc_class: 'desc') }
71
+
72
+ it 'adds it' do
73
+ is_expected.to have_tag(:a, with: { class: 'desc' })
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ context 'else' do
80
+ it 'does not add it' do
81
+ is_expected.not_to have_tag(:a, with: { class: 'is-asc' })
82
+ end
83
+ end
84
+
85
+ context 'label' do
86
+ it 'uses the provided argument' do
87
+ is_expected.to have_tag(:a, text: 'Titolo')
88
+ end
89
+ end
90
+
91
+ context 'URL' do
92
+ before do
93
+ allow(presenter).to receive(:scope_path).with(:by_title).and_return('URL')
94
+ end
95
+
96
+ it 'uses #scope_path method' do
97
+ is_expected.to have_tag(:a, href: 'URL')
98
+ end
99
+ end
100
+ end
101
+
102
+ describe '#scope_params' do
103
+ subject { presenter.scope_params(:by_title) }
104
+
105
+ before do
106
+ allow(sorting).to receive(:is_scope_active?).with(:by_title).and_return(false)
107
+ end
108
+
109
+ it 'preserves other params' do
110
+ expect(subject[:other]).to eq 'value'
111
+ end
112
+
113
+ it 'keeps the request parameters intact' do
114
+ subject
115
+ expect(request_object.query_parameters[:sorting]).to eq 'by_date'
116
+ end
117
+
118
+ it 'sets the sorting param as the scope' do
119
+ expect(subject[:sorting]).to eq 'by_title'
120
+ end
121
+
122
+ context 'scope is active' do
123
+ before do
124
+ allow(sorting).to receive(:is_scope_active?).with(:by_title).and_return(true)
125
+ end
126
+
127
+ context 'is currently ascending' do
128
+ before do
129
+ allow(sorting).to receive(:ascending?).and_return(true)
130
+ end
131
+
132
+ it 'sets the sorting order to descending' do
133
+ expect(subject[:sort_order]).to eq 'desc'
134
+ end
135
+ end
136
+
137
+ context 'is currently descending' do
138
+ before do
139
+ allow(sorting).to receive(:ascending?).and_return(false)
140
+ end
141
+
142
+ it 'sets the sorting order to ascending' do
143
+ expect(subject[:sort_order]).to eq 'asc'
144
+ end
145
+ end
146
+ end
147
+
148
+ context 'scope is the default one' do
149
+ let(:sorting) { double('Sorting', default_scope: :by_title) }
150
+
151
+ context 'default scope is ascending' do
152
+ before do
153
+ allow(sorting).to receive(:default_direction).and_return(:asc)
154
+ end
155
+
156
+ it 'sets the sorting order to ascending' do
157
+ expect(subject[:sort_order]).to eq 'asc'
158
+ end
159
+ end
160
+
161
+ context 'default scope is descending' do
162
+ before do
163
+ allow(sorting).to receive(:default_direction).and_return(:desc)
164
+ end
165
+
166
+ it 'sets the sorting order to descending' do
167
+ expect(subject[:sort_order]).to eq 'desc'
168
+ end
169
+ end
170
+ end
171
+
172
+ context 'else' do
173
+ before do
174
+ allow(sorting).to receive(:is_scope_active?).with(:by_title).and_return(false)
175
+ end
176
+
177
+ it 'sets the sorting order to ascending' do
178
+ expect(subject[:sort_order]).to eq 'asc'
179
+ end
180
+ end
181
+ end
182
+
183
+ describe '#scope_name' do
184
+ context do
185
+ before do
186
+ I18n.backend.store_translations(
187
+ :en,
188
+ query: { sorting_scopes: { query_name: { by_name: 'Sort by name' } } }
189
+ )
190
+ end
191
+
192
+ it 'returns a I18n translatable name for the scope' do
193
+ expect(presenter.scope_name(:by_name)).to eq 'Sort by name'
194
+ end
195
+ end
196
+
197
+ context 'if no translation is available' do
198
+ it 'falls back to a titleized version of the scope name' do
199
+ expect(presenter.scope_name(:by_name)).to eq 'By name'
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
206
+
@@ -0,0 +1,137 @@
1
+ require 'spec_helper'
2
+
3
+ module Admino
4
+ module Query
5
+ describe Sorting do
6
+ subject(:sorting) { Sorting.new(config, params) }
7
+ let(:config) do
8
+ Configuration::Sorting.new(sorting_scopes, options)
9
+ end
10
+ let(:sorting_scopes) { [:by_title, :by_date] }
11
+ let(:options) { {} }
12
+ let(:params) { {} }
13
+
14
+ describe '#active_scope' do
15
+ context 'with no param' do
16
+ let(:params) { {} }
17
+
18
+ it 'returns false' do
19
+ expect(sorting.active_scope).to be_nil
20
+ end
21
+
22
+ context 'if a default scope is configured' do
23
+ let(:options) { { default_scope: :by_date } }
24
+
25
+ it 'returns it' do
26
+ expect(sorting.active_scope).to eq :by_date
27
+ end
28
+ end
29
+ end
30
+
31
+ context 'with an invalid value' do
32
+ let(:params) { { 'sorting' => 'foo' } }
33
+
34
+ it 'returns false' do
35
+ expect(sorting.active_scope).to be_nil
36
+ end
37
+ end
38
+
39
+ context 'with a valid value' do
40
+ let(:params) { { 'sorting' => 'by_title' } }
41
+
42
+ it 'returns the scope name' do
43
+ expect(sorting.active_scope).to eq :by_title
44
+ end
45
+ end
46
+ end
47
+
48
+ describe '#ascending?' do
49
+ context 'with no param' do
50
+ let(:params) { {} }
51
+
52
+ it 'returns true' do
53
+ expect(sorting).to be_ascending
54
+ end
55
+ end
56
+
57
+ context 'with invalid value' do
58
+ let(:params) { { 'sort_order' => 'foo' } }
59
+
60
+ it 'returns true' do
61
+ expect(sorting).to be_ascending
62
+ end
63
+ end
64
+
65
+ context 'with "asc" value' do
66
+ let(:params) { { 'sort_order' => 'asc' } }
67
+
68
+ it 'returns nil' do
69
+ expect(sorting).to be_ascending
70
+ end
71
+ end
72
+
73
+ context 'with "desc" value' do
74
+ let(:params) { { 'sort_order' => 'desc' } }
75
+
76
+ it 'returns the param value for the search_field' do
77
+ expect(sorting).not_to be_ascending
78
+ end
79
+ end
80
+
81
+ context 'if a default scope and direction are set and default scope is current' do
82
+ let(:options) { { default_scope: :by_date, default_direction: :desc } }
83
+ let(:params) { { 'sorting' => 'by_date', 'sort_order' => 'desc' } }
84
+
85
+ it 'returns it' do
86
+ expect(sorting).not_to be_ascending
87
+ end
88
+ end
89
+ end
90
+
91
+ describe '#augment_scope' do
92
+ let(:result) { sorting.augment_scope(scope) }
93
+ let(:scope) { ScopeMock.new('original') }
94
+
95
+ context 'if the search_field has a value' do
96
+ let(:params) { { 'sorting' => 'by_title', 'sort_order' => 'desc' } }
97
+
98
+ it 'returns the original scope chained with the current scope' do
99
+ expect(result.chain).to eq [:by_title, [:desc]]
100
+ end
101
+ end
102
+
103
+ context 'else' do
104
+ it 'returns the original scope' do
105
+ expect(result).to eq scope
106
+ end
107
+ end
108
+
109
+ context 'if a default scope is configured' do
110
+ let(:options) { { default_scope: :by_date } }
111
+ let(:params) { {} }
112
+
113
+ it 'returns the original scope chained with the default scope' do
114
+ expect(result.chain).to eq [:by_date, [:asc]]
115
+ end
116
+
117
+ context 'if a default direction is configured' do
118
+ let(:options) { { default_scope: :by_date, default_direction: :desc } }
119
+
120
+ it 'returns the original scope chained with the default scope and default direction' do
121
+ expect(result.chain).to eq [:by_date, [:desc]]
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ describe '#is_scope_active?' do
128
+ let(:params) { { 'sorting' => 'by_date' } }
129
+
130
+ it 'returns true if the provided scope is the one currently active' do
131
+ expect(sorting.is_scope_active?(:by_date)).to be_truthy
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+
@@ -0,0 +1,158 @@
1
+ require 'spec_helper'
2
+
3
+ module Admino
4
+ module Table
5
+ describe HeadRow do
6
+ subject(:row) { HeadRow.new(klass, query, view) }
7
+ let(:klass) { Post }
8
+ let(:query) { nil }
9
+ let(:view) { RailsViewContext.new }
10
+
11
+ it 'takes a class and a view context' do
12
+ expect(row.resource_klass).to eq klass
13
+ expect(row.view_context).to eq view
14
+ end
15
+
16
+ describe '#column' do
17
+ subject { row.to_html }
18
+
19
+ context 'text' do
20
+ context 'with string label' do
21
+ before do
22
+ row.column(:title, 'This is a title')
23
+ end
24
+
25
+ it 'generates a label with it' do
26
+ is_expected.to have_tag(:th, text: 'This is a title')
27
+ end
28
+ end
29
+
30
+ context 'with symbol label and I18n set up' do
31
+ before do
32
+ I18n.backend.store_translations(
33
+ :en,
34
+ activemodel: { attributes: { post: { foo: 'This is foo' } } }
35
+ )
36
+ end
37
+
38
+ before do
39
+ row.column(:title, :foo)
40
+ end
41
+
42
+ it 'generates a label with the human attribute name' do
43
+ is_expected.to have_tag(:th, text: 'This is foo')
44
+ end
45
+ end
46
+
47
+ context 'with no label' do
48
+ before { row.column(:title) }
49
+
50
+ it 'generates a label with the titleized attribute name' do
51
+ is_expected.to have_tag(:th, text: 'Title')
52
+ end
53
+
54
+ context 'with I18n set up' do
55
+ before do
56
+ I18n.backend.store_translations(
57
+ :en,
58
+ activemodel: { attributes: { post: { title: 'Post title' } } }
59
+ )
60
+ end
61
+
62
+ before { row.column(:title) }
63
+
64
+ it 'generates a label with the human attribute name' do
65
+ is_expected.to have_tag(:th, text: 'Post title')
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ context 'sorting' do
72
+ context 'if no query object is present' do
73
+ it 'raises an ArgumentError' do
74
+ expect { row.column(:title, sorting: :by_title) }.to raise_error ArgumentError
75
+ end
76
+ end
77
+
78
+ context 'else' do
79
+ let(:query) { double('Query', sorting: sorting) }
80
+ let(:sorting) { double('Sorting') }
81
+
82
+ before do
83
+ allow(sorting).to receive(:scope_link).with(:by_title, 'Title', {}).
84
+ and_return('Link')
85
+ end
86
+
87
+ before { row.column(:title, sorting: :by_title) }
88
+
89
+ it 'generates a sorting scope link' do
90
+ is_expected.to have_tag(:th, text: 'Link')
91
+ end
92
+ end
93
+ end
94
+
95
+ context 'role' do
96
+ before { row.column(:author_name) }
97
+
98
+ it 'generates a role attribute with the snake-cased name of the attribute' do
99
+ is_expected.to have_tag(:th, with: { role: 'author-name' })
100
+ end
101
+ end
102
+
103
+ context 'with html options param' do
104
+ before { row.column(:title, class: 'foo') }
105
+
106
+ it 'uses it to build attributes' do
107
+ is_expected.to have_tag(:th, with: { class: 'foo' })
108
+ end
109
+ end
110
+ end
111
+
112
+ describe '#actions' do
113
+ subject { row.to_html }
114
+
115
+ context do
116
+ before { row.actions }
117
+
118
+ it 'renders a th cell with role "actions"' do
119
+ is_expected.to have_tag(:th, with: { role: 'actions' })
120
+ end
121
+
122
+ it 'renders a th cell with text "Actions"' do
123
+ is_expected.to have_tag(:th, text: 'Actions')
124
+ end
125
+ end
126
+
127
+ context 'with generic I18n set up' do
128
+ before do
129
+ I18n.backend.store_translations(
130
+ :en,
131
+ table: { actions: { title: 'Available actions' } }
132
+ )
133
+ end
134
+
135
+ it 'renders a th cell with I18n text' do
136
+ row.actions
137
+ is_expected.to have_tag(:th, text: 'Available actions')
138
+ end
139
+
140
+ context 'and specific I18n set up' do
141
+ before do
142
+ I18n.backend.store_translations(
143
+ :en,
144
+ table: { actions: { post: { title: 'Post actions' } } }
145
+ )
146
+ end
147
+
148
+ it 'uses the specific I18n text' do
149
+ row.actions
150
+ is_expected.to have_tag(:th, text: 'Post actions')
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+
@@ -0,0 +1,180 @@
1
+ require 'spec_helper'
2
+ require 'ostruct'
3
+
4
+ module Admino
5
+ module Table
6
+ describe Presenter do
7
+ subject(:presenter) { presenter_klass.new(collection, Post, query, view) }
8
+ let(:presenter_klass) { Presenter }
9
+ let(:query) { double('Query') }
10
+ let(:view) { RailsViewContext.new }
11
+
12
+ let(:collection) { [ first_post, second_post ] }
13
+ let(:first_post) { Post.new('1') }
14
+ let(:second_post) { Post.new('2') }
15
+
16
+ let(:head_row) { double('HeadRow', to_html: '<td id="thead_td"></td>'.html_safe) }
17
+ let(:resource_row) { double('ResourceRow', to_html: '<td id="tbody_td"></td>'.html_safe) }
18
+
19
+ before do
20
+ allow(HeadRow).to receive(:new).with(Post, query, view).and_return(head_row)
21
+ allow(ResourceRow).to receive(:new).with(first_post, view).and_return(resource_row)
22
+ allow(ResourceRow).to receive(:new).with(second_post, view).and_return(resource_row)
23
+ end
24
+
25
+ describe '#to_html' do
26
+ let(:result) { presenter.to_html }
27
+
28
+ it 'outputs a table' do
29
+ expect(result).to have_tag(:table)
30
+ end
31
+
32
+ it 'outputs a thead with a single row' do
33
+ expect(result).to have_tag('table thead tr')
34
+ end
35
+
36
+ it 'outputs a tbody with a row for each collection member' do
37
+ expect(result).to have_tag('table tbody tr', count: 2)
38
+ end
39
+
40
+ context 'if the record responds to #dom_id' do
41
+ before do
42
+ allow(first_post).to receive(:dom_id).and_return('post_1')
43
+ allow(second_post).to receive(:dom_id).and_return('post_2')
44
+ end
45
+
46
+ it 'adds a record identifier to each collection row' do
47
+ expect(result).to have_tag('tbody tr#post_1:first-child')
48
+ expect(result).to have_tag('tbody tr#post_2:last-child')
49
+ end
50
+ end
51
+
52
+ it 'adds zebra classes to each collection row' do
53
+ expect(result).to have_tag('tbody tr.is-even:first-child')
54
+ expect(result).to have_tag('tbody tr.is-odd:last-child')
55
+ end
56
+
57
+ it 'delegates thead columns creation to .to_html HeadRow' do
58
+ expect(result).to have_tag('thead tr td#thead_td')
59
+ end
60
+
61
+ it 'delegates tbody columns creation to .to_html ResourceRow' do
62
+ expect(result).to have_tag('tbody tr td#tbody_td')
63
+ end
64
+
65
+ it 'allows passing table HTML options' do
66
+ expect(presenter.to_html(id: 'table')).to have_tag(:table, with: { id: 'table' })
67
+ end
68
+
69
+ context 'with a block' do
70
+ let(:block_call_args) do
71
+ block_call_args = []
72
+ presenter.to_html do |*args|
73
+ block_call_args << args
74
+ end
75
+ block_call_args
76
+ end
77
+
78
+ it 'calls it once passing the HeadRow instance' do
79
+ expect(block_call_args[0]).to eq [head_row, nil]
80
+ end
81
+
82
+ it 'calls it once for each collection member passing the ResourceRow instance and the member itself' do
83
+ expect(block_call_args[1]).to eq [resource_row, first_post]
84
+ expect(block_call_args[2]).to eq [resource_row, second_post]
85
+ end
86
+ end
87
+
88
+ context 'custom table HTML options' do
89
+ let(:presenter_klass) do
90
+ Class.new(Presenter) do
91
+ private
92
+
93
+ def table_html_options
94
+ { id: 'table' }
95
+ end
96
+
97
+ def thead_html_options
98
+ { id: 'thead' }
99
+ end
100
+
101
+ def thead_tr_html_options
102
+ { id: 'thead_tr' }
103
+ end
104
+
105
+ def tbody_html_options
106
+ { id: 'tbody' }
107
+ end
108
+
109
+ def tbody_tr_html_options(resource, index)
110
+ { class: "index-#{index}" }
111
+ end
112
+
113
+ def zebra_css_classes
114
+ %w(one two)
115
+ end
116
+ end
117
+ end
118
+
119
+ it "allows customizing the default table html attributes" do
120
+ expect(presenter.to_html).to have_tag(:table, with: { id: 'table' })
121
+ end
122
+
123
+ it "allows customizing the the default thead html attributes" do
124
+ expect(presenter.to_html).to have_tag(:thead, with: { id: 'thead' })
125
+ end
126
+
127
+ it "allows customizing the the default thead_tr html attributes" do
128
+ expect(presenter.to_html).to have_tag('thead tr#thead_tr')
129
+ end
130
+
131
+ it "allows customizing the the default tbody html attributes" do
132
+ expect(presenter.to_html).to have_tag(:tbody, with: { id: 'tbody' })
133
+ end
134
+
135
+ it "allows customizing the tbody_tr html attributes" do
136
+ expect(presenter.to_html).to have_tag("tbody tr.index-0:first-child")
137
+ end
138
+
139
+ it 'allows customizing zebra classes' do
140
+ expect(presenter.to_html).to have_tag("tbody tr.one:first-child")
141
+ end
142
+ end
143
+
144
+ context 'custom row builders' do
145
+ let(:presenter_klass) do
146
+ Class.new(Presenter) do
147
+ private
148
+
149
+ def head_row(*args)
150
+ OpenStruct.new(to_html: '<td id="custom_thead_td"></td>'.html_safe)
151
+ end
152
+
153
+ def resource_row(*args)
154
+ OpenStruct.new(to_html: '<td id="custom_tbody_td"></td>'.html_safe)
155
+ end
156
+ end
157
+ end
158
+
159
+ it 'allows customizing head row renderers' do
160
+ expect(presenter.to_html).to have_tag('thead tr td#custom_thead_td')
161
+ end
162
+
163
+ it 'allows customizing resource row renderers' do
164
+ expect(presenter.to_html).to have_tag('tbody tr td#custom_tbody_td')
165
+ end
166
+ end
167
+
168
+ context 'with empty collection and no collection_klass' do
169
+ let(:collection) { [] }
170
+ subject(:presenter) { presenter_klass.new(collection, nil, query, view) }
171
+
172
+ it 'raises an Exception' do
173
+ expect { result }.to raise_error(ArgumentError)
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+