cms42 0.1.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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +299 -0
- data/Rakefile +10 -0
- data/app/assets/stylesheets/cms/application.css +3 -0
- data/app/controllers/cms/admin/api_keys_controller.rb +52 -0
- data/app/controllers/cms/admin/base_controller.rb +54 -0
- data/app/controllers/cms/admin/documents_controller.rb +56 -0
- data/app/controllers/cms/admin/form_fields_controller.rb +70 -0
- data/app/controllers/cms/admin/form_submissions_controller.rb +36 -0
- data/app/controllers/cms/admin/images_controller.rb +67 -0
- data/app/controllers/cms/admin/pages_controller.rb +188 -0
- data/app/controllers/cms/admin/sections_controller.rb +177 -0
- data/app/controllers/cms/admin/sites_controller.rb +60 -0
- data/app/controllers/cms/admin/webhook_deliveries_controller.rb +19 -0
- data/app/controllers/cms/admin/webhooks_controller.rb +51 -0
- data/app/controllers/cms/api/base_controller.rb +45 -0
- data/app/controllers/cms/api/v1/base_controller.rb +29 -0
- data/app/controllers/cms/api/v1/pages_controller.rb +21 -0
- data/app/controllers/cms/api/v1/sites_controller.rb +18 -0
- data/app/controllers/cms/application_controller.rb +11 -0
- data/app/controllers/cms/public/base_controller.rb +23 -0
- data/app/controllers/cms/public/form_submissions_controller.rb +63 -0
- data/app/controllers/cms/public/previews_controller.rb +21 -0
- data/app/controllers/cms/public/sites_controller.rb +37 -0
- data/app/controllers/concerns/cms/admin/page_scoped_sections.rb +42 -0
- data/app/controllers/concerns/cms/current_site_resolver.rb +17 -0
- data/app/controllers/concerns/cms/public/page_paths.rb +31 -0
- data/app/controllers/concerns/cms/public/page_rendering.rb +23 -0
- data/app/controllers/concerns/cms/site_resolvable.rb +27 -0
- data/app/helpers/cms/admin/pages_helper.rb +29 -0
- data/app/helpers/cms/admin/sections_helper.rb +86 -0
- data/app/helpers/cms/admin/sites_helper.rb +8 -0
- data/app/helpers/cms/application_helper.rb +51 -0
- data/app/helpers/cms/media_helper.rb +28 -0
- data/app/helpers/cms/pages_helper.rb +16 -0
- data/app/helpers/cms/sections_helper.rb +25 -0
- data/app/helpers/cms/sites_helper.rb +11 -0
- data/app/javascript/cms/controllers/sortable_controller.js +38 -0
- data/app/jobs/cms/application_job.rb +6 -0
- data/app/jobs/cms/deliver_webhook_job.rb +66 -0
- data/app/mailers/cms/application_mailer.rb +9 -0
- data/app/mailers/cms/form_submission_mailer.rb +16 -0
- data/app/models/cms/api_key.rb +27 -0
- data/app/models/cms/application_record.rb +7 -0
- data/app/models/cms/document.rb +48 -0
- data/app/models/cms/form_field.rb +27 -0
- data/app/models/cms/form_submission.rb +42 -0
- data/app/models/cms/image.rb +61 -0
- data/app/models/cms/image_translation.rb +22 -0
- data/app/models/cms/page.rb +228 -0
- data/app/models/cms/page_section.rb +43 -0
- data/app/models/cms/page_translation.rb +22 -0
- data/app/models/cms/section/block_base.rb +32 -0
- data/app/models/cms/section/blocks/call_to_action_block.rb +16 -0
- data/app/models/cms/section/blocks/hero_block.rb +14 -0
- data/app/models/cms/section/blocks/image_block.rb +13 -0
- data/app/models/cms/section/blocks/rich_text_block.rb +12 -0
- data/app/models/cms/section/kind_registry.rb +66 -0
- data/app/models/cms/section.rb +94 -0
- data/app/models/cms/section_image.rb +10 -0
- data/app/models/cms/section_translation.rb +25 -0
- data/app/models/cms/site.rb +87 -0
- data/app/models/cms/webhook.rb +41 -0
- data/app/models/cms/webhook_delivery.rb +12 -0
- data/app/serializers/cms/api/base_serializer.rb +51 -0
- data/app/serializers/cms/api/page_serializer.rb +145 -0
- data/app/serializers/cms/api/site_serializer.rb +45 -0
- data/app/services/cms/locale_resolver.rb +30 -0
- data/app/services/cms/page_resolver.rb +73 -0
- data/app/services/cms/public_page_context.rb +49 -0
- data/app/views/cms/admin/api_keys/_form.html.erb +23 -0
- data/app/views/cms/admin/api_keys/create.html.erb +9 -0
- data/app/views/cms/admin/api_keys/edit.html.erb +5 -0
- data/app/views/cms/admin/api_keys/index.html.erb +36 -0
- data/app/views/cms/admin/api_keys/new.html.erb +5 -0
- data/app/views/cms/admin/documents/_form.html.erb +24 -0
- data/app/views/cms/admin/documents/edit.html.erb +2 -0
- data/app/views/cms/admin/documents/index.html.erb +37 -0
- data/app/views/cms/admin/documents/new.html.erb +2 -0
- data/app/views/cms/admin/form_fields/_form.html.erb +46 -0
- data/app/views/cms/admin/form_fields/edit.html.erb +2 -0
- data/app/views/cms/admin/form_fields/index.html.erb +41 -0
- data/app/views/cms/admin/form_fields/new.html.erb +2 -0
- data/app/views/cms/admin/form_submissions/index.html.erb +38 -0
- data/app/views/cms/admin/images/_form.html.erb +36 -0
- data/app/views/cms/admin/images/edit.html.erb +2 -0
- data/app/views/cms/admin/images/index.html.erb +25 -0
- data/app/views/cms/admin/images/new.html.erb +2 -0
- data/app/views/cms/admin/pages/_attach_section_panel.html.erb +20 -0
- data/app/views/cms/admin/pages/_form.html.erb +116 -0
- data/app/views/cms/admin/pages/_section_editor_frame.html.erb +3 -0
- data/app/views/cms/admin/pages/_sections_list.html.erb +9 -0
- data/app/views/cms/admin/pages/edit.html.erb +2 -0
- data/app/views/cms/admin/pages/index.html.erb +62 -0
- data/app/views/cms/admin/pages/new.html.erb +2 -0
- data/app/views/cms/admin/pages/show.html.erb +111 -0
- data/app/views/cms/admin/sections/_form.html.erb +128 -0
- data/app/views/cms/admin/sections/_section.html.erb +22 -0
- data/app/views/cms/admin/sections/edit.html.erb +9 -0
- data/app/views/cms/admin/sections/index.html.erb +47 -0
- data/app/views/cms/admin/sections/new.html.erb +9 -0
- data/app/views/cms/admin/sections/page_update.turbo_stream.erb +17 -0
- data/app/views/cms/admin/sections/show.html.erb +97 -0
- data/app/views/cms/admin/sites/_form.html.erb +44 -0
- data/app/views/cms/admin/sites/edit.html.erb +3 -0
- data/app/views/cms/admin/sites/new.html.erb +5 -0
- data/app/views/cms/admin/sites/show.html.erb +22 -0
- data/app/views/cms/admin/webhook_deliveries/index.html.erb +29 -0
- data/app/views/cms/admin/webhooks/_form.html.erb +38 -0
- data/app/views/cms/admin/webhooks/edit.html.erb +5 -0
- data/app/views/cms/admin/webhooks/index.html.erb +34 -0
- data/app/views/cms/admin/webhooks/new.html.erb +5 -0
- data/app/views/cms/form_submission_mailer/notify.html.erb +14 -0
- data/app/views/cms/form_submission_mailer/notify.text.erb +7 -0
- data/app/views/cms/public/pages/_content.html.erb +48 -0
- data/app/views/cms/public/pages/show.html.erb +44 -0
- data/app/views/cms/public/pages/templates/_custom.html.erb +3 -0
- data/app/views/cms/public/pages/templates/_form.html.erb +3 -0
- data/app/views/cms/public/pages/templates/_landing.html.erb +3 -0
- data/app/views/cms/public/pages/templates/_standard.html.erb +3 -0
- data/app/views/cms/sections/kinds/_cta.html.erb +13 -0
- data/app/views/cms/sections/kinds/_hero.html.erb +14 -0
- data/app/views/cms/sections/kinds/_image.html.erb +19 -0
- data/app/views/cms/sections/kinds/_rich_text.html.erb +6 -0
- data/app/views/layouts/cms/application.html.erb +13 -0
- data/app/views/layouts/cms/public.html.erb +14 -0
- data/bin/rails +19 -0
- data/bin/rubocop +9 -0
- data/cms.gemspec +29 -0
- data/config/importmap.rb +4 -0
- data/config/locales/activerecord.cms.en.yml +65 -0
- data/config/locales/en.yml +390 -0
- data/config/routes.rb +56 -0
- data/lib/cms/engine.rb +45 -0
- data/lib/cms/version.rb +5 -0
- data/lib/cms.rb +75 -0
- data/lib/cms42.rb +3 -0
- data/lib/generators/cms/install/install_generator.rb +26 -0
- data/lib/generators/cms/install/templates/create_cms_tables.rb +194 -0
- data/lib/generators/cms/install/templates/initializer.rb +21 -0
- data/lib/generators/cms/views/views_generator.rb +79 -0
- data/lib/tasks/cms_tasks.rake +6 -0
- data/lib/tasks/version.rake +8 -0
- metadata +281 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cms
|
|
4
|
+
class ApiKey < ApplicationRecord
|
|
5
|
+
self.table_name = "cms_api_keys"
|
|
6
|
+
|
|
7
|
+
belongs_to :site, class_name: "Cms::Site", inverse_of: :api_keys
|
|
8
|
+
|
|
9
|
+
validates :name, presence: true
|
|
10
|
+
validates :token, presence: true, uniqueness: true
|
|
11
|
+
|
|
12
|
+
before_validation :generate_token, on: :create
|
|
13
|
+
|
|
14
|
+
scope :active, -> { where(active: true) }
|
|
15
|
+
scope :ordered, -> { order(:name) }
|
|
16
|
+
|
|
17
|
+
def touch_last_used!
|
|
18
|
+
update_column(:last_used_at, Time.current)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def generate_token
|
|
24
|
+
self.token = SecureRandom.hex(32)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cms
|
|
4
|
+
class Document < ApplicationRecord
|
|
5
|
+
self.table_name = "cms_documents"
|
|
6
|
+
|
|
7
|
+
WORD_CONTENT_TYPES = [
|
|
8
|
+
"application/msword",
|
|
9
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
10
|
+
"application/vnd.ms-excel",
|
|
11
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
12
|
+
"application/vnd.ms-powerpoint",
|
|
13
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
PERMITTED_CONTENT_TYPES = [
|
|
17
|
+
"application/pdf",
|
|
18
|
+
"image/png",
|
|
19
|
+
"image/jpeg",
|
|
20
|
+
"image/tiff",
|
|
21
|
+
"text/plain",
|
|
22
|
+
"text/csv",
|
|
23
|
+
"application/zip",
|
|
24
|
+
"application/x-zip-compressed"
|
|
25
|
+
] + WORD_CONTENT_TYPES
|
|
26
|
+
|
|
27
|
+
belongs_to :site, class_name: "Cms::Site", inverse_of: :documents
|
|
28
|
+
has_one_attached :file
|
|
29
|
+
|
|
30
|
+
validates :title, :file, presence: true
|
|
31
|
+
validate :file_must_be_a_supported_document
|
|
32
|
+
|
|
33
|
+
scope :ordered, -> { order(:title) }
|
|
34
|
+
|
|
35
|
+
def display_title
|
|
36
|
+
title
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def file_must_be_a_supported_document
|
|
42
|
+
return unless file.attached?
|
|
43
|
+
return if PERMITTED_CONTENT_TYPES.include?(file.blob.content_type)
|
|
44
|
+
|
|
45
|
+
errors.add(:file, I18n.t("cms.errors.document.invalid_file_type", default: "must be a supported document file"))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cms
|
|
4
|
+
class FormField < ApplicationRecord
|
|
5
|
+
self.table_name = "cms_form_fields"
|
|
6
|
+
|
|
7
|
+
KINDS = %w[text email textarea select checkbox].freeze
|
|
8
|
+
|
|
9
|
+
belongs_to :page, class_name: "Cms::Page", inverse_of: :form_fields
|
|
10
|
+
|
|
11
|
+
validates :kind, presence: true, inclusion: { in: KINDS }
|
|
12
|
+
validates :label, :field_name, presence: true
|
|
13
|
+
validates :field_name, uniqueness: { scope: :page_id },
|
|
14
|
+
format: { with: /\A[a-z0-9_]+\z/,
|
|
15
|
+
message: ->(*) { I18n.t("cms.errors.form_field.invalid_field_name") } }
|
|
16
|
+
|
|
17
|
+
scope :ordered, -> { order(:position, :id) }
|
|
18
|
+
|
|
19
|
+
def select?
|
|
20
|
+
kind == "select"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def parsed_options
|
|
24
|
+
Array(options)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cms
|
|
4
|
+
class FormSubmission < ApplicationRecord
|
|
5
|
+
self.table_name = "cms_form_submissions"
|
|
6
|
+
|
|
7
|
+
belongs_to :page, class_name: "Cms::Page", inverse_of: :form_submissions
|
|
8
|
+
|
|
9
|
+
validates :data, presence: true
|
|
10
|
+
validate :contains_response_data
|
|
11
|
+
validate :required_fields_present
|
|
12
|
+
|
|
13
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
14
|
+
|
|
15
|
+
def self.to_csv(submissions, fields)
|
|
16
|
+
require "csv"
|
|
17
|
+
|
|
18
|
+
CSV.generate(headers: true) do |csv|
|
|
19
|
+
csv << ([I18n.t("cms.admin.form_submissions.index.submitted_at")] + fields.map(&:label))
|
|
20
|
+
submissions.each do |sub|
|
|
21
|
+
csv << ([I18n.l(sub.created_at, format: :cms_csv_datetime)] + fields.map { |f| sub.data[f.field_name] })
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def contains_response_data
|
|
29
|
+
values = data.to_h.values.reject { |value| value.to_s.in?(%w[0]) }
|
|
30
|
+
errors.add(:base, I18n.t("cms.errors.form_submission.blank")) if values.all?(&:blank?)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def required_fields_present
|
|
34
|
+
page.form_fields.ordered.select(&:required?).each do |field|
|
|
35
|
+
value = data.to_h[field.field_name]
|
|
36
|
+
next unless value.to_s.blank? || value.to_s == "0"
|
|
37
|
+
|
|
38
|
+
errors.add(field.field_name.to_sym, I18n.t("cms.errors.form_submission.required", label: field.label))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cms
|
|
4
|
+
class Image < ApplicationRecord
|
|
5
|
+
self.table_name = "cms_images"
|
|
6
|
+
|
|
7
|
+
PERMITTED_CONTENT_TYPES = [
|
|
8
|
+
"image/jpeg",
|
|
9
|
+
"image/png",
|
|
10
|
+
"image/webp",
|
|
11
|
+
"image/gif",
|
|
12
|
+
"image/svg+xml",
|
|
13
|
+
"image/tiff"
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
belongs_to :site, class_name: "Cms::Site", inverse_of: :images
|
|
17
|
+
has_one_attached :file
|
|
18
|
+
has_many :section_images,
|
|
19
|
+
class_name: "Cms::SectionImage",
|
|
20
|
+
foreign_key: :image_id,
|
|
21
|
+
inverse_of: :image,
|
|
22
|
+
dependent: :destroy
|
|
23
|
+
|
|
24
|
+
has_many :image_translations,
|
|
25
|
+
class_name: "Cms::ImageTranslation",
|
|
26
|
+
foreign_key: :image_id,
|
|
27
|
+
inverse_of: :image,
|
|
28
|
+
dependent: :destroy
|
|
29
|
+
|
|
30
|
+
accepts_nested_attributes_for :image_translations
|
|
31
|
+
|
|
32
|
+
has_one :localised, -> { where(locale: I18n.locale.to_s) },
|
|
33
|
+
class_name: "Cms::ImageTranslation",
|
|
34
|
+
foreign_key: :image_id,
|
|
35
|
+
inverse_of: :image
|
|
36
|
+
|
|
37
|
+
delegate :alt_text, :caption, to: :localised, allow_nil: true
|
|
38
|
+
|
|
39
|
+
validates :title, :file, presence: true
|
|
40
|
+
validate :file_must_be_an_image
|
|
41
|
+
|
|
42
|
+
scope :ordered, -> { order(created_at: :desc) }
|
|
43
|
+
|
|
44
|
+
def display_title
|
|
45
|
+
title.presence || file.filename.to_s
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def variant(dimensions)
|
|
49
|
+
file.variant(resize_to_limit: dimensions.split("x").map(&:to_i))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def file_must_be_an_image
|
|
55
|
+
return unless file.attached?
|
|
56
|
+
return if PERMITTED_CONTENT_TYPES.include?(file.blob.content_type)
|
|
57
|
+
|
|
58
|
+
errors.add(:file, I18n.t("cms.errors.image.invalid_file_type", default: "must be a valid image file"))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cms
|
|
4
|
+
class ImageTranslation < ApplicationRecord
|
|
5
|
+
self.table_name = "cms_image_translations"
|
|
6
|
+
|
|
7
|
+
belongs_to :image,
|
|
8
|
+
class_name: "Cms::Image",
|
|
9
|
+
inverse_of: :image_translations
|
|
10
|
+
|
|
11
|
+
validates :locale, :alt_text, presence: true
|
|
12
|
+
validates :locale, uniqueness: { scope: :image_id }
|
|
13
|
+
|
|
14
|
+
before_validation :normalize_locale
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def normalize_locale
|
|
19
|
+
self.locale = locale.to_s.downcase.presence
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cms
|
|
4
|
+
class Page < ApplicationRecord
|
|
5
|
+
include ::Discard::Model
|
|
6
|
+
|
|
7
|
+
self.table_name = "cms_pages"
|
|
8
|
+
|
|
9
|
+
MAX_DEPTH = 10
|
|
10
|
+
|
|
11
|
+
module TemplateRegistry
|
|
12
|
+
class << self
|
|
13
|
+
def register(key)
|
|
14
|
+
normalized = key.to_s.parameterize.underscore
|
|
15
|
+
return if normalized.blank?
|
|
16
|
+
|
|
17
|
+
registered[normalized] = true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def registered_keys
|
|
21
|
+
registered.keys
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def registered
|
|
27
|
+
@registered ||= begin
|
|
28
|
+
defaults = %w[standard landing form custom]
|
|
29
|
+
defaults.index_with { true }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
has_one_attached :hero_image
|
|
36
|
+
has_many_attached :media_files
|
|
37
|
+
|
|
38
|
+
belongs_to :site,
|
|
39
|
+
class_name: "Cms::Site",
|
|
40
|
+
inverse_of: :pages
|
|
41
|
+
|
|
42
|
+
belongs_to :parent,
|
|
43
|
+
class_name: "Cms::Page",
|
|
44
|
+
optional: true,
|
|
45
|
+
inverse_of: :subpages
|
|
46
|
+
|
|
47
|
+
has_many :subpages,
|
|
48
|
+
class_name: "Cms::Page",
|
|
49
|
+
foreign_key: :parent_id,
|
|
50
|
+
dependent: :nullify,
|
|
51
|
+
inverse_of: :parent
|
|
52
|
+
|
|
53
|
+
has_many :page_translations,
|
|
54
|
+
class_name: "Cms::PageTranslation",
|
|
55
|
+
foreign_key: :page_id,
|
|
56
|
+
inverse_of: :page,
|
|
57
|
+
dependent: :destroy
|
|
58
|
+
|
|
59
|
+
accepts_nested_attributes_for :page_translations
|
|
60
|
+
|
|
61
|
+
has_one :localised, -> { where(locale: I18n.locale.to_s) },
|
|
62
|
+
class_name: "Cms::PageTranslation",
|
|
63
|
+
foreign_key: :page_id,
|
|
64
|
+
inverse_of: :page
|
|
65
|
+
|
|
66
|
+
delegate :title, :seo_title, :seo_description,
|
|
67
|
+
to: :localised, allow_nil: true
|
|
68
|
+
|
|
69
|
+
has_many :page_sections,
|
|
70
|
+
class_name: "Cms::PageSection",
|
|
71
|
+
foreign_key: :page_id,
|
|
72
|
+
inverse_of: :page,
|
|
73
|
+
dependent: :destroy
|
|
74
|
+
|
|
75
|
+
has_many :sections,
|
|
76
|
+
through: :page_sections,
|
|
77
|
+
source: :section
|
|
78
|
+
|
|
79
|
+
has_many :form_fields,
|
|
80
|
+
class_name: "Cms::FormField",
|
|
81
|
+
foreign_key: :page_id,
|
|
82
|
+
inverse_of: :page,
|
|
83
|
+
dependent: :destroy
|
|
84
|
+
|
|
85
|
+
has_many :form_submissions,
|
|
86
|
+
class_name: "Cms::FormSubmission",
|
|
87
|
+
foreign_key: :page_id,
|
|
88
|
+
inverse_of: :page,
|
|
89
|
+
dependent: :destroy
|
|
90
|
+
|
|
91
|
+
enum :status,
|
|
92
|
+
{
|
|
93
|
+
draft: "draft",
|
|
94
|
+
published: "published",
|
|
95
|
+
archived: "archived"
|
|
96
|
+
},
|
|
97
|
+
prefix: true
|
|
98
|
+
|
|
99
|
+
validates :slug, presence: true
|
|
100
|
+
validates :slug, uniqueness: { scope: :site_id }
|
|
101
|
+
validates :template_key, presence: true, inclusion: { in: ->(_) { template_keys } }
|
|
102
|
+
validates :depth, numericality: { less_than_or_equal_to: MAX_DEPTH }
|
|
103
|
+
validate :published_pages_require_sections
|
|
104
|
+
|
|
105
|
+
before_create :generate_preview_token
|
|
106
|
+
before_validation :set_slug
|
|
107
|
+
before_validation :normalize_home_slug
|
|
108
|
+
before_validation :set_defaults
|
|
109
|
+
before_validation :compute_depth
|
|
110
|
+
before_save :ensure_single_home_page, if: -> { home? && will_save_change_to_home? }
|
|
111
|
+
after_commit :fire_webhooks_on_status_change, on: %i[create update]
|
|
112
|
+
after_save :update_descendant_depths, if: :saved_change_to_parent_id?
|
|
113
|
+
|
|
114
|
+
scope :ordered, -> { order(:position, :id) }
|
|
115
|
+
scope :root, -> { where(parent_id: nil) }
|
|
116
|
+
scope :published, -> { where(status: "published") }
|
|
117
|
+
scope :header_nav, -> { where(show_in_header: true).where.not(nav_group: "none").order(:nav_order, :position, :id) }
|
|
118
|
+
scope :footer_nav, lambda {
|
|
119
|
+
where(show_in_footer: true).where.not(nav_group: "none").order(:footer_order, :position, :id)
|
|
120
|
+
}
|
|
121
|
+
scope :search, lambda { |q|
|
|
122
|
+
joins(:page_translations)
|
|
123
|
+
.where(
|
|
124
|
+
"lower(unaccent(cms_page_translations.title)) ilike lower(unaccent(?)) OR " \
|
|
125
|
+
"lower(unaccent(cms_pages.slug)) ilike lower(unaccent(?))",
|
|
126
|
+
"%#{q}%", "%#{q}%"
|
|
127
|
+
).distinct
|
|
128
|
+
}
|
|
129
|
+
scope :search_ordered, ->(q) { search(q).ordered }
|
|
130
|
+
|
|
131
|
+
def self.template_keys
|
|
132
|
+
TemplateRegistry.registered_keys
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def display_title
|
|
136
|
+
title.presence || slug.to_s.humanize
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def display_meta_title
|
|
140
|
+
seo_title.presence || display_title
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def ancestors
|
|
144
|
+
parent ? parent.ancestors + [parent] : []
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def descendants
|
|
148
|
+
subpages.flat_map { |p| [p] + p.descendants }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def ordered_page_sections
|
|
152
|
+
page_sections.ordered.includes(:section)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def public_path_segments
|
|
156
|
+
return [] if home?
|
|
157
|
+
|
|
158
|
+
(ancestors + [self]).reject(&:home?).map(&:slug)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def public_path
|
|
162
|
+
public_path_segments.join("/")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def compute_depth
|
|
168
|
+
self.depth = parent ? (parent.depth + 1) : 0
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def update_descendant_depths(parent_depth = depth)
|
|
172
|
+
subpages.find_each do |child|
|
|
173
|
+
child.update_column(:depth, parent_depth + 1)
|
|
174
|
+
child.update_descendant_depths(parent_depth + 1)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def fire_webhooks_on_status_change
|
|
179
|
+
return unless saved_change_to_status?
|
|
180
|
+
|
|
181
|
+
event = case status
|
|
182
|
+
when "published" then "page.published"
|
|
183
|
+
when "archived" then "page.unpublished"
|
|
184
|
+
end
|
|
185
|
+
return if event.blank?
|
|
186
|
+
|
|
187
|
+
site.webhooks.active.each do |webhook|
|
|
188
|
+
Cms::DeliverWebhookJob.perform_later(webhook.id, event, { "page_id" => id, "slug" => slug })
|
|
189
|
+
end
|
|
190
|
+
rescue StandardError
|
|
191
|
+
# Webhook delivery failures must never break page saves
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def set_slug
|
|
195
|
+
default_locale = site.default_locale.presence || I18n.default_locale.to_s
|
|
196
|
+
translation_title = page_translations.find { |t| t.locale == default_locale }&.title if page_translations.loaded?
|
|
197
|
+
|
|
198
|
+
self.slug = slug.to_s.parameterize if slug.present?
|
|
199
|
+
self.slug = translation_title.to_s.parameterize if slug.blank? && translation_title.present?
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def normalize_home_slug
|
|
203
|
+
self.slug = "home" if home?
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def set_defaults
|
|
207
|
+
self.template_key = "standard" if template_key.blank?
|
|
208
|
+
self.nav_group = "main" if nav_group.blank?
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def generate_preview_token
|
|
212
|
+
self.preview_token ||= SecureRandom.urlsafe_base64(32)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def ensure_single_home_page
|
|
216
|
+
site.pages.where.not(id: id).where(home: true).find_each do |page|
|
|
217
|
+
page.update!(home: false)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def published_pages_require_sections
|
|
222
|
+
return unless status_published?
|
|
223
|
+
return if page_sections.loaded? ? page_sections.reject(&:marked_for_destruction?).any? : page_sections.exists?
|
|
224
|
+
|
|
225
|
+
errors.add(:base, I18n.t("cms.errors.page.sections_required"))
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cms
|
|
4
|
+
class PageSection < ApplicationRecord
|
|
5
|
+
self.table_name = "cms_page_sections"
|
|
6
|
+
|
|
7
|
+
belongs_to :page, class_name: "Cms::Page", inverse_of: :page_sections
|
|
8
|
+
belongs_to :section, class_name: "Cms::Section", inverse_of: :page_sections
|
|
9
|
+
|
|
10
|
+
validates :section_id, uniqueness: { scope: :page_id }
|
|
11
|
+
validate :section_site_matches_page
|
|
12
|
+
validate :global_sections_cannot_become_orphaned, on: :destroy
|
|
13
|
+
|
|
14
|
+
scope :ordered, -> { order(:position, :id) }
|
|
15
|
+
|
|
16
|
+
after_destroy :destroy_orphaned_section
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def section_site_matches_page
|
|
21
|
+
return unless page && section
|
|
22
|
+
return if page.site_id == section.site_id
|
|
23
|
+
|
|
24
|
+
errors.add(:section, :invalid)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def global_sections_cannot_become_orphaned
|
|
28
|
+
return unless section&.global?
|
|
29
|
+
return if section.page_sections.where.not(id: id).exists?
|
|
30
|
+
|
|
31
|
+
errors.add(:base, I18n.t("cms.errors.section.global_section_requires_attachment"))
|
|
32
|
+
throw :abort
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def destroy_orphaned_section
|
|
36
|
+
return unless Cms.config.auto_destroy_orphaned_sections
|
|
37
|
+
return if section.global?
|
|
38
|
+
return if section.page_sections.exists?
|
|
39
|
+
|
|
40
|
+
section.destroy
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cms
|
|
4
|
+
class PageTranslation < ApplicationRecord
|
|
5
|
+
self.table_name = "cms_page_translations"
|
|
6
|
+
|
|
7
|
+
belongs_to :page,
|
|
8
|
+
class_name: "Cms::Page",
|
|
9
|
+
inverse_of: :page_translations
|
|
10
|
+
|
|
11
|
+
validates :locale, :title, presence: true
|
|
12
|
+
validates :locale, uniqueness: { scope: :page_id }
|
|
13
|
+
|
|
14
|
+
before_validation :normalize_locale
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def normalize_locale
|
|
19
|
+
self.locale = locale.to_s.downcase.presence
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cms
|
|
4
|
+
class Section
|
|
5
|
+
class BlockBase
|
|
6
|
+
# Each subclass gets its own independent schema array
|
|
7
|
+
def self.inherited(subclass)
|
|
8
|
+
super
|
|
9
|
+
subclass.instance_variable_set(:@_settings_schema, [])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.settings_field(name, type:, required: false, default: nil, options: nil)
|
|
13
|
+
@_settings_schema ||= []
|
|
14
|
+
@_settings_schema << {
|
|
15
|
+
name: name.to_s,
|
|
16
|
+
type: type,
|
|
17
|
+
required: required,
|
|
18
|
+
default: default,
|
|
19
|
+
options: options
|
|
20
|
+
}.compact
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.settings_schema
|
|
24
|
+
@_settings_schema || []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.kind
|
|
28
|
+
raise NotImplementedError, "#{self} must define .kind"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cms
|
|
4
|
+
class Section
|
|
5
|
+
module Blocks
|
|
6
|
+
class CallToActionBlock < BlockBase
|
|
7
|
+
def self.kind = "cta"
|
|
8
|
+
|
|
9
|
+
settings_field :button_text, type: :string, required: true
|
|
10
|
+
settings_field :button_url, type: :url, required: true
|
|
11
|
+
settings_field :alignment, type: :select, default: "center",
|
|
12
|
+
options: %w[left center right]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cms
|
|
4
|
+
class Section
|
|
5
|
+
module Blocks
|
|
6
|
+
class HeroBlock < BlockBase
|
|
7
|
+
def self.kind = "hero"
|
|
8
|
+
|
|
9
|
+
settings_field :background_color, type: :color, default: "#ffffff"
|
|
10
|
+
settings_field :cta_url, type: :url
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cms
|
|
4
|
+
class Section
|
|
5
|
+
module KindRegistry
|
|
6
|
+
BUILT_IN_KINDS = %w[rich_text image hero cta].freeze
|
|
7
|
+
|
|
8
|
+
class UnknownKindError < StandardError; end
|
|
9
|
+
|
|
10
|
+
@registry = {}
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Register a section kind.
|
|
14
|
+
#
|
|
15
|
+
# @param kind [String, Symbol]
|
|
16
|
+
# @param partial [String, nil] override the default partial path
|
|
17
|
+
# @param block_class [Class, nil] block class that defines settings_schema
|
|
18
|
+
def register(kind, partial: nil, block_class: nil)
|
|
19
|
+
@registry[kind.to_s] = {
|
|
20
|
+
partial: partial.presence || "cms/sections/kinds/#{kind}",
|
|
21
|
+
block_class: block_class
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param kind [String]
|
|
26
|
+
# @return [String] partial path
|
|
27
|
+
# @raise [Cms::Section::KindRegistry::UnknownKindError]
|
|
28
|
+
def partial_for(kind)
|
|
29
|
+
entry_for(kind)[:partial]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param kind [String]
|
|
33
|
+
# @return [Class, nil] block class or nil if not set
|
|
34
|
+
def block_class_for(kind)
|
|
35
|
+
entry_for(kind)[:block_class]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Array<String>]
|
|
39
|
+
def registered_kinds
|
|
40
|
+
@registry.keys
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param kind [String, Symbol]
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def registered?(kind)
|
|
46
|
+
@registry.key?(kind.to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# For testing — resets to an empty registry
|
|
50
|
+
def reset!
|
|
51
|
+
@registry = {}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def entry_for(kind)
|
|
57
|
+
@registry.fetch(kind.to_s) do
|
|
58
|
+
raise UnknownKindError,
|
|
59
|
+
"No renderer registered for section kind: #{kind.inspect}. " \
|
|
60
|
+
"Registered kinds: #{@registry.keys.inspect}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|