ama_layout 6.3.0.pre → 6.10.0.pre

Sign up to get free protection for your applications and to get access to all the features.
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