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 +4 -4
- data/Appraisals +11 -0
- data/README.md +146 -58
- data/draft_punk.gemspec +3 -1
- data/lib/activerecord_class_methods.rb +35 -15
- data/lib/activerecord_instance_methods.rb +47 -21
- data/lib/draft_punk.rb +10 -1
- data/lib/draft_punk/version.rb +1 -1
- data/lib/previous_version_instance_methods.rb +41 -0
- metadata +34 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 904244863752dbaa1f78496c19b136af59d1dba5
|
4
|
+
data.tar.gz: 8c4e60640cd7728e6988253af6cdbf0bfc5d86d4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 02d77960c5c1771d34b2d2b17bb32d1061e9d53ae8223c64b8df0286814bb3056d24e50a93ef37c51b439be10fed8b79090b6c491d0a822deae18783e618ae8f
|
7
|
+
data.tar.gz: d7844a4ccdc4e2cfe552499e913d25a805d9bbadfcbdc14974a703fa0d72d13ee34aff01ef93538ea2308f50610eeb0a55b6f424b005ba2d13363a57886c3cd5
|
data/Appraisals
ADDED
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
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
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
|
-
###
|
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
|
-
|
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
|
-
|
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
|
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
|
61
|
-
draft object (as expected). If your form includes associated objects which weren't defined in requires_approval
|
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
|
95
|
+
So your draft is automatically getting updated.
|
97
96
|
|
98
|
-
|
97
|
+
If your `@business` has a `name` attribute:
|
99
98
|
|
100
99
|
@business.name
|
101
100
|
=> "DraftPunk LLC"
|
102
101
|
|
103
|
-
|
102
|
+
And you change your name:
|
104
103
|
|
105
104
|
@my_draft = @business.editable_version
|
106
|
-
@my_draft.name = "DraftPunk
|
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
|
-
|
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
|
119
|
+
=> "DraftPunk Inc"
|
119
120
|
@business.publish_draft!
|
120
121
|
@business.name
|
121
|
-
=> "DraftPunk
|
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
|
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
|
-
###
|
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
|
-
|
169
|
+
### Set a Rails `default_scope` on your model.
|
169
170
|
|
170
|
-
This is the quickest, most DRY way to address this
|
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
|
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
|
-
|
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
|
-
##
|
188
|
-
|
189
|
-
## TODO: Customizing approvable_attributes, changes_require_approval
|
188
|
+
## Options
|
190
189
|
|
191
|
-
|
190
|
+
### When creating a draft
|
192
191
|
|
193
|
-
|
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
|
-
|
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
|
-
|
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
|
-
###
|
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)
|
data/draft_punk.gemspec
CHANGED
@@ -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', "
|
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?
|
54
|
-
self.const_set :DRAFT_NULLIFY_ATTRIBUTES,
|
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)
|
69
|
-
send(:remove_const, :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,
|
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,
|
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
|
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)
|
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
|
data/lib/draft_punk.rb
CHANGED
@@ -25,7 +25,16 @@ module DraftPunk
|
|
25
25
|
@message = message
|
26
26
|
end
|
27
27
|
def to_s
|
28
|
-
|
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
|
|
data/lib/draft_punk/version.rb
CHANGED
@@ -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.
|
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-
|
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
|