ama_layout 6.3.0.pre → 6.10.0.pre

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +2 -0
  3. data/ama_layout.gemspec +22 -20
  4. data/app/assets/javascripts/ama_layout/desktop/foundation-custom.js +10 -8
  5. data/app/assets/javascripts/ama_layout/desktop/index.js +1 -0
  6. data/app/assets/javascripts/ama_layout/notifications.coffee +17 -0
  7. data/app/controllers/ama_layout/api/v1/notifications_controller.rb +18 -0
  8. data/app/helpers/ama_layout_path_helper.rb +4 -4
  9. data/app/views/ama_layout/_notification.html.erb +10 -0
  10. data/app/views/ama_layout/_notification_sidebar.html.erb +22 -0
  11. data/app/views/ama_layout/_notifications.html.erb +6 -0
  12. data/app/views/ama_layout/_siteheader.html.erb +8 -3
  13. data/config/routes.rb +9 -0
  14. data/lib/ama_layout.rb +27 -20
  15. data/lib/ama_layout/decorators/navigation_decorator.rb +47 -0
  16. data/lib/ama_layout/decorators/notification_decorator.rb +45 -0
  17. data/lib/ama_layout/navigation.rb +3 -0
  18. data/lib/ama_layout/navigation.yml +58 -6
  19. data/lib/ama_layout/navigation_helper.rb +31 -0
  20. data/lib/ama_layout/notification.rb +89 -0
  21. data/lib/ama_layout/notification_scrubber.rb +13 -0
  22. data/lib/ama_layout/notification_set.rb +140 -0
  23. data/lib/ama_layout/notifications.rb +73 -0
  24. data/lib/ama_layout/notifications/abstract_store.rb +17 -0
  25. data/lib/ama_layout/notifications/redis_store.rb +38 -0
  26. data/lib/ama_layout/version.rb +1 -1
  27. data/spec/ama_layout/controllers/ama_layout/api/v1/notifications_controller_spec.rb +13 -0
  28. data/spec/ama_layout/decorators/navigation_decorator_spec.rb +121 -2
  29. data/spec/ama_layout/decorators/notification_decorator_spec.rb +57 -0
  30. data/spec/ama_layout/navigation_helper_spec.rb +63 -0
  31. data/spec/ama_layout/navigation_spec.rb +13 -43
  32. data/spec/ama_layout/notification_scrubber_spec.rb +10 -0
  33. data/spec/ama_layout/notification_set_spec.rb +281 -0
  34. data/spec/ama_layout/notification_spec.rb +193 -0
  35. data/spec/ama_layout/notifications/abstract_store_spec.rb +23 -0
  36. data/spec/ama_layout/notifications/redis_store_spec.rb +94 -0
  37. data/spec/ama_layout/notifications_spec.rb +109 -0
  38. data/spec/factories/users.rb +35 -0
  39. data/spec/helpers/ama_layout_path_helper_spec.rb +6 -6
  40. data/spec/internal/app/controllers/application_controller.rb +21 -0
  41. data/spec/internal/config/routes.rb +1 -0
  42. data/spec/spec_helper.rb +9 -14
  43. data/spec/support/shared_examples/member_navigation.rb +105 -0
  44. metadata +81 -18
  45. data/styles.scss +0 -0
