draft_punk 0.2.8 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 736d197cd13d789cca1213de1254c89a1082fddc
4
- data.tar.gz: faa8ed97c0233efc97494180dfdfa4c26cfec3ee
3
+ metadata.gz: 904244863752dbaa1f78496c19b136af59d1dba5
4
+ data.tar.gz: 8c4e60640cd7728e6988253af6cdbf0bfc5d86d4
5
5
  SHA512:
6
- metadata.gz: 1b1f61d8ad5f526b41f406c5aeb6e410f5eda164fa1d55efc356695b7af42314c6d43984ff877b6693429e9562587c47dc2b9e75de975bd0308b0bd2a052c902
7
- data.tar.gz: c21fb1a305501bb941e8ba2cdce4da2dbb4efea82e32fe82f959746c16340c0b90d874e137f2a0deffd1fa14e544bcc26eda3928910e6fdaf55bf588a8e542a6
6
+ metadata.gz: 02d77960c5c1771d34b2d2b17bb32d1061e9d53ae8223c64b8df0286814bb3056d24e50a93ef37c51b439be10fed8b79090b6c491d0a822deae18783e618ae8f
7
+ data.tar.gz: d7844a4ccdc4e2cfe552499e913d25a805d9bbadfcbdc14974a703fa0d72d13ee34aff01ef93538ea2308f50610eeb0a55b6f424b005ba2d13363a57886c3cd5
@@ -0,0 +1,11 @@
1
+ appraise "rails-5-0" do
2
+ gem "rails", "~> 5.0.0"
3
+ end
4
+
5
+ appraise "rails-5-1" do
6
+ gem "rails", "~> 5.1.0"
7
+ end
8
+
9
+ appraise "rails-5-2" do
10
+ gem "rails", "~> 5.2.0"
11
+ end
data/README.md CHANGED
@@ -6,28 +6,29 @@ When it's time to edit, a draft version is created in the same table as the obje
6
6
 
7
7
  When it's time to publish, any attributes changed on your draft object persist to the original object. All associated objects behave the same way. Any associated have_many objects which are deleted on the draft are deleted on the original object.
8
8
 
