composite_content 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +43 -0
  4. data/CHANGELOG.md +5 -0
  5. data/Gemfile +10 -0
  6. data/Gemfile.lock +239 -0
  7. data/LICENSE +13 -0
  8. data/README.md +112 -0
  9. data/Rakefile +17 -0
  10. data/composite_content.gemspec +47 -0
  11. data/config/locales/en.yml +10 -0
  12. data/config/locales/fr.yml +10 -0
  13. data/db/migrate/20220807184334_create_composite_content_slots.rb +17 -0
  14. data/db/migrate/20220807185808_create_composite_content_blocks.rb +14 -0
  15. data/db/migrate/20220807191353_create_composite_content_headings.rb +12 -0
  16. data/db/migrate/20220807191402_create_composite_content_quotes.rb +12 -0
  17. data/db/migrate/20220807191410_create_composite_content_texts.rb +11 -0
  18. data/gemfiles/rails-6.1.x.gemfile +14 -0
  19. data/lib/composite_content/action_view.rb +84 -0
  20. data/lib/composite_content/active_record.rb +45 -0
  21. data/lib/composite_content/block.rb +11 -0
  22. data/lib/composite_content/blocks/heading.rb +21 -0
  23. data/lib/composite_content/blocks/quote.rb +12 -0
  24. data/lib/composite_content/blocks/text.rb +12 -0
  25. data/lib/composite_content/blocks.rb +11 -0
  26. data/lib/composite_content/engine.rb +50 -0
  27. data/lib/composite_content/model/base.rb +13 -0
  28. data/lib/composite_content/model/blockable.rb +21 -0
  29. data/lib/composite_content/model/builder/base.rb +47 -0
  30. data/lib/composite_content/model/builder/block.rb +36 -0
  31. data/lib/composite_content/model/builder/slot.rb +31 -0
  32. data/lib/composite_content/model/builder.rb +13 -0
  33. data/lib/composite_content/model.rb +11 -0
  34. data/lib/composite_content/slot.rb +55 -0
  35. data/lib/composite_content/version.rb +5 -0
  36. data/lib/composite_content.rb +14 -0
  37. data/lib/generators/composite_content/install_generator.rb +20 -0
  38. data/lib/generators/composite_content/templates/block/_form.html.erb +13 -0
  39. data/lib/generators/composite_content/templates/blocks/heading/_form.html.erb +9 -0
  40. data/lib/generators/composite_content/templates/blocks/heading/_show.html.erb +1 -0
  41. data/lib/generators/composite_content/templates/blocks/quote/_form.html.erb +9 -0
  42. data/lib/generators/composite_content/templates/blocks/quote/_show.html.erb +7 -0
  43. data/lib/generators/composite_content/templates/blocks/text/_form.html.erb +4 -0
  44. data/lib/generators/composite_content/templates/blocks/text/_show.html.erb +1 -0
  45. data/lib/generators/composite_content/templates/slot/_form.html.erb +13 -0
  46. metadata +275 -0
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ # Additional views helpers to manipulate composite content.
5
+ module ActionView
6
+ # Render the content of a CompositeContent::Slot.
7
+ def composite_content_render(slot)
8
+ renders = slot.blocks.collect do |block|
9
+ render "composite_content/blocks/#{block.blockable.block_type}/show", block: block
10
+ end
11
+
12
+ safe_join(renders)
13
+ end
14
+
15
+ # Output an action link to add a block to a slot.
16
+ #
17
+ # ==== Signatures
18
+ #
19
+ # composite_content_add_block_link(label, form, block_type, options = {})
20
+ # # Explicit name
21
+ #
22
+ # composite_content_add_block_link(form, block_type, options = {}) do
23
+ # # Name as a block
24
+ # end
25
+ #
26
+ # composite_content_add_block_link(form, block_type, options = {})
27
+ # # Use default name
28
+ #
29
+ # ==== Parameters
30
+ #
31
+ # `label` is the text to be used as the link label.
32
+ # Just as when you use the Rails builtin helper +link_to+, you can give an explicit
33
+ # label to your link or use a block to build it. If you provide neither an explicit
34
+ # label nor a block, the default label will be used, looking for an I18n key named
35
+ # `composite_content.blocks.{block type}.add`.
36
+ #
37
+ # `form` is your form builder. Can be a SimpleForm::Builder, Formtastic::Builder or
38
+ # a standard Rails FormBuilder.
39
+ #
40
+ # `options` are passed to the cocooned_add_item_link helper, which some adjustements:
41
+ # :count, :form_name, :force_non_association_create are ignored, :form_name, :partial
42
+ # and :wrap_object are forced on purpose. Any other option will be passed.
43
+ # See the documentation of cocooned_add_item_link for details.
44
+ def composite_content_add_block_link(*args, &block)
45
+ return composite_content_add_block_link(capture(&block), *args) if block
46
+ return composite_content_add_block_link(nil, *args) if args.first.respond_to?(:object)
47
+
48
+ label, form, block_type, options = *args
49
+ label ||= composite_content_default_label(block_type)
50
+ opts = (options || {}).except(:count, :form_name, :force_non_association_create)
51
+ .merge!(form_name: :form,
52
+ partial: 'composite_content/block/form',
53
+ wrap_object: ->(b) { composite_content_wrap_block(b, block_type) })
54
+
55
+ cocooned_add_item_link(label, form, :blocks, opts)
56
+ end
57
+
58
+ # Alias to cocooned_move_item_up_link
59
+ def composite_content_move_block_up_link(*args, &block)
60
+ cocooned_move_item_up_link(*args, &block)
61
+ end
62
+
63
+ # Alias to cocooned_move_item_down_link
64
+ def composite_content_move_block_down_link(*args, &block)
65
+ cocooned_move_item_down_link(*args, &block)
66
+ end
67
+
68
+ # Alias to cocooned_remove_item_link
69
+ def composite_content_remove_block_link(*args, &block)
70
+ cocooned_remove_item_link(*args, &block)
71
+ end
72
+
73
+ protected
74
+
75
+ def composite_content_default_label(block_type)
76
+ I18n.t("composite_content.blocks.#{block_type}.add")
77
+ end
78
+
79
+ def composite_content_wrap_block(block, block_type)
80
+ block.blockable = CompositeContent::Blocks.const_get(block_type.to_s.classify).new
81
+ block
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ module ActiveRecord # :nodoc:
5
+ extend ActiveSupport::Concern
6
+
7
+ # Integration methods with ActiveRecord models
8
+ # These methods are available on any ActiveRecord model once CompositeContent is loaded.
9
+ #
10
+ # rubocop:disable Naming/PredicateName, Rails/ReflectionClassName
11
+ module ClassMethods
12
+ def has_composite_content(name = :composite_content, types: [])
13
+ CompositeContent::Model::Builder::Block.build(self, name, types)
14
+ slot_class = CompositeContent::Model::Builder::Slot.build(self, name, types)
15
+
16
+ has_one name, class_name: slot_class.name, as: :parent, dependent: :destroy
17
+ accepts_nested_attributes_for name, reject_if: :all_blank
18
+
19
+ include Mixins.instance_mixin(name)
20
+ extend Mixins.class_mixin(name)
21
+ end
22
+ end
23
+ # rubocop:enable Naming/PredicateName, Rails/ReflectionClassName
24
+
25
+ module Mixins # :nodoc:
26
+ class << self
27
+ def instance_mixin(name)
28
+ Module.new do
29
+ define_method name do
30
+ super() || send(:"build_#{name}")
31
+ end
32
+ end
33
+ end
34
+
35
+ def class_mixin(name)
36
+ Module.new do
37
+ define_method "strong_parameters_for_#{name}" do
38
+ reflect_on_association(name).class_name.constantize.strong_parameters
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ # Base class for all types of blocks.
5
+ class Block < ::CompositeContent::Model::Base
6
+ acts_as_list scope: :slot_id
7
+
8
+ # Delegation to block types is dynamically declared when building dedicated slot and
9
+ # block classes. See CompositeContent::Model::Builder.
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ module Blocks
5
+ class Heading < ::CompositeContent::Model::Base
6
+ include ::CompositeContent::Model::Blockable
7
+
8
+ validates :level,
9
+ presence: true,
10
+ numericality: {
11
+ allow_blank: true,
12
+ only_integer: true,
13
+ greater_than_or_equal_to: 1,
14
+ less_than_or_equal_to: 6
15
+ }
16
+
17
+ validates :content,
18
+ presence: true
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ module Blocks
5
+ class Quote < ::CompositeContent::Model::Base
6
+ include ::CompositeContent::Model::Blockable
7
+
8
+ validates :content,
9
+ presence: true
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ module Blocks
5
+ class Text < ::CompositeContent::Model::Base
6
+ include ::CompositeContent::Model::Blockable
7
+
8
+ validates :content,
9
+ presence: true
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ module Blocks
5
+ extend ActiveSupport::Autoload
6
+
7
+ autoload :Heading
8
+ autoload :Quote
9
+ autoload :Text
10
+ end
11
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails'
4
+ require 'active_model_validations_reflection'
5
+ require 'acts_as_list'
6
+ require 'cocooned'
7
+
8
+ module CompositeContent
9
+ class Engine < ::Rails::Engine # :nodoc:
10
+ # Configurable engine options
11
+ #
12
+ # Feel free to adjust these settings in a initializer in your application.
13
+ # Ex:
14
+ #
15
+ # # In config/initializers/composite_content.rb
16
+ # require 'composite_content'
17
+ # CompositeContent::Engine.config.block_types += %w[CompositeContent::Blocks::Image]
18
+
19
+ # Allowed block types
20
+ config.block_types = %w[
21
+ CompositeContent::Blocks::Heading
22
+ CompositeContent::Blocks::Quote
23
+ CompositeContent::Blocks::Text
24
+ ]
25
+
26
+ # Engine internals
27
+ #
28
+ # Following configurations are here for engine initialization and internal
29
+ # configurations and are not meant to be changed by your application.
30
+
31
+ # Isolate models (and their tables) in their own namespace.
32
+ isolate_namespace CompositeContent
33
+
34
+ config.i18n.load_path += Dir[root.join('config', 'locales', '**', '*.{rb,yml}')]
35
+
36
+ initializer 'composite_content.active_record' do |_app|
37
+ ::ActiveSupport.on_load :active_record do
38
+ require 'composite_content/active_record'
39
+ include CompositeContent::ActiveRecord
40
+ end
41
+ end
42
+
43
+ initializer 'composite_content.action_view' do |_app|
44
+ ActiveSupport.on_load :action_view do
45
+ require 'composite_content/action_view'
46
+ include CompositeContent::ActionView
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ module Model
5
+ # Base class for engine's models.
6
+ # Equivalent to ApplicationRecord in a Rails app.
7
+ class Base < ::ActiveRecord::Base
8
+ self.abstract_class = true
9
+
10
+ include ActiveModel::Validations::Reflection
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ module Model
5
+ # Shared behaviors for concrete block implementations
6
+ module Blockable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ has_one :block,
11
+ as: :blockable,
12
+ touch: true,
13
+ dependent: :destroy
14
+ end
15
+
16
+ def block_type
17
+ self.class.name.demodulize.underscore
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ module Model
5
+ module Builder
6
+ class Base # :nodoc:
7
+ BLOCK_SUFFIX = 'Block'
8
+ SLOT_SUFFIX = 'Slot'
9
+
10
+ class << self
11
+ def build(parent, association, types = [])
12
+ new.build(parent, association, types)
13
+ end
14
+ end
15
+
16
+ def build(parent, association, types = [])
17
+ demodulized = build_classname(parent, association).demodulize
18
+
19
+ parent.const_set(demodulized, build_class(parent, association, types))
20
+ parent.const_get(demodulized)
21
+ end
22
+
23
+ def build_class(_parent, _association, _types = [])
24
+ raise NotImplementedError
25
+ end
26
+
27
+ def build_classname(_parent, _association)
28
+ raise NotImplementedError
29
+ end
30
+
31
+ protected
32
+
33
+ def classname_for_slot(parent, association)
34
+ classname_for(parent, association, SLOT_SUFFIX)
35
+ end
36
+
37
+ def classname_for_block(parent, association)
38
+ classname_for(parent, association, BLOCK_SUFFIX)
39
+ end
40
+
41
+ def classname_for(parent, association, suffix)
42
+ [parent.model_name.to_s, [association.to_s.singularize.classify, suffix].join].join('::')
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ module Model
5
+ module Builder
6
+ # Class builder for Block models
7
+ #
8
+ # Every composite content slot as its own block class, subclass of CompositeContent::Block.
9
+ # This allows to set distinct validations on allowed block types used in each composite
10
+ # content.
11
+ class Block < Base
12
+ alias build_classname classname_for_block
13
+
14
+ def build_class(parent, association, types = [])
15
+ Class.new(::CompositeContent::Block).tap do |klass|
16
+ klass.belongs_to :slot, class_name: classname_for_slot(parent, association), inverse_of: :blocks
17
+
18
+ klass.delegated_type :blockable, types: blockable_types(types), dependent: :destroy
19
+ klass.accepts_nested_attributes_for :blockable, reject_if: :all_blank
20
+
21
+ klass.validates_associated :blockable
22
+ klass.validates :blockable_type, inclusion: { in: blockable_types(types) }
23
+ end
24
+ end
25
+
26
+ protected
27
+
28
+ def blockable_types(types)
29
+ return CompositeContent::Engine.config.block_types if types.empty?
30
+
31
+ CompositeContent::Engine.config.block_types & types
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ module Model
5
+ module Builder
6
+ # Class builder for Slot models
7
+ #
8
+ # Every composite content slot is defined by its own class, subclass of CompositeContent::Slot.
9
+ # This allows to use of a dedicated block class where we can set validations on block types and
10
+ # to rely on ActiveRecord default behaviors for Single Table Inheritance models (as adding a
11
+ # constraint on type to queries).
12
+ class Slot < Base
13
+ alias build_classname classname_for_slot
14
+
15
+ def build_class(parent, association, _types = [])
16
+ Class.new(CompositeContent::Slot).tap do |klass|
17
+ klass.has_many :blocks,
18
+ -> { order(position: :asc) },
19
+ class_name: classname_for_block(parent, association),
20
+ foreign_key: :slot_id,
21
+ inverse_of: :slot,
22
+ dependent: :destroy
23
+
24
+ klass.accepts_nested_attributes_for :blocks, allow_destroy: true, reject_if: :all_blank
25
+ klass.validates_associated :blocks
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ module Model
5
+ module Builder # :nodoc:
6
+ extend ActiveSupport::Autoload
7
+
8
+ autoload :Base
9
+ autoload :Block
10
+ autoload :Slot
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ module Model
5
+ extend ActiveSupport::Autoload
6
+
7
+ autoload :Base
8
+ autoload :Blockable
9
+ autoload :Builder
10
+ end
11
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ # Slots are used as container for blocks collection.
5
+ #
6
+ # Whether a model as one or more composite content slots does not matter: blocks are always
7
+ # contained in a slot. This allows to keep programming interface consistent between uses and
8
+ # ease future integration of multiple slots on model where only one was expected at first.
9
+ class Slot < ::CompositeContent::Model::Base
10
+ belongs_to :parent, polymorphic: true
11
+
12
+ # Compatibility with parent models using Single Table Inheritance.
13
+ # See ActiveRecord::Associations::ClassMethods's overview for more detail.
14
+ def parent_type=(class_name)
15
+ super(class_name.constantize.base_class.to_s)
16
+ end
17
+
18
+ # Association with blocks is dynamically declared when building dedicated slot and
19
+ # block classes. See CompositeContent::Model::Builder.
20
+
21
+ class << self
22
+ def block_class
23
+ @block_class ||= reflect_on_association(:blocks).class_name.constantize
24
+ end
25
+
26
+ def blockable_classes
27
+ @blockable_classes ||= begin
28
+ validators = block_class.validators_on_of_kinds(:blockable_type, :inclusion)
29
+ types = validators.collect(&:options).collect { |opts| opts.fetch(:in) }.flatten.compact.uniq
30
+ types.collect(&:constantize)
31
+ end
32
+ end
33
+
34
+ def strong_parameters
35
+ [:id, { blocks_attributes: block_column_names + [{ blockable_attributes: blockable_column_names }, :_destroy] }]
36
+ end
37
+
38
+ protected
39
+
40
+ def block_column_names
41
+ @block_column_names ||= begin
42
+ columns = block_class.column_names.collect(&:to_sym)
43
+ columns - %i[slot_id blockable_id created_at updated_at]
44
+ end
45
+ end
46
+
47
+ def blockable_column_names
48
+ @blockable_column_names ||= begin
49
+ columns = blockable_classes.collect(&:column_names).flatten.collect(&:to_sym).compact.uniq
50
+ columns - %i[created_at updated_at]
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'composite_content/version'
4
+ require 'composite_content/engine'
5
+ require 'composite_content/model'
6
+ require 'composite_content/active_record'
7
+
8
+ module CompositeContent # :nodoc:
9
+ extend ActiveSupport::Autoload
10
+
11
+ autoload :Block
12
+ autoload :Blocks
13
+ autoload :Slot
14
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CompositeContent
4
+ module Generators
5
+ # Generator to copy CompositeContent views into an application.
6
+ class InstallGenerator < Rails::Generators::Base
7
+ desc 'Copies composite_content partial templates to your application.'
8
+
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ def copy_views
12
+ directory File.join(self.class.source_root), 'app/views/composite_content'
13
+ end
14
+
15
+ def copy_migrations
16
+ rake 'composite_content:install:migrations'
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ <div class="cocooned-item">
2
+ <%= form.hidden_field :id %>
3
+ <%= form.hidden_field :position %>
4
+ <%= form.hidden_field :blockable_type %>
5
+
6
+ <%= form.fields_for :blockable, form.object.blockable do |blockable_form| %>
7
+ <%= render "composite_content/blocks/#{form.object.blockable.block_type}/form", form: blockable_form %>
8
+ <% end %>
9
+
10
+ <%= composite_content_move_block_up_link form %>
11
+ <%= composite_content_move_block_down_link form %>
12
+ <%= composite_content_remove_block_link form %>
13
+ </div>
@@ -0,0 +1,9 @@
1
+ <div>
2
+ <%= form.label :level %>
3
+ <%= form.text_field :level %>
4
+ </div>
5
+
6
+ <div>
7
+ <%= form.label :content %>
8
+ <%= form.text_field :content %>
9
+ </div>
@@ -0,0 +1 @@
1
+ <h<%= block.blockable.level %>><%= block.blockable.content %></h<%= block.blockable.level %>>
@@ -0,0 +1,9 @@
1
+ <div>
2
+ <%= form.label :source %>
3
+ <%= form.text_field :source %>
4
+ </div>
5
+
6
+ <div>
7
+ <%= form.label :content %>
8
+ <%= form.text_area :content %>
9
+ </div>
@@ -0,0 +1,7 @@
1
+ <figure class="quote">
2
+ <blockquote><%= block.blockable.content %></blockquote>
3
+
4
+ <% if block.blockable.source.present? %>
5
+ <figcaption><%= block.blockable.source %></figcaption>
6
+ <% end %>
7
+ </figure>
@@ -0,0 +1,4 @@
1
+ <div>
2
+ <%= form.label :content %>
3
+ <%= form.text_area :content %>
4
+ </div>
@@ -0,0 +1 @@
1
+ <%= block.blockable.content %>
@@ -0,0 +1,13 @@
1
+ <%= form.hidden_field :id %>
2
+
3
+ <div data-cocooned-options="<%= { reorderable: true }.to_json %>">
4
+ <%= form.fields_for :blocks do |block_form| %>
5
+ <%= render 'composite_content/block/form', form: block_form %>
6
+ <% end %>
7
+
8
+ <div class="links">
9
+ <%= composite_content_add_block_link form, :heading %>
10
+ <%= composite_content_add_block_link form, :quote %>
11
+ <%= composite_content_add_block_link form, :text %>
12
+ </div>
13
+ </div>