@@ -0,0 +1,31 @@
1
+ module AmaLayout
2
+ module NavigationHelper
3
+ def navigation
4
+ return AmaLayout::Navigation.non_member unless member?
5
+ case
6
+ when _has_outstanding_balance?
7
+ AmaLayout::Navigation.member_with_outstanding_balance
8
+ when _in_renewal_late?
9
+ AmaLayout::Navigation.member_in_renewal_late
10
+ when _in_renewal?
11
+ AmaLayout::Navigation.member_in_renewal
12
+ else
13
+ AmaLayout::Navigation.member
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def _in_renewal?
20
+ in_renewal
21
+ end
22
+
23
+ def _in_renewal_late?
24
+ status == "AL"
25
+ end
26
+
27
+ def _has_outstanding_balance?
28
+ has_outstanding_balance
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,89 @@
1
+ module AmaLayout
2
+ class Notification
3
+ include Draper::Decoratable
4
+
5
+ TYPES = %i[notice warning alert].freeze
6
+ DEFAULT_LIFESPAN = 1.year.freeze
7
+ FORMAT_VERSION = '1.0.0'.freeze
8
+
9
+ # NOTE: The following attributes are designed to be immutable - you need
10
+ # make a new instance to change them. The only mutable attribute is :active.
11
+ attr_reader :id, :type, :brand, :header, :content, :created_at, :lifespan,
12
+ :version
13
+ attr_accessor :active
14
+
15
+ def initialize(args = {})
16
+ args = args.with_indifferent_access
17
+ @id = args[:id]
18
+ @type = args.fetch(:type, :notice).to_sym
19
+ @brand = args[:brand]
20
+ @header = args.fetch(:header)
21
+ @content = args.fetch(:content)
22
+ @created_at = parse_time(args.fetch(:created_at))
23
+ @lifespan = parse_duration(args.fetch(:lifespan, DEFAULT_LIFESPAN))
24
+ @version = args.fetch(:version, FORMAT_VERSION)
25
+ self.active = args.fetch(:active)
26
+ invalid_type! if TYPES.exclude?(type)
27
+ end
28
+
29
+ def <=>(other)
30
+ created_at <=> other.created_at
31
+ end
32
+
33
+ def active?
34
+ active
35
+ end
36
+
37
+ def dismissed?
38
+ !active?
39
+ end
40
+
41
+ def dismiss!
42
+ self.active = false
43
+ dismissed?
44
+ end
45
+
46
+ def digest
47
+ Digest::SHA256.hexdigest(
48
+ "#{type}#{header}#{content}#{brand}#{version}"
49
+ )
50
+ end
51
+
52
+ def stale?
53
+ Time.current > created_at + lifespan
54
+ end
55
+
56
+ def to_h
57
+ # NOTE: We want the following keys to be strings to provide
58
+ # consistency with the underlying data store.
59
+ {
60
+ 'type' => type.to_s,
61
+ 'brand' => brand,
62
+ 'header' => header,
63
+ 'content' => content,
64
+ 'created_at' => created_at.iso8601,
65
+ 'active' => active,
66
+ 'lifespan' => lifespan.to_i,
67
+ 'version' => version
68
+ }
69
+ end
70
+
71
+ private
72
+
73
+ def invalid_type!
74
+ raise ArgumentError, "invalid notification type: #{type}"
75
+ end
76
+
77
+ def parse_time(time)
78
+ time.is_a?(String) ? Time.zone.parse(time) : time
79
+ end
80
+
81
+ def parse_duration(duration)
82
+ if duration.is_a?(ActiveSupport::Duration)
83
+ duration
84
+ else
85
+ duration.seconds
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,13 @@
1
+ module AmaLayout
2
+ class NotificationScrubber < Rails::Html::PermitScrubber
3
+ def initialize
4
+ super
5
+ self.tags = %w(i a div span strong br em h1 h2 h3 h4 h5 h6 blockquote)
6
+ self.attributes = %w(href class id)
7
+ end
8
+
9
+ def skip_node?(node)
10
+ node.text?
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,140 @@
1
+ module AmaLayout
2
+ # An array-like object that handles the storage and retrieval of notifications
3
+ # from the underlying data store.
4
+ #
5
+ # The raw serialization format is JSON as follows (keys are SHA256 hashes):
6
+ #
7
+ # {
8
+ # "57107043eab0f60a37f7735307dc6fc6709d04eec2dbeea8c284958057af9b77": {
9
+ # "type": "notice",
10
+ # "brand": "membership",
11
+ # "header": "test",
12
+ # "content": "test",
13
+ # "created_at": "2017-06-19T11:26:57.730-06:00",
14
+ # "lifespan": 31557600,
15
+ # "active": true,
16
+ # "version": "1.0.0"
17
+ # }
18
+ # }
19
+ #
20
+ class NotificationSet
21
+ include Enumerable
22
+ attr_accessor :base, :data_store, :key
23
+
24
+ delegate :each, :first, :last, :size, :[], :empty?, :any?, to: :all
25
+
26
+ def initialize(data_store, key)
27
+ self.data_store = data_store
28
+ self.key = key
29
+ self.base = fetch
30
+ clean!
31
+ end
32
+
33
+ def active
34
+ all.select(&:active?)
35
+ end
36
+
37
+ def all
38
+ @all ||= normalize(base_notifications)
39
+ end
40
+
41
+ def create(args = {})
42
+ args[:created_at] = Time.current
43
+ args[:active] = true
44
+ notification = Notification.new(args)
45
+ # previously dismissed notifications always take precendence
46
+ all.push(notification) unless base.key?(notification.digest)
47
+ save
48
+ end
49
+
50
+ def destroy!
51
+ data_store.delete(key) && reload!
52
+ end
53
+
54
+ def delete(*digests)
55
+ digests = Array.wrap(digests.flatten)
56
+ delta = all.reject { |n| digests.include?(n.digest) }
57
+ if delta != all
58
+ @all = delta
59
+ save
60
+ end
61
+ end
62
+
63
+ def find(digest)
64
+ all.find { |n| n.id == digest }
65
+ end
66
+
67
+ def save
68
+ data_store.transaction do |store|
69
+ normalized = normalize(all)
70
+ self.base = serialize(normalized)
71
+ store.set(key, base.to_json)
72
+ end
73
+ reload!
74
+ end
75
+
76
+ def inspect
77
+ "<#{self.class.name}>: #{all}"
78
+ end
79
+ alias_method :to_s, :inspect
80
+
81
+ private
82
+
83
+ def clean!
84
+ if dirty?
85
+ all.reject!(&:stale?)
86
+ save
87
+ end
88
+ end
89
+
90
+ def dirty?
91
+ all.any?(&:stale?)
92
+ end
93
+
94
+ def reload!
95
+ @all = nil
96
+ self.base = fetch
97
+ all
98
+ self
99
+ end
100
+
101
+ def base_notifications
102
+ base.map { |k, v| Notification.new(v.merge(id: k)) }
103
+ end
104
+
105
+ def serialize(data)
106
+ data.inject({}) do |hash, element|
107
+ hash[element.digest] = element.to_h
108
+ hash
109
+ end
110
+ end
111
+
112
+ def normalize(data)
113
+ # sort by reverse chronological order
114
+ data.sort { |a, b| b <=> a }
115
+ end
116
+
117
+ def fetch
118
+ result = data_store.get(key)
119
+ result.present? ? build(result) : {}
120
+ end
121
+
122
+ def build(raw)
123
+ JSON.parse(raw)
124
+ rescue JSON::ParserError
125
+ data_store.delete(key) # we should try to prevent further errors
126
+ ::Rails.logger.error json_message(__FILE__, __LINE__, raw)
127
+ {}
128
+ end
129
+
130
+ def json_message(file, line, raw)
131
+ {
132
+ error: "#{self.class.name} - Invalid JSON",
133
+ file: file,
134
+ line: line,
135
+ key: key,
136
+ raw: raw
137
+ }.to_json
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,73 @@
1
+ module AmaLayout
2
+ # Usage:
3
+ #
4
+ # class MyClass
5
+ # include AmaLayout::Notifications
6
+ #
7
+ # notification_store AmaLayout::Notifications::RedisStore.new(options)
8
+ # notification_foreign_key :a_method_name_or_proc # defaults to :id
9
+ #
10
+ # ...
11
+ # end
12
+ #
13
+ module Notifications
14
+ InvalidNotificationStore = Class.new(StandardError)
15
+
16
+ def self.included(base)
17
+ base.extend(ClassMethods)
18
+ base.include(InstanceMethods)
19
+ end
20
+
21
+ module InstanceMethods
22
+ def notifications
23
+ @notifications ||= NotificationSet.new(_store, _foreign_key)
24
+ end
25
+
26
+ def notifications=(other)
27
+ @notifications = other
28
+ end
29
+
30
+ private
31
+
32
+ def _store
33
+ self.class._notification_store || invalid_store!
34
+ end
35
+
36
+ def _foreign_key
37
+ self.class._notification_foreign_key.call(self)
38
+ end
39
+
40
+ def invalid_store!
41
+ raise InvalidNotificationStore, 'a notification store must be specified'
42
+ end
43
+ end
44
+
45
+ module ClassMethods
46
+ def notification_store(store)
47
+ self._notification_store = store
48
+ end
49
+
50
+ def notification_foreign_key(key)
51
+ self._notification_foreign_key = key
52
+ end
53
+
54
+ def _notification_foreign_key
55
+ @_notification_foreign_key || Proc.new(&:id)
56
+ end
57
+
58
+ def _notification_store
59
+ @_notification_store
60
+ end
61
+
62
+ private
63
+
64
+ def _notification_store=(store)
65
+ @_notification_store = store
66
+ end
67
+
68
+ def _notification_foreign_key=(key)
69
+ @_notification_foreign_key = key.to_proc
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,17 @@
1
+ module AmaLayout
2
+ module Notifications
3
+ class AbstractStore
4
+ def get(key, opts = {})
5
+ raise NotImplementedError, 'you must define a #get method in a subclass'
6
+ end
7
+
8
+ def set(key, value, opts = {})
9
+ raise NotImplementedError, 'you must define a #set method in a subclass'
10
+ end
11
+
12
+ def delete(key, opts = {})
13
+ raise NotImplementedError, 'you must define a #delete method in a subclass'
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,38 @@
1
+ module AmaLayout
2
+ module Notifications
3
+ class RedisStore < AbstractStore
4
+ delegate :clear, to: :base
5
+
6
+ attr_accessor :base
7
+
8
+ def initialize(opts = {})
9
+ self.base = ActiveSupport::Cache.lookup_store(
10
+ :redis_store,
11
+ opts.merge(raw: true)
12
+ )
13
+ end
14
+
15
+ def get(key, opts = {})
16
+ if opts.fetch(:default, false)
17
+ base.fetch(key) { opts[:default] }
18
+ else
19
+ base.read(key)
20
+ end
21
+ end
22
+
23
+ def set(key, value, opts = {})
24
+ base.write(key, value, opts) == 'OK'
25
+ end
26
+
27
+ def delete(key, opts = {})
28
+ base.delete(key, opts) == 1
29
+ end
30
+
31
+ def transaction
32
+ base.data.multi do
33
+ yield self
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,3 +1,3 @@
1
1
  module AmaLayout
