plum-cms 0.1.2 → 0.2.1
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 +51 -0
- data/README.md +81 -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/services/plum/site_archive.rb +367 -0
- 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/portability.md +40 -0
- data/docs/product-principles.md +73 -0
- data/docs/roadmap.md +100 -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/engine.rb +3 -1
- data/lib/plum/version.rb +1 -1
- data/lib/plum.rb +4 -0
- data/lib/tasks/plum_portability.rake +37 -0
- data/lib/tasks/plum_styles.rake +12 -0
- metadata +36 -8
|
@@ -2,6 +2,7 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
|
|
3
3
|
export default class extends Controller {
|
|
4
4
|
static targets = ["fields", "input"]
|
|
5
|
+
static values = { types: Array, nestedTypes: Array, applyFieldsetUrl: String }
|
|
5
6
|
|
|
6
7
|
connect() {
|
|
7
8
|
this.syncFieldConfigVisibility()
|
|
@@ -12,26 +13,17 @@ export default class extends Controller {
|
|
|
12
13
|
event.preventDefault()
|
|
13
14
|
|
|
14
15
|
const fieldHtml = `
|
|
15
|
-
<div class="
|
|
16
|
+
<div class="plum-blueprint-field" data-plum--blueprint-target="field">
|
|
17
|
+
<div class="plum-blueprint-field-header col-span-full">
|
|
18
|
+
<p class="plum-blueprint-field-title" data-field-summary>Untitled field<span class="plum-blueprint-field-type">text</span></p>
|
|
19
|
+
<button type="button" data-action="plum--blueprint#removeField" class="text-sm font-medium text-red-600 hover:text-red-700">Remove</button>
|
|
20
|
+
</div>
|
|
16
21
|
<input type="text" placeholder="handle" data-field="handle"
|
|
17
22
|
data-action="input->plum--blueprint#inputChanged"
|
|
18
23
|
class="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 font-mono text-sm">
|
|
19
24
|
<select data-field="type" data-action="change->plum--blueprint#inputChanged"
|
|
20
25
|
class="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 text-sm">
|
|
21
|
-
|
|
22
|
-
<option value="textarea">Textarea</option>
|
|
23
|
-
<option value="rich_text">Rich Text</option>
|
|
24
|
-
<option value="number">Number</option>
|
|
25
|
-
<option value="boolean">Boolean</option>
|
|
26
|
-
<option value="date">Date</option>
|
|
27
|
-
<option value="select">Select</option>
|
|
28
|
-
<option value="checkboxes">Checkboxes</option>
|
|
29
|
-
<option value="color">Color</option>
|
|
30
|
-
<option value="url">URL</option>
|
|
31
|
-
<option value="taxonomy">Taxonomy</option>
|
|
32
|
-
<option value="image">Image</option>
|
|
33
|
-
<option value="relationship">Relationship</option>
|
|
34
|
-
<option value="blocks">Blocks</option>
|
|
26
|
+
${this.typesValue.map(type => `<option value="${type.handle}">${type.label}</option>`).join("")}
|
|
35
27
|
</select>
|
|
36
28
|
<input type="text" placeholder="Label" data-field="label"
|
|
37
29
|
data-action="input->plum--blueprint#inputChanged"
|
|
@@ -39,17 +31,27 @@ export default class extends Controller {
|
|
|
39
31
|
<input type="text" placeholder="Related type handle" data-field="content_type" data-field-config="relationship"
|
|
40
32
|
data-action="input->plum--blueprint#inputChanged"
|
|
41
33
|
class="hidden px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 font-mono text-sm">
|
|
34
|
+
<label class="hidden flex items-center gap-2 text-sm text-gray-700" data-field-config="relationship"><input type="checkbox" data-field="multiple" data-action="change->plum--blueprint#inputChanged"> Allow multiple entries</label>
|
|
42
35
|
<input type="text" placeholder="Taxonomy handle" data-field="taxonomy" data-field-config="taxonomy"
|
|
43
36
|
data-action="input->plum--blueprint#inputChanged"
|
|
44
37
|
class="hidden px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 font-mono text-sm">
|
|
45
|
-
<
|
|
38
|
+
<textarea rows="4" placeholder="Published | published Draft | draft" data-field="options" data-field-config="select radio button_group checkboxes"
|
|
46
39
|
data-action="input->plum--blueprint#inputChanged"
|
|
47
|
-
class="hidden px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 text-sm">
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
40
|
+
class="hidden col-span-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500 font-mono text-sm"></textarea>
|
|
41
|
+
${this.nestedEditorHtml()}
|
|
42
|
+
${this.typeConstraintsHtml()}
|
|
43
|
+
<div class="col-span-full grid gap-3 border-t border-gray-200 pt-3 sm:grid-cols-3">
|
|
44
|
+
<input type="text" placeholder="Instructions" data-field="instructions" data-action="input->plum--blueprint#inputChanged"
|
|
45
|
+
class="px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm">
|
|
46
|
+
<input type="text" placeholder="Placeholder" data-field="placeholder" data-action="input->plum--blueprint#inputChanged"
|
|
47
|
+
class="px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm">
|
|
48
|
+
<input type="text" placeholder="Default value" data-field="default" data-action="input->plum--blueprint#inputChanged"
|
|
49
|
+
class="px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm">
|
|
50
|
+
<label class="flex items-center gap-2 text-sm text-gray-700">
|
|
51
|
+
<input type="checkbox" data-field="required" data-action="change->plum--blueprint#inputChanged"> Required
|
|
52
|
+
</label>
|
|
53
|
+
</div>
|
|
54
|
+
${this.presentationHtml()}
|
|
53
55
|
</div>
|
|
54
56
|
`
|
|
55
57
|
|
|
@@ -65,6 +67,63 @@ export default class extends Controller {
|
|
|
65
67
|
this.updateHiddenField()
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
async applyFieldset(event) {
|
|
71
|
+
event.preventDefault()
|
|
72
|
+
const picker = this.element.querySelector("[data-fieldset-picker]")
|
|
73
|
+
const status = this.element.querySelector("[data-fieldset-status]")
|
|
74
|
+
if (!picker?.value || !this.hasApplyFieldsetUrlValue) return
|
|
75
|
+
|
|
76
|
+
event.currentTarget.disabled = true
|
|
77
|
+
if (status) {
|
|
78
|
+
status.textContent = "Inserting…"
|
|
79
|
+
status.className = "text-sm text-gray-600"
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetch(this.applyFieldsetUrlValue, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: {
|
|
85
|
+
Accept: "application/json",
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content || ""
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({ fieldset_id: picker.value })
|
|
90
|
+
})
|
|
91
|
+
const payload = await response.json()
|
|
92
|
+
if (!response.ok) throw new Error(payload.error || "Could not insert fieldset")
|
|
93
|
+
window.location.assign(payload.redirect_url)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
event.currentTarget.disabled = false
|
|
96
|
+
if (status) {
|
|
97
|
+
status.textContent = error.message
|
|
98
|
+
status.className = "text-sm text-red-600"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
addNestedField(event) {
|
|
104
|
+
event.preventDefault()
|
|
105
|
+
const editor = event.currentTarget.closest("[data-nested-editor]")
|
|
106
|
+
editor.querySelector("[data-nested-fields]").insertAdjacentHTML("beforeend", this.nestedFieldHtml())
|
|
107
|
+
this.updateHiddenField()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
removeNestedField(event) {
|
|
111
|
+
event.preventDefault()
|
|
112
|
+
event.currentTarget.closest("[data-nested-field]").remove()
|
|
113
|
+
this.updateHiddenField()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
moveNestedField(event) {
|
|
117
|
+
event.preventDefault()
|
|
118
|
+
const row = event.currentTarget.closest("[data-nested-field]")
|
|
119
|
+
const direction = Number(event.currentTarget.dataset.direction)
|
|
120
|
+
const sibling = direction < 0 ? row.previousElementSibling : row.nextElementSibling
|
|
121
|
+
if (!sibling) return
|
|
122
|
+
if (direction < 0) row.parentElement.insertBefore(row, sibling)
|
|
123
|
+
else row.parentElement.insertBefore(sibling, row)
|
|
124
|
+
this.updateHiddenField()
|
|
125
|
+
}
|
|
126
|
+
|
|
68
127
|
updateHiddenField() {
|
|
69
128
|
const fields = []
|
|
70
129
|
const fieldElements = this.fieldsTarget.querySelectorAll('[data-plum--blueprint-target="field"]')
|
|
@@ -78,6 +137,21 @@ export default class extends Controller {
|
|
|
78
137
|
if (handle) {
|
|
79
138
|
const field = { handle, type, label }
|
|
80
139
|
|
|
140
|
+
;["instructions", "placeholder", "default", "number_kind", "step", "unit", "date_mode", "min_items", "max_items", "width"].forEach(key => {
|
|
141
|
+
const value = fieldEl.querySelector(`[data-field="${key}"]`)?.value?.trim()
|
|
142
|
+
if (value) field[key] = value
|
|
143
|
+
})
|
|
144
|
+
if (fieldEl.querySelector('[data-field="required"]')?.checked) field.required = true
|
|
145
|
+
if (type === "relationship" && fieldEl.querySelector('[data-field="multiple"]')?.checked) field.multiple = true
|
|
146
|
+
if (type === "list" && fieldEl.querySelector('[data-field="unique"]')?.checked) field.unique = true
|
|
147
|
+
if (type === "number" || type === "date") {
|
|
148
|
+
const constraints = fieldEl.querySelector(`[data-field-config="${type}"]`)
|
|
149
|
+
;["min", "max"].forEach(key => {
|
|
150
|
+
const value = constraints?.querySelector(`[data-field="${key}"]`)?.value.trim()
|
|
151
|
+
if (value) field[key] = value
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
81
155
|
if (type === "relationship" && contentType) {
|
|
82
156
|
field.content_type = contentType
|
|
83
157
|
}
|
|
@@ -88,8 +162,24 @@ export default class extends Controller {
|
|
|
88
162
|
}
|
|
89
163
|
|
|
90
164
|
const optionsVal = fieldEl.querySelector('[data-field="options"]')?.value?.trim()
|
|
91
|
-
if (
|
|
92
|
-
field.options = optionsVal.split("
|
|
165
|
+
if (["select", "radio", "button_group", "checkboxes"].includes(type) && optionsVal) {
|
|
166
|
+
field.options = optionsVal.split("\n").map(line => {
|
|
167
|
+
const [label, ...valueParts] = line.split("|")
|
|
168
|
+
const value = valueParts.join("|").trim()
|
|
169
|
+
return value ? { label: label.trim(), value } : label.trim()
|
|
170
|
+
}).filter(option => typeof option === "string" ? option : option.label && option.value)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const conditionField = fieldEl.querySelector('[data-field="condition_field"]')?.value.trim()
|
|
174
|
+
const conditionOperator = fieldEl.querySelector('[data-field="condition_operator"]')?.value
|
|
175
|
+
const conditionValue = fieldEl.querySelector('[data-field="condition_value"]')?.value.trim()
|
|
176
|
+
if (conditionField && conditionOperator) {
|
|
177
|
+
field.condition = { field: conditionField, operator: conditionOperator }
|
|
178
|
+
if (conditionValue) field.condition.value = conditionValue
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (type === "group" || type === "repeater") {
|
|
182
|
+
field.fields = this.readNestedFields(fieldEl)
|
|
93
183
|
}
|
|
94
184
|
|
|
95
185
|
fields.push(field)
|
|
@@ -99,6 +189,82 @@ export default class extends Controller {
|
|
|
99
189
|
this.inputTarget.value = JSON.stringify({ fields })
|
|
100
190
|
}
|
|
101
191
|
|
|
192
|
+
readNestedFields(fieldEl) {
|
|
193
|
+
return Array.from(fieldEl.querySelectorAll("[data-nested-field]")).filter(row => {
|
|
194
|
+
return row.querySelector('[data-nested="handle"]')?.value.trim()
|
|
195
|
+
}).map(row => {
|
|
196
|
+
const handle = row.querySelector('[data-nested="handle"]').value.trim()
|
|
197
|
+
const field = {
|
|
198
|
+
handle,
|
|
199
|
+
type: row.querySelector('[data-nested="type"]').value,
|
|
200
|
+
label: row.querySelector('[data-nested="label"]').value.trim() || handle.replaceAll("_", " ")
|
|
201
|
+
}
|
|
202
|
+
;["instructions", "placeholder", "default"].forEach(key => {
|
|
203
|
+
const value = row.querySelector(`[data-nested="${key}"]`)?.value.trim()
|
|
204
|
+
if (value) field[key] = value
|
|
205
|
+
})
|
|
206
|
+
if (row.querySelector('[data-nested="required"]')?.checked) field.required = true
|
|
207
|
+
return field
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
nestedEditorHtml() {
|
|
212
|
+
return `<div class="hidden col-span-full space-y-3 rounded-md border border-gray-200 bg-white p-3" data-field-config="group repeater" data-nested-editor>
|
|
213
|
+
<div class="flex items-center justify-between"><p class="text-sm font-medium text-gray-700">Nested fields</p>
|
|
214
|
+
<button type="button" data-action="plum--blueprint#addNestedField" class="text-sm font-medium text-purple-600 hover:text-purple-700">+ Add nested field</button></div>
|
|
215
|
+
<div class="space-y-3" data-nested-fields></div>
|
|
216
|
+
</div>`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
typeConstraintsHtml() {
|
|
220
|
+
return `<div class="hidden col-span-full grid gap-3 rounded-md border border-gray-200 bg-white p-3 sm:grid-cols-4" data-field-config="number">
|
|
221
|
+
<select data-field="number_kind" data-action="change->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm"><option value="decimal">Decimal</option><option value="integer">Integer</option></select>
|
|
222
|
+
<input type="number" placeholder="Minimum" data-field="min" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
223
|
+
<input type="number" placeholder="Maximum" data-field="max" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
224
|
+
<input type="number" placeholder="Step" data-field="step" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
225
|
+
<input type="text" placeholder="Unit (optional)" data-field="unit" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
226
|
+
</div>
|
|
227
|
+
<div class="hidden col-span-full grid gap-3 rounded-md border border-gray-200 bg-white p-3 sm:grid-cols-3" data-field-config="date">
|
|
228
|
+
<select data-field="date_mode" data-action="change->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm"><option value="date">Date</option><option value="time">Time</option><option value="datetime">Date and time</option></select>
|
|
229
|
+
<input type="text" placeholder="Earliest value" data-field="min" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
230
|
+
<input type="text" placeholder="Latest value" data-field="max" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
231
|
+
</div>
|
|
232
|
+
<div class="hidden col-span-full grid gap-3 rounded-md border border-gray-200 bg-white p-3 sm:grid-cols-3" data-field-config="list repeater images relationship" data-multiple-only>
|
|
233
|
+
<input type="number" min="0" placeholder="Minimum items" data-field="min_items" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
234
|
+
<input type="number" min="0" placeholder="Maximum items" data-field="max_items" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
235
|
+
<label class="flex items-center gap-2 text-sm text-gray-700" data-field-config="list"><input type="checkbox" data-field="unique" data-action="change->plum--blueprint#inputChanged"> Unique values</label>
|
|
236
|
+
</div>`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
presentationHtml() {
|
|
240
|
+
return `<div class="col-span-full grid gap-3 rounded-md border border-gray-200 bg-white p-3 sm:grid-cols-4">
|
|
241
|
+
<select data-field="width" data-action="change->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
242
|
+
<option value="12">Full width</option><option value="9">Three quarters</option><option value="8">Two thirds</option><option value="6">Half width</option><option value="4">One third</option><option value="3">Quarter width</option>
|
|
243
|
+
</select>
|
|
244
|
+
<input type="text" placeholder="Show when field…" data-field="condition_field" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 font-mono text-sm">
|
|
245
|
+
<select data-field="condition_operator" data-action="change->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
246
|
+
<option value="">No condition</option><option value="equals">Equals</option><option value="not_equals">Does not equal</option><option value="contains">Contains</option><option value="empty">Is empty</option><option value="not_empty">Is not empty</option>
|
|
247
|
+
</select>
|
|
248
|
+
<input type="text" placeholder="Value" data-field="condition_value" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
249
|
+
</div>`
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
nestedFieldHtml() {
|
|
253
|
+
const options = this.nestedTypesValue.map(type => `<option value="${type.handle}">${type.label}</option>`).join("")
|
|
254
|
+
return `<div class="grid gap-2 rounded-md border border-gray-200 bg-gray-50 p-3 sm:grid-cols-3" data-nested-field>
|
|
255
|
+
<input type="text" placeholder="handle" data-nested="handle" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 font-mono text-sm">
|
|
256
|
+
<select data-nested="type" data-action="change->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">${options}</select>
|
|
257
|
+
<input type="text" placeholder="Label" data-nested="label" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
258
|
+
<input type="text" placeholder="Instructions" data-nested="instructions" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
259
|
+
<input type="text" placeholder="Placeholder" data-nested="placeholder" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
260
|
+
<input type="text" placeholder="Default value" data-nested="default" data-action="input->plum--blueprint#inputChanged" class="rounded-md border border-gray-300 px-3 py-2 text-sm">
|
|
261
|
+
<label class="flex items-center gap-2 text-sm text-gray-700"><input type="checkbox" data-nested="required" data-action="change->plum--blueprint#inputChanged"> Required</label>
|
|
262
|
+
<div class="flex gap-3"><button type="button" data-direction="-1" data-action="plum--blueprint#moveNestedField" class="text-sm text-gray-600">↑ Up</button>
|
|
263
|
+
<button type="button" data-direction="1" data-action="plum--blueprint#moveNestedField" class="text-sm text-gray-600">↓ Down</button>
|
|
264
|
+
<button type="button" data-action="plum--blueprint#removeNestedField" class="text-sm font-medium text-red-600">Remove</button></div>
|
|
265
|
+
</div>`
|
|
266
|
+
}
|
|
267
|
+
|
|
102
268
|
inputChanged(event) {
|
|
103
269
|
const fieldEl = event.target.closest('[data-plum--blueprint-target="field"]')
|
|
104
270
|
if (fieldEl) this.syncFieldConfigVisibility(fieldEl)
|
|
@@ -111,9 +277,20 @@ export default class extends Controller {
|
|
|
111
277
|
|
|
112
278
|
fieldElements.forEach(fieldEl => {
|
|
113
279
|
const type = fieldEl.querySelector('[data-field="type"]')?.value
|
|
280
|
+
const label = fieldEl.querySelector('[data-field="label"]')?.value.trim()
|
|
281
|
+
const handle = fieldEl.querySelector('[data-field="handle"]')?.value.trim()
|
|
282
|
+
const summary = fieldEl.querySelector("[data-field-summary]")
|
|
283
|
+
if (summary) {
|
|
284
|
+
summary.replaceChildren(document.createTextNode(label || handle || "Untitled field"))
|
|
285
|
+
const typeLabel = document.createElement("span")
|
|
286
|
+
typeLabel.className = "plum-blueprint-field-type"
|
|
287
|
+
typeLabel.textContent = type || "text"
|
|
288
|
+
summary.append(typeLabel)
|
|
289
|
+
}
|
|
114
290
|
fieldEl.querySelectorAll('[data-field-config]').forEach(configEl => {
|
|
115
291
|
const configTypes = configEl.dataset.fieldConfig.split(" ")
|
|
116
|
-
configEl.
|
|
292
|
+
const multipleAllowed = !configEl.hasAttribute("data-multiple-only") || type !== "relationship" || fieldEl.querySelector('[data-field="multiple"]')?.checked
|
|
293
|
+
configEl.classList.toggle("hidden", !configTypes.includes(type) || !multipleAllowed)
|
|
117
294
|
})
|
|
118
295
|
})
|
|
119
296
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
connect() {
|
|
5
|
+
this.refresh = this.refresh.bind(this)
|
|
6
|
+
this.element.addEventListener("input", this.refresh)
|
|
7
|
+
this.element.addEventListener("change", this.refresh)
|
|
8
|
+
this.refresh()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
disconnect() {
|
|
12
|
+
this.element.removeEventListener("input", this.refresh)
|
|
13
|
+
this.element.removeEventListener("change", this.refresh)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
refresh() {
|
|
17
|
+
this.element.querySelectorAll("[data-field-condition]").forEach(field => {
|
|
18
|
+
const condition = JSON.parse(field.dataset.fieldCondition || "{}")
|
|
19
|
+
const values = this.valuesFor(condition.field)
|
|
20
|
+
const expected = String(condition.value ?? "")
|
|
21
|
+
let visible = true
|
|
22
|
+
|
|
23
|
+
if (condition.operator === "equals") visible = values.includes(expected)
|
|
24
|
+
if (condition.operator === "not_equals") visible = !values.includes(expected)
|
|
25
|
+
if (condition.operator === "contains") visible = values.some(value => value.includes(expected))
|
|
26
|
+
if (condition.operator === "empty") visible = values.length === 0 || values.every(value => !value)
|
|
27
|
+
if (condition.operator === "not_empty") visible = values.some(Boolean)
|
|
28
|
+
|
|
29
|
+
field.classList.toggle("hidden", !visible)
|
|
30
|
+
field.querySelectorAll("input, select, textarea, button").forEach(input => {
|
|
31
|
+
if (input.type !== "hidden") input.disabled = !visible
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
valuesFor(handle) {
|
|
37
|
+
if (!handle) return []
|
|
38
|
+
const selector = `[name="entry[data][${CSS.escape(handle)}]"], [name="entry[data][${CSS.escape(handle)}][]"]`
|
|
39
|
+
return Array.from(this.element.querySelectorAll(selector)).filter(input => {
|
|
40
|
+
return !["checkbox", "radio"].includes(input.type) || input.checked
|
|
41
|
+
}).map(input => input.value)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["preview", "x", "y", "label"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
this.refresh()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
refresh() {
|
|
11
|
+
const x = this.xTarget.value
|
|
12
|
+
const y = this.yTarget.value
|
|
13
|
+
if (this.hasPreviewTarget) this.previewTarget.style.objectPosition = `${x}% ${y}%`
|
|
14
|
+
if (this.hasLabelTarget) this.labelTarget.textContent = `${x}% × ${y}%`
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["input", "rows", "empty", "add"]
|
|
5
|
+
static values = { type: String, fields: Array, minItems: Number, maxItems: Number }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.value = this.read()
|
|
9
|
+
this.render()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
add(event) {
|
|
13
|
+
event.preventDefault()
|
|
14
|
+
if (this.hasMaxItemsValue && this.value.length >= this.maxItemsValue) return
|
|
15
|
+
this.value.push(this.typeValue === "list" ? "" : {})
|
|
16
|
+
this.render()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
remove(event) {
|
|
20
|
+
event.preventDefault()
|
|
21
|
+
if (this.hasMinItemsValue && this.value.length <= this.minItemsValue) return
|
|
22
|
+
this.value.splice(Number(event.currentTarget.dataset.index), 1)
|
|
23
|
+
this.render()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
move(event) {
|
|
27
|
+
event.preventDefault()
|
|
28
|
+
const index = Number(event.currentTarget.dataset.index)
|
|
29
|
+
const target = index + Number(event.currentTarget.dataset.direction)
|
|
30
|
+
if (target < 0 || target >= this.value.length) return
|
|
31
|
+
const [row] = this.value.splice(index, 1)
|
|
32
|
+
this.value.splice(target, 0, row)
|
|
33
|
+
this.render()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
changed(event) {
|
|
37
|
+
const index = Number(event.currentTarget.dataset.index)
|
|
38
|
+
const field = event.currentTarget.dataset.field
|
|
39
|
+
const value = event.currentTarget.type === "checkbox" ? event.currentTarget.checked : event.currentTarget.value
|
|
40
|
+
if (this.typeValue === "list") this.value[index] = value
|
|
41
|
+
else this.value[index][field] = value
|
|
42
|
+
this.serialize()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
read() {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(this.inputTarget.value || (this.typeValue === "group" ? "{}" : "[]"))
|
|
48
|
+
if (this.typeValue === "group") return [parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}]
|
|
49
|
+
return Array.isArray(parsed) ? parsed : []
|
|
50
|
+
} catch (_error) {
|
|
51
|
+
return this.typeValue === "group" ? [{}] : []
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
serialize() {
|
|
56
|
+
this.inputTarget.value = JSON.stringify(this.typeValue === "group" ? (this.value[0] || {}) : this.value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
render() {
|
|
60
|
+
this.rowsTarget.innerHTML = ""
|
|
61
|
+
this.value.forEach((row, index) => this.rowsTarget.appendChild(this.row(row, index)))
|
|
62
|
+
if (this.hasEmptyTarget) this.emptyTarget.classList.toggle("hidden", this.value.length > 0)
|
|
63
|
+
if (this.hasAddTarget) this.addTarget.disabled = this.hasMaxItemsValue && this.value.length >= this.maxItemsValue
|
|
64
|
+
this.serialize()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
row(row, index) {
|
|
68
|
+
const wrapper = document.createElement("div")
|
|
69
|
+
wrapper.className = "space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-4"
|
|
70
|
+
|
|
71
|
+
const fields = this.typeValue === "list" ? [{ handle: "value", label: "Value", type: "text" }] : this.fieldsValue
|
|
72
|
+
fields.forEach(field => wrapper.appendChild(this.control(field, row, index)))
|
|
73
|
+
|
|
74
|
+
if (this.typeValue !== "group") {
|
|
75
|
+
const controls = document.createElement("div")
|
|
76
|
+
controls.className = "flex items-center gap-3"
|
|
77
|
+
controls.appendChild(this.actionButton("↑ Up", "move", index, { direction: -1, disabled: index === 0 }))
|
|
78
|
+
controls.appendChild(this.actionButton("↓ Down", "move", index, { direction: 1, disabled: index === this.value.length - 1 }))
|
|
79
|
+
const remove = document.createElement("button")
|
|
80
|
+
remove.type = "button"
|
|
81
|
+
remove.textContent = "Remove"
|
|
82
|
+
remove.dataset.index = index
|
|
83
|
+
remove.dataset.action = "plum--structured-field#remove"
|
|
84
|
+
remove.disabled = this.hasMinItemsValue && this.value.length <= this.minItemsValue
|
|
85
|
+
remove.className = "text-sm font-medium text-red-600 hover:text-red-700 disabled:opacity-30"
|
|
86
|
+
controls.appendChild(remove)
|
|
87
|
+
wrapper.appendChild(controls)
|
|
88
|
+
}
|
|
89
|
+
return wrapper
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
actionButton(label, action, index, options = {}) {
|
|
93
|
+
const button = document.createElement("button")
|
|
94
|
+
button.type = "button"
|
|
95
|
+
button.textContent = label
|
|
96
|
+
button.dataset.index = index
|
|
97
|
+
button.dataset.action = `plum--structured-field#${action}`
|
|
98
|
+
if (options.direction) button.dataset.direction = options.direction
|
|
99
|
+
button.disabled = options.disabled || false
|
|
100
|
+
button.className = "text-sm text-gray-600 hover:text-gray-900 disabled:opacity-30"
|
|
101
|
+
return button
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
control(field, row, index) {
|
|
105
|
+
const label = document.createElement("label")
|
|
106
|
+
label.className = "block space-y-1"
|
|
107
|
+
const caption = document.createElement("span")
|
|
108
|
+
caption.className = "block text-xs font-medium text-gray-600"
|
|
109
|
+
caption.textContent = field.label || field.handle
|
|
110
|
+
label.appendChild(caption)
|
|
111
|
+
if (field.instructions) {
|
|
112
|
+
const instructions = document.createElement("span")
|
|
113
|
+
instructions.className = "block text-xs text-gray-500"
|
|
114
|
+
instructions.textContent = field.instructions
|
|
115
|
+
label.appendChild(instructions)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const input = field.type === "textarea" ? document.createElement("textarea") : document.createElement("input")
|
|
119
|
+
if (field.type !== "textarea") input.type = field.type === "boolean" ? "checkbox" : (field.type === "number" ? "number" : field.type === "date" ? "date" : "text")
|
|
120
|
+
const stored = this.typeValue === "list" ? row : row?.[field.handle]
|
|
121
|
+
const value = stored === undefined || stored === null ? field.default : stored
|
|
122
|
+
if (input.type === "checkbox") input.checked = value === true || value === "true"
|
|
123
|
+
else input.value = value ?? ""
|
|
124
|
+
if (input.type !== "checkbox") input.className = "block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm"
|
|
125
|
+
if (field.placeholder) input.placeholder = field.placeholder
|
|
126
|
+
input.required = field.required === true || field.required === "true"
|
|
127
|
+
input.dataset.index = index
|
|
128
|
+
input.dataset.field = field.handle
|
|
129
|
+
input.dataset.action = "input->plum--structured-field#changed change->plum--structured-field#changed"
|
|
130
|
+
label.appendChild(input)
|
|
131
|
+
return label
|
|
132
|
+
}
|
|
133
|
+
}
|
data/app/models/plum/asset.rb
CHANGED
|
@@ -5,6 +5,7 @@ module Plum
|
|
|
5
5
|
has_one_attached :file
|
|
6
6
|
|
|
7
7
|
validates :file, presence: true
|
|
8
|
+
validates :focal_x, :focal_y, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
|
|
8
9
|
validate :file_must_be_image
|
|
9
10
|
|
|
10
11
|
before_validation :normalize_folder
|
|
@@ -49,7 +50,10 @@ module Plum
|
|
|
49
50
|
"filename" => filename,
|
|
50
51
|
"content_type" => content_type.to_s,
|
|
51
52
|
"byte_size" => file.attached? ? file.byte_size : nil,
|
|
52
|
-
"folder" => folder.to_s
|
|
53
|
+
"folder" => folder.to_s,
|
|
54
|
+
"focal_x" => focal_x,
|
|
55
|
+
"focal_y" => focal_y,
|
|
56
|
+
"object_position" => "#{focal_x}% #{focal_y}%"
|
|
53
57
|
}
|
|
54
58
|
end
|
|
55
59
|
|
|
@@ -2,13 +2,14 @@ module Plum
|
|
|
2
2
|
class ContentType < ApplicationRecord
|
|
3
3
|
include SiteScoped
|
|
4
4
|
|
|
5
|
-
FIELD_TYPES =
|
|
5
|
+
FIELD_TYPES = FieldTypeRegistry.handles.freeze
|
|
6
6
|
|
|
7
7
|
has_many :entries, dependent: :destroy
|
|
8
8
|
|
|
9
9
|
validates :name, presence: true
|
|
10
10
|
validates :handle, presence: true, uniqueness: { scope: :site_id }, format: { with: /\A[a-z][a-z0-9_]*\z/ }
|
|
11
11
|
validate :route_prefix_format
|
|
12
|
+
validate :blueprint_fields_are_valid
|
|
12
13
|
|
|
13
14
|
before_validation :generate_handle, on: :create
|
|
14
15
|
|
|
@@ -31,5 +32,66 @@ module Plum
|
|
|
31
32
|
|
|
32
33
|
errors.add(:blueprint, "route prefix must contain lowercase letters, numbers, and hyphens")
|
|
33
34
|
end
|
|
35
|
+
|
|
36
|
+
def blueprint_fields_are_valid
|
|
37
|
+
handles = fields.map { |field| field["handle"].to_s }
|
|
38
|
+
fields.each do |field|
|
|
39
|
+
handle = field["handle"].to_s
|
|
40
|
+
type = field["type"].to_s
|
|
41
|
+
errors.add(:blueprint, "field handle #{handle.inspect} is invalid") unless handle.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
42
|
+
errors.add(:blueprint, "field #{handle.presence || '(unnamed)'} has unknown type #{type.inspect}") unless FieldTypeRegistry.include?(type)
|
|
43
|
+
validate_nested_fields(field, handle) if %w[group repeater].include?(type)
|
|
44
|
+
validate_collection_configuration(field, handle) if %w[list repeater images].include?(type) || (type == "relationship" && ActiveModel::Type::Boolean.new.cast(field["multiple"]))
|
|
45
|
+
validate_number_configuration(field, handle) if type == "number"
|
|
46
|
+
validate_presentation_configuration(field, handle, handles)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_presentation_configuration(field, handle, handles)
|
|
51
|
+
width = Integer(field["width"], exception: false) if field["width"].present?
|
|
52
|
+
errors.add(:blueprint, "field #{handle} width must be between 1 and 12") if field["width"].present? && (!width || !width.between?(1, 12))
|
|
53
|
+
|
|
54
|
+
condition = field["condition"]
|
|
55
|
+
return if condition.blank?
|
|
56
|
+
|
|
57
|
+
source = condition["field"].to_s
|
|
58
|
+
operator = condition["operator"].to_s
|
|
59
|
+
errors.add(:blueprint, "field #{handle} condition references an unknown field") unless handles.include?(source) && source != handle
|
|
60
|
+
unless %w[equals not_equals contains empty not_empty].include?(operator)
|
|
61
|
+
errors.add(:blueprint, "field #{handle} condition has an invalid operator")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_collection_configuration(field, handle)
|
|
66
|
+
minimum = Integer(field["min_items"], exception: false) if field["min_items"].present?
|
|
67
|
+
maximum = Integer(field["max_items"], exception: false) if field["max_items"].present?
|
|
68
|
+
errors.add(:blueprint, "field #{handle} minimum items must be zero or greater") if field["min_items"].present? && (!minimum || minimum.negative?)
|
|
69
|
+
errors.add(:blueprint, "field #{handle} maximum items must be zero or greater") if field["max_items"].present? && (!maximum || maximum.negative?)
|
|
70
|
+
errors.add(:blueprint, "field #{handle} maximum items must be at least its minimum") if minimum && maximum && maximum < minimum
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_number_configuration(field, handle)
|
|
74
|
+
minimum = Float(field["min"], exception: false) if field["min"].present?
|
|
75
|
+
maximum = Float(field["max"], exception: false) if field["max"].present?
|
|
76
|
+
step = Float(field["step"], exception: false) if field["step"].present?
|
|
77
|
+
errors.add(:blueprint, "field #{handle} minimum must be numeric") if field["min"].present? && !minimum
|
|
78
|
+
errors.add(:blueprint, "field #{handle} maximum must be numeric") if field["max"].present? && !maximum
|
|
79
|
+
errors.add(:blueprint, "field #{handle} step must be greater than zero") if field["step"].present? && (!step || !step.positive?)
|
|
80
|
+
errors.add(:blueprint, "field #{handle} maximum must be at least its minimum") if minimum && maximum && maximum < minimum
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_nested_fields(field, parent_handle)
|
|
84
|
+
nested_fields = Array(field["fields"])
|
|
85
|
+
errors.add(:blueprint, "field #{parent_handle} must define nested fields") if nested_fields.empty?
|
|
86
|
+
|
|
87
|
+
nested_fields.each do |nested|
|
|
88
|
+
nested_handle = nested["handle"].to_s
|
|
89
|
+
nested_type = nested["type"].to_s
|
|
90
|
+
errors.add(:blueprint, "field #{parent_handle} has invalid nested handle #{nested_handle.inspect}") unless nested_handle.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
91
|
+
unless FieldTypeRegistry::NESTED_FIELD_TYPES.include?(nested_type)
|
|
92
|
+
errors.add(:blueprint, "field #{parent_handle}.#{nested_handle.presence || '(unnamed)'} has unsupported nested type #{nested_type.inspect}")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
34
96
|
end
|
|
35
97
|
end
|