plum-cms 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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +252 -0
  5. data/app/assets/builds/tailwind.css +2 -0
  6. data/app/assets/images/plum-mark.svg +7 -0
  7. data/app/assets/images/table-needs-logo.svg +83 -0
  8. data/app/assets/javascripts/plum/application.js +15 -0
  9. data/app/assets/stylesheets/application.css +10 -0
  10. data/app/assets/stylesheets/plum/control_panel.css +2 -0
  11. data/app/assets/tailwind/application.css +47 -0
  12. data/app/controllers/plum/application_controller.rb +34 -0
  13. data/app/controllers/plum/cp/assets_controller.rb +84 -0
  14. data/app/controllers/plum/cp/base_controller.rb +45 -0
  15. data/app/controllers/plum/cp/content_types_controller.rb +59 -0
  16. data/app/controllers/plum/cp/dashboard_controller.rb +10 -0
  17. data/app/controllers/plum/cp/entries_controller.rb +240 -0
  18. data/app/controllers/plum/cp/form_definitions_controller.rb +105 -0
  19. data/app/controllers/plum/cp/form_submissions_controller.rb +26 -0
  20. data/app/controllers/plum/cp/globals_controller.rb +84 -0
  21. data/app/controllers/plum/cp/nav_items_controller.rb +68 -0
  22. data/app/controllers/plum/cp/nav_menus_controller.rb +56 -0
  23. data/app/controllers/plum/cp/site_settings_controller.rb +87 -0
  24. data/app/controllers/plum/cp/taxonomies_controller.rb +56 -0
  25. data/app/controllers/plum/cp/terms_controller.rb +55 -0
  26. data/app/controllers/plum/cp/theme_previews_controller.rb +27 -0
  27. data/app/controllers/plum/cp/themes_controller.rb +98 -0
  28. data/app/controllers/plum/form_submissions_controller.rb +44 -0
  29. data/app/controllers/plum/pages_controller.rb +161 -0
  30. data/app/controllers/plum/sessions_controller.rb +25 -0
  31. data/app/controllers/plum/theme_assets_controller.rb +14 -0
  32. data/app/helpers/application_helper.rb +2 -0
  33. data/app/helpers/markdown_helper.rb +21 -0
  34. data/app/helpers/plum/cp/assets_helper.rb +21 -0
  35. data/app/helpers/plum/cp/theme_settings_helper.rb +26 -0
  36. data/app/javascript/application.js +18 -0
  37. data/app/javascript/controllers/application.js +9 -0
  38. data/app/javascript/controllers/index.js +4 -0
  39. data/app/javascript/controllers/plum/blocks_editor_controller.js +275 -0
  40. data/app/javascript/controllers/plum/blueprint_controller.js +120 -0
  41. data/app/javascript/controllers/plum/form_fields_controller.js +84 -0
  42. data/app/javascript/controllers/plum/image_picker_controller.js +143 -0
  43. data/app/javascript/controllers/plum/theme_settings_controller.js +17 -0
  44. data/app/models/plum/application_record.rb +5 -0
  45. data/app/models/plum/asset.rb +69 -0
  46. data/app/models/plum/content_type.rb +35 -0
  47. data/app/models/plum/entry.rb +67 -0
  48. data/app/models/plum/entry_term.rb +8 -0
  49. data/app/models/plum/form_definition.rb +68 -0
  50. data/app/models/plum/form_submission.rb +76 -0
  51. data/app/models/plum/global.rb +24 -0
  52. data/app/models/plum/nav_item.rb +58 -0
  53. data/app/models/plum/nav_menu.rb +24 -0
  54. data/app/models/plum/site.rb +112 -0
  55. data/app/models/plum/site_scoped.rb +17 -0
  56. data/app/models/plum/site_setting.rb +35 -0
  57. data/app/models/plum/taxonomy.rb +24 -0
  58. data/app/models/plum/term.rb +23 -0
  59. data/app/models/plum/user.rb +16 -0
  60. data/app/services/plum/base_blocks.rb +161 -0
  61. data/app/services/plum/block_editor_config.rb +65 -0
  62. data/app/services/plum/block_library.rb +31 -0
  63. data/app/services/plum/builder_renderer.rb +63 -0
  64. data/app/services/plum/field_expander.rb +78 -0
  65. data/app/services/plum/form_renderer.rb +110 -0
  66. data/app/services/plum/liquid_context.rb +335 -0
  67. data/app/services/plum/liquid_filters.rb +58 -0
  68. data/app/services/plum/liquid_renderer.rb +49 -0
  69. data/app/services/plum/theme.rb +160 -0
  70. data/app/services/plum/theme_asset_path.rb +31 -0
  71. data/app/services/plum/theme_package_installer.rb +287 -0
  72. data/app/services/plum/theme_registry.rb +63 -0
  73. data/app/services/plum/theme_resolver.rb +55 -0
  74. data/app/services/plum/theme_settings_params.rb +54 -0
  75. data/app/themes/bagel-boy/assets/screenshot.svg +92 -0
  76. data/app/themes/bagel-boy/assets/theme.css +184 -0
  77. data/app/themes/bagel-boy/blocks/hours.liquid +4 -0
  78. data/app/themes/bagel-boy/blocks/menu_item.liquid +7 -0
  79. data/app/themes/bagel-boy/layouts/base.liquid +64 -0
  80. data/app/themes/bagel-boy/templates/collections/posts.liquid +32 -0
  81. data/app/themes/bagel-boy/templates/entries/_default.liquid +13 -0
  82. data/app/themes/bagel-boy/templates/entries/landing.liquid +4 -0
  83. data/app/themes/bagel-boy/templates/entries/pages.liquid +18 -0
  84. data/app/themes/bagel-boy/templates/entries/posts.liquid +8 -0
  85. data/app/themes/bagel-boy/templates/index.liquid +10 -0
  86. data/app/themes/bagel-boy/templates/search.liquid +28 -0
  87. data/app/themes/bagel-boy/theme.yml +47 -0
  88. data/app/themes/bagel-shop/assets/screenshot.svg +28 -0
  89. data/app/themes/bagel-shop/assets/theme.css +7 -0
  90. data/app/themes/bagel-shop/layouts/base.liquid +507 -0
  91. data/app/themes/bagel-shop/templates/entries/_default.liquid +15 -0
  92. data/app/themes/bagel-shop/templates/entries/pages.liquid +12 -0
  93. data/app/themes/bagel-shop/templates/entries/posts.liquid +19 -0
  94. data/app/themes/bagel-shop/templates/index.liquid +55 -0
  95. data/app/themes/bagel-shop/theme.yml +30 -0
  96. data/app/themes/default/assets/screenshot.svg +24 -0
  97. data/app/themes/default/assets/theme.css +89 -0
  98. data/app/themes/default/layouts/base.liquid +117 -0
  99. data/app/themes/default/templates/collections/_default.liquid +16 -0
  100. data/app/themes/default/templates/collections/posts.liquid +27 -0
  101. data/app/themes/default/templates/entries/_default.liquid +26 -0
  102. data/app/themes/default/templates/entries/landing.liquid +5 -0
  103. data/app/themes/default/templates/entries/pages.liquid +17 -0
  104. data/app/themes/default/templates/entries/posts.liquid +20 -0
  105. data/app/themes/default/templates/index.liquid +22 -0
  106. data/app/themes/default/templates/search.liquid +28 -0
  107. data/app/themes/default/templates/taxonomies/_default.liquid +25 -0
  108. data/app/themes/default/templates/taxonomies/_default_index.liquid +14 -0
  109. data/app/themes/default/theme.yml +23 -0
  110. data/app/views/layouts/plum/cp.html.erb +138 -0
  111. data/app/views/layouts/plum/session.html.erb +43 -0
  112. data/app/views/plum/cp/assets/_form.html.erb +44 -0
  113. data/app/views/plum/cp/assets/edit.html.erb +14 -0
  114. data/app/views/plum/cp/assets/index.html.erb +52 -0
  115. data/app/views/plum/cp/assets/new.html.erb +9 -0
  116. data/app/views/plum/cp/content_types/_form.html.erb +113 -0
  117. data/app/views/plum/cp/content_types/edit.html.erb +8 -0
  118. data/app/views/plum/cp/content_types/index.html.erb +48 -0
  119. data/app/views/plum/cp/content_types/new.html.erb +5 -0
  120. data/app/views/plum/cp/content_types/show.html.erb +59 -0
  121. data/app/views/plum/cp/dashboard/show.html.erb +50 -0
  122. data/app/views/plum/cp/entries/_form.html.erb +196 -0
  123. data/app/views/plum/cp/entries/edit.html.erb +9 -0
  124. data/app/views/plum/cp/entries/index.html.erb +51 -0
  125. data/app/views/plum/cp/entries/new.html.erb +5 -0
  126. data/app/views/plum/cp/form_definitions/_form.html.erb +90 -0
  127. data/app/views/plum/cp/form_definitions/edit.html.erb +11 -0
  128. data/app/views/plum/cp/form_definitions/index.html.erb +43 -0
  129. data/app/views/plum/cp/form_definitions/new.html.erb +5 -0
  130. data/app/views/plum/cp/form_definitions/show.html.erb +70 -0
  131. data/app/views/plum/cp/form_submissions/show.html.erb +20 -0
  132. data/app/views/plum/cp/globals/_form.html.erb +44 -0
  133. data/app/views/plum/cp/globals/edit.html.erb +11 -0
  134. data/app/views/plum/cp/globals/index.html.erb +41 -0
  135. data/app/views/plum/cp/globals/new.html.erb +5 -0
  136. data/app/views/plum/cp/globals/show.html.erb +14 -0
  137. data/app/views/plum/cp/nav_items/_form.html.erb +56 -0
  138. data/app/views/plum/cp/nav_items/edit.html.erb +11 -0
  139. data/app/views/plum/cp/nav_items/new.html.erb +6 -0
  140. data/app/views/plum/cp/nav_menus/_form.html.erb +33 -0
  141. data/app/views/plum/cp/nav_menus/_nav_item.html.erb +24 -0
  142. data/app/views/plum/cp/nav_menus/edit.html.erb +11 -0
  143. data/app/views/plum/cp/nav_menus/index.html.erb +41 -0
  144. data/app/views/plum/cp/nav_menus/new.html.erb +5 -0
  145. data/app/views/plum/cp/nav_menus/show.html.erb +24 -0
  146. data/app/views/plum/cp/shared/_icon.html.erb +39 -0
  147. data/app/views/plum/cp/shared/_image_picker.html.erb +38 -0
  148. data/app/views/plum/cp/site_settings/edit.html.erb +145 -0
  149. data/app/views/plum/cp/site_settings/show.html.erb +62 -0
  150. data/app/views/plum/cp/taxonomies/_form.html.erb +38 -0
  151. data/app/views/plum/cp/taxonomies/edit.html.erb +2 -0
  152. data/app/views/plum/cp/taxonomies/index.html.erb +38 -0
  153. data/app/views/plum/cp/taxonomies/new.html.erb +2 -0
  154. data/app/views/plum/cp/taxonomies/show.html.erb +46 -0
  155. data/app/views/plum/cp/terms/_form.html.erb +30 -0
  156. data/app/views/plum/cp/terms/edit.html.erb +2 -0
  157. data/app/views/plum/cp/terms/new.html.erb +2 -0
  158. data/app/views/plum/cp/themes/index.html.erb +160 -0
  159. data/app/views/plum/form_mailer/submission_notification.html.erb +13 -0
  160. data/app/views/plum/form_mailer/submission_notification.text.erb +7 -0
  161. data/app/views/plum/sessions/new.html.erb +20 -0
  162. data/compat/plum-cms.rb +1 -0
  163. data/config/locales/en.yml +31 -0
  164. data/config/plum_importmap.rb +4 -0
  165. data/config/plum_routes.rb +34 -0
  166. data/db/engine_migrate/20260101000001_create_plum_tables.rb +154 -0
  167. data/lib/generators/plum/install/install_generator.rb +54 -0
  168. data/lib/generators/plum/install/templates/plum_initializer.rb +37 -0
  169. data/lib/plum/configuration.rb +88 -0
  170. data/lib/plum/content_source.rb +45 -0
  171. data/lib/plum/content_source_context.rb +30 -0
  172. data/lib/plum/content_source_registry.rb +120 -0
  173. data/lib/plum/engine.rb +30 -0
  174. data/lib/plum/liquid_tags/form_tag.rb +17 -0
  175. data/lib/plum/version.rb +7 -0
  176. data/lib/plum.rb +11 -0
  177. data/vendor/javascript/lexxy.js +11859 -0
  178. metadata +405 -0