2
- VERSION = '6.3.0.pre'
2
+ VERSION = '6.10.0.pre'
3
3
  end
@@ -0,0 +1,13 @@
1
+ describe AmaLayout::Api::V1::NotificationsController, type: :controller do
2
+ describe 'DELETE api/v1/notifications' do
3
+ routes { AmaLayout::Engine.routes }
4
+
5
+ before(:each) do
6
+ delete :dismiss_all
7
+ end
8
+
9
+ it 'returns a 204 No Content status' do
10
+ expect(response).to have_http_status(:no_content)
11
+ end
12
+ end
13
+ end
@@ -7,8 +7,8 @@ describe AmaLayout::NavigationDecorator do
7
7
  let(:membership_site) { "http://membership.waffles.ca" }
8
8
  let(:driveredonline_site) { "http://driveredonline.waffles.ca" }
9
9
  let(:registries_site) { "http://registries.waffles.ca" }
10
- let(:automotive_site) { "http://automotive.waffles.ca"}
11
- let(:travel_site) { "http://travel.waffles.ca"}
10
+ let(:automotive_site) { "http://automotive.waffles.ca" }
11
+ let(:travel_site) { "http://travel.waffles.ca" }
12
12
 
13
13
  before(:each) do
14
14
  allow(Rails.configuration).to receive(:gatekeeper_site).and_return(gatekeeper_site)