9
- ## Why this gem compared to similar gems?
10
-
11
- I wrote this gem because other draft/publish gems had limitations that ruled them out in my use case. Here's a few reasons I ended up rolling my own:
12
-
13
- 1. This gem simply works with your existing database (plus one new column on your original object).
14
- 2. I tried using an approach that stores incremental changes in another table. For instance, some draft gems rely on a Versioning gem, or otherwise store incremental changes in the database.
15
-
16
- That gets super complicated, or simply don't work, with associations, nested associations, and/or if you want users to be able to *edit* those changes.
17
- 3. This gem works with Rails `accepts_nested_attributes`. That Rails pattern doesn't work when you pass in objects which aren't associated; for instance, if you try to save a new draft image on your Blog post via nested_attributes, Rails will throw a 404 error. It got nasty, fast, so I needed a solution that worked well with Rails.
18
- 4. I prefer to store drafts in the same table as the original. While this has a downside (see downsides, below), it means:
19
- 1. Your draft acts like the original. You can execute all the same methods on it, reuse presenters, forms, form_objects, decorators, or anything else. It doesn't just quack like a duck, it **is** a duck.
20
-
21
- 2. This prevents your table structure from getting out of sync. If you're using DraftPunk, when you add a new attribute to your model, or change a column, both your live/approved version and your draft version are affected. Using a different pattern, if they live in separate tables, you may need to run migrations on both tables (or, migrate the internals of a version diff if your draft gem relies on something like Paper Trail or VestalVersion)
22
-
23
-
24
- ### Downsides
25
-
26
- Since DraftPunk saves in the same table as the original, your queries in those tables will return both approved and draft objects. In other words, without modifying your Rails app further, your BusinessController index action (in a typical rails app) will return drafts and approved objects. DraftPunk adds scopes to help you manage this. See the "What about the rest of the application? People are seeing draft businesses!" section below for two patterns to address this.
9
+ * [Usage](#usage)
10
+ * [Enable Draft Creation](#enable-draft-creation)
11
+ * [Association Drafts](#association-drafts) - including customization of which associations use drafts
12
+ * [Updating a draft](#updating-a-draft) - including controllers and forms
13
+ * [Publish a draft](#publish-a-draft) - including controllers and forms
14
+ * [Tracking drafts](#tracking-drafts) - methods to tell you if the object is a draft or is approved
15
+ * [What about the rest of the application? People are seeing draft businesses!](#what-about-the-rest-of-the-application-people-are-seeing-draft-businesses)
16
+ * Options
17
+ * [When creating a draft](#when-creating-a-draft)
18
+ * [Handling attributes with uniqueness validations](#handling-attributes-with-uniqueness-validations)
19
+ * [Before create draft callback](#before-create-draft-callback)
20
+ * [After create draft callback](#after-create-draft-callback)
21
+ * [When publishing a draft](#when-publishing-a-draft)
22
+ * [Before publish draft callback](#before-publish-draft-callback)
23
+ * [Customize which attributes are published](#customize-which-attributes-are-published)
24
+ * [Storing approved version history](#storing-approved-version-history)
25
+ * [Prevent historic versions from being saved](#prevent-historic-versions-from-being-saved)
26
+ * [Installation](#installation)
27
+ * [Testing the gem](#testing-the-gem)
27
28
 
28
29
  ## Usage
29
30
 
30
- ### Getting Started
31
+ ### Enable Draft Creation
31
32
  To enable drafts for a model, first add an approved_version_id column (Integer), which will be used to track its draft.
32
33
 
33
34
  Simply call requires_approval in your model to enable DraftPunk on that model and its associations:
@@ -41,29 +42,27 @@ Simply call requires_approval in your model to enable DraftPunk on that model an
41
42
  requires_approval
42
43
  end
43
44
 
44
- DraftPunk will generate drafts for all associations, by default. So when you create a draft Business, that draft will also have draft `employees`, `vending_machines`, `images`, and an `address`. The whole tree is recursively duplicated.
45
-
46
- **Do not call `requires_approval` on Business's associated objects.** The behavior automatically cascades down til there are no more associations.
45
+ ### Association Drafts
46
+ **DraftPunk will generate drafts for all associations**, by default. So when you create a draft Business, that draft will also have draft `employees`, `vending_machines`, `images`, and an `address`. **The whole tree is recursively duplicated.**
47
47
 
48
- ### Customize which associations have drafts created
48
+ **Do not call `requires_approval` on Business's associated objects.** The behavior automatically cascades down until there are no more associations.
49
49
 
50
50
  Optionally, you can tell DraftPunk which associations the user will edit - the associations which should have a draft created.
51
51
 
52
- If you only want the :address association to have a draft created, add a CREATES_NESTED_DRAFTS_FOR constant in your model:
52
+ If you only want the :address association to have a draft created, add a `CREATES_NESTED_DRAFTS_FOR` constant in your model:
53
53
 
54
54
  CREATES_NESTED_DRAFTS_FOR = [:address] # When creating a business's draft, only :address will have drafts created
55
55
 
56
- To disable drafts for all assocations for this model, simply pass an empty array:
56
+ To disable drafts for all associations for this model, simply pass an empty array:
57
57
 
58
58
  CREATES_NESTED_DRAFTS_FOR = [] # When creating a business's draft, no associations will have drafts created
59
59
 
60
- **WARNING: If you are setting associations via accepts_nested_attributes** _all changes to the draft, including associations, get set on the
61
- draft object (as expected). If your form includes associated objects which weren't defined in requires_approval, your save will fail since
60
+ **WARNING: If you are setting associations via `accepts_nested_attributes`** _all changes to the draft, including associations, get set on the
61
+ draft object (as expected). If your form includes associated objects which weren't defined in `requires_approval`, your save will fail since
62
62
  the draft object doesn't HAVE those associations to update! In this case, you should probably add that association to the
63
63
  `associations` param when you call `requires_approval`._
64
64
 
65
-
66
- ### Working with a draft
65
+ ### Updating a draft
67
66
 
68
67
  So you have an ActiveRecord object:
69
68
 
@@ -93,32 +92,34 @@ Your update action might look like so:
93
92
  .... do some stuff here
94
93
  end
95
94
 
96
- So your draft is automagically getting updated.
95
+ So your draft is automatically getting updated.
97
96
 
98
- Say your `@business` has a `name` attribute:
97
+ If your `@business` has a `name` attribute:
99
98
 
100
99
  @business.name
101
100
  => "DraftPunk LLC"
102
101
 
103
- Ok, you just incorperated so your name changed.
102
+ And you change your name:
104
103
 
105
104
  @my_draft = @business.editable_version
106
- @my_draft.name = "DraftPunk Incorperated"
105
+ @my_draft.name = "DraftPunk Inc"
107
106
  @my_draft.save
108
107
 
109
108
  At this point, that change is only saved on the draft version of your business. The original business still has the name DraftPunk LLC.
110
109
 
111
- #### Publish the draft - aka making your changes live
110
+ ### Publish a draft
111
+
112
+ Publishing the draft publishes the draft object's changes onto the approved version. The draft is then destroyed. Example:
112
113
 
113
114
  So you want to make your changes live:
114
115
 
115
116
  @business.name
116
117
  => "DraftPunk LLC"
117
118
  @business.draft.name
118
- => "DraftPunk Incorperated"
119
+ => "DraftPunk Inc"
119
120
  @business.publish_draft!
120
121
  @business.name
121
- => "DraftPunk Incorperated"
122
+ => "DraftPunk Inc"
122
123
 
123
124
  **All of the @business associations copied from the draft**. More correctly, the foreign_keys on has_many associations are changed, set to the original object (@business) id. All the old associations (specified in requires_approval) on @business are destroyed.
124
125
 
@@ -128,7 +129,7 @@ At this point, the draft is destroyed. Next time you call `editable_version`, a
128
129
  Your original model has a few methods available:
129
130
 
130
131
  @business.id
131
- => 1
132
+ => 1
132
133
  @draft = @business.draft
133
134
  => Business(id: 2, ...)
134
135
  @draft.approved_version
@@ -149,7 +150,7 @@ Your associations can have this behavior, too, which could be useful in your app
149
150
  @draft_image = @business.draft.images.first
150
151
  => Image(id: 2, ...)
151
152
 
152
- At this point, if you don't have `approved_version_id` on the `images` table, there's no way for you to know that @draft_image was originally a copy of @live_image. If you have approved_version_id on your table, you can call:
153
+ At this point, if you don't have `approved_version_id` on the `images` table, there's no way for you to know that `@draft_image` was originally a copy of `@live_image`. If you have `approved_version_id` on your table, you can call:
153
154
 
154
155
  @draft_image.approved_version
155
156
  => Image(id: 1, ...)
@@ -158,23 +159,23 @@ At this point, if you don't have `approved_version_id` on the `images` table, th
158
159
 
159
160
  You now know for certain that the two are associated, which could be useful in your app.
160
161
 
161
- ### Free ActiveRecord scopes
162
+ ### ActiveRecord scopes
162
163
  All models which have `approved_version_id` also have these scopes: `approved` and `draft`.
163
164
 
164
165
  ## What about the rest of the application? People are seeing draft businesses!
165
166
 
166
167
  You can implement this in a variety of ways. Here's two approaches:
167
168
 
168
- #### Set a Rails `default_scope` on your model.
169
+ ### Set a Rails `default_scope` on your model.
169
170
 
170
- This is the quickest, most DRY way to address this, but of course default_scopes aren't always the right answer for every Rails app.
171
+ This is the quickest, most DRY way to address this:
171
172
 
172
173
  default_scope Business.approved
173
174
 
174
- Then, any ActiveRecord queries for Business will be scoped to only approved models. Your `draft` scope, and `draft` association will ignore this scope, so @business.draft and Business.draft will both continue to return draft objects.
175
+ Then, any ActiveRecord queries for Business will be scoped to only approved models. Your `draft` scope, and `draft` association will ignore this scope, so `@business.draft` and `Business.draft` will both continue to return draft objects.
175
176
 
176
177
 
177
- #### Or, modify your controllers to use the `approved` scope
178
+ ### Or, modify your controllers to use the `approved` scope
178
179
  Alternately, you may want to modify your controllers to only access _approved_ objects. For instance, your business controller should use that `approved` scope when it looks up businesses. i.e.
179
180
 
180
181
  class BusinessesController < ApplicationController
@@ -184,19 +185,11 @@ Alternately, you may want to modify your controllers to only access _approved_ o
184
185
  end
185
186
  end
186
187
 
187
- ## TODO: Customizing Association associations (grandchildren) using accepts_nested_drafts_for
188
-
189
- ## TODO: Customizing approvable_attributes, changes_require_approval
188
+ ## Options
190
189
 
191
- ## Options before creating a draft
190
+ ### When creating a draft
192
191
 
193
- When calling `requires_approval`, you can pass a `nullify` option to set attributes to null once the draft is created:
194
-
195
- requires_approval nullify: [:subdomain]
196
-
197
- This could be useful if your model has an attribute which should not persist. In this example, each Business has a unique subdomain (ie. business_name.foo.com ). By nullifying this out, the subdomain on the draft would be nil.
198
-
199
- ### Before create callback
192
+ #### Before create draft callback
200
193
 
201
194
  If you define a method on your model called `before_create_draft`, that method will be executed before the draft is created.
202
195
 
@@ -206,20 +199,80 @@ You can access `self` (which is the DRAFT version being created), or the `tempor
206
199
  logger.warn "#{self.name} is being created from #{temporary_approved_object.class.name} ##{temporary_approved_object.id}" # outputs: DerpCorp is being created from Business #1
207
200
  end
208
201
 
209
- ### After create callback
202
+ #### After create draft callback
210
203
 
211
204
  If you define a method on your model called `after_create_draft`, that method will be executed before the draft is created. This is useful in cases when you need a fully set-up draft to modify. For instance, after all of its associations have been set.
212
205
 
213
206
  You can access `self` (which is the DRAFT version being created), or the `temporary_approved_object` (the original object) in this method
214
207
 
215
- **Note that you are responsible for any saves needed**. draft_punk does not save again after your after_create executes
208
+ **Note that you are responsible for any saves needed**. `draft_punk` does not save again after your after_create executes
216
209
 
217
- ## Options before publishing a draft
210
+ #### Handling attributes with uniqueness validations
211
+ When calling `requires_approval`, you can pass a `nullify` option to set attributes to null once the draft is created:
212
+
213
+ requires_approval nullify: [:subdomain]
214
+
215
+ This could be useful if your model has an attribute which should not persist. In this example, each Business has a unique subdomain (ie. business_name.foo.com ). By nullifying this out, the subdomain on the draft would be nil.
218
216
 
219
- ### Before publish draft method
217
+ ### When publishing a draft
218
+
219
+ #### Before publish draft callback
220
220
 
221
221
  If you define a method on your model called `before_publish_draft`, that method will be executed before the draft is published. Specifically, it happens after all attributes are copied from the draft to the approved version, and right before the approved version is saved. This allows you to do whatever you'd like to the model before it is saved.
222
222
 
223
+ #### Customize which attributes are published
224
+
225
+ When a draft is published, most attributes are copied from the draft to the approved version. Naturally, created-at and id would not be copied.
226
+
227
+ You can control the whitelist of attributes to copy by defining `approvable_attributes` method in each model where you need custom behavior.
228
+
229
+ For instance, if each object has a unique `token` attributes, you may not want to copy that to the approved version upon publishing:
230
+
231
+ def approvable_attributes
232
+ self.attributes.keys - ["token"]
233
+ end
234
+
235
+ @business.token
236
+ => '12345'
237
+ @business.draft.token
238
+ => 'abcde'
239
+ @business.publish_draft!
240
+ @business.token
241
+ => '12345' # it was not changed
242
+
243
+ ### TODO: Customizing Association associations (grandchildren) using accepts_nested_drafts_for
244
+ ### TODO: Customizing changes_require_approval
245
+
246
+ ### Storing approved version history
247
+
248
+ You can optionally store the history of approved versions of an object. For instance:
249
+
250
+ @business.name
251
+ => "DraftPunk LLC"
252
+ @business.draft.name
253
+ => "DraftPunk Inc"
254
+ @business.publish_draft!
255
+ @business.draft.name = "DraftPunk Incorperated"
256
+ => "DraftPunk Incorperated"
257
+ @business.publish_draft!
258
+
259
+ @business.name
260
+ => "DraftPunk Incorperated"
261
+
262
+ @business.previous_versions.pluck(:name)
263
+ => ['DraftPunk Inc', 'DraftPunk LLC']
264
+
265
+ @business.previous_versions
266
+ => [Business(id: 2, name: 'DraftPunk Inc', ...), Business(id: 3, name: 'DraftPunk LLC', ...)]
267
+
268
+ To enable this feature, add a `current_approved_version_id` column (Integer) to the model you call `requires_approval` on. Version history will be automatically tracked if that column is present.
269
+
270
+ #### Prevent historic versions from being saved
271
+
272
+ Since these are historic versions, and not the draft or the current live/approved version, you may want to prevent saving. In your model, set the `allow_previous_versions_to_be_changed` option, which adds a `before_save` callback halting any save.
273
+
274
+ requires_approval allow_previous_versions_to_be_changed: false
275
+
223
276
  ## Installation
224
277
 
225
278
  Add this line to your application's Gemfile:
@@ -236,6 +289,24 @@ Or install it yourself as:
236
289
 
237
290
  $ gem install draft_punk
238
291
 
292
+ ## Why this gem compared to similar gems?
293
+
294
+ I wrote this gem because other draft/publish gems had limitations that ruled them out in my use case. Here's a few reasons I ended up rolling my own:
295
+
296
+ 1. This gem simply works with your existing database (plus one new column on your original object).
297
+ 2. I tried using an approach that stores incremental changes in another table. For instance, some draft gems rely on a Versioning gem, or otherwise store incremental changes in the database.
298
+
299
+ That gets super complicated, or simply don't work, with associations, nested associations, and/or if you want users to be able to *edit* those changes.
300
+ 3. This gem works with Rails `accepts_nested_attributes`. That Rails pattern doesn't work when you pass in objects which aren't associated; for instance, if you try to save a new draft image on your Blog post via nested_attributes, Rails will throw a 404 error. It got nasty, fast, so I needed a solution that worked well with Rails.
301
+ 4. I prefer to store drafts in the same table as the original. While this has a downside (see downsides, below), it means:
302
+ 1. Your draft acts like the original. You can execute all the same methods on it, reuse presenters, forms, form_objects, decorators, or anything else. It doesn't just quack like a duck, it **is** a duck.
303
+
304
+ 2. This prevents your table structure from getting out of sync. If you're using DraftPunk, when you add a new attribute to your model, or change a column, both your live/approved version and your draft version are affected. Using a different pattern, if they live in separate tables, you may need to run migrations on both tables (or, migrate the internals of a version diff if your draft gem relies on something like Paper Trail or VestalVersion)
305
+
306
+ ### Downsides
307
+
308
+ Since DraftPunk saves in the same table as the original, your queries in those tables will return both approved and draft objects. In other words, without modifying your Rails app further, your BusinessController index action (in a typical rails app) will return drafts and approved objects. DraftPunk adds scopes to help you manage this. See the "What about the rest of the application? People are seeing draft businesses!" section below for two patterns to address this.
309
+
239
310
  ## Contributing
240
311
 
241
312
  1. Fork it ( https://github.com/stevehodges/draft_punk/fork )
@@ -244,3 +315,20 @@ Or install it yourself as:
244
315
  4. Commit your changes (`git commit -am 'Add some feature'`)
245
316
  5. Push to the branch (`git push origin my-new-feature`)
246
317
  6. Create a new Pull Request
318
+
319
+ ## Testing the gem
320
+
321
+ To test the gem against the current version of Rails (in [Gemfile.lock](Gemfile.lock)):
322
+
323
+ 1. `bundle install`
324
+ 2. `bundle exec rspec`
325
+
326
+ Or, you can run tests for all supported Rails versions
327
+
328
+ 1. `gem install appraisal`
329
+ 1. `bundle exec appraisal install` *(this Generates gemfiles for all permutations of our dependencies, so you'll see lots of bundler output))*
330
+ 1. `bundle exec appraisal rspec`. *(This runs rspec for each dependency permutation. If one fails, appraisal exits immediately and does not test permutations it hasn't gotten to yet. Tests are not considered passing until all permutations are passing)*
331
+
332
+ If you only want to test a certain dependency set, such as Rails 5.2: `bundle exec appraisal rails-5-2 rspec`.
333
+
334
+ You can view all available dependency sets in [Appraisals](Appraisals)
@@ -33,11 +33,13 @@ EOF
33
33
 
34
34
  spec.add_runtime_dependency "amoeba", "~> 3.1"
35
35
  spec.add_runtime_dependency "differ", "< 0.2"
36
- spec.add_runtime_dependency 'rails', "> 5.0", "< 5.3"
36
+ spec.add_runtime_dependency 'rails', ">= 5.0", "< 5.3"
37
37
 
38
38
  spec.add_development_dependency "bundler", "~> 1.9"
39
39
  spec.add_development_dependency "rake", "~> 10.0"
40
40
  spec.add_development_dependency "rspec", "~> 2.0"
41
+ spec.add_development_dependency 'appraisal', '~> 2.2'
42
+ spec.add_development_dependency "timecop", "~> 0.1"
41
43
  spec.add_development_dependency "sqlite3", "~> 1.0"
42
44
  spec.add_development_dependency "yard", "< 1.0"
43
45
 
@@ -1,5 +1,6 @@
1
1
  require 'activerecord_instance_methods'
2
2
  require 'draft_diff_instance_methods'
3
+ require 'previous_version_instance_methods'
3
4
 
4
5
  module DraftPunk
5
6
  module Model
@@ -42,22 +43,24 @@ module DraftPunk
42
43
  # persist those on the draft.
43
44
  # @param set_default_scope [Boolean] If true, set a default scope on this model for the approved scope; only approved objects
44
45
  # will be returned in ActiveRecord queries, unless you call Model.unscoped
46
+ # @param allow_previous_versions_to_be_changed [Boolean] If the model tracks approved version history, and this
47
+ # param is false, previously-approved versions of the object cannot be saved (via a before_save callback )
45
48
  # @param associations [Array] Use internally; set associations to create drafts for in the CREATES_NESTED_DRAFTS_FOR constant
46
49
  # @return true
47
- def requires_approval(associations: [], nullify: [], set_default_scope: false)
50
+ def requires_approval(associations: [], nullify: [], set_default_scope: false, allow_previous_versions_to_be_changed: true)
48
51
  return unless draft_punk_table_exists?(table_name) # Short circuits if you're migrating
49
52
 
50
53
  associations = draft_target_associations if associations.empty?
51
54
  set_valid_associations(associations)
52
55
 
53
- raise DraftPunk::ConfigurationError, "Cannot call requires_approval multiple times for #{name}" if const_defined? :DRAFT_PUNK_IS_SETUP
54
- self.const_set :DRAFT_NULLIFY_ATTRIBUTES, [nullify].flatten
56
+ raise DraftPunk::ConfigurationError, "Cannot call requires_approval multiple times for #{name}" if const_defined?(:DRAFT_PUNK_IS_SETUP)
57
+ self.const_set :DRAFT_NULLIFY_ATTRIBUTES, Array(nullify).flatten.freeze
55
58
 
56
59
  amoeba do
57
60
  nullify nullify
58
61
  # Note that the amoeba associations and customize options are being set in setup_associations_and_scopes_for
59
62
  end
60
- setup_amoeba_for self, set_default_scope: set_default_scope
63
+ setup_amoeba_for self, set_default_scope: set_default_scope, allow_previous_versions_to_be_changed: allow_previous_versions_to_be_changed
61
64
  true
62
65
  end
63
66
 
@@ -65,8 +68,9 @@ module DraftPunk
65
68
  # called multiple times. Only the usage for that use case is supported. Use at your own risk for other
66
69
  # use cases.
67
70
  def disable_approval!
68
- send(:remove_const, :DRAFT_PUNK_IS_SETUP) if const_defined? :DRAFT_PUNK_IS_SETUP
69
- send(:remove_const, :DRAFT_NULLIFY_ATTRIBUTES) if const_defined? :DRAFT_NULLIFY_ATTRIBUTES
71
+ send(:remove_const, :DRAFT_PUNK_IS_SETUP) if const_defined? :DRAFT_PUNK_IS_SETUP
72
+ send(:remove_const, :DRAFT_NULLIFY_ATTRIBUTES) if const_defined? :DRAFT_NULLIFY_ATTRIBUTES
73
+ send(:remove_const, :ALLOW_PREVIOUS_VERSIONS_TO_BE_CHANGED) if const_defined? :ALLOW_PREVIOUS_VERSIONS_TO_BE_CHANGED
70
74
  fresh_amoeba do
71
75
  disable
72
76
  end
@@ -90,6 +94,14 @@ module DraftPunk
90
94
  column_names.include? 'approved_version_id'
91
95
  end
92
96
 
97
+ # Whether this model is configured to store previously-approved versions of the model.
98
+ # This will be true if the model has an current_approved_version_id column
99
+ #
100
+ # @return (Boolean)
101
+ def tracks_approved_version_history?
102
+ column_names.include?('current_approved_version_id')
103
+ end
104
+
93
105
  protected #################################################################
94
106
 
95
107
  def default_draft_target_associations
@@ -114,13 +126,13 @@ module DraftPunk
114
126
  false
115
127
  end
116
128
  end
117
- self.const_set :DRAFT_VALID_ASSOCIATIONS, valid_assocations
129
+ self.const_set :DRAFT_VALID_ASSOCIATIONS, valid_assocations.freeze
118
130
  valid_assocations
119
131
  end
120
132
 
121
133
  private ###################################################################
122
134
 
123
- def setup_amoeba_for(target_class, set_default_scope: false)
135
+ def setup_amoeba_for(target_class, options={})
124
136
  return if target_class.const_defined?(:DRAFT_PUNK_IS_SETUP)
125
137
  associations = target_class.draft_target_associations
126
138
  associations = target_class.set_valid_associations(associations)
@@ -129,9 +141,9 @@ module DraftPunk
129
141
  include_associations target_class.const_get(:DRAFT_VALID_ASSOCIATIONS) unless target_class.const_get(:DRAFT_VALID_ASSOCIATIONS).empty?
130
142
  customize target_class.set_approved_version_id_callback
131
143
  end
132
- target_class.const_set :DRAFT_PUNK_IS_SETUP, true
144
+ target_class.const_set :DRAFT_PUNK_IS_SETUP, true.freeze
133
145
 
134
- setup_associations_and_scopes_for target_class, set_default_scope: set_default_scope
146
+ setup_associations_and_scopes_for target_class, **options
135
147
  setup_draft_association_persistance_for_children_of target_class, associations
136
148
  end
137
149
 
@@ -157,11 +169,23 @@ module DraftPunk
157
169
  end
158
170
  end
159
171
 
160
- def setup_associations_and_scopes_for(target_class, set_default_scope: false)
172
+ def setup_associations_and_scopes_for(target_class, set_default_scope: false, allow_previous_versions_to_be_changed: true)
161
173
  target_class.send :include, InstanceInterrogators unless target_class.method_defined?(:has_draft?)
162
174
  target_class.send :attr_accessor, :temporary_approved_object
163
175
  target_class.send :before_create, :before_create_draft if target_class.method_defined?(:before_create_draft)
164
176
 
177
+ target_class.const_set :ALLOW_PREVIOUS_VERSIONS_TO_BE_CHANGED, allow_previous_versions_to_be_changed.freeze
178
+ if target_class.tracks_approved_version_history?
179
+ target_class.belongs_to :current_approved_version, class_name: target_class.name, optional: true
180
+ target_class.has_many :previous_versions, -> { order(id: :desc) }, class_name: target_class.name, foreign_key: :current_approved_version_id
181
+ target_class.before_update :prevent_previous_versions_from_saving
182
+ target_class.send :include, PreviousVersionInstanceMethods
183
+ end
184
+
185
+ if set_default_scope
186
+ target_class.default_scope -> { approved }
187
+ end
188
+
165
189
  return if target_class.reflect_on_association(:approved_version) || !target_class.column_names.include?('approved_version_id')
166
190
  target_class.send :include, ActiveRecordInstanceMethods
167
191
  target_class.send :include, DraftDiffInstanceMethods
@@ -169,13 +193,9 @@ module DraftPunk
169
193
  target_class.scope :approved, -> { where("#{target_class.quoted_table_name}.approved_version_id IS NULL") }
170
194
  target_class.has_one :draft, -> { unscope(where: :approved) }, class_name: target_class.name, foreign_key: :approved_version_id
171
195
  target_class.scope :draft, -> { unscoped.where("#{target_class.quoted_table_name}.approved_version_id IS NOT NULL") }
172
- if set_default_scope
173
- target_class.default_scope -> { approved }
174
- end
175
196
  end
176
197
 
177
198
  def is_relevant_association_type?(activerecord_reflection)
178
- # Note when implementing for Rails 4, macro is renamed to something else
179
199
  activerecord_reflection.macro.in? Amoeba::Config::DEFAULTS[:known_macros]
180
200
  end
181
201
 
@@ -6,17 +6,6 @@ module DraftPunk
6
6
  # You can overwrite these methods in your model for custom behavior
7
7
  #############################
8
8
 
9
- # Determines whether to edit a draft, or the original object. This only controls
10
- # the object returned by editable version, and draft publishing. If changes to not
11
- # require approval, publishing of the draft is short circuited and will do nothing.
12
- #
13
- # Overwrite in your model to implement logic for whether to use a draft.
14
- #
15
- # @return [Boolean]
16
- def changes_require_approval?
17
- true # By default, all changes require approval
18
- end
19
-
20
9
  # Which attributes of this model are published from the draft to the approved object. Overwrite in model
21
10
  # if you don't want all attributes of the draft to be saved on the live object.
22
11
  #
@@ -36,6 +25,17 @@ module DraftPunk
36
25
  self.attributes.keys - ["created_at"]
37
26
  end
38
27
 
28
+ # Determines whether to edit a draft, or the original object. This only controls
29
+ # the object returned by editable version, and draft publishing. If changes to not
30
+ # require approval, publishing of the draft is short circuited and will do nothing.
31
+ #
32
+ # Overwrite in your model to implement logic for whether to use a draft.
33
+ #
34
+ # @return [Boolean]
35
+ def changes_require_approval?
36
+ true # By default, all changes require approval
37
+ end
38
+
39
39
  # Evaluates after the draft is created.
40
40
  # Override in your model to implement custom behavior.
41
41
  def after_create_draft
@@ -58,9 +58,9 @@ module DraftPunk
58
58
  def publish_draft!
59
59
  @live_version = get_approved_version
60
60
  @draft_version = editable_version
61
- return unless changes_require_approval? && @draft_version.is_draft? # No-op. ie. the business is in a state that doesn't require approval.
62
-
61
+ return unless changes_require_approval? && @draft_version.is_draft? # No-op. ie. the live version is in a state that doesn't require approval.
63
62
  transaction do
63
+ create_historic_version_of_approved_object if tracks_approved_version_history?
64
64
  save_attribute_changes_and_belongs_to_assocations_from_draft
65
65
  update_has_many_and_has_one_associations_from_draft
66
66
  # We have to destroy the draft this since we moved all the draft's has_many associations to @live_version. If you call "editable_version" later, it'll build the draft.
@@ -86,6 +86,10 @@ module DraftPunk
86
86
  approved_version || self
87
87
  end
88
88
 
89
+ def tracks_approved_version_history?
90
+ self.class.tracks_approved_version_history?
91
+ end
92
+
89
93
  protected #################################################################
90
94
 
91
95
  def get_draft
@@ -109,6 +113,21 @@ module DraftPunk
109
113
  draft
110
114
  end
111
115
 
116
+ def create_historic_version_of_approved_object
117
+ begin
118
+ dupe = @live_version.amoeba_dup
119
+ dupe.assign_attributes(
120
+ approved_version_id: nil,
121
+ current_approved_version_id: @live_version.id,
122
+ created_at: @live_version.created_at,
123
+ updated_at: @live_version.updated_at )
124
+ dupe.save!(validate: false)
125
+ rescue => message
126
+ raise HistoricVersionCreationError, message
127
+ end
128
+ true
129
+ end
130
+
112
131
  def save_attribute_changes_and_belongs_to_assocations_from_draft
113
132
  @draft_version.attributes.each do |attribute, value|
114
133
  next unless attribute.in? usable_approvable_attributes
@@ -120,8 +139,7 @@ module DraftPunk
120
139
 
121
140
  def update_has_many_and_has_one_associations_from_draft
122
141
  self.class.draft_target_associations.each do |assoc|
123
- reflection = self.class.reflect_on_association(assoc)
124
-
142
+ reflection = self.class.reflect_on_association(assoc) || next
125
143
  reflection_is_has_many(reflection) ? @live_version.send(assoc).destroy_all : @live_version.send(assoc).try(:destroy)
126
144
 
127
145
  attribute_updates = {}
@@ -129,13 +147,17 @@ module DraftPunk
129
147
  attribute_updates['updated_at'] = Time.now if reflection.klass.column_names.include?('updated_at')
130
148
  attribute_updates['approved_version_id'] = nil if reflection.klass.tracks_approved_version?
131
149
 
132
- reflection_is_has_many(reflection) ? @draft_version.send(assoc).update_all(attribute_updates) : @draft_version.send(assoc).update_columns(attribute_updates)
150
+ if reflection_is_has_many(reflection)
151
+ @draft_version.send(assoc).update_all attribute_updates
152
+ elsif @draft_version.send(assoc).present?
153
+ @draft_version.send(assoc).update_columns attribute_updates
154
+ end
133
155
  end
134
156
  end
135
157
 
136
158
  def usable_approvable_attributes
137
159
  nullified_attributes = self.class.const_defined?(:DRAFT_NULLIFY_ATTRIBUTES) ? self.class.const_get(:DRAFT_NULLIFY_ATTRIBUTES) : []
138
- approvable_attributes.map(&:to_s) - nullified_attributes.map(&:to_s) - ['approved_version_id', 'id']
160
+ approvable_attributes.map(&:to_s) - nullified_attributes.map(&:to_s) - ['approved_version_id', 'id', 'current_approved_version_id']
139
161
  end
140
162
 
141
163
  def current_approvable_attributes
@@ -150,15 +172,12 @@ module DraftPunk
150
172
  end
151
173
 
152
174
  def association_is_has_many(name)
153
- # Note when implementing for Rails 4, macro is renamed to something else
154
175
  self.class.reflect_on_association(name.to_sym).macro == :has_many
155
176
  end
156
177
 
157
178
  def reflection_is_has_many(reflection)
158
- # Note when implementing for Rails 4, macro is renamed to something else
159
179
  reflection.macro == :has_many
160
180
  end
161
-
162
181
  end
163
182
 
164
183
  module InstanceInterrogators
@@ -173,7 +192,14 @@ module DraftPunk
173
192
  raise DraftPunk::ApprovedVersionIdError unless respond_to?(:approved_version_id)
174
193
  draft.present?
175
194
  end
176
- end
177
195
 
196
+ # @return [Boolean] whether the current ActiveRecord object is a previously-approved
197
+ # version of another instance of this class
198
+ def is_previous_version?
199
+ tracks_approved_version_history? &&
200
+ !is_draft? &&
201
+ current_approved_version_id.present?
202
+ end
203
+ end
178
204
  end
179
205
  end
@@ -25,7 +25,16 @@ module DraftPunk
25
25
  @message = message
26
26
  end
27
27
  def to_s
28
- "this model doesn't have an approved_version_id column, so you cannot access its draft or approved versions. Add a column approved_version_id (Integer) to enable this tracking."
28
+ "this model doesn't have an approved_version_id column, so you cannot access its draft or approved versions. Add a column approved_version_id (Integer) to enable this tracking."
29
+ end
30
+ end
31
+
32
+ class HistoricVersionCreationError < ArgumentError
33
+ def initialize(message=nil)
34
+ @message = message
35
+ end
36
+ def to_s
37
+ "could not create previously-approved version: #{@message}"
29
38
  end
30
39
  end
31
40
 
@@ -1,3 +1,3 @@
1
1
  module DraftPunk
2
- VERSION = "0.2.8"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -0,0 +1,41 @@
1
+ module DraftPunk
2
+ module Model
3
+ module PreviousVersionInstanceMethods
4
+
5
+ # If the current object A is in the previous version history of a
6
+ # object B, this method updates B to match the approvable attributes
7
+ # and associations of A. A copy of B is added to the previous version
8
+ # history.
9
+ #
10
+ # @return [Activerecord Object] updated version of the approved object
11
+ def make_current!
12
+ return unless is_previous_version?
13
+ @live_version = current_approved_version
14
+ @previous_version = self
15
+ @live_version.draft.try(:destroy)
16
+ @previous_version.update_attributes(
17
+ current_approved_version_id: nil,
18
+ approved_version: @live_version)
19
+ @live_version.publish_draft!
20
+ end
21
+
22
+ # @return [Activerecord Object]
23
+ def previous_version
24
+ previous_versions.first
25
+ end
26
+
27
+ private ###################################################################
28
+
29
+ # before_save
30
+ def prevent_previous_versions_from_saving
31
+ if is_previous_version? &&
32
+ self.class.const_defined?(:ALLOW_PREVIOUS_VERSIONS_TO_BE_CHANGED) &&
33
+ !self.class::ALLOW_PREVIOUS_VERSIONS_TO_BE_CHANGED
34
+ errors.add :base, 'cannot save previously approved version'
35
+ throw :abort
36
+ end
37
+ end
38
+
39
+ end
40
+ end
41
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: draft_punk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.8
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steve Hodges
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2018-08-28 00:00:00.000000000 Z
11
+ date: 2018-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: amoeba
@@ -42,7 +42,7 @@ dependencies:
42
42
  name: rails
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - ">"
45
+ - - ">="
46
46
  - !ruby/object:Gem::Version
47
47
  version: '5.0'
48
48
  - - "<"
@@ -52,7 +52,7 @@ dependencies:
52
52
  prerelease: false
53
53
  version_requirements: !ruby/object:Gem::Requirement
54
54
  requirements:
55
- - - ">"
55
+ - - ">="
56
56
  - !ruby/object:Gem::Version
57
57
  version: '5.0'
58
58
  - - "<"
@@ -100,6 +100,34 @@ dependencies:
100
100
  - - "~>"
101
101
  - !ruby/object:Gem::Version
102
102
  version: '2.0'
103
+ - !ruby/object:Gem::Dependency
104
+ name: appraisal
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '2.2'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '2.2'
117
+ - !ruby/object:Gem::Dependency
118
+ name: timecop
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '0.1'
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '0.1'
103
131
  - !ruby/object:Gem::Dependency
104
132
  name: sqlite3
105
133
  requirement: !ruby/object:Gem::Requirement
@@ -145,6 +173,7 @@ files:
145
173
  - ".gitignore"
146
174
  - ".rspec"
147
175
  - ".travis.yml"
176
+ - Appraisals
148
177
  - CODE_OF_CONDUCT.md
149
178
  - Gemfile
150
179
  - LICENSE.txt
@@ -162,6 +191,7 @@ files:
162
191
  - lib/draft_punk.rb
163
192
  - lib/draft_punk/version.rb
164
193
  - lib/helper_methods.rb
194
+ - lib/previous_version_instance_methods.rb
165
195
  homepage: https://github.com/stevehodges/draftpunk
166
196
  licenses:
167
197
  - MIT