plum-cms 0.1.2 → 0.2.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 +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +66 -3
- data/app/assets/builds/tailwind.css +1 -1
- data/app/assets/stylesheets/plum/control_panel.css +1 -1
- data/app/assets/tailwind/application.css +42 -0
- data/app/controllers/plum/api/v1/entries_controller.rb +48 -0
- data/app/controllers/plum/cp/assets_controller.rb +4 -1
- data/app/controllers/plum/cp/content_types_controller.rb +25 -2
- data/app/controllers/plum/cp/entries_controller.rb +119 -9
- data/app/controllers/plum/cp/entry_revisions_controller.rb +27 -0
- data/app/controllers/plum/cp/fieldsets_controller.rb +37 -0
- data/app/controllers/plum/cp/site_settings_controller.rb +20 -1
- data/app/controllers/plum/pages_controller.rb +26 -1
- data/app/javascript/controllers/plum/asset_collection_controller.js +18 -0
- data/app/javascript/controllers/plum/blueprint_controller.js +202 -25
- data/app/javascript/controllers/plum/conditional_fields_controller.js +43 -0
- data/app/javascript/controllers/plum/focal_point_controller.js +16 -0
- data/app/javascript/controllers/plum/structured_field_controller.js +133 -0
- data/app/models/plum/asset.rb +5 -1
- data/app/models/plum/content_type.rb +63 -1
- data/app/models/plum/entry.rb +225 -1
- data/app/models/plum/entry_revision.rb +14 -0
- data/app/models/plum/fieldset.rb +17 -0
- data/app/models/plum/site.rb +15 -0
- data/app/models/plum/user.rb +1 -0
- data/app/services/plum/entry_serializer.rb +36 -0
- data/app/services/plum/field_expander.rb +12 -1
- data/app/services/plum/field_options.rb +21 -0
- data/app/services/plum/field_type_registry.rb +88 -0
- data/app/services/plum/liquid_context.rb +7 -1
- data/app/views/layouts/plum/cp.html.erb +2 -0
- data/app/views/plum/cp/assets/_form.html.erb +13 -1
- data/app/views/plum/cp/content_types/_form.html.erb +81 -24
- data/app/views/plum/cp/custom_fields/_input.html.erb +5 -0
- data/app/views/plum/cp/entries/_form.html.erb +108 -31
- data/app/views/plum/cp/entries/edit.html.erb +16 -0
- data/app/views/plum/cp/entry_revisions/index.html.erb +25 -0
- data/app/views/plum/cp/fieldsets/index.html.erb +30 -0
- data/app/views/plum/cp/site_settings/edit.html.erb +12 -0
- data/config/plum_routes.rb +15 -0
- data/db/engine_migrate/20260807130000_create_plum_entry_revisions.rb +16 -0
- data/db/engine_migrate/20260807140000_create_plum_fieldsets.rb +13 -0
- data/db/engine_migrate/20260807150000_add_focal_point_to_plum_assets.rb +6 -0
- data/db/engine_migrate/20260807160000_add_localization_to_plum_entries.rb +9 -0
- data/docs/blueprint-fields.md +55 -0
- data/docs/extensions.md +32 -0
- data/docs/product-principles.md +73 -0
- data/docs/roadmap.md +98 -0
- data/docs/site/homepage.md +104 -0
- data/docs/site/information-architecture.md +176 -0
- data/docs/statamic-parity.md +28 -0
- data/docs/vision.md +82 -0
- data/lib/plum/version.rb +1 -1
- data/lib/plum.rb +4 -0
- data/lib/tasks/plum_styles.rake +12 -0
- metadata +31 -6
data/app/models/plum/entry.rb
CHANGED
|
@@ -4,9 +4,12 @@ module Plum
|
|
|
4
4
|
|
|
5
5
|
belongs_to :content_type
|
|
6
6
|
belongs_to :author, class_name: "Plum::User", optional: true
|
|
7
|
+
belongs_to :origin, class_name: "Plum::Entry", optional: true
|
|
7
8
|
|
|
8
9
|
has_many :entry_terms, dependent: :destroy
|
|
9
10
|
has_many :terms, through: :entry_terms
|
|
11
|
+
has_many :revisions, class_name: "Plum::EntryRevision", dependent: :destroy
|
|
12
|
+
has_many :translations, class_name: "Plum::Entry", foreign_key: :origin_id, dependent: :destroy
|
|
10
13
|
|
|
11
14
|
# The page served at "/" — Plum resolves the homepage by this slug
|
|
12
15
|
# (convention over configuration). Its slug is locked and it can't be
|
|
@@ -17,11 +20,16 @@ module Plum
|
|
|
17
20
|
enum :status, { draft: 0, published: 1, scheduled: 2 }
|
|
18
21
|
|
|
19
22
|
validates :title, presence: true
|
|
20
|
-
validates :slug, presence: true, uniqueness: { scope: :site_id }, format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ }
|
|
23
|
+
validates :slug, presence: true, uniqueness: { scope: [ :site_id, :locale ] }, format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ }
|
|
24
|
+
validates :locale, presence: true, format: { with: /\A[a-z]{2}(?:-[A-Z]{2})?\z/ }
|
|
25
|
+
validate :origin_matches_site_and_content_type
|
|
21
26
|
validates :status, presence: true
|
|
22
27
|
validate :homepage_slug_is_unchanged
|
|
28
|
+
validate :required_blueprint_fields_are_present
|
|
29
|
+
validate :blueprint_field_values_are_valid
|
|
23
30
|
|
|
24
31
|
before_validation :generate_slug
|
|
32
|
+
before_validation :set_default_locale, on: :create
|
|
25
33
|
before_validation :set_published_at, if: :published?
|
|
26
34
|
before_destroy :prevent_homepage_destroy
|
|
27
35
|
|
|
@@ -41,8 +49,66 @@ module Plum
|
|
|
41
49
|
slug == HOMEPAGE_SLUG
|
|
42
50
|
end
|
|
43
51
|
|
|
52
|
+
def record_revision!(editor: nil)
|
|
53
|
+
identity = revision_editor_identity(editor)
|
|
54
|
+
attributes = {
|
|
55
|
+
site: site,
|
|
56
|
+
editor_name: identity[:name],
|
|
57
|
+
editor_email: identity[:email],
|
|
58
|
+
editor_gid: identity[:gid],
|
|
59
|
+
snapshot: {
|
|
60
|
+
"title" => title,
|
|
61
|
+
"slug" => slug,
|
|
62
|
+
"status" => status,
|
|
63
|
+
"published_at" => published_at&.iso8601,
|
|
64
|
+
"data" => data.to_h.deep_dup,
|
|
65
|
+
"term_ids" => term_ids
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
attributes[:editor] = editor if editor.is_a?(Plum::User)
|
|
69
|
+
revisions.create!(attributes)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def restore_revision!(revision, editor: nil)
|
|
73
|
+
raise ArgumentError, "Revision does not belong to this entry" unless revision.entry_id == id && revision.site_id == site_id
|
|
74
|
+
|
|
75
|
+
snapshot = revision.snapshot.to_h
|
|
76
|
+
transaction do
|
|
77
|
+
update!(snapshot.slice("title", "slug", "status", "published_at", "data"))
|
|
78
|
+
self.term_ids = site.terms.where(id: Array(snapshot["term_ids"])).pluck(:id)
|
|
79
|
+
record_revision!(editor: editor)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def translation_group
|
|
84
|
+
source = origin || self
|
|
85
|
+
[ source, *source.translations ].uniq.sort_by(&:locale)
|
|
86
|
+
end
|
|
87
|
+
|
|
44
88
|
private
|
|
45
89
|
|
|
90
|
+
def set_default_locale
|
|
91
|
+
self.locale = site&.default_locale if locale.blank?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def origin_matches_site_and_content_type
|
|
95
|
+
return unless origin
|
|
96
|
+
|
|
97
|
+
errors.add(:origin, "must belong to the same site") if origin.site_id != site_id
|
|
98
|
+
errors.add(:origin, "must use the same content type") if origin.content_type_id != content_type_id
|
|
99
|
+
errors.add(:origin, "cannot itself be a translation") if origin.origin_id.present?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def revision_editor_identity(editor)
|
|
103
|
+
return {} unless editor
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
name: editor.respond_to?(:name) ? editor.name : nil,
|
|
107
|
+
email: editor.respond_to?(:email) ? editor.email : nil,
|
|
108
|
+
gid: editor.respond_to?(:to_global_id) ? editor.to_global_id.to_s : nil
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
46
112
|
def generate_slug
|
|
47
113
|
self.slug = title&.parameterize if slug.blank?
|
|
48
114
|
end
|
|
@@ -53,6 +119,164 @@ module Plum
|
|
|
53
119
|
errors.add(:slug, "can't be changed — this is the homepage")
|
|
54
120
|
end
|
|
55
121
|
|
|
122
|
+
def required_blueprint_fields_are_present
|
|
123
|
+
content_type&.fields.to_a.each do |field|
|
|
124
|
+
next if field["type"] == "section"
|
|
125
|
+
next unless blueprint_field_visible?(field)
|
|
126
|
+
|
|
127
|
+
handle = field["handle"].to_s
|
|
128
|
+
value = if field["type"] == "taxonomy"
|
|
129
|
+
terms.joins(:taxonomy).where(plum_taxonomies: { handle: field["taxonomy"].to_s }).exists?
|
|
130
|
+
else
|
|
131
|
+
data&.dig(handle)
|
|
132
|
+
end
|
|
133
|
+
value = parsed_structured_validation_value(value) if %w[group repeater].include?(field["type"])
|
|
134
|
+
|
|
135
|
+
validate_required_value(field, value, field["label"].presence || handle.titleize)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parsed_structured_validation_value(value)
|
|
140
|
+
return value unless value.is_a?(String)
|
|
141
|
+
|
|
142
|
+
JSON.parse(value)
|
|
143
|
+
rescue JSON::ParserError
|
|
144
|
+
value
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def validate_required_value(field, value, path)
|
|
148
|
+
if ActiveModel::Type::Boolean.new.cast(field["required"]) && value != false && value.blank?
|
|
149
|
+
errors.add(:data, "#{path} is required")
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
case field["type"]
|
|
154
|
+
when "group"
|
|
155
|
+
values = value.respond_to?(:to_h) ? value.to_h : {}
|
|
156
|
+
validate_required_nested_fields(field, values, path)
|
|
157
|
+
when "repeater"
|
|
158
|
+
Array(value).each_with_index do |row, index|
|
|
159
|
+
values = row.respond_to?(:to_h) ? row.to_h : {}
|
|
160
|
+
validate_required_nested_fields(field, values, "#{path} row #{index + 1}")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def validate_required_nested_fields(field, values, path)
|
|
166
|
+
Array(field["fields"]).each do |nested_field|
|
|
167
|
+
handle = nested_field["handle"].to_s
|
|
168
|
+
validate_required_value(nested_field, values[handle], "#{path} #{nested_field['label'].presence || handle.titleize}")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def blueprint_field_values_are_valid
|
|
173
|
+
content_type&.fields.to_a.each do |field|
|
|
174
|
+
next if field["type"] == "section"
|
|
175
|
+
next unless blueprint_field_visible?(field)
|
|
176
|
+
|
|
177
|
+
value = data&.dig(field["handle"].to_s)
|
|
178
|
+
value = parsed_structured_validation_value(value) if %w[list repeater].include?(field["type"])
|
|
179
|
+
label = field["label"].presence || field["handle"].to_s.titleize
|
|
180
|
+
|
|
181
|
+
case field["type"]
|
|
182
|
+
when "list", "repeater", "images"
|
|
183
|
+
validate_collection_constraints(field, Array(value), label)
|
|
184
|
+
when "relationship"
|
|
185
|
+
validate_collection_constraints(field, Array(value), label) if ActiveModel::Type::Boolean.new.cast(field["multiple"])
|
|
186
|
+
when "number"
|
|
187
|
+
validate_number_constraints(field, value, label)
|
|
188
|
+
when "date"
|
|
189
|
+
validate_date_constraints(field, value, label)
|
|
190
|
+
when "select", "radio", "button_group", "checkboxes"
|
|
191
|
+
validate_option_values(field, value, label)
|
|
192
|
+
else
|
|
193
|
+
validate_custom_field(definition: FieldTypeRegistry.find(field["type"]), field: field, value: value, label: label)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def validate_custom_field(definition:, field:, value:, label:)
|
|
199
|
+
return unless definition&.validator
|
|
200
|
+
|
|
201
|
+
messages = definition.validator.call(value: value, field: field, entry: self)
|
|
202
|
+
Array(messages).select(&:present?).each { |message| errors.add(:data, "#{label} #{message}") }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def blueprint_field_visible?(field)
|
|
206
|
+
condition = field["condition"]
|
|
207
|
+
return true if condition.blank?
|
|
208
|
+
|
|
209
|
+
values = Array(data&.dig(condition["field"].to_s)).map(&:to_s)
|
|
210
|
+
expected = condition["value"].to_s
|
|
211
|
+
case condition["operator"]
|
|
212
|
+
when "equals" then values.include?(expected)
|
|
213
|
+
when "not_equals" then !values.include?(expected)
|
|
214
|
+
when "contains" then values.any? { |value| value.include?(expected) }
|
|
215
|
+
when "empty" then values.empty? || values.all?(&:blank?)
|
|
216
|
+
when "not_empty" then values.any?(&:present?)
|
|
217
|
+
else true
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def validate_option_values(field, value, label)
|
|
222
|
+
return if value.blank?
|
|
223
|
+
|
|
224
|
+
allowed = FieldOptions.pairs(field["options"]).map(&:last)
|
|
225
|
+
invalid = Array(value).map(&:to_s) - allowed
|
|
226
|
+
errors.add(:data, "#{label} contains an invalid option") if invalid.any?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def validate_collection_constraints(field, values, label)
|
|
230
|
+
minimum = field["min_items"].presence&.to_i
|
|
231
|
+
maximum = field["max_items"].presence&.to_i
|
|
232
|
+
errors.add(:data, "#{label} must have at least #{minimum} items") if minimum && values.length < minimum
|
|
233
|
+
errors.add(:data, "#{label} must have no more than #{maximum} items") if maximum && values.length > maximum
|
|
234
|
+
if field["type"] == "list" && ActiveModel::Type::Boolean.new.cast(field["unique"])
|
|
235
|
+
normalized = values.map { |value| value.to_s.strip.downcase }.reject(&:blank?)
|
|
236
|
+
errors.add(:data, "#{label} values must be unique") if normalized.uniq.length != normalized.length
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_number_constraints(field, value, label)
|
|
241
|
+
return if value.blank?
|
|
242
|
+
|
|
243
|
+
number = BigDecimal(value.to_s)
|
|
244
|
+
if field["number_kind"] == "integer" && number.frac.nonzero?
|
|
245
|
+
errors.add(:data, "#{label} must be a whole number")
|
|
246
|
+
end
|
|
247
|
+
errors.add(:data, "#{label} must be at least #{field['min']}") if field["min"].present? && number < BigDecimal(field["min"].to_s)
|
|
248
|
+
errors.add(:data, "#{label} must be no more than #{field['max']}") if field["max"].present? && number > BigDecimal(field["max"].to_s)
|
|
249
|
+
if field["step"].present? && BigDecimal(field["step"].to_s).positive?
|
|
250
|
+
base = field["min"].present? ? BigDecimal(field["min"].to_s) : 0
|
|
251
|
+
errors.add(:data, "#{label} does not match the required step") unless ((number - base) % BigDecimal(field["step"].to_s)).zero?
|
|
252
|
+
end
|
|
253
|
+
rescue ArgumentError
|
|
254
|
+
errors.add(:data, "#{label} must be a number")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def validate_date_constraints(field, value, label)
|
|
258
|
+
return if value.blank?
|
|
259
|
+
|
|
260
|
+
parsed = parsed_temporal_value(value, field["date_mode"])
|
|
261
|
+
minimum = parsed_temporal_value(field["min"], field["date_mode"]) if field["min"].present?
|
|
262
|
+
maximum = parsed_temporal_value(field["max"], field["date_mode"]) if field["max"].present?
|
|
263
|
+
errors.add(:data, "#{label} must be on or after #{field['min']}") if minimum && parsed < minimum
|
|
264
|
+
errors.add(:data, "#{label} must be on or before #{field['max']}") if maximum && parsed > maximum
|
|
265
|
+
rescue ArgumentError
|
|
266
|
+
errors.add(:data, "#{label} is not a valid #{field['date_mode'].presence || 'date'}")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def parsed_temporal_value(value, mode)
|
|
270
|
+
case mode
|
|
271
|
+
when "time"
|
|
272
|
+
Time.strptime(value.to_s, "%H:%M")
|
|
273
|
+
when "datetime"
|
|
274
|
+
Time.zone.parse(value.to_s) || raise(ArgumentError)
|
|
275
|
+
else
|
|
276
|
+
Date.iso8601(value.to_s)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
56
280
|
def prevent_homepage_destroy
|
|
57
281
|
return unless homepage?
|
|
58
282
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
class EntryRevision < ApplicationRecord
|
|
3
|
+
include SiteScoped
|
|
4
|
+
|
|
5
|
+
belongs_to :entry
|
|
6
|
+
belongs_to :editor, class_name: "Plum::User", optional: true
|
|
7
|
+
|
|
8
|
+
validates :snapshot, presence: true
|
|
9
|
+
|
|
10
|
+
def editor_label
|
|
11
|
+
editor_name.presence || editor_email.presence || editor&.email.presence || "Unknown editor"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
class Fieldset < ApplicationRecord
|
|
3
|
+
include SiteScoped
|
|
4
|
+
|
|
5
|
+
validates :name, presence: true
|
|
6
|
+
validates :handle, presence: true, uniqueness: { scope: :site_id }, format: { with: /\A[a-z][a-z0-9_]*\z/ }
|
|
7
|
+
validates :fields, presence: true
|
|
8
|
+
|
|
9
|
+
before_validation :generate_handle, on: :create
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def generate_handle
|
|
14
|
+
self.handle = name&.parameterize(separator: "_") if handle.blank?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/app/models/plum/site.rb
CHANGED
|
@@ -13,6 +13,7 @@ module Plum
|
|
|
13
13
|
has_many :form_submissions, dependent: :destroy
|
|
14
14
|
has_many :taxonomies, dependent: :destroy
|
|
15
15
|
has_many :terms, dependent: :destroy
|
|
16
|
+
has_many :fieldsets, dependent: :destroy
|
|
16
17
|
|
|
17
18
|
attribute :skip_defaults, :boolean, default: false
|
|
18
19
|
|
|
@@ -51,6 +52,20 @@ module Plum
|
|
|
51
52
|
super || {}
|
|
52
53
|
end
|
|
53
54
|
|
|
55
|
+
def locales
|
|
56
|
+
configured = Array(settings.to_h["locales"]).map(&:to_s).select { |locale| locale.match?(/\A[a-z]{2}(?:-[A-Z]{2})?\z/) }
|
|
57
|
+
configured.presence || [ "en" ]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def default_locale
|
|
61
|
+
configured = settings.to_h["default_locale"].to_s
|
|
62
|
+
locales.include?(configured) ? configured : locales.first
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def localized?
|
|
66
|
+
locales.many?
|
|
67
|
+
end
|
|
68
|
+
|
|
54
69
|
private
|
|
55
70
|
|
|
56
71
|
def self.owner_default_name(owner)
|
data/app/models/plum/user.rb
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
class EntrySerializer
|
|
3
|
+
def initialize(site:)
|
|
4
|
+
@site = site
|
|
5
|
+
@expander = FieldExpander.new(site: site, url_builder: method(:entry_path))
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def as_json(entry)
|
|
9
|
+
{
|
|
10
|
+
"id" => entry.id,
|
|
11
|
+
"title" => entry.title,
|
|
12
|
+
"slug" => entry.slug,
|
|
13
|
+
"locale" => entry.locale,
|
|
14
|
+
"url" => entry_path(entry),
|
|
15
|
+
"status" => entry.status,
|
|
16
|
+
"published_at" => entry.published_at&.iso8601,
|
|
17
|
+
"updated_at" => entry.updated_at&.iso8601,
|
|
18
|
+
"collection" => {
|
|
19
|
+
"handle" => entry.content_type.handle,
|
|
20
|
+
"title" => entry.content_type.name
|
|
21
|
+
},
|
|
22
|
+
"data" => @expander.expand(values: entry.data, fields: entry.content_type.fields),
|
|
23
|
+
"terms" => entry.terms.group_by { |term| term.taxonomy.handle }.transform_values do |terms|
|
|
24
|
+
terms.map { |term| { "name" => term.name, "slug" => term.slug } }
|
|
25
|
+
end
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def entry_path(entry)
|
|
32
|
+
locale_prefix = entry.locale == @site.default_locale ? nil : entry.locale
|
|
33
|
+
"/#{[ locale_prefix, entry.content_type.route_prefix, entry.slug ].compact.join('/')}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -24,8 +24,19 @@ module Plum
|
|
|
24
24
|
case field["type"]
|
|
25
25
|
when "image"
|
|
26
26
|
data[handle] = image_asset_context(data[handle])
|
|
27
|
+
when "images"
|
|
28
|
+
data[handle] = Array(data[handle]).filter_map { |value| image_asset_context(value) }
|
|
27
29
|
when "relationship"
|
|
28
|
-
data[handle] =
|
|
30
|
+
data[handle] = if ActiveModel::Type::Boolean.new.cast(field["multiple"])
|
|
31
|
+
Array(data[handle]).filter_map { |value| relationship_entry_context(value, relationship_depth: relationship_depth) }
|
|
32
|
+
else
|
|
33
|
+
relationship_entry_context(data[handle], relationship_depth: relationship_depth)
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
definition = FieldTypeRegistry.find(field["type"])
|
|
37
|
+
if definition&.expander
|
|
38
|
+
data[handle] = definition.expander.call(value: data[handle], field: field, site: site, expander: self)
|
|
39
|
+
end
|
|
29
40
|
end
|
|
30
41
|
end
|
|
31
42
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
module FieldOptions
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def pairs(options)
|
|
6
|
+
Array(options).filter_map do |option|
|
|
7
|
+
if option.is_a?(Hash)
|
|
8
|
+
value = option["value"] || option[:value]
|
|
9
|
+
label = option["label"] || option[:label] || value
|
|
10
|
+
[ label.to_s, value.to_s ] if value.present?
|
|
11
|
+
elsif option.present?
|
|
12
|
+
[ option.to_s, option.to_s ]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def editor_value(options)
|
|
18
|
+
pairs(options).map { |label, value| label == value ? label : "#{label} | #{value}" }.join("\n")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
class FieldTypeRegistry
|
|
3
|
+
Definition = Struct.new(
|
|
4
|
+
:handle, :label, :configuration, :partial, :normalizer, :validator, :expander,
|
|
5
|
+
keyword_init: true
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
COMMON_CONFIGURATION = %w[label instructions required default placeholder].freeze
|
|
9
|
+
NESTED_FIELD_TYPES = %w[text textarea number boolean date url].freeze
|
|
10
|
+
|
|
11
|
+
DEFINITIONS = [
|
|
12
|
+
Definition.new(handle: "text", label: "Text", configuration: []),
|
|
13
|
+
Definition.new(handle: "textarea", label: "Textarea", configuration: []),
|
|
14
|
+
Definition.new(handle: "rich_text", label: "Rich Text", configuration: []),
|
|
15
|
+
Definition.new(handle: "number", label: "Number", configuration: %w[number_kind min max step unit]),
|
|
16
|
+
Definition.new(handle: "boolean", label: "Boolean", configuration: []),
|
|
17
|
+
Definition.new(handle: "date", label: "Date / Time", configuration: %w[date_mode min max]),
|
|
18
|
+
Definition.new(handle: "select", label: "Select", configuration: %w[options]),
|
|
19
|
+
Definition.new(handle: "radio", label: "Radio", configuration: %w[options]),
|
|
20
|
+
Definition.new(handle: "button_group", label: "Button Group", configuration: %w[options]),
|
|
21
|
+
Definition.new(handle: "checkboxes", label: "Checkboxes", configuration: %w[options]),
|
|
22
|
+
Definition.new(handle: "color", label: "Color", configuration: []),
|
|
23
|
+
Definition.new(handle: "url", label: "URL", configuration: []),
|
|
24
|
+
Definition.new(handle: "taxonomy", label: "Taxonomy", configuration: %w[taxonomy]),
|
|
25
|
+
Definition.new(handle: "image", label: "Image", configuration: []),
|
|
26
|
+
Definition.new(handle: "images", label: "Images", configuration: %w[min_items max_items]),
|
|
27
|
+
Definition.new(handle: "relationship", label: "Relationship", configuration: %w[content_type multiple min_items max_items]),
|
|
28
|
+
Definition.new(handle: "blocks", label: "Blocks", configuration: %w[blocks]),
|
|
29
|
+
Definition.new(handle: "list", label: "List", configuration: %w[min_items max_items unique]),
|
|
30
|
+
Definition.new(handle: "group", label: "Group", configuration: %w[fields]),
|
|
31
|
+
Definition.new(handle: "repeater", label: "Repeater", configuration: %w[fields min_items max_items]),
|
|
32
|
+
Definition.new(handle: "section", label: "Section", configuration: [])
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
def all
|
|
37
|
+
DEFINITIONS + custom_definitions.values
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def handles
|
|
41
|
+
all.map(&:handle)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find(handle)
|
|
45
|
+
all.find { |definition| definition.handle == handle.to_s }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def include?(handle)
|
|
49
|
+
find(handle).present?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def options
|
|
53
|
+
all.map { |definition| [ definition.label, definition.handle ] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def as_json
|
|
57
|
+
all.map { |definition| { handle: definition.handle, label: definition.label } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def register(handle:, label:, configuration: [], partial: nil, normalizer: nil, validator: nil, expander: nil)
|
|
61
|
+
normalized_handle = handle.to_s
|
|
62
|
+
raise ArgumentError, "Field type handle is invalid" unless normalized_handle.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
63
|
+
raise ArgumentError, "Field type #{normalized_handle} is already registered" if include?(normalized_handle)
|
|
64
|
+
raise ArgumentError, "Custom field types require an editor partial" if partial.blank?
|
|
65
|
+
|
|
66
|
+
custom_definitions[normalized_handle] = Definition.new(
|
|
67
|
+
handle: normalized_handle,
|
|
68
|
+
label: label.to_s.presence || normalized_handle.titleize,
|
|
69
|
+
configuration: Array(configuration).map(&:to_s),
|
|
70
|
+
partial: partial.to_s,
|
|
71
|
+
normalizer: normalizer,
|
|
72
|
+
validator: validator,
|
|
73
|
+
expander: expander
|
|
74
|
+
).freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def reset_custom!
|
|
78
|
+
@custom_definitions = {}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def custom_definitions
|
|
84
|
+
@custom_definitions ||= {}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -306,6 +306,7 @@ module Plum
|
|
|
306
306
|
|
|
307
307
|
def live_entries_by_type
|
|
308
308
|
@live_entries_by_type ||= Entry.for_site(site).live
|
|
309
|
+
.where(locale: current_locale)
|
|
309
310
|
.includes(:content_type, terms: :taxonomy)
|
|
310
311
|
.order(published_at: :desc, created_at: :desc)
|
|
311
312
|
.group_by(&:content_type_id)
|
|
@@ -320,10 +321,15 @@ module Plum
|
|
|
320
321
|
end
|
|
321
322
|
|
|
322
323
|
def public_entry_path(entry)
|
|
323
|
-
|
|
324
|
+
locale_prefix = entry.locale == site.default_locale ? nil : entry.locale
|
|
325
|
+
path = [ locale_prefix, entry.content_type.route_prefix, entry.slug ].compact.join("/")
|
|
324
326
|
"#{controller.request.script_name.to_s.chomp("/")}/#{path}"
|
|
325
327
|
end
|
|
326
328
|
|
|
329
|
+
def current_locale
|
|
330
|
+
entry&.locale.presence || controller.params[:locale].presence || site.default_locale
|
|
331
|
+
end
|
|
332
|
+
|
|
327
333
|
def public_form_path(form)
|
|
328
334
|
"#{controller.request.script_name.to_s.chomp("/")}/forms/#{form.handle}"
|
|
329
335
|
end
|
|
@@ -78,6 +78,8 @@
|
|
|
78
78
|
<% end %>
|
|
79
79
|
<%= link_to "+ New Type", new_cp_content_type_path,
|
|
80
80
|
class: "flex items-center px-4 py-2 mt-2 text-sm font-medium plum-sidebar-link" %>
|
|
81
|
+
<%= link_to "Fieldsets", cp_fieldsets_path,
|
|
82
|
+
class: "flex items-center px-4 py-2 mt-2 text-sm font-medium rounded-md plum-sidebar-link #{request.path.include?('/cp/fieldsets') ? 'active' : ''}" %>
|
|
81
83
|
<%= link_to "Assets", cp_assets_path,
|
|
82
84
|
class: "flex items-center px-4 py-2 mt-2 text-sm font-medium rounded-md plum-sidebar-link #{request.path.include?('/cp/assets') ? 'active' : ''}" %>
|
|
83
85
|
<%= link_to "Globals", cp_globals_path,
|
|
@@ -9,12 +9,24 @@
|
|
|
9
9
|
</div>
|
|
10
10
|
<% end %>
|
|
11
11
|
|
|
12
|
+
<div data-controller="plum--focal-point">
|
|
12
13
|
<% if asset.persisted? && plum_asset_image_url(asset).present? %>
|
|
13
14
|
<div class="overflow-hidden rounded-lg border border-gray-200 bg-gray-100">
|
|
14
|
-
<%= image_tag plum_asset_image_url(asset), alt: asset.alt_text.to_s, class: "h-44 w-full object-cover" %>
|
|
15
|
+
<%= image_tag plum_asset_image_url(asset), alt: asset.alt_text.to_s, class: "h-44 w-full object-cover", data: { "plum--focal-point-target": "preview" } %>
|
|
15
16
|
</div>
|
|
16
17
|
<% end %>
|
|
17
18
|
|
|
19
|
+
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
|
20
|
+
<label class="text-sm font-medium text-gray-700">Horizontal focal point
|
|
21
|
+
<%= f.range_field :focal_x, min: 0, max: 100, data: { "plum--focal-point-target": "x", action: "input->plum--focal-point#refresh" }, class: "mt-2 block w-full" %>
|
|
22
|
+
</label>
|
|
23
|
+
<label class="text-sm font-medium text-gray-700">Vertical focal point
|
|
24
|
+
<%= f.range_field :focal_y, min: 0, max: 100, data: { "plum--focal-point-target": "y", action: "input->plum--focal-point#refresh" }, class: "mt-2 block w-full" %>
|
|
25
|
+
</label>
|
|
26
|
+
</div>
|
|
27
|
+
<p class="mt-2 text-xs text-gray-500">Crop focus: <span data-plum--focal-point-target="label"></span></p>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
18
30
|
<div>
|
|
19
31
|
<%= f.label :file, asset.persisted? ? "Replace image" : "Image", class: "block text-sm font-medium text-gray-700" %>
|
|
20
32
|
<%= f.file_field :file, accept: "image/*", class: "mt-1 block w-full text-sm text-gray-700" %>
|