draft_punk 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.rspec +2 -0
- data/.travis.yml +3 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +238 -0
- data/Rakefile +4 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/draft_punk.gemspec +45 -0
- data/lib/activerecord_class_methods.rb +179 -0
- data/lib/activerecord_instance_methods.rb +169 -0
- data/lib/draft_diff_instance_methods.rb +86 -0
- data/lib/draft_punk.rb +46 -0
- data/lib/draft_punk/version.rb +3 -0
- data/lib/helper_methods.rb +33 -0
- metadata +196 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: e3f046dd681c4ce95232727b80e08571f86a9b3d
|
4
|
+
data.tar.gz: d9d79883283cf851eaf23d62ff7a799b881747b5
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9f3c56febd0bddc7e51aa5b8977be58cd00609cb22d9d08af7d186f236ee980f22e0850f7a8b198af91d9258fa229c8b2249c96274d526a7aaf5f62617b93d9c
|
7
|
+
data.tar.gz: c3186ea26726487f53d9d061c03ba1c1756ca92d3b5050cc730f2c874b1c98da7f80006b119f0bc6df6c505c7770a0088ddb811d63b50e4c5bbea73074108d4d
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# Contributor Code of Conduct
|
2
|
+
|
3
|
+
As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
|
4
|
+
|
5
|
+
We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion.
|
6
|
+
|
7
|
+
Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
|
8
|
+
|
9
|
+
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team.
|
10
|
+
|
11
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
|
12
|
+
|
13
|
+
This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/)
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2015 Steve Hodges
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,238 @@
|
|
1
|
+
# DraftPunk
|
2
|
+
|
3
|
+
DraftPunk allows editing of a draft version of an ActiveRecord model and its associations.
|
4
|
+
|
5
|
+
When it's time to edit, a draft version is created in the same table as the object. You can specify which associations should also be edited and stored with that draft version. All associations are stored in their native table.
|
6
|
+
|
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
|
+
|
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.
|
27
|
+
|
28
|
+
## Usage
|
29
|
+
|
30
|
+
### Getting Started
|
31
|
+
To enable drafts for a model, first add an approved_version_id column (Integer), which will be used to track its draft.
|
32
|
+
|
33
|
+
Simply call requires_approval in your model to enable DraftPunk on that model and its associations:
|
34
|
+
|
35
|
+
class Business << ActiveRecord::Base
|
36
|
+
has_many :employees
|
37
|
+
has_many :images
|
38
|
+
has_one :address
|
39
|
+
has_many :vending_machines
|
40
|
+
|
41
|
+
requires_approval
|
42
|
+
end
|
43
|
+
|
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.
|
47
|
+
|
48
|
+
### Customize which associations have drafts created
|
49
|
+
|
50
|
+
Optionally, you can tell DraftPunk which associations the user will edit - the associations which should have a draft created.
|
51
|
+
|
52
|
+
If you only want the :address association to have a draft created, add a CREATES_NESTED_DRAFTS_FOR constant in your model:
|
53
|
+
|
54
|
+
CREATES_NESTED_DRAFTS_FOR = [:address] # When creating a business's draft, only :address will have drafts created
|
55
|
+
|
56
|
+
To disable drafts for all assocations for this model, simply pass an empty array:
|
57
|
+
|
58
|
+
CREATES_NESTED_DRAFTS_FOR = [] # When creating a business's draft, no associations will have drafts created
|
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
|
62
|
+
the draft object doesn't HAVE those associations to update! In this case, you should probably add that association to the
|
63
|
+
`associations` param when you call `requires_approval`._
|
64
|
+
|
65
|
+
|
66
|
+
### Working with a draft
|
67
|
+
|
68
|
+
So you have an ActiveRecord object:
|
69
|
+
|
70
|
+
@business = Business.first
|
71
|
+
|
72
|
+
And you want it's editable version - its draft:
|
73
|
+
|
74
|
+
@my_draft = @business.editable_version #If @business doesn't have a draft yet, it creates one for you.
|
75
|
+
|
76
|
+
Now you can edit the draft. Perhaps in your controller, you have:
|
77
|
+
|
78
|
+
def edit
|
79
|
+
@my_draft = @business.editable_version
|
80
|
+
render 'form'
|
81
|
+
end
|
82
|
+
|
83
|
+
In the view (or even in rails console), you'll want to be editing that draft version. For instance, pass your draft into the business's form, and it'll just work!
|
84
|
+
|
85
|
+
form_for @my_draft
|
86
|
+
|
87
|
+
And, voila, the user is editing the draft.
|
88
|
+
|
89
|
+
Your update action might look like so:
|
90
|
+
|
91
|
+
def update
|
92
|
+
@my_draft = Business.find(params[:id])
|
93
|
+
.... do some stuff here
|
94
|
+
end
|
95
|
+
|
96
|
+
So your draft is automagically getting updated.
|
97
|
+
|
98
|
+
Say your `@business` has a `name` attribute:
|
99
|
+
|
100
|
+
@business.name
|
101
|
+
=> "DraftPunk LLC"
|
102
|
+
|
103
|
+
Ok, you just incorperated so your name changed.
|
104
|
+
|
105
|
+
@my_draft = @business.editable_version
|
106
|
+
@my_draft.name = "DraftPunk Incorperated"
|
107
|
+
@my_draft.save
|
108
|
+
|
109
|
+
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
|
+
|
111
|
+
#### Publish the draft - aka making your changes live
|
112
|
+
|
113
|
+
So you want to make your changes live:
|
114
|
+
|
115
|
+
@business.name
|
116
|
+
=> "DraftPunk LLC"
|
117
|
+
@business.draft.name
|
118
|
+
=> "DraftPunk Incorperated"
|
119
|
+
@business.publish_draft!
|
120
|
+
@business.name
|
121
|
+
=> "DraftPunk Incorperated"
|
122
|
+
|
123
|
+
**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
|
+
At this point, the draft is destroyed. Next time you call `editable_version`, a draft will be created for you.
|
126
|
+
|
127
|
+
### Tracking drafts
|
128
|
+
Your original model has a few methods available:
|
129
|
+
|
130
|
+
@business.id
|
131
|
+
=> 1
|
132
|
+
@draft = @business.draft
|
133
|
+
=> Business(id: 2, ...)
|
134
|
+
@draft.approved_version
|
135
|
+
=> Business(id: 1, ...)
|
136
|
+
@draft.is_draft?
|
137
|
+
=> true
|
138
|
+
@draft.has_draft?
|
139
|
+
=> false
|
140
|
+
@business.is_draft?
|
141
|
+
=> false
|
142
|
+
@business.has_draft?
|
143
|
+
=> true
|
144
|
+
|
145
|
+
Your associations can have this behavior, too, which could be useful in your application. If you want your draft associations to track their live version, add an `approved_version_id` column (Integer) to each table. You'll have all the methods demonstrated above. This also allows you to access a child association directly, ie.
|
146
|
+
|
147
|
+
@live_image = @business.images.first
|
148
|
+
=> Image(id: 1, ...)
|
149
|
+
@draft_image = @business.draft.images.first
|
150
|
+
=> Image(id: 2, ...)
|
151
|
+
|
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
|
+
|
154
|
+
@draft_image.approved_version
|
155
|
+
=> Image(id: 1, ...)
|
156
|
+
@live_image.draft
|
157
|
+
=> Image(id: 2, ...)
|
158
|
+
|
159
|
+
You now know for certain that the two are associated, which could be useful in your app.
|
160
|
+
|
161
|
+
### Free ActiveRecord scopes
|
162
|
+
All models which have `approved_version_id` also have these scopes: `approved` and `draft`.
|
163
|
+
|
164
|
+
## What about the rest of the application? People are seeing draft businesses!
|
165
|
+
|
166
|
+
You can implement this in a variety of ways. Here's two approaches:
|
167
|
+
|
168
|
+
#### Set a Rails `default_scope` on your model.
|
169
|
+
|
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
|
+
|
172
|
+
default_scope Business.approved
|
173
|
+
|
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
|
+
|
176
|
+
|
177
|
+
#### Or, modify your controllers to use the `approved` scope
|
178
|
+
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
|
+
class BusinessesController < ApplicationController
|
181
|
+
def index
|
182
|
+
@businesses = Business.approved.all
|
183
|
+
... more code
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
## TODO: Customizing Association associations (grandchildren) using accepts_nested_drafts_for
|
188
|
+
|
189
|
+
## TODO: Customizing approvable_attributes, changes_require_approval
|
190
|
+
|
191
|
+
## Options before creating a draft
|
192
|
+
|
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
|
200
|
+
|
201
|
+
If you define a method on your model called `before_create_draft`, that method will be executed before the draft is created.
|
202
|
+
|
203
|
+
You can access `self` (which is the DRAFT version being created), or the `temporary_approved_object` (the original object) in this method
|
204
|
+
|
205
|
+
def before_create_draft
|
206
|
+
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
|
+
end
|
208
|
+
|
209
|
+
## Options before publishing a draft
|
210
|
+
|
211
|
+
### Before publish draft method
|
212
|
+
|
213
|
+
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.
|
214
|
+
|
215
|
+
## Installation
|
216
|
+
|
217
|
+
Add this line to your application's Gemfile:
|
218
|
+
|
219
|
+
```ruby
|
220
|
+
gem 'draft_punk'
|
221
|
+
```
|
222
|
+
|
223
|
+
And then execute:
|
224
|
+
|
225
|
+
$ bundle
|
226
|
+
|
227
|
+
Or install it yourself as:
|
228
|
+
|
229
|
+
$ gem install draft_punk
|
230
|
+
|
231
|
+
## Contributing
|
232
|
+
|
233
|
+
1. Fork it ( https://github.com/stevehodges/draft_punk/fork )
|
234
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
235
|
+
3. Be consistent with the rest of this repo. Write thorough tests (rspec) and documentation (yard)
|
236
|
+
4. Commit your changes (`git commit -am 'Add some feature'`)
|
237
|
+
5. Push to the branch (`git push origin my-new-feature`)
|
238
|
+
6. Create a new Pull Request
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "draft_punk"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/draft_punk.gemspec
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'draft_punk/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "draft_punk"
|
8
|
+
spec.version = DraftPunk::VERSION
|
9
|
+
spec.authors = ["Steve Hodges"]
|
10
|
+
spec.email = ["steve.hodges@localstake.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{Easy editing of a draft version of an ActiveRecord model and its associations, and publishing said draft's changes back to the original models.}
|
13
|
+
spec.description = <<-EOF
|
14
|
+
DraftPunk allows editing of a draft version of an ActiveRecord model and its associations.
|
15
|
+
|
16
|
+
When it's time to edit, a draft version is created in the same table as the object. You can specify which associations should also be edited and stored with that draft version. All associations are stored in their native table.
|
17
|
+
|
18
|
+
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.
|
19
|
+
|
20
|
+
This gem doesn't rely on a versioning gem and doesn't store incremental diffs of the model. It simply works with your existing database (plus one new column on your original object).
|
21
|
+
EOF
|
22
|
+
spec.homepage = "https://github.com/stevehodges/draftpunk"
|
23
|
+
spec.license = "MIT"
|
24
|
+
spec.has_rdoc = 'yard'
|
25
|
+
|
26
|
+
|
27
|
+
spec.required_ruby_version = ">= 2.0.0"
|
28
|
+
|
29
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
30
|
+
spec.bindir = "exe"
|
31
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
32
|
+
spec.require_paths = ["lib"]
|
33
|
+
|
34
|
+
spec.add_runtime_dependency "amoeba", "~> 3.0"
|
35
|
+
spec.add_runtime_dependency "unscoped_associations", "< 1.0"
|
36
|
+
spec.add_runtime_dependency "differ", "< 0.2"
|
37
|
+
spec.add_runtime_dependency 'rails', "~> 3.0"
|
38
|
+
|
39
|
+
spec.add_development_dependency "bundler", "~> 1.9"
|
40
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
41
|
+
spec.add_development_dependency "rspec", "~> 2.0"
|
42
|
+
spec.add_development_dependency "sqlite3", "~> 1.0"
|
43
|
+
spec.add_development_dependency "yard", "< 1.0"
|
44
|
+
|
45
|
+
end
|
@@ -0,0 +1,179 @@
|
|
1
|
+
require 'activerecord_instance_methods'
|
2
|
+
require 'draft_diff_instance_methods'
|
3
|
+
|
4
|
+
module DraftPunk
|
5
|
+
module Model
|
6
|
+
module ActiveRecordClassMethods
|
7
|
+
# Call this method in your model to setup approval. It will recursively apply to its associations,
|
8
|
+
# thus does not need to be explicity called on its associated models (and will error if you try).
|
9
|
+
#
|
10
|
+
# This model must have an approved_version_id column (Integer), which will be used to track its draft
|
11
|
+
#
|
12
|
+
# For instance, your Business model:
|
13
|
+
# class Business << ActiveRecord::Base
|
14
|
+
# has_many :employees
|
15
|
+
# has_many :images
|
16
|
+
# has_one :address
|
17
|
+
# has_many :vending_machines
|
18
|
+
#
|
19
|
+
# requires_approval # When creating a business's draft, :employees, :vending_machines, :images, and :address will all have drafts created
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# Optionally, specify which associations which the user will edit - associations which should have a draft created
|
23
|
+
# by defining a CREATES_NESTED_DRAFTS_FOR constant for this model.
|
24
|
+
#
|
25
|
+
# CREATES_NESTED_DRAFTS_FOR = [:address] # When creating a business's draft, only :address will have drafts created
|
26
|
+
#
|
27
|
+
# To disable drafts for all assocations for this model, simply pass an empty array:
|
28
|
+
# by defining a CREATES_NESTED_DRAFTS_FOR constant for this model.
|
29
|
+
# CREATES_NESTED_DRAFTS_FOR = [] # When creating a business's draft, no associations will have drafts created
|
30
|
+
#
|
31
|
+
# WARNING: If you are setting associations via accepts_nested_attributes all changes to the draft, including associations, get set on the
|
32
|
+
# draft object (as expected). If your form includes associated objects which weren't defined in requires_approval, your save will fail since
|
33
|
+
# the draft object doesn't HAVE those associations to update! In this case, you should probably add that association to the
|
34
|
+
# +associations+ param here.
|
35
|
+
#
|
36
|
+
# If you want your draft associations to track their live version, add an :approved_version_id column
|
37
|
+
# to each association's table. You'll be able to access that associated object's live version, just
|
38
|
+
# like you can with the original model which called requires_approval.
|
39
|
+
#
|
40
|
+
# @param nullify [Array] A list of attributes on this model to set to null on the draft when it is created. For
|
41
|
+
# instance, the _id_ and _created_at_ columns are nullified by default, since you don't want Rails to try to
|
42
|
+
# persist those on the draft.
|
43
|
+
# @param set_default_scope [Boolean] If true, set a default scope on this model for the approved scope; only approved objects
|
44
|
+
# will be returned in ActiveRecord queries, unless you call Model.unscoped
|
45
|
+
# @param associations [Array] Use internally; set associations to create drafts for in the CREATES_NESTED_DRAFTS_FOR constant
|
46
|
+
# @return true
|
47
|
+
def requires_approval(associations: [], nullify: [], set_default_scope: false)
|
48
|
+
return unless ActiveRecord::Base.connection.table_exists?(table_name) # Short circuits if you're migrating
|
49
|
+
|
50
|
+
associations = draft_target_associations if associations.empty?
|
51
|
+
set_valid_associations(associations)
|
52
|
+
|
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
|
55
|
+
|
56
|
+
amoeba do
|
57
|
+
nullify nullify
|
58
|
+
# Note that the amoeba associations and customize options are being set in setup_associations_and_scopes_for
|
59
|
+
end
|
60
|
+
setup_amoeba_for self, set_default_scope: set_default_scope
|
61
|
+
true
|
62
|
+
end
|
63
|
+
|
64
|
+
# This will generally be only used in testing scenarios, in cases when requires_approval need to be
|
65
|
+
# called multiple times. Only the usage for that use case is supported. Use at your own risk for other
|
66
|
+
# use cases.
|
67
|
+
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
|
70
|
+
fresh_amoeba do
|
71
|
+
disable
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# List of association names this model is configured to have drafts for
|
76
|
+
# @return [Array]
|
77
|
+
def draft_target_associations
|
78
|
+
targets = if const_defined?(:CREATES_NESTED_DRAFTS_FOR) && const_get(:CREATES_NESTED_DRAFTS_FOR).is_a?(Array)
|
79
|
+
const_get(:CREATES_NESTED_DRAFTS_FOR).compact
|
80
|
+
else
|
81
|
+
default_draft_target_associations
|
82
|
+
end.map(&:to_sym)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Whether this model is configured to track the approved version of a draft object.
|
86
|
+
# This will be true if the model has an approved_version_id column
|
87
|
+
#
|
88
|
+
# @return (Boolean)
|
89
|
+
def tracks_approved_version?
|
90
|
+
column_names.include? 'approved_version_id'
|
91
|
+
end
|
92
|
+
|
93
|
+
protected #################################################################
|
94
|
+
|
95
|
+
def default_draft_target_associations
|
96
|
+
reflect_on_all_associations.select do |reflection|
|
97
|
+
is_relevant_association_type?(reflection) &&
|
98
|
+
!reflection.name.in?(%i(draft approved_version)) &&
|
99
|
+
reflection.class_name != name # Self referential associations!!! Don't do them!
|
100
|
+
end.map{|r| r.name.downcase.to_sym }
|
101
|
+
end
|
102
|
+
|
103
|
+
# Rejects the associations if the table hasn't been defined yet. This happens when
|
104
|
+
# running migrations which add that association's table.
|
105
|
+
def set_valid_associations(associations)
|
106
|
+
return const_get(:DRAFT_VALID_ASSOCIATIONS) if const_defined?(:DRAFT_VALID_ASSOCIATIONS)
|
107
|
+
associations = associations.map(&:to_sym)
|
108
|
+
valid_assocations = associations.select do |assoc|
|
109
|
+
reflection = reflect_on_association(assoc)
|
110
|
+
if reflection
|
111
|
+
table_name = reflection.klass.table_name
|
112
|
+
ActiveRecord::Base.connection.table_exists?(table_name)
|
113
|
+
else
|
114
|
+
false
|
115
|
+
end
|
116
|
+
end
|
117
|
+
self.const_set :DRAFT_VALID_ASSOCIATIONS, valid_assocations
|
118
|
+
valid_assocations
|
119
|
+
end
|
120
|
+
|
121
|
+
private ###################################################################
|
122
|
+
|
123
|
+
def setup_amoeba_for(target_class, set_default_scope: false)
|
124
|
+
return if target_class.const_defined?(:DRAFT_PUNK_IS_SETUP)
|
125
|
+
associations = target_class.draft_target_associations
|
126
|
+
associations = target_class.set_valid_associations(associations)
|
127
|
+
target_class.amoeba do
|
128
|
+
enable
|
129
|
+
include_association target_class.const_get(:DRAFT_VALID_ASSOCIATIONS)
|
130
|
+
customize(lambda {|live_obj, draft_obj|
|
131
|
+
draft_obj.approved_version_id = live_obj.id if draft_obj.respond_to?(:approved_version_id)
|
132
|
+
draft_obj.temporary_approved_object = live_obj
|
133
|
+
})
|
134
|
+
end
|
135
|
+
target_class.const_set :DRAFT_PUNK_IS_SETUP, true
|
136
|
+
|
137
|
+
setup_associations_and_scopes_for target_class, set_default_scope: set_default_scope
|
138
|
+
setup_draft_association_persistance_for_children_of target_class, associations
|
139
|
+
end
|
140
|
+
|
141
|
+
def setup_draft_association_persistance_for_children_of(target_class, associations=nil)
|
142
|
+
associations = target_class.draft_target_associations unless associations
|
143
|
+
target_reflections = associations.map do |assoc|
|
144
|
+
reflection = target_class.reflect_on_association(assoc.to_sym)
|
145
|
+
reflection.presence || (raise DraftPunk::ConfigurationError, "#{name} includes invalid association (#{assoc})")
|
146
|
+
end
|
147
|
+
target_reflections.select{|r| is_relevant_association_type?(r) }.each do |assoc|
|
148
|
+
setup_amoeba_for assoc.klass
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def setup_associations_and_scopes_for(target_class, set_default_scope: false)
|
153
|
+
target_class.send :include, InstanceInterrogators unless target_class.method_defined?(:has_draft?)
|
154
|
+
target_class.send :attr_accessor, :temporary_approved_object
|
155
|
+
target_class.send :before_create, :before_create_draft if target_class.method_defined?(:before_create_draft)
|
156
|
+
|
157
|
+
return if target_class.reflect_on_association(:approved_version) || !target_class.column_names.include?('approved_version_id')
|
158
|
+
target_class.send :include, ActiveRecordInstanceMethods
|
159
|
+
target_class.send :include, DraftDiffInstanceMethods
|
160
|
+
target_class.belongs_to :approved_version, class_name: target_class.name
|
161
|
+
target_class.has_one :draft, class_name: target_class.name, foreign_key: :approved_version_id, unscoped: true
|
162
|
+
target_class.scope :approved, -> { where("#{target_class.quoted_table_name}.approved_version_id IS NULL") }
|
163
|
+
if set_default_scope
|
164
|
+
target_class.default_scope target_class.approved
|
165
|
+
else
|
166
|
+
# TODO: fix - the unscoped isn't working with default scope, so not defining this draft scope if set_default_scope
|
167
|
+
target_class.scope :draft, -> { unscoped.where("#{target_class.quoted_table_name}.approved_version_id IS NOT NULL") }
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def is_relevant_association_type?(activerecord_reflection)
|
172
|
+
# Note when implementing for Rails 4, macro is renamed to something else
|
173
|
+
activerecord_reflection.macro.in? Amoeba::Config::DEFAULTS[:known_macros]
|
174
|
+
end
|
175
|
+
|
176
|
+
end
|
177
|
+
|
178
|
+
end
|
179
|
+
end
|
@@ -0,0 +1,169 @@
|
|
1
|
+
module DraftPunk
|
2
|
+
module Model
|
3
|
+
module ActiveRecordInstanceMethods
|
4
|
+
#############################
|
5
|
+
# BEGIN CONFIGURABLE METHODS
|
6
|
+
# You can overwrite these methods in your model for custom behavior
|
7
|
+
#############################
|
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
|
+
# Which attributes of this model are published from the draft to the approved object. Overwrite in model
|
21
|
+
# if you don't want all attributes of the draft to be saved on the live object.
|
22
|
+
#
|
23
|
+
# This is an array of attributes (including has_one association id columns) which will be saved
|
24
|
+
# on the object when its' draft is approved.
|
25
|
+
#
|
26
|
+
# For instance, if you want to omit updated_at, for whatever reason, you would define this in your model:
|
27
|
+
#
|
28
|
+
# def approvable_attributes
|
29
|
+
# self.attributes.keys - ["created_at", "updated_at"]
|
30
|
+
# end
|
31
|
+
#
|
32
|
+
# WARNING: Don't include "created_at" if you don't want to modify this object's created_at!
|
33
|
+
#
|
34
|
+
# @return [Array] names of approvable attributes
|
35
|
+
def approvable_attributes
|
36
|
+
self.attributes.keys - ["created_at"]
|
37
|
+
end
|
38
|
+
#############################
|
39
|
+
# END CONFIGURABLE METHODS
|
40
|
+
#############################
|
41
|
+
|
42
|
+
|
43
|
+
# Updates the approved version with any changes on the draft, and all the drafts' associated objects.
|
44
|
+
#
|
45
|
+
# If the approved version changes_require_approval? returns false, this method exits early and does nothing
|
46
|
+
# to the approved version.
|
47
|
+
#
|
48
|
+
# THE DRAFT VERSION IS DESTROYED IN THIS PROCESS. To generate a new draft, simply call <tt>editable_version</tt>
|
49
|
+
# again on the approved object.
|
50
|
+
#
|
51
|
+
# @return [ActiveRecord Object] updated version of the approved object
|
52
|
+
def publish_draft!
|
53
|
+
@live_version = get_approved_version
|
54
|
+
@draft_version = editable_version
|
55
|
+
return unless changes_require_approval? && @draft_version.is_draft? # No-op. ie. the business is in a state that doesn't require approval.
|
56
|
+
|
57
|
+
transaction do
|
58
|
+
save_attribute_changes_and_belongs_to_assocations_from_draft
|
59
|
+
update_has_many_and_has_one_associations_from_draft
|
60
|
+
@live_version.draft.destroy # We have to do 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.
|
61
|
+
end
|
62
|
+
@live_version = self.class.find(@live_version.id)
|
63
|
+
end
|
64
|
+
|
65
|
+
# Get the object's draft if changes require approval; this method creates one if it doesn't exist yet
|
66
|
+
# If changes do not require approval, the original approved object is returned
|
67
|
+
#
|
68
|
+
# @return ActiveRecord Object
|
69
|
+
def editable_version
|
70
|
+
return get_approved_version unless changes_require_approval?
|
71
|
+
is_draft? ? self : get_draft
|
72
|
+
end
|
73
|
+
|
74
|
+
# Get the approved version. Intended for use on a draft object, but works on a live/approved object too
|
75
|
+
#
|
76
|
+
# @return (ActiveRecord Object)
|
77
|
+
def get_approved_version
|
78
|
+
approved_version || self
|
79
|
+
end
|
80
|
+
|
81
|
+
protected #################################################################
|
82
|
+
|
83
|
+
def get_draft
|
84
|
+
draft || create_draft_version
|
85
|
+
end
|
86
|
+
|
87
|
+
private ####################################################################
|
88
|
+
|
89
|
+
def create_draft_version
|
90
|
+
# Don't call this directly. Use editable_version instead.
|
91
|
+
return draft if draft.present?
|
92
|
+
dupe = amoeba_dup
|
93
|
+
begin
|
94
|
+
dupe.approved_version = self
|
95
|
+
dupe.save!
|
96
|
+
rescue => message
|
97
|
+
raise DraftCreationError, message
|
98
|
+
end
|
99
|
+
draft
|
100
|
+
end
|
101
|
+
|
102
|
+
def save_attribute_changes_and_belongs_to_assocations_from_draft
|
103
|
+
@draft_version.attributes.each do |attribute, value|
|
104
|
+
next unless attribute.in? usable_approvable_attributes
|
105
|
+
@live_version.send("#{attribute}=", value)
|
106
|
+
end
|
107
|
+
@live_version.before_publish_draft if @live_version.respond_to?(:before_publish_draft)
|
108
|
+
@live_version.save!
|
109
|
+
end
|
110
|
+
|
111
|
+
def update_has_many_and_has_one_associations_from_draft
|
112
|
+
self.class.draft_target_associations.each do |assoc|
|
113
|
+
reflection = self.class.reflect_on_association(assoc)
|
114
|
+
|
115
|
+
reflection_is_has_many(reflection) ? @live_version.send(assoc).destroy_all : @live_version.send(assoc).destroy
|
116
|
+
|
117
|
+
attribute_updates = {}
|
118
|
+
attribute_updates[reflection.foreign_key] = @live_version.id
|
119
|
+
attribute_updates['updated_at'] = Time.now if reflection.klass.column_names.include?('updated_at')
|
120
|
+
attribute_updates['approved_version_id'] = nil if reflection.klass.tracks_approved_version?
|
121
|
+
|
122
|
+
reflection_is_has_many(reflection) ? @draft_version.send(assoc).update_all(attribute_updates) : @draft_version.send(assoc).update_attributes(attribute_updates, without_protection: true)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def usable_approvable_attributes
|
127
|
+
nullified_attributes = self.class.const_defined?(:DRAFT_NULLIFY_ATTRIBUTES) ? self.class.const_get(:DRAFT_NULLIFY_ATTRIBUTES) : []
|
128
|
+
approvable_attributes.map(&:to_s) - nullified_attributes.map(&:to_s) - ['approved_version_id', 'id']
|
129
|
+
end
|
130
|
+
|
131
|
+
def current_approvable_attributes
|
132
|
+
attribs = {}
|
133
|
+
attributes.each do |k,v|
|
134
|
+
attribs[k] = v if k.in?(diff_relevant_attributes)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def association_tracks_approved_version?(name)
|
139
|
+
self.class.reflect_on_association(name.to_sym).klass.column_names.include? 'approved_version_id'
|
140
|
+
end
|
141
|
+
|
142
|
+
def association_is_has_many(name)
|
143
|
+
# Note when implementing for Rails 4, macro is renamed to something else
|
144
|
+
self.class.reflect_on_association(name.to_sym).macro == :has_many
|
145
|
+
end
|
146
|
+
|
147
|
+
def reflection_is_has_many(reflection)
|
148
|
+
# Note when implementing for Rails 4, macro is renamed to something else
|
149
|
+
reflection.macro == :has_many
|
150
|
+
end
|
151
|
+
|
152
|
+
end
|
153
|
+
|
154
|
+
module InstanceInterrogators
|
155
|
+
# @return [Boolean] whether the current ActiveRecord object is a draft
|
156
|
+
def is_draft?
|
157
|
+
raise DraftPunk::ApprovedVersionIdError unless respond_to?("approved_version_id")
|
158
|
+
approved_version_id.present?
|
159
|
+
end
|
160
|
+
|
161
|
+
# @return [Boolean] whether the current ActiveRecord object has a draft version
|
162
|
+
def has_draft?
|
163
|
+
raise DraftPunk::ApprovedVersionIdError unless respond_to?(:approved_version_id)
|
164
|
+
draft.present?
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
end
|
169
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module DraftPunk
|
2
|
+
module Model
|
3
|
+
module DraftDiffInstanceMethods
|
4
|
+
require 'differ'
|
5
|
+
# Return the differences between the live and draft object.
|
6
|
+
#
|
7
|
+
# If include_associations is true, it will return the diff for all child associations, recursively until it gets
|
8
|
+
# to the bottom of your draft tree. This only works for associations which have the approved_version_id column
|
9
|
+
#
|
10
|
+
# @param include_associations [Boolean] include diff for child objects, recursive (possibly down to grandchildren and beyond)
|
11
|
+
# @param include_all_attributes [Boolean] return all attributes in the results, including those which have not changed
|
12
|
+
# @param include_diff [Boolean] include an html formatted diff of changes between the live and draft, for each attribute
|
13
|
+
# @param diff_format [Symbol] format the diff output per the options available in differ (:html, :ascii, :color)
|
14
|
+
# @return (Hash)
|
15
|
+
def draft_diff(include_associations: false, parent_object_fk: nil, include_all_attributes: false, include_diff: false, diff_format: :html, recursed: false)
|
16
|
+
draft_obj = recursed ? draft : get_draft # get_draft will create missing drafts. Based on the logic, this should only happen when you *first* call draft_diff
|
17
|
+
get_object_changes(self, draft_obj, include_associations, parent_object_fk, include_all_attributes, include_diff, diff_format)
|
18
|
+
end
|
19
|
+
|
20
|
+
protected #################################################################
|
21
|
+
def current_approvable_attributes
|
22
|
+
attribs = {}
|
23
|
+
attributes.each do |k,v|
|
24
|
+
attribs[k] = v if k.in?(diff_relevant_attributes)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private ####################################################################
|
29
|
+
|
30
|
+
def get_object_changes(approved_obj, draft_obj, include_associations, parent_object_fk, include_all_attributes, include_diff, diff_format)
|
31
|
+
diff = {}
|
32
|
+
approved_attribs = approved_obj ? approved_obj.current_approvable_attributes : {}
|
33
|
+
draft_attribs = draft_obj ? draft_obj.current_approvable_attributes : {}
|
34
|
+
diff_relevant_attributes(parent_object_fk).each do |attrib|
|
35
|
+
live = approved_attribs[attrib]
|
36
|
+
draft = draft_attribs[attrib]
|
37
|
+
if include_all_attributes || live != draft
|
38
|
+
diff[attrib] = {live: live, draft: draft}
|
39
|
+
diff[attrib].merge!({diff: Differ.diff_by_word(draft, live).format_as(diff_format)}) if include_diff && (live.present? && draft.present? && live.is_a?(String) && draft.is_a?(String))
|
40
|
+
end
|
41
|
+
end
|
42
|
+
diff.merge!(draft_status: :deleted) if parent_object_fk.present? && draft_attribs[parent_object_fk].nil?
|
43
|
+
diff.merge!(associations_diff(include_all_attributes, include_diff, diff_format)) if include_associations
|
44
|
+
diff[:draft_status] = diff_status(diff, parent_object_fk) unless diff.has_key?(:draft_status)
|
45
|
+
diff[:class_info] = {table_name: approved_obj.class.table_name, class_name: approved_obj.class.name}
|
46
|
+
diff
|
47
|
+
end
|
48
|
+
|
49
|
+
def associations_diff(include_all_attributes, include_diff, diff_format)
|
50
|
+
diff = {}
|
51
|
+
self.class.draft_target_associations.each do |assoc|
|
52
|
+
next unless association_tracks_approved_version?(assoc)
|
53
|
+
diff[assoc] = []
|
54
|
+
draft_versions = draft.present? ? [draft.send(assoc)].flatten.compact : []
|
55
|
+
approved_versions = [get_approved_version.send(assoc)].flatten.compact
|
56
|
+
foreign_key = self.class.reflect_on_association(assoc).foreign_key
|
57
|
+
|
58
|
+
approved_versions.each do |approved|
|
59
|
+
obj_diff = approved.draft_diff(include_associations: true, parent_object_fk: foreign_key, include_all_attributes: include_all_attributes, include_diff: include_diff, diff_format: diff_format, recursed: true)
|
60
|
+
obj_diff.merge(draft_status: :deleted) unless draft_versions.find{|obj| obj.approved_version_id == approved.id }
|
61
|
+
diff[assoc] << obj_diff if (include_all_attributes || obj_diff[:draft_status] != :unchanged)
|
62
|
+
end
|
63
|
+
draft_versions.select{|obj| obj.approved_version_id.nil? }.each do |draft|
|
64
|
+
diff[assoc] << draft.draft_diff(include_associations: true, include_all_attributes: include_all_attributes, recursed: true).merge(draft_status: :added)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
diff.select{|k,v| v.present? || include_all_attributes}
|
68
|
+
end
|
69
|
+
|
70
|
+
def diff_status(diff, parent_object_fk)
|
71
|
+
return if diff.has_key?(:status)
|
72
|
+
diff.each do |attrib, value|
|
73
|
+
if value.is_a?(Hash) && !attrib.in?([parent_object_fk.to_s, 'id'])
|
74
|
+
return :changed unless value[:live] == value[:draft]
|
75
|
+
end
|
76
|
+
end
|
77
|
+
:unchanged
|
78
|
+
end
|
79
|
+
|
80
|
+
def diff_relevant_attributes(parent_object_fk=nil)
|
81
|
+
(usable_approvable_attributes + ['id'] - ['updated_at', 'approved_version_id', parent_object_fk]).map(&:to_s).uniq.compact
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
data/lib/draft_punk.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'draft_punk/version'
|
2
|
+
require 'amoeba'
|
3
|
+
require 'unscoped_associations'
|
4
|
+
require 'activerecord_class_methods'
|
5
|
+
require 'helper_methods'
|
6
|
+
|
7
|
+
module DraftPunk
|
8
|
+
module Model
|
9
|
+
def self.included(base)
|
10
|
+
base.send :extend, ActiveRecordClassMethods
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class ConfigurationError < RuntimeError
|
15
|
+
def initialize(message)
|
16
|
+
@caller = caller[0]
|
17
|
+
@message = message
|
18
|
+
end
|
19
|
+
def to_s
|
20
|
+
"#{@caller} error: #{@message}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class ApprovedVersionIdError < ArgumentError
|
25
|
+
def initialize(message=nil)
|
26
|
+
@message = message
|
27
|
+
end
|
28
|
+
def to_s
|
29
|
+
"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."
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class DraftCreationError < ActiveRecord::RecordInvalid
|
34
|
+
def initialize(message)
|
35
|
+
@message = message
|
36
|
+
end
|
37
|
+
def to_s
|
38
|
+
"the draft failed to be created: #{@message}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
ActiveSupport.on_load(:active_record) do
|
45
|
+
include DraftPunk::Model
|
46
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module DraftPunkHelper
|
2
|
+
def summarize_draft_changes(draft_diff)
|
3
|
+
changed = []
|
4
|
+
draft_diff.except("id", :draft_status, :class_info).each do |k,v|
|
5
|
+
if v.is_a?(Array)
|
6
|
+
changed << summarize_association_changes(k,v)
|
7
|
+
else
|
8
|
+
changed << k
|
9
|
+
end
|
10
|
+
end
|
11
|
+
return "Changed: #{changed.compact.to_sentence}" if changed.present?
|
12
|
+
end
|
13
|
+
|
14
|
+
def summarize_association_changes(assoc, values)
|
15
|
+
return assoc if did_association_change?(values)
|
16
|
+
end
|
17
|
+
|
18
|
+
def did_association_change?(children)
|
19
|
+
return true if children.is_a?(Hash) && children[:diff_status] !=:unchanged
|
20
|
+
children.each do |obj|
|
21
|
+
obj.each do |k,v|
|
22
|
+
if v.is_a?(Array) #nested association
|
23
|
+
return true if did_association_change?(v)
|
24
|
+
elsif k == :draft_status
|
25
|
+
return true if v != :unchanged
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
false
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
ActionView::Base.send :include, DraftPunkHelper
|
metadata
ADDED
@@ -0,0 +1,196 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: draft_punk
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Steve Hodges
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-01-19 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: amoeba
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: unscoped_associations
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "<"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "<"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: differ
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "<"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0.2'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "<"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0.2'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rails
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: bundler
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '1.9'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '1.9'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rake
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '10.0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '10.0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rspec
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '2.0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '2.0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: sqlite3
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '1.0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '1.0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: yard
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - "<"
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '1.0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - "<"
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '1.0'
|
139
|
+
description: |
|
140
|
+
DraftPunk allows editing of a draft version of an ActiveRecord model and its associations.
|
141
|
+
|
142
|
+
When it's time to edit, a draft version is created in the same table as the object. You can specify which associations should also be edited and stored with that draft version. All associations are stored in their native table.
|
143
|
+
|
144
|
+
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.
|
145
|
+
|
146
|
+
This gem doesn't rely on a versioning gem and doesn't store incremental diffs of the model. It simply works with your existing database (plus one new column on your original object).
|
147
|
+
email:
|
148
|
+
- steve.hodges@localstake.com
|
149
|
+
executables: []
|
150
|
+
extensions: []
|
151
|
+
extra_rdoc_files: []
|
152
|
+
files:
|
153
|
+
- ".gitignore"
|
154
|
+
- ".rspec"
|
155
|
+
- ".travis.yml"
|
156
|
+
- CODE_OF_CONDUCT.md
|
157
|
+
- Gemfile
|
158
|
+
- LICENSE.txt
|
159
|
+
- README.md
|
160
|
+
- Rakefile
|
161
|
+
- bin/console
|
162
|
+
- bin/setup
|
163
|
+
- draft_punk.gemspec
|
164
|
+
- lib/activerecord_class_methods.rb
|
165
|
+
- lib/activerecord_instance_methods.rb
|
166
|
+
- lib/draft_diff_instance_methods.rb
|
167
|
+
- lib/draft_punk.rb
|
168
|
+
- lib/draft_punk/version.rb
|
169
|
+
- lib/helper_methods.rb
|
170
|
+
homepage: https://github.com/stevehodges/draftpunk
|
171
|
+
licenses:
|
172
|
+
- MIT
|
173
|
+
metadata: {}
|
174
|
+
post_install_message:
|
175
|
+
rdoc_options: []
|
176
|
+
require_paths:
|
177
|
+
- lib
|
178
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
179
|
+
requirements:
|
180
|
+
- - ">="
|
181
|
+
- !ruby/object:Gem::Version
|
182
|
+
version: 2.0.0
|
183
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
184
|
+
requirements:
|
185
|
+
- - ">="
|
186
|
+
- !ruby/object:Gem::Version
|
187
|
+
version: '0'
|
188
|
+
requirements: []
|
189
|
+
rubyforge_project:
|
190
|
+
rubygems_version: 2.2.2
|
191
|
+
signing_key:
|
192
|
+
specification_version: 4
|
193
|
+
summary: Easy editing of a draft version of an ActiveRecord model and its associations,
|
194
|
+
and publishing said draft's changes back to the original models.
|
195
|
+
test_files: []
|
196
|
+
has_rdoc: yard
|