Skip to content
Open
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,45 @@ drafts = Message.drafts(current_user)
messages = drafts.restore_all
```

### Migration
### Migrations & Their Features

If you are upgrading from previous versions, simply run `rails g drafting:migration` again to generate the mising migration files.

#### 0.5.x

Starting from 0.5.x, you will be able to save drafts under a non `User` model as such:
```

```ruby
message.save_draft(author)
```

If you are upgrading from previous versions, simply run `rails g drafting:migration` again to generate the mising migration files.
#### 0.6.x

Starting from 0.6.x, you will be able to save metadata to your `draft` (eg. to label your drafts) as such:

```ruby
message.save_draft(author, { title: 'Final Draft 2022-08-06' })
draft = Draft.find(message.draft_id)
draft.title # => 'Final Draft 2022-08-06'

message.update_draft(
author,
{ content: 'New content for message' },
{
title: 'Final Final Draft 2022-08-06',
version: '1.123'
}
)
message.content # => 'New content for message'
draft = Draft.find(message.draft_id)
draft.title # => 'Final Final Draft 2022-08-06'
draft.version # => '1.123'
draft.version = '1.5'
draft.save
draft.reload.version # => '1.5'
```

Note that the `metadata` on the draft is essentially a [Rails store](https://api.rubyonrails.org/classes/ActiveRecord/Store.html) whose keys are dynamically generated according to your application needs. They are generated on the fly via `method_missing`.

### Linking to parent instance

Expand Down
8 changes: 6 additions & 2 deletions bin/console
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ require "bundler/setup"
require "drafting"

$LOAD_PATH.unshift(File.expand_path('..', File.dirname(__FILE__)))
require 'lib/generators/drafting/migration/templates/migration.rb'
Dir["#{Drafting.root}/lib/generators/drafting/migration/templates/*.rb"].each do |filename|
require filename
end
require 'spec/support/spec_migration.rb'

require 'spec/models/user'
Expand All @@ -24,7 +26,9 @@ ActiveRecord::Base.configurations = YAML.load_file('spec/database.yml')
ActiveRecord::Base.establish_connection(:sqlite)
ActiveRecord::Migration.verbose = false

DraftingMigration.up
Drafting::MigrationGenerator.loop_through_migration_files do |_, filename|
Object.const_get(filename.gsub('.rb', '').camelize).up
end
SpecMigration.up

require "irb"
Expand Down
19 changes: 19 additions & 0 deletions lib/drafting/draft.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,30 @@ class Draft < ActiveRecord::Base

validates_presence_of :data, :target_type

store :metadata, accessors: [], coder: JSON

def restore
target_type.constantize.from_draft(self)
end

def self.restore_all
find_each.map(&:restore)
end

private

def method_missing(name, *args)
method_name = name.to_s
if method_name !~ /[a-z0-9_]+=?$/
super
else
# TODO: executing on instance's class, not on it's eigenclass, so that store_accessors are permanent in application lifecycle, good idea? 🤔
Draft.store_accessor :metadata, method_name.gsub('=', "").to_sym
public_send(name, *args)
end
end

def respond_to_missing?(sym, include_private)
true
end
end
10 changes: 7 additions & 3 deletions lib/drafting/instance_methods.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module Drafting
module InstanceMethods
def save_draft(user=nil)
def save_draft(user=nil, metadata={})
return false unless self.new_record?

draft = Draft.find_by_id(self.draft_id) || Draft.new
Expand All @@ -10,16 +10,20 @@ def save_draft(user=nil)
draft.user_id = user.try(:id)
draft.user_type = user.try(:class).try(:name)
draft.parent = self.send(self.class.draft_parent) if self.class.draft_parent
metadata = metadata.with_indifferent_access
metadata.each_key do |key|
draft.public_send("#{key}=", metadata[key])
end

result = draft.save
self.draft_id = draft.id if result
result
end

def update_draft(user, attributes)
def update_draft(user, attributes, metadata={})
with_transaction_returning_status do
assign_attributes(attributes)
save_draft(user)
save_draft(user, metadata)
end
end

Expand Down
6 changes: 5 additions & 1 deletion lib/drafting/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
module Drafting
VERSION = "0.5.1"
VERSION = "0.6.0"

def self.root
File.expand_path '../../..', __FILE__
end
end
53 changes: 44 additions & 9 deletions lib/generators/drafting/migration/migration_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,54 @@ class MigrationGenerator < Rails::Generators::Base
desc 'Generates migration for Drafting'
source_root File.expand_path('../templates', __FILE__)

def create_migration_file
migration_template 'migration.rb', 'db/migrate/drafting_migration.rb'
migration_template 'non_user_migration.rb', 'db/migrate/non_user_drafting_migration.rb'
def self.loop_through_migration_files(reverse: false)
files = Dir.glob("#{MigrationGenerator.source_root}/*.rb")

files = files.reverse if reverse

files.each_with_index do |abs_path, index|
original_filename = File.basename(abs_path)
filename = original_filename.split('-').last

yield original_filename, filename, index
end
end

def validation
Drafting::MigrationGenerator.loop_through_migration_files do |original_filename|
# these numbers will keep the migration files generated in order
# for backwards compatibility, do NOT change the order of existing migration file templates🙏
raise 'Migration files should start with a number followed by a dash to dictate the order of migration files to be generated' if original_filename !~ /^[\d]+\-.*drafting_migration\.rb/
end
end

#########
# USAGE #
#########

# Instance methods in this Generator will run in sequence (starting from `validation` above👆)
# The methods generated dynamically below will create the migration file in order
# This order is dictated by the number prefix in the name of the migration template files
# naming format should follow: `<number>-custom_name_drafting_migration.rb`

loop_through_migration_files do |original_filename, filename, index|
define_method "create_migration_file#{index}" do
migration_template original_filename, "db/migrate/#{filename}"
end
end

def self.next_migration_number(dirname)
if ActiveRecord::Base.timestamped_migrations
# ensure timestamp of the multiple migration files generated
# will be different
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
timestamp += 1 if current_migration_number(dirname) == timestamp
format = '%Y%m%d%H%M%S'

# check if migration number already a timestamp for timestamped migrations
# strptime throws error, and rescue handles if not the case
DateTime.strptime(current_migration_number(dirname).to_s, format)

timestamp
(DateTime.parse(current_migration_number(dirname).to_s) + 1.second).strftime(format)
rescue ArgumentError
if ActiveRecord::Base.timestamped_migrations
# this will only run the first migration file is generated
Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
else
"%.3d" % (current_migration_number(dirname) + 1)
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class MetadataDraftingMigration < Drafting::MIGRATION_BASE_CLASS
def self.up
add_column :drafts, :metadata, :text
Draft.reset_column_information
end

def self.down
remove_column :drafts, :metadata
end
end
2 changes: 1 addition & 1 deletion spec/drafting/draft_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
before do
Draft.delete_all

message1.save_draft(user)
message1.save_draft(user, title: 'Title1')
message2.save_draft(user)
end

Expand Down
46 changes: 44 additions & 2 deletions spec/drafting/instance_methods_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,23 @@
expect(draft.user_id).to eq(admin_user.id)
end

it 'should save metadata on draft and allow changes' do
message.save_draft(
nil,
{
title: 'Message Title',
sub_title: 'Message SubTitle'
}
)
draft = Draft.find(message.draft_id)
expect(draft.title).to eq 'Message Title'
expect(draft.sub_title).to eq 'Message SubTitle'

draft.title = 'New Title'
draft.save
expect(draft.reload.title).to eq 'New Title'
end

it 'should store extra attributes to Draft' do
message.priority = 5
message.save_draft(user)
Expand All @@ -85,7 +102,7 @@
expect(draft.restore.priority).to eq(5)
end

it 'should store assocations to Draft' do
it 'should store associations to Draft' do
message = topic.messages.build user: user, content: 'foo'
message.tags.build name: 'important'
message.tags.build name: 'ruby'
Expand Down Expand Up @@ -118,7 +135,7 @@
end

describe 'update_draft' do
it 'should update existing Draft object' do
it 'should update existing Draft object (without metadata)' do
message.save_draft(user)

expect {
Expand All @@ -129,6 +146,31 @@
draft = Draft.find(message.draft_id)
expect(draft.restore.attributes).to eq(message.attributes)
end

it 'should update existing Draft object (with metadata)' do
message.save_draft(user)

expect {
message.update_draft(
user,
{ content: 'bar' },
{
title: 'Message Title',
sub_title: 'Message SubTitle'
}
)
}.to change(Draft, :count).by(0).and \
change(Message, :count).by(0)

draft = Draft.find(message.draft_id)
expect(draft.title).to eq 'Message Title'
expect(draft.sub_title).to eq 'Message SubTitle'
expect(draft.restore.attributes).to eq(message.attributes)

draft.title = 'New Title'
draft.save
expect(draft.reload.title).to eq 'New Title'
end
end

describe 'clear_draft' do
Expand Down
Loading