@@ -0,0 +1,84 @@
1
+ module Plum
2
+ module Cp
3
+ class AssetsController < BaseController
4
+ include Plum::Cp::AssetsHelper
5
+
6
+ before_action :set_asset, only: [ :edit, :update, :destroy ]
7
+ before_action :require_editor, only: [ :new, :create, :edit, :update, :destroy ]
8
+
9
+ def index
10
+ @assets = asset_scope.order(created_at: :desc)
11
+ @asset = asset_scope.build
12
+
13
+ respond_to do |format|
14
+ format.html
15
+ format.json { render json: @assets.map { |asset| asset_json(asset) } }
16
+ end
17
+ end
18
+
19
+ def new
20
+ @asset = asset_scope.build
21
+ end
22
+
23
+ def create
24
+ @asset = asset_scope.build(asset_params)
25
+
26
+ if @asset.save
27
+ respond_to do |format|
28
+ format.html { redirect_to cp_assets_path, notice: "Asset uploaded" }
29
+ format.json { render json: asset_json(@asset), status: :created }
30
+ end
31
+ else
32
+ respond_to do |format|
33
+ format.html do
34
+ @assets = asset_scope.order(created_at: :desc)
35
+ render :index, status: :unprocessable_entity
36
+ end
37
+ format.json { render json: { errors: @asset.errors.full_messages }, status: :unprocessable_entity }
38
+ end
39
+ end
40
+ end
41
+
42
+ def edit
43
+ end
44
+
45
+ def update
46
+ if @asset.update(asset_params)
47
+ redirect_to cp_assets_path, notice: "Asset updated"
48
+ else
49
+ render :edit, status: :unprocessable_entity
50
+ end
51
+ end
52
+
53
+ def destroy
54
+ @asset.destroy
55
+ redirect_to cp_assets_path, notice: "Asset deleted"
56
+ end
57
+
58
+ private
59
+
60
+ def asset_scope
61
+ current_site.assets.with_attached_file
62
+ end
63
+
64
+ def set_asset
65
+ @asset = asset_scope.find(params[:id])
66
+ end
67
+
68
+ def asset_params
69
+ params.require(:asset).permit(:file, :alt_text, :caption, :folder)
70
+ end
71
+
72
+ def asset_json(asset)
73
+ {
74
+ id: asset.id,
75
+ url: asset.url,
76
+ filename: asset.filename,
77
+ folder: asset.folder.to_s,
78
+ alt_text: asset.alt_text.to_s,
79
+ label: plum_asset_label(asset)
80
+ }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,45 @@
1
+ module Plum
2
+ module Cp
3
+ class BaseController < Plum::ApplicationController
4
+ layout "plum/cp"
5
+ helper AssetsHelper
6
+ helper ThemeSettingsHelper
7
+
8
+ before_action :require_login
9
+ helper_method :cp_prefix
10
+
11
+ private
12
+
13
+ def cp_prefix
14
+ @cp_prefix ||= cp_root_path.chomp("/")
15
+ end
16
+
17
+ def require_login
18
+ return if Plum.authorized?(self)
19
+
20
+ if Plum.configuration.authorize_with == :plum
21
+ redirect_to login_path, alert: "Please log in to continue"
22
+ else
23
+ head :forbidden
24
+ end
25
+ end
26
+
27
+ def require_role(*roles)
28
+ return unless Plum.configuration.authorize_with == :plum
29
+
30
+ user = Plum.current_user(self)
31
+ return if user&.respond_to?(:role) && roles.map(&:to_s).include?(user.role.to_s)
32
+
33
+ redirect_to cp_root_path, alert: "You don't have permission to do that"
34
+ end
35
+
36
+ def require_editor
37
+ require_role(:editor, :admin)
38
+ end
39
+
40
+ def require_admin
41
+ require_role(:admin)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,59 @@
1
+ module Plum
2
+ module Cp
3
+ class ContentTypesController < BaseController
4
+ before_action :set_content_type, only: [ :show, :edit, :update, :destroy ]
5
+ before_action :require_admin, only: [ :new, :create, :edit, :update, :destroy ]
6
+
7
+ def index
8
+ @content_types = ContentType.for_site(current_site)
9
+ end
10
+
11
+ def show
12
+ @entries = @content_type.entries.order(updated_at: :desc)
13
+ end
14
+
15
+ def new
16
+ @content_type = current_site.content_types.build
17
+ end
18
+
19
+ def create
20
+ @content_type = current_site.content_types.build(content_type_params)
21
+ if @content_type.save
22
+ redirect_to cp_content_type_path(@content_type), notice: "Content type created"
23
+ else
24
+ render :new, status: :unprocessable_entity
25
+ end
26
+ end
27
+
28
+ def edit
29
+ end
30
+
31
+ def update
32
+ if @content_type.update(content_type_params)
33
+ redirect_to cp_content_type_path(@content_type), notice: "Content type updated"
34
+ else
35
+ render :edit, status: :unprocessable_entity
36
+ end
37
+ end
38
+
39
+ def destroy
40
+ @content_type.destroy
41
+ redirect_to cp_content_types_path, notice: "Content type deleted"
42
+ end
43
+
44
+ private
45
+
46
+ def set_content_type
47
+ @content_type = current_site.content_types.find(params[:id])
48
+ end
49
+
50
+ def content_type_params
51
+ permitted = params.require(:content_type).permit(:name, :handle, :singleton, :icon, :blueprint)
52
+ if permitted[:blueprint].is_a?(String)
53
+ permitted[:blueprint] = JSON.parse(permitted[:blueprint]) rescue {}
54
+ end
55
+ permitted
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,10 @@
1
+ module Plum
2
+ module Cp
3
+ class DashboardController < BaseController
4
+ def show
5
+ @content_types = ContentType.for_site(current_site)
6
+ @recent_entries = Entry.for_site(current_site).includes(:content_type).order(updated_at: :desc).limit(10)
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,240 @@
1
+ module Plum
2
+ module Cp
3
+ class EntriesController < BaseController
4
+ ALLOWED_RICH_TEXT_TAGS = %w[
5
+ a action-text-attachment blockquote br code em figcaption figure
6
+ h1 h2 h3 hr img li ol p pre s strong table tbody td th thead tr ul
7
+ ].freeze
8
+ ALLOWED_RICH_TEXT_ATTRIBUTES = %w[
9
+ alt caption content-type filename filesize height href presentation
10
+ rel sgid src target title url width
11
+ ].freeze
12
+
13
+ before_action :set_content_type
14
+ before_action :set_entry, only: [ :edit, :update, :destroy ]
15
+ before_action :set_form_collections, only: [ :new, :create, :edit, :update ]
16
+ before_action :require_editor, only: [ :new, :create, :edit, :update, :destroy ]
17
+
18
+ def index
19
+ @entries = @content_type.entries.order(updated_at: :desc)
20
+ end
21
+
22
+ def new
23
+ @entry = @content_type.entries.build(status: :draft, site: current_site)
24
+ end
25
+
26
+ def create
27
+ @entry = @content_type.entries.build(entry_params)
28
+ @entry.site = current_site
29
+ @entry.author = current_user if current_user.is_a?(Plum::User)
30
+
31
+ if save_entry(@entry)
32
+ redirect_to edit_cp_content_type_entry_path(@content_type, @entry), notice: "Entry created"
33
+ else
34
+ render :new, status: :unprocessable_entity
35
+ end
36
+ end
37
+
38
+ def edit
39
+ end
40
+
41
+ def update
42
+ @entry.assign_attributes(entry_params)
43
+
44
+ if save_entry(@entry)
45
+ redirect_to edit_cp_content_type_entry_path(@content_type, @entry), notice: "Entry updated"
46
+ else
47
+ render :edit, status: :unprocessable_entity
48
+ end
49
+ end
50
+
51
+ def destroy
52
+ @entry.destroy
53
+ redirect_to cp_content_type_entries_path(@content_type), notice: "Entry deleted"
54
+ end
55
+
56
+ private
57
+
58
+ def set_content_type
59
+ @content_type = current_site.content_types.find(params[:content_type_id])
60
+ end
61
+
62
+ def set_entry
63
+ @entry = @content_type.entries.find(params[:id])
64
+ end
65
+
66
+ def set_form_collections
67
+ @assets = current_site.assets.with_attached_file.order(created_at: :desc)
68
+ @theme = current_site.theme
69
+ @relationship_entries_by_field = relationship_fields.each_with_object({}) do |field, entries_by_field|
70
+ entries_by_field[field["handle"].to_s] = relationship_entry_scope(@entry, field).order(:title)
71
+ end
72
+ @taxonomy_terms_by_field = taxonomy_fields.each_with_object({}) do |field, terms_by_field|
73
+ taxonomy = current_site.taxonomies.find_by(handle: field["taxonomy"].to_s)
74
+ terms_by_field[field["handle"].to_s] = taxonomy ? taxonomy.terms.ordered : []
75
+ end
76
+ end
77
+
78
+ def entry_params
79
+ permitted = params.require(:entry).permit(:title, :slug, :status, :published_at)
80
+ if params[:entry][:data].is_a?(ActionController::Parameters)
81
+ permitted[:data] = params[:entry][:data].permit!.to_h
82
+ end
83
+ permitted
84
+ end
85
+
86
+ def save_entry(entry)
87
+ return false unless entry.valid?
88
+ return false unless attach_uploaded_image_fields(entry)
89
+
90
+ entry.save.tap { |saved| sync_entry_terms(entry) if saved }
91
+ end
92
+
93
+ def sync_entry_terms(entry)
94
+ submitted_ids = Array(params.dig(:entry, :term_ids)).map(&:to_i).select(&:positive?)
95
+ valid_ids = Plum::Term.where(id: submitted_ids, site: current_site).pluck(:id)
96
+ entry.term_ids = valid_ids
97
+ end
98
+
99
+ def attach_uploaded_image_fields(entry)
100
+ image_uploads = params.dig(:entry, :image_uploads)
101
+ unless image_uploads.respond_to?(:[])
102
+ return normalize_entry_data_fields(entry)
103
+ end
104
+
105
+ image_fields.each do |field|
106
+ handle = field["handle"].to_s
107
+ upload = image_uploads[handle]
108
+ next if upload.blank?
109
+
110
+ asset = current_site.assets.build(
111
+ file: upload,
112
+ alt_text: default_asset_alt_text(entry, field),
113
+ folder: field["folder"].presence || @content_type.handle
114
+ )
115
+
116
+ unless asset.save
117
+ entry.errors.add(:base, "#{field_label(field)} #{asset.errors.full_messages.to_sentence}")
118
+ return false
119
+ end
120
+
121
+ entry.data ||= {}
122
+ entry.data[handle] = asset.id
123
+ end
124
+
125
+ normalize_entry_data_fields(entry)
126
+ end
127
+
128
+ def normalize_entry_data_fields(entry)
129
+ entry.data ||= {}
130
+
131
+ @content_type.fields.each do |field|
132
+ handle = field["handle"].to_s
133
+ next if handle.blank?
134
+
135
+ value = entry.data[handle]
136
+ entry.data[handle] = normalized_field_value(entry, field, value)
137
+ return false if entry.errors.any?
138
+ end
139
+
140
+ true
141
+ end
142
+
143
+ def normalized_field_value(entry, field, value)
144
+ case field["type"]
145
+ when "boolean"
146
+ ActiveModel::Type::Boolean.new.cast(value)
147
+ when "checkboxes"
148
+ Array(value).select(&:present?)
149
+ when "image"
150
+ value.present? ? value.to_i : nil
151
+ when "rich_text"
152
+ value
153
+ when "relationship"
154
+ normalized_relationship_value(entry, field, value)
155
+ when "blocks"
156
+ normalized_blocks_value(value)
157
+ else
158
+ value
159
+ end
160
+ end
161
+
162
+ def normalized_relationship_value(entry, field, value)
163
+ return nil if value.blank?
164
+
165
+ related_entry = relationship_entry_scope(entry, field).find_by(id: value.to_i)
166
+ return related_entry.id if related_entry
167
+
168
+ entry.errors.add(:base, "#{field_label(field)} is not a valid entry")
169
+ nil
170
+ end
171
+
172
+ # A blocks field is submitted as a JSON string from the block editor. We
173
+ # parse it and normalize each block instance to { id, type, fields }.
174
+ def normalized_blocks_value(value)
175
+ parsed = value.is_a?(String) ? safe_parse_blocks(value) : value
176
+ return [] unless parsed.is_a?(Array)
177
+
178
+ parsed.filter_map do |block|
179
+ block = block.to_h if block.respond_to?(:to_h)
180
+ next unless block.is_a?(Hash)
181
+
182
+ type = block["type"].to_s
183
+ next if type.blank?
184
+
185
+ fields = block["fields"]
186
+ fields = fields.to_h if fields.respond_to?(:to_h)
187
+
188
+ {
189
+ "id" => block["id"].presence || SecureRandom.uuid,
190
+ "type" => type,
191
+ "fields" => fields.is_a?(Hash) ? fields : {}
192
+ }
193
+ end
194
+ end
195
+
196
+ def safe_parse_blocks(value)
197
+ JSON.parse(value)
198
+ rescue JSON::ParserError
199
+ []
200
+ end
201
+
202
+ def sanitized_rich_text(value)
203
+ Rails::HTML5::SafeListSanitizer.new.sanitize(
204
+ value.to_s,
205
+ tags: ALLOWED_RICH_TEXT_TAGS,
206
+ attributes: ALLOWED_RICH_TEXT_ATTRIBUTES
207
+ )
208
+ end
209
+
210
+ def image_fields
211
+ @content_type.fields.select { |field| field["type"] == "image" && field["handle"].present? }
212
+ end
213
+
214
+ def relationship_fields
215
+ @content_type.fields.select { |field| field["type"] == "relationship" && field["handle"].present? }
216
+ end
217
+
218
+ def taxonomy_fields
219
+ @content_type.fields.select { |field| field["type"] == "taxonomy" && field["handle"].present? }
220
+ end
221
+
222
+ def relationship_entry_scope(entry, field)
223
+ site = entry&.site || current_site
224
+ scope = Entry.for_site(site).includes(:content_type)
225
+ target_handle = field["content_type"].to_s.strip.presence
226
+ return scope unless target_handle
227
+
228
+ scope.joins(:content_type).where(ContentType.table_name => { handle: target_handle })
229
+ end
230
+
231
+ def field_label(field)
232
+ field["label"].presence || field["handle"].to_s.humanize
233
+ end
234
+
235
+ def default_asset_alt_text(entry, field)
236
+ field["label"].present? ? "#{entry.title} #{field['label']}" : entry.title
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,105 @@
1
+ module Plum
2
+ module Cp
3
+ class FormDefinitionsController < BaseController
4
+ helper_method :form_fields_json
5
+
6
+ before_action :set_form_definition, only: [ :show, :edit, :update, :destroy ]
7
+ before_action :require_editor, only: [ :new, :create, :edit, :update, :destroy ]
8
+
9
+ def index
10
+ @form_definitions = current_site.form_definitions.includes(:form_submissions).order(:name)
11
+ end
12
+
13
+ def show
14
+ @submissions = @form_definition.form_submissions.order(created_at: :desc)
15
+ end
16
+
17
+ def new
18
+ @form_definition = current_site.form_definitions.build(fields: [])
19
+ end
20
+
21
+ def create
22
+ @form_definition = current_site.form_definitions.build(form_definition_params)
23
+
24
+ if assign_fields(@form_definition) && @form_definition.save
25
+ redirect_to cp_form_definition_path(@form_definition), notice: "Form created"
26
+ else
27
+ render :new, status: :unprocessable_entity
28
+ end
29
+ end
30
+
31
+ def edit
32
+ end
33
+
34
+ def update
35
+ @form_definition.assign_attributes(form_definition_params)
36
+
37
+ if assign_fields(@form_definition) && @form_definition.save
38
+ redirect_to cp_form_definition_path(@form_definition), notice: "Form updated"
39
+ else
40
+ render :edit, status: :unprocessable_entity
41
+ end
42
+ end
43
+
44
+ def destroy
45
+ @form_definition.destroy
46
+ redirect_to cp_form_definitions_path, notice: "Form deleted"
47
+ end
48
+
49
+ private
50
+
51
+ def set_form_definition
52
+ @form_definition = current_site.form_definitions.find(params[:id])
53
+ end
54
+
55
+ def form_definition_params
56
+ params.require(:form_definition).permit(:name, :handle, :notification_email)
57
+ end
58
+
59
+ def assign_fields(form_definition)
60
+ @fields_json = params.dig(:form_definition, :fields_json)
61
+ form_definition.fields = normalized_fields(@fields_json)
62
+ true
63
+ rescue JSON::ParserError
64
+ form_definition.errors.add(:fields, "must be valid JSON")
65
+ false
66
+ end
67
+
68
+ def normalized_fields(raw_json)
69
+ parsed = JSON.parse(raw_json.presence || '{"fields":[]}')
70
+ fields = parsed.is_a?(Hash) ? parsed["fields"] : parsed
71
+ Array(fields).filter_map { |field| normalized_field(field) }
72
+ end
73
+
74
+ def normalized_field(field)
75
+ return unless field.is_a?(Hash)
76
+
77
+ handle = field["handle"].to_s.strip
78
+ return if handle.blank?
79
+
80
+ {
81
+ "handle" => handle,
82
+ "type" => normalized_field_type(field["type"]),
83
+ "label" => field["label"].to_s.strip.presence || handle.humanize,
84
+ "required" => ActiveModel::Type::Boolean.new.cast(field["required"]),
85
+ "options" => normalized_options(field["options"])
86
+ }
87
+ end
88
+
89
+ def normalized_field_type(type)
90
+ type = type.to_s
91
+ FormDefinition::FIELD_TYPES.include?(type) ? type : "text"
92
+ end
93
+
94
+ def normalized_options(options)
95
+ Array(options).flat_map { |value| value.to_s.split(",") }.map(&:strip).reject(&:blank?)
96
+ end
97
+
98
+ def form_fields_json(form_definition)
99
+ return @fields_json if defined?(@fields_json) && @fields_json.present?
100
+
101
+ JSON.pretty_generate({ fields: form_definition.form_fields })
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,26 @@
1
+ module Plum
2
+ module Cp
3
+ class FormSubmissionsController < BaseController
4
+ before_action :set_form_definition
5
+ before_action :set_form_submission
6
+
7
+ def show
8
+ end
9
+
10
+ def destroy
11
+ @form_submission.destroy
12
+ redirect_to cp_form_definition_path(@form_definition), notice: "Submission deleted"
13
+ end
14
+
15
+ private
16
+
17
+ def set_form_definition
18
+ @form_definition = current_site.form_definitions.find(params[:form_definition_id])
19
+ end
20
+
21
+ def set_form_submission
22
+ @form_submission = @form_definition.form_submissions.find(params[:id])
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,84 @@
1
+ module Plum
2
+ module Cp
3
+ class GlobalsController < BaseController
4
+ helper_method :global_data_json
5
+
6
+ before_action :set_global, only: [ :show, :edit, :update, :destroy ]
7
+ before_action :require_editor, only: [ :new, :create, :edit, :update, :destroy ]
8
+
9
+ def index
10
+ @globals = current_site.globals.order(:name)
11
+ end
12
+
13
+ def show
14
+ end
15
+
16
+ def new
17
+ @global = current_site.globals.build(data: {})
18
+ end
19
+
20
+ def create
21
+ @global = current_site.globals.build(global_params)
22
+
23
+ if assign_data(@global) && @global.save
24
+ redirect_to cp_global_path(@global), notice: "Global created"
25
+ else
26
+ render :new, status: :unprocessable_entity
27
+ end
28
+ end
29
+
30
+ def edit
31
+ end
32
+
33
+ def update
34
+ @global.assign_attributes(global_params)
35
+
36
+ if assign_data(@global) && @global.save
37
+ redirect_to cp_global_path(@global), notice: "Global updated"
38
+ else
39
+ render :edit, status: :unprocessable_entity
40
+ end
41
+ end
42
+
43
+ def destroy
44
+ @global.destroy
45
+ redirect_to cp_globals_path, notice: "Global deleted"
46
+ end
47
+
48
+ private
49
+
50
+ def set_global
51
+ @global = current_site.globals.find(params[:id])
52
+ end
53
+
54
+ def global_params
55
+ params.require(:global).permit(:name, :handle)
56
+ end
57
+
58
+ def assign_data(global)
59
+ @data_json = params.dig(:global, :data_json)
60
+ global.data = parse_data_json(@data_json)
61
+ true
62
+ rescue JSON::ParserError
63
+ global.errors.add(:data, "must be valid JSON")
64
+ false
65
+ rescue TypeError
66
+ global.errors.add(:data, "must be a JSON object")
67
+ false
68
+ end
69
+
70
+ def parse_data_json(raw_json)
71
+ data = JSON.parse(raw_json.presence || "{}")
72
+ raise TypeError unless data.is_a?(Hash)
73
+
74
+ data
75
+ end
76
+
77
+ def global_data_json(global)
78
+ return @data_json if defined?(@data_json) && @data_json.present?
79
+
80
+ JSON.pretty_generate(global.data)
81
+ end
82
+ end
83
+ end
84
+ end