@@ -129,4 +129,123 @@ describe AmaLayout::NavigationDecorator do
129
129
  expect(navigation_presenter.account_toggle).to eq "render"
130
130
  end
131
131
  end
132
+
133
+ context 'notification center' do
134
+ let(:store) do
135
+ AmaLayout::Notifications::RedisStore.new(
136
+ db: 4,
137
+ namespace: 'test_notifications',
138
+ host: 'localhost'
139
+ )
140
+ end
141
+ let(:notification_set) { AmaLayout::NotificationSet.new(store, 1) }
142
+ let(:user) { OpenStruct.new(navigation: 'member', notifications: notification_set) }
143
+ let(:navigation) { FactoryGirl.build :navigation, user: user }
144
+ subject { described_class.new(navigation) }
145
+
146
+ around(:each) do |example|
147
+ Timecop.freeze(Time.zone.local(2017, 6, 19)) do
148
+ store.clear
149
+ example.run
150
+ store.clear
151
+ end
152
+ end
153
+
154
+ describe '#notifications' do
155
+ it 'renders the content to the page' do
156
+ expect(subject.h).to receive(:render).once.and_return true
157
+ expect(subject.notifications).to be true
158
+ end
159
+ end
160
+
161
+ describe '#notification_badge' do
162
+ context 'with 1 active notification' do
163
+ before(:each) do
164
+ user.notifications.create(
165
+ type: :warning,
166
+ header: 'test',
167
+ content: 'test'
168
+ )
169
+ end
170
+
171
+ it 'returns a div with the count of active notifications' do
172
+ expect(subject.notification_badge).to include('div')
173
+ expect(subject.notification_badge).to include('1')
174
+ end
175
+ end
176
+
177
+ context 'with only inactive notifications' do
178
+ before(:each) do
179
+ user.notifications.create(
180
+ type: :warning,
181
+ header: 'test',
182
+ content: 'test'
183
+ )
184
+ user.notifications.first.dismiss!
185
+ user.notifications.save
186
+ end
187
+
188
+ it 'does not return the badge markup' do
189
+ expect(subject.notification_badge).to be nil
190
+ end
191
+ end
192
+
193
+ context 'with only active and inactive notifications' do
194
+ before(:each) do
195
+ user.notifications.create(
196
+ type: :warning,
197
+ header: 'test',
198
+ content: 'test'
199
+ )
200
+ 2.times do |i|
201
+ user.notifications.create(
202
+ type: :notice,
203
+ header: i,
204
+ content: i
205
+ )
206
+ end
207
+ user.notifications.first.dismiss!
208
+ user.notifications.save
209
+ end
210
+
211
+ it 'returns a div with the count of active notifications' do
212
+ expect(subject.notification_badge).to include('div')
213
+ expect(subject.notification_badge).to include('2')
214
+ end
215
+ end
216
+ end
217
+
218
+ describe '#notification_sidebar' do
219
+ it 'renders content to the page' do
220
+ expect(subject.h).to receive(:render).once.and_return true
221
+ expect(subject.notification_sidebar).to be true
222
+ end
223
+ end
224
+
225
+ describe '#notifications_heading' do
226
+ context 'when notifications are present' do
227
+ before(:each) do
228
+ user.notifications.create(
229
+ type: :warning,
230
+ header: 'test',
231
+ content: 'test'
232
+ )
233
+ end
234
+
235
+ it 'returns the correct heading' do
236
+ expect(subject.notifications_heading).to include('Most Recent Notifications')
237
+ end
238
+ end
239
+
240
+ context 'when notifications are not present' do
241
+ it 'returns the correct heading' do
242
+ expect(subject.notifications_heading).to include('No Recent Notifications')
243
+ end
244
+
245
+ it 'italicizes the message' do
246
+ expect(subject.notifications_heading).to include('italic')
247
+ end
248
+ end
249
+ end
250
+ end
132
251
  end