composite_content 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +43 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +239 -0
- data/LICENSE +13 -0
- data/README.md +112 -0
- data/Rakefile +17 -0
- data/composite_content.gemspec +47 -0
- data/config/locales/en.yml +10 -0
- data/config/locales/fr.yml +10 -0
- data/db/migrate/20220807184334_create_composite_content_slots.rb +17 -0
- data/db/migrate/20220807185808_create_composite_content_blocks.rb +14 -0
- data/db/migrate/20220807191353_create_composite_content_headings.rb +12 -0
- data/db/migrate/20220807191402_create_composite_content_quotes.rb +12 -0
- data/db/migrate/20220807191410_create_composite_content_texts.rb +11 -0
- data/gemfiles/rails-6.1.x.gemfile +14 -0
- data/lib/composite_content/action_view.rb +84 -0
- data/lib/composite_content/active_record.rb +45 -0
- data/lib/composite_content/block.rb +11 -0
- data/lib/composite_content/blocks/heading.rb +21 -0
- data/lib/composite_content/blocks/quote.rb +12 -0
- data/lib/composite_content/blocks/text.rb +12 -0
- data/lib/composite_content/blocks.rb +11 -0
- data/lib/composite_content/engine.rb +50 -0
- data/lib/composite_content/model/base.rb +13 -0
- data/lib/composite_content/model/blockable.rb +21 -0
- data/lib/composite_content/model/builder/base.rb +47 -0
- data/lib/composite_content/model/builder/block.rb +36 -0
- data/lib/composite_content/model/builder/slot.rb +31 -0
- data/lib/composite_content/model/builder.rb +13 -0
- data/lib/composite_content/model.rb +11 -0
- data/lib/composite_content/slot.rb +55 -0
- data/lib/composite_content/version.rb +5 -0
- data/lib/composite_content.rb +14 -0
- data/lib/generators/composite_content/install_generator.rb +20 -0
- data/lib/generators/composite_content/templates/block/_form.html.erb +13 -0
- data/lib/generators/composite_content/templates/blocks/heading/_form.html.erb +9 -0
- data/lib/generators/composite_content/templates/blocks/heading/_show.html.erb +1 -0
- data/lib/generators/composite_content/templates/blocks/quote/_form.html.erb +9 -0
- data/lib/generators/composite_content/templates/blocks/quote/_show.html.erb +7 -0
- data/lib/generators/composite_content/templates/blocks/text/_form.html.erb +4 -0
- data/lib/generators/composite_content/templates/blocks/text/_show.html.erb +1 -0
- data/lib/generators/composite_content/templates/slot/_form.html.erb +13 -0
- 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,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,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,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 @@
|
|
1
|
+
<h<%= block.blockable.level %>><%= block.blockable.content %></h<%= block.blockable.level %>>
|
@@ -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>
|