keystone_ui-pages 0.8.0 → 0.9.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/app/assets/builds/keystone_ui_pages/forms.js +1 -0
- data/app/assets/builds/keystone_ui_pages/reporting.js +1 -0
- data/app/controllers/concerns/keystone_ui/pages/host_checks.rb +6 -0
- data/app/controllers/keystone_ui/pages/events_controller.rb +41 -0
- data/app/controllers/keystone_ui/pages/manage/elements_controller.rb +1 -1
- data/app/controllers/keystone_ui/pages/pages_controller.rb +2 -7
- data/app/controllers/keystone_ui/pages/submissions_controller.rb +47 -0
- data/app/helpers/keystone_ui/pages/drawing_helper.rb +25 -3
- data/app/helpers/keystone_ui/pages/editor_helper.rb +1 -1
- data/app/helpers/keystone_ui/pages/form_helper.rb +56 -0
- data/app/helpers/keystone_ui/pages/host_helper.rb +1 -1
- data/app/javascript/keystone_ui_pages/forms.js +6 -0
- data/app/javascript/keystone_ui_pages/reporting.js +24 -0
- data/app/models/keystone_ui/pages/page.rb +17 -1
- data/app/views/keystone_ui/pages/pages/show.html.erb +1 -3
- data/app/views/keystone_ui/pages/submissions/create.html.erb +1 -0
- data/app/views/keystone_ui/pages/submissions/failed.html.erb +1 -0
- data/app/views/keystone_ui/pages/submissions/refused.html.erb +1 -0
- data/config/routes.rb +2 -0
- data/lib/keystone_ui/pages/catalog.rb +13 -3
- data/lib/keystone_ui/pages/engine.rb +4 -0
- data/lib/keystone_ui/pages/event.rb +7 -0
- data/lib/keystone_ui/pages/form_field_kinds.rb +35 -0
- data/lib/keystone_ui/pages/keystone_elements.rb +15 -0
- data/lib/keystone_ui/pages/submission.rb +7 -0
- data/lib/keystone_ui/pages/submissions.rb +66 -0
- data/lib/keystone_ui/pages/turnstile.rb +25 -0
- data/lib/keystone_ui/pages/version.rb +1 -1
- data/lib/keystone_ui/pages.rb +28 -3
- data/the_local/agents/keystone_ui-pages-develop.md +90 -4
- data/the_local/interface.yml +2 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b2fa7cf313f2a2a1683f81cf2596fd0a8ad2cb2054a0f3deb54909f0e9fd34c
|
|
4
|
+
data.tar.gz: 8b35ba167349c28f76a01d1f24b0934e2f1e9aa336267380a3b8ad7a0c86db88
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9a43808d3e942184d341e70ac5cb67b5f28b1ee5164382333b12489554e1d245fc076b609da9394180b39bdc409ac4566d219cde722c0b03d9dfdb6640efe95e
|
|
7
|
+
data.tar.gz: d42a1af97b0bc8b2f45c21484eed8b6964b00c7b404ad8f9d3de80a26eacda1a776cd070800ae111aa52a0eb9e4b5b6eeb4c2ffb13b065b2114fcfb27882cde3
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(()=>{document.addEventListener("turbo:frame-missing",t=>{t.target.id.startsWith("keystone-ui-pages-form-")&&(t.preventDefault(),t.detail.visit(t.detail.response))});})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(()=>{function d(e,t,n={}){document.dispatchEvent(new CustomEvent(`keystone-ui-pages:${t}`,{detail:{slug:e.dataset.pageSlug,place:e.dataset.pagePlace,...n}})),fetch(e.dataset.pageEvents,{method:"POST",body:new URLSearchParams({kind:t,element:n.element??""}),credentials:"same-origin",keepalive:!0}).catch(()=>{})}function a(){if(document.prerendering)return document.addEventListener("prerenderingchange",a,{once:!0});document.querySelectorAll("[data-page-events]:not([data-page-viewed])").forEach(e=>{e.dataset.pageViewed="",d(e,"view")})}function o(e){let t=e.target.closest("a, button"),n=t?.closest("[data-page-events]"),c=t?.closest("[data-page-element]");n&&c&&d(n,"click",{element:c.dataset.pageElement,href:t.href??null})}document.addEventListener("DOMContentLoaded",a);document.addEventListener("turbo:load",a);document.addEventListener("click",o);})();
|
|
@@ -15,6 +15,12 @@ module KeystoneUi
|
|
|
15
15
|
send(KeystoneUi::Pages.owner_method) if KeystoneUi::Pages.owner_method
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
def viewable?(page)
|
|
19
|
+
return true unless KeystoneUi::Pages.view_check_method
|
|
20
|
+
|
|
21
|
+
send(KeystoneUi::Pages.view_check_method, page)
|
|
22
|
+
end
|
|
23
|
+
|
|
18
24
|
def refuse
|
|
19
25
|
return head(:not_found) unless KeystoneUi::Pages.refusal_method
|
|
20
26
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "keystone_ui/pages/event"
|
|
4
|
+
require "keystone_ui/pages/operations"
|
|
5
|
+
|
|
6
|
+
module KeystoneUi
|
|
7
|
+
module Pages
|
|
8
|
+
class EventsController < KeystoneUi::Pages.base_controller.constantize
|
|
9
|
+
include HostChecks
|
|
10
|
+
skip_forgery_protection
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
page = owned_pages.find_by(slug: params[:slug])
|
|
14
|
+
return refuse unless page&.reporting? && viewable?(page) && described?(page)
|
|
15
|
+
|
|
16
|
+
Rails.error.handle(context: { page_id: page.id, kind: kind }) do
|
|
17
|
+
KeystoneUi::Pages.place_settings.dig(page.place, :events).call(Event.new(page: page, place: page.place, kind: kind, element: element, request: request))
|
|
18
|
+
end
|
|
19
|
+
head :no_content
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def described?(page)
|
|
25
|
+
kind == "view" || (kind == "click" && on_live_version?(page))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def on_live_version?(page)
|
|
29
|
+
Operations.elements(page.live_version.document).any? { |placed| placed["id"] == element }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def kind
|
|
33
|
+
params.fetch(:kind, "view")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def element
|
|
37
|
+
params[:element].presence if kind == "click"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -7,7 +7,7 @@ module KeystoneUi
|
|
|
7
7
|
include EditsPages
|
|
8
8
|
|
|
9
9
|
def create
|
|
10
|
-
changed(Operations.add(page.document, params.require(:key), column_id: params.require(:column_id)))
|
|
10
|
+
changed(Operations.add(page.document, params.require(:key), column_id: params.require(:column_id), catalog: KeystoneUi::Pages.offered_catalog(page.place)))
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def bind
|
|
@@ -11,9 +11,10 @@ module KeystoneUi
|
|
|
11
11
|
def show
|
|
12
12
|
page = owned_pages.find_by(slug: params[:slug])
|
|
13
13
|
@version = page&.live_version
|
|
14
|
-
return refuse if @version.nil? || !viewable?(page)
|
|
14
|
+
return refuse if @version.nil? || !KeystoneUi::Pages.slugged?(page.place) || !viewable?(page)
|
|
15
15
|
|
|
16
16
|
@values = visitor_values(page)
|
|
17
|
+
@page = page
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
private
|
|
@@ -21,12 +22,6 @@ module KeystoneUi
|
|
|
21
22
|
def visitor_values(page)
|
|
22
23
|
send(KeystoneUi::Pages.values_method, page) if KeystoneUi::Pages.values_method
|
|
23
24
|
end
|
|
24
|
-
|
|
25
|
-
def viewable?(page)
|
|
26
|
-
return true unless KeystoneUi::Pages.view_check_method
|
|
27
|
-
|
|
28
|
-
send(KeystoneUi::Pages.view_check_method, page)
|
|
29
|
-
end
|
|
30
25
|
end
|
|
31
26
|
end
|
|
32
27
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "keystone_ui/pages/submission"
|
|
4
|
+
require "keystone_ui/pages/submissions"
|
|
5
|
+
require "keystone_ui/pages/turnstile"
|
|
6
|
+
require "keystone_ui/pages/operations"
|
|
7
|
+
|
|
8
|
+
module KeystoneUi
|
|
9
|
+
module Pages
|
|
10
|
+
class SubmissionsController < KeystoneUi::Pages.base_controller.constantize
|
|
11
|
+
include HostChecks
|
|
12
|
+
helper KeystoneUiHelper
|
|
13
|
+
helper FormHelper
|
|
14
|
+
layout -> { KeystoneUi::Pages.layout }
|
|
15
|
+
|
|
16
|
+
def create
|
|
17
|
+
page = owned_pages.find_by(slug: params[:slug])
|
|
18
|
+
version = page&.versions&.find_by(number: params[:number])
|
|
19
|
+
form = version && Operations.elements(version.document).find { |element| element["id"] == params[:element_id] && element["key"] == "form" }
|
|
20
|
+
handler = form && KeystoneUi::Pages.submission_handler(page.place)
|
|
21
|
+
return head(:not_found) unless handler
|
|
22
|
+
return refuse unless viewable?(page)
|
|
23
|
+
|
|
24
|
+
values = Submissions.accepted(form, params)
|
|
25
|
+
problems = Submissions.problems(form, values)
|
|
26
|
+
return render(:refused, status: :unprocessable_entity, locals: { form: form, version: version, problems: problems, values: values }) if problems.any?
|
|
27
|
+
|
|
28
|
+
begin
|
|
29
|
+
handler.call(Submission.new(page: page, version: version, form_name: Submissions.form_name(form), element_id: form["id"], values: Submissions.delivered(form, values), request: request, bot: bot?))
|
|
30
|
+
rescue StandardError => error
|
|
31
|
+
Rails.error.report(error, handled: true, context: { page_id: page.id, form_name: Submissions.form_name(form) })
|
|
32
|
+
return render(:failed, status: :internal_server_error, locals: { form: form, version: version, values: values })
|
|
33
|
+
end
|
|
34
|
+
return redirect_to(Submissions.next_address(form), status: :see_other, allow_other_host: true) if Submissions.next_address(form)
|
|
35
|
+
|
|
36
|
+
@element_id = form["id"]
|
|
37
|
+
@thank_you = Submissions.thank_you(form)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def bot?
|
|
43
|
+
Submissions.bot?(params) || (Turnstile.on? && !Turnstile.confirmed?(params["cf-turnstile-response"], request.remote_ip))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -5,10 +5,13 @@ require "keystone_ui/pages/operations"
|
|
|
5
5
|
module KeystoneUi
|
|
6
6
|
module Pages
|
|
7
7
|
module DrawingHelper
|
|
8
|
+
include FormHelper
|
|
9
|
+
|
|
8
10
|
NARROW_SPANS = { "1/1" => "col-span-12", "3/4" => "col-span-9", "2/3" => "col-span-8", "1/2" => "col-span-6", "1/3" => "col-span-4", "1/4" => "col-span-3" }.freeze
|
|
9
11
|
WIDE_SPANS = { "1/1" => "@sm:col-span-12", "3/4" => "@sm:col-span-9", "2/3" => "@sm:col-span-8", "1/2" => "@sm:col-span-6", "1/3" => "@sm:col-span-4", "1/4" => "@sm:col-span-3" }.freeze
|
|
10
12
|
ELEMENT_ALIGNMENTS = { "stretch" => "flex flex-col", "start" => "flex justify-start", "center" => "flex justify-center", "end" => "flex justify-end" }.freeze
|
|
11
13
|
COLUMN_ALIGNMENTS = { "top" => "justify-start", "center" => "justify-center", "bottom" => "justify-end" }.freeze
|
|
14
|
+
DRAWING_FORMAT = 2
|
|
12
15
|
SIZED_SECTION_PADDING = "py-[calc(var(--ks-spacing)*16)] lg:py-[calc(var(--ks-spacing)*24)]"
|
|
13
16
|
SHADED_CLASSES = "bg-(color:--ks-color-fill-muted) dark:bg-(color:--ks-color-fill-muted-dark)"
|
|
14
17
|
PLAIN_FIGURE_DIVIDER = "@sm:[.ks-pages-column:has(.ks-metric-plain)+&:has(.ks-metric-plain)]:border-l-(length:--ks-border-width) " \
|
|
@@ -16,9 +19,18 @@ module KeystoneUi
|
|
|
16
19
|
"dark:@sm:[.ks-pages-column:has(.ks-metric-plain)+&:has(.ks-metric-plain)]:border-(color:--ks-color-border-dark)"
|
|
17
20
|
|
|
18
21
|
def drawn_version(version, values: {})
|
|
19
|
-
key = [ "keystone_ui_pages", version.id, Operations.digest(values.to_h.stringify_keys) ]
|
|
22
|
+
key = [ "keystone_ui_pages", DRAWING_FORMAT, KeystoneUi::Pages.turnstile_site_key, version.id, Operations.digest(values.to_h.stringify_keys) ]
|
|
20
23
|
@drawn_page_id = version.page_id
|
|
21
|
-
|
|
24
|
+
@drawn_version = version
|
|
25
|
+
load_forms_script(version)
|
|
26
|
+
with_visitor_token(Rails.cache.fetch(key) { without_visitor_tokens(drawn_document(version.document, values: values).to_str) }).html_safe
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reporting_data(page)
|
|
30
|
+
return {} unless page.reporting?
|
|
31
|
+
|
|
32
|
+
content_for :head, javascript_include_tag("keystone_ui_pages/reporting", defer: true)
|
|
33
|
+
{ page_events: keystone_ui_pages.page_events_path(page.slug), page_slug: page.slug, page_place: page.place }
|
|
22
34
|
end
|
|
23
35
|
|
|
24
36
|
def pages_paragraph(text:)
|
|
@@ -29,11 +41,20 @@ module KeystoneUi
|
|
|
29
41
|
|
|
30
42
|
def drawn_document(document, editing: false, values: {})
|
|
31
43
|
@drawn_values = values.to_h.stringify_keys
|
|
44
|
+
@drawn_version = nil if editing
|
|
32
45
|
safe_join(document["sections"].map { |section| drawn_section(section, editing) })
|
|
33
46
|
end
|
|
34
47
|
|
|
35
48
|
private
|
|
36
49
|
|
|
50
|
+
def load_forms_script(version)
|
|
51
|
+
return if @forms_script_loaded || Operations.elements(version.document).none? { |element| element["key"] == "form" }
|
|
52
|
+
|
|
53
|
+
@forms_script_loaded = true
|
|
54
|
+
content_for :head, javascript_include_tag("keystone_ui_pages/forms", defer: true)
|
|
55
|
+
content_for :head, javascript_include_tag("https://challenges.cloudflare.com/turnstile/v0/api.js", async: true, defer: true) if KeystoneUi::Pages.turnstile_site_key.present?
|
|
56
|
+
end
|
|
57
|
+
|
|
37
58
|
def rich_paragraphs(html)
|
|
38
59
|
stored = Nokogiri::HTML5.fragment(html)
|
|
39
60
|
stored.css("script, style").each(&:remove)
|
|
@@ -99,7 +120,7 @@ module KeystoneUi
|
|
|
99
120
|
def drawn_element(element)
|
|
100
121
|
return reported_missing(element) unless KeystoneUi::Pages.catalog.find(element["key"])
|
|
101
122
|
|
|
102
|
-
wrapped_element(element) unless hidden?(element)
|
|
123
|
+
wrapped_element(element, data: { page_element: element["id"] }) unless hidden?(element)
|
|
103
124
|
end
|
|
104
125
|
|
|
105
126
|
def hidden?(node)
|
|
@@ -149,6 +170,7 @@ module KeystoneUi
|
|
|
149
170
|
def component_for(element)
|
|
150
171
|
definition = KeystoneUi::Pages.catalog.find(element["key"])
|
|
151
172
|
values = definition.fields.reject { |field| field[:classes] }.to_h { |field| [ field[:key].to_sym, field_value(field, filled(element["fields"][field[:key].to_s]) || field[:default]) ] }
|
|
173
|
+
values.merge!(element_id: element["id"], version: @drawn_version) if definition.placed
|
|
152
174
|
return send(definition.component, **values) unless definition.body
|
|
153
175
|
|
|
154
176
|
body = values.delete(definition.body)
|
|
@@ -7,7 +7,7 @@ module KeystoneUi
|
|
|
7
7
|
module EditorHelper
|
|
8
8
|
def editor_props(page)
|
|
9
9
|
{
|
|
10
|
-
elements: KeystoneUi::Pages.
|
|
10
|
+
elements: KeystoneUi::Pages.offered_elements(page.place).map { |element| element.to_h.slice(:key, :name, :fields, :icon, :group, :align) },
|
|
11
11
|
canvas: editor_canvas(page),
|
|
12
12
|
document: page.document,
|
|
13
13
|
elements_url: manage_page_elements_path(page),
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "keystone_ui/pages/submissions"
|
|
4
|
+
require "keystone_ui/pages/form_field_kinds"
|
|
5
|
+
|
|
6
|
+
module KeystoneUi
|
|
7
|
+
module Pages
|
|
8
|
+
module FormHelper
|
|
9
|
+
TOKEN_PLACEHOLDER = "keystone-ui-pages-form-token"
|
|
10
|
+
|
|
11
|
+
def pages_form(form_name:, fields:, submit_label:, thank_you:, element_id:, version:, after_submitting: nil, next_address: nil, problems: {}, values: {}, failure: nil)
|
|
12
|
+
content = safe_join([ (ui_alert(message: failure, type: :error) if failure) ].compact + fields.map { |field| form_field(field, problems, values) } + [ trap_field, turnstile_widget, ui_button(label: submit_label) ].compact)
|
|
13
|
+
return tag.div(content, class: "ks-form") unless version
|
|
14
|
+
|
|
15
|
+
content_tag("turbo-frame", ui_form(action: keystone_ui_pages.page_form_path(version.page.slug, version.number, element_id)) { token_placeholder + content }, id: form_frame_id(element_id), class: "print:hidden")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def redrawn_form(form, version, problems: {}, values: {}, failure: nil)
|
|
19
|
+
drawn = pages_form(**Submissions.drawing_settings(form), element_id: form["id"], version: version, problems: problems, values: values, failure: failure)
|
|
20
|
+
with_visitor_token(drawn.to_str).html_safe
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def form_frame_id(element_id)
|
|
24
|
+
"keystone-ui-pages-form-#{element_id}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def without_visitor_tokens(html)
|
|
28
|
+
html.gsub(/<input type="hidden" name="#{request_forgery_protection_token}" value="(?!#{TOKEN_PLACEHOLDER})[^"]*"[^>]*>/, "")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def with_visitor_token(html)
|
|
32
|
+
html.gsub(TOKEN_PLACEHOLDER) { protect_against_forgery? ? form_authenticity_token : "" }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def turnstile_widget
|
|
38
|
+
tag.div(class: "cf-turnstile", data: { sitekey: KeystoneUi::Pages.turnstile_site_key }) if KeystoneUi::Pages.turnstile_site_key.present?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def trap_field
|
|
42
|
+
tag.div(tag.input(type: "text", name: Submissions::TRAP_NAME, tabindex: -1, autocomplete: "off"), class: "absolute -left-[10000px] h-px w-px overflow-hidden", aria: { hidden: true })
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def token_placeholder
|
|
46
|
+
tag.input(type: "hidden", name: request_forgery_protection_token, value: TOKEN_PLACEHOLDER, autocomplete: "off")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def form_field(field, problems, values)
|
|
50
|
+
name = field[:name].to_s
|
|
51
|
+
ui_form_field(attribute: name, label: field[:label], type: FormFieldKinds.find(field[:kind]).drawn_as, required: field[:required].to_s == "true",
|
|
52
|
+
errors: Array(problems[name]), value: values[name], options: FormFieldKinds.choices(field[:choices]).map { |choice| [ choice, choice ] })
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -10,7 +10,7 @@ module KeystoneUi
|
|
|
10
10
|
version = version ? page.versions.find_by(number: version) : page.live_version
|
|
11
11
|
return if version.nil?
|
|
12
12
|
|
|
13
|
-
tag.div(drawn_version(version, values: values), class: "@container")
|
|
13
|
+
tag.div(drawn_version(version, values: values), class: "@container", data: version == page.live_version ? reporting_data(page) : {})
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
function report(drawn, kind, details = {}) {
|
|
2
|
+
document.dispatchEvent(new CustomEvent(`keystone-ui-pages:${kind}`, { detail: { slug: drawn.dataset.pageSlug, place: drawn.dataset.pagePlace, ...details } }))
|
|
3
|
+
fetch(drawn.dataset.pageEvents, { method: "POST", body: new URLSearchParams({ kind, element: details.element ?? "" }), credentials: "same-origin", keepalive: true }).catch(() => {})
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function reportViews() {
|
|
7
|
+
if (document.prerendering) return document.addEventListener("prerenderingchange", reportViews, { once: true })
|
|
8
|
+
|
|
9
|
+
document.querySelectorAll("[data-page-events]:not([data-page-viewed])").forEach((drawn) => {
|
|
10
|
+
drawn.dataset.pageViewed = ""
|
|
11
|
+
report(drawn, "view")
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function reportClick(event) {
|
|
16
|
+
const clicked = event.target.closest("a, button")
|
|
17
|
+
const drawn = clicked?.closest("[data-page-events]")
|
|
18
|
+
const element = clicked?.closest("[data-page-element]")
|
|
19
|
+
if (drawn && element) report(drawn, "click", { element: element.dataset.pageElement, href: clicked.href ?? null })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
document.addEventListener("DOMContentLoaded", reportViews)
|
|
23
|
+
document.addEventListener("turbo:load", reportViews)
|
|
24
|
+
document.addEventListener("click", reportClick)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require "keystone_ui/pages/document"
|
|
5
|
+
require "keystone_ui/pages/operations"
|
|
5
6
|
|
|
6
7
|
module KeystoneUi
|
|
7
8
|
module Pages
|
|
@@ -25,7 +26,8 @@ module KeystoneUi
|
|
|
25
26
|
|
|
26
27
|
validates :name, :slug, presence: true
|
|
27
28
|
validates :slug, uniqueness: { scope: %i[owner_type owner_id] }
|
|
28
|
-
validates :place, inclusion: { in: -> { KeystoneUi::Pages.places.keys } },
|
|
29
|
+
validates :place, inclusion: { in: -> { KeystoneUi::Pages.places.keys } }, if: -> { KeystoneUi::Pages.places.any? }
|
|
30
|
+
validate :place_offers_its_elements, if: :will_save_change_to_place?
|
|
29
31
|
validates :access_exception, inclusion: { in: -> { KeystoneUi::Pages.access_exceptions.keys } }, allow_nil: true, if: -> { KeystoneUi::Pages.access_exceptions.any? }
|
|
30
32
|
|
|
31
33
|
def publish
|
|
@@ -84,6 +86,10 @@ module KeystoneUi
|
|
|
84
86
|
versions.find_by(status: :live)
|
|
85
87
|
end
|
|
86
88
|
|
|
89
|
+
def reporting?
|
|
90
|
+
live_version.present? && KeystoneUi::Pages.reported?(place)
|
|
91
|
+
end
|
|
92
|
+
|
|
87
93
|
def next_number
|
|
88
94
|
(versions.maximum(:number) || 0) + 1
|
|
89
95
|
end
|
|
@@ -93,6 +99,16 @@ module KeystoneUi
|
|
|
93
99
|
used = Page.where(owner: owner, place: place).pluck(:name).filter_map { |taken| taken[/\A#{Regexp.escape(word)} (\d+)\z/, 1]&.to_i }
|
|
94
100
|
"#{word} #{(used.max || 0) + 1}"
|
|
95
101
|
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def place_offers_its_elements
|
|
106
|
+
offered = KeystoneUi::Pages.offered_catalog(place)
|
|
107
|
+
held = Operations.elements(document).map { |element| element["key"] }.uniq
|
|
108
|
+
held.reject { |key| offered.find(key) }.filter_map { |key| KeystoneUi::Pages.catalog.find(key) }.each do |element|
|
|
109
|
+
errors.add(:place, "#{KeystoneUi::Pages.places[place]} does not offer #{element.name}")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
96
112
|
end
|
|
97
113
|
end
|
|
98
114
|
end
|
|
@@ -1,4 +1,2 @@
|
|
|
1
1
|
<% content_for(:title, @version.title) if @version.title %>
|
|
2
|
-
|
|
3
|
-
<%= drawn_version(@version, values: @values.to_h) %>
|
|
4
|
-
</div>
|
|
2
|
+
<%= tag.div drawn_version(@version, values: @values.to_h), data: { live_page: "", **(@page ? reporting_data(@page) : {}) }, class: "@container print:bg-white print:[print-color-adjust:exact]" %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= content_tag "turbo-frame", ui_paragraph(text: @thank_you), id: form_frame_id(@element_id) %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= redrawn_form(form, version, values: values, failure: "Your form was not sent. Please try again.") %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= redrawn_form(form, version, problems: problems, values: values) %>
|
data/config/routes.rb
CHANGED
|
@@ -32,5 +32,7 @@ KeystoneUi::Pages::Engine.routes.draw do
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
post ":slug/events", to: "events#create", as: :page_events
|
|
36
|
+
post ":slug/versions/:number/forms/:element_id", to: "submissions#create", as: :page_form
|
|
35
37
|
get ":slug", to: "pages#show", as: :page
|
|
36
38
|
end
|
|
@@ -3,20 +3,24 @@
|
|
|
3
3
|
module KeystoneUi
|
|
4
4
|
module Pages
|
|
5
5
|
class Catalog
|
|
6
|
-
Element = Data.define(:key, :name, :component, :fields, :body, :icon, :group, :align)
|
|
6
|
+
Element = Data.define(:key, :name, :component, :fields, :body, :icon, :group, :align, :placed)
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
9
|
@elements = {}
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def register(key:, name:, component:, fields:, body: nil, icon: nil, group: nil, align: nil)
|
|
13
|
-
@elements[key.to_sym] = Element.new(key: key.to_sym, name: name, component: component, fields: fields, body: body, icon: icon, group: group, align: align)
|
|
12
|
+
def register(key:, name:, component:, fields:, body: nil, icon: nil, group: nil, align: nil, placed: false)
|
|
13
|
+
@elements[key.to_sym] = Element.new(key: key.to_sym, name: name, component: component, fields: fields, body: body, icon: icon, group: group, align: align, placed: placed)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def leave_out(*keys)
|
|
17
17
|
keys.each { |key| @elements.delete(key.to_sym) }
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
def without(*keys)
|
|
21
|
+
Catalog.new.tap { |copy| elements.each { |element| copy.add(element) unless keys.include?(element.key) } }
|
|
22
|
+
end
|
|
23
|
+
|
|
20
24
|
def elements
|
|
21
25
|
@elements.values
|
|
22
26
|
end
|
|
@@ -24,6 +28,12 @@ module KeystoneUi
|
|
|
24
28
|
def find(key)
|
|
25
29
|
@elements[key.to_sym]
|
|
26
30
|
end
|
|
31
|
+
|
|
32
|
+
protected
|
|
33
|
+
|
|
34
|
+
def add(element)
|
|
35
|
+
@elements[element.key] = element
|
|
36
|
+
end
|
|
27
37
|
end
|
|
28
38
|
end
|
|
29
39
|
end
|
|
@@ -14,6 +14,10 @@ module KeystoneUi
|
|
|
14
14
|
ActiveSupport.on_load(:action_view) { include KeystoneUi::Pages::HostHelper }
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
initializer "keystone_ui_pages.check_places", after: :load_config_initializers do |app|
|
|
18
|
+
app.config.after_initialize { KeystoneUi::Pages.check_places! }
|
|
19
|
+
end
|
|
20
|
+
|
|
17
21
|
initializer "keystone_ui_pages.tailwind" do
|
|
18
22
|
KeystoneUi.configuration.tailwind_sources << root.join("app/views/**/*.erb").to_s
|
|
19
23
|
KeystoneUi.configuration.tailwind_sources << root.join("app/helpers/**/*.rb").to_s
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module KeystoneUi
|
|
6
|
+
module Pages
|
|
7
|
+
module FormFieldKinds
|
|
8
|
+
Kind = Data.define(:drawn_as, :limit, :check, :delivered)
|
|
9
|
+
|
|
10
|
+
KINDS = {
|
|
11
|
+
"text" => Kind.new(drawn_as: :text, limit: 200, check: nil, delivered: nil),
|
|
12
|
+
"email" => Kind.new(drawn_as: :email, limit: 254, delivered: nil,
|
|
13
|
+
check: ->(value, _field) { "Enter an email address" unless value.match?(URI::MailTo::EMAIL_REGEXP) }),
|
|
14
|
+
"long_text" => Kind.new(drawn_as: :textarea, limit: 5000, check: nil, delivered: nil),
|
|
15
|
+
"choice" => Kind.new(drawn_as: :select, limit: 200, delivered: nil,
|
|
16
|
+
check: ->(value, field) { "Choose one of the options" unless FormFieldKinds.choices(field["choices"]).include?(value) }),
|
|
17
|
+
"checkbox" => Kind.new(drawn_as: :checkbox, limit: 1, check: nil, delivered: ->(value) { value.to_s == "1" })
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def keys
|
|
23
|
+
KINDS.keys
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def find(key)
|
|
27
|
+
KINDS.fetch(key.to_s.presence || "text", KINDS["text"])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def choices(text)
|
|
31
|
+
text.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "keystone_ui/pages/form_field_kinds"
|
|
4
|
+
|
|
3
5
|
module KeystoneUi
|
|
4
6
|
module Pages
|
|
5
7
|
module KeystoneElements
|
|
@@ -71,6 +73,19 @@ module KeystoneUi
|
|
|
71
73
|
default: [ { "title" => "Fast", "description" => "Pages load quickly." },
|
|
72
74
|
{ "title" => "Clear", "description" => "Every page reads plainly." },
|
|
73
75
|
{ "title" => "Yours", "description" => "Built from your own components." } ] } ])
|
|
76
|
+
catalog.register(key: :form, name: "Form", component: :pages_form, icon: :card, group: "Content", placed: true,
|
|
77
|
+
fields: [ { key: :form_name, label: "Form name", kind: :text, default: "Contact" },
|
|
78
|
+
{ key: :fields, label: "Fields", kind: :list,
|
|
79
|
+
entry: [ { key: :label, label: "Label", kind: :text }, { key: :name, label: "Field name", kind: :text },
|
|
80
|
+
{ key: :kind, label: "Kind", kind: :one_of, options: FormFieldKinds.keys, default: "text" },
|
|
81
|
+
{ key: :choices, label: "Choices, separated by commas", kind: :text },
|
|
82
|
+
{ key: :required, label: "Required", kind: :yes_or_no } ],
|
|
83
|
+
default: [ { "label" => "Name", "name" => "name", "kind" => "text" },
|
|
84
|
+
{ "label" => "Email", "name" => "email", "kind" => "email", "required" => "true" } ] },
|
|
85
|
+
{ key: :submit_label, label: "Submit label", kind: :text, default: "Send" },
|
|
86
|
+
{ key: :thank_you, label: "Thank-you message", kind: :long_text, default: "Thanks, we will be in touch." },
|
|
87
|
+
{ key: :after_submitting, label: "After submitting", kind: :one_of, options: %w[thank_you address], default: "thank_you" },
|
|
88
|
+
{ key: :next_address, label: "Address to send the visitor to", kind: :address } ])
|
|
74
89
|
catalog.register(key: :accordion, name: "Accordion", component: :ui_accordion, icon: :code, group: "Text",
|
|
75
90
|
fields: [ { key: :items, label: "Questions", kind: :list,
|
|
76
91
|
entry: [ { key: :question, label: "Question", kind: :text }, { key: :answer, label: "Answer", kind: :long_text } ],
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "keystone_ui/pages/form_field_kinds"
|
|
4
|
+
|
|
5
|
+
module KeystoneUi
|
|
6
|
+
module Pages
|
|
7
|
+
module Submissions
|
|
8
|
+
TRAP_NAME = "keystone_ui_pages_trap"
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def bot?(submitted)
|
|
13
|
+
submitted[TRAP_NAME].present?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def form_name(form, catalog: Pages.catalog)
|
|
17
|
+
setting(form, :form_name, catalog)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def thank_you(form, catalog: Pages.catalog)
|
|
21
|
+
setting(form, :thank_you, catalog)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def drawing_settings(form, catalog: Pages.catalog)
|
|
25
|
+
fields = Array(setting(form, :fields, catalog)).map { |field| { kind: "text" }.merge(field.to_h.symbolize_keys.compact_blank) }
|
|
26
|
+
{ form_name: form_name(form, catalog: catalog), fields: fields, submit_label: setting(form, :submit_label, catalog), thank_you: thank_you(form, catalog: catalog) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def next_address(form, catalog: Pages.catalog)
|
|
30
|
+
setting(form, :next_address, catalog).presence if setting(form, :after_submitting, catalog) == "address"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def accepted(form, submitted, catalog: Pages.catalog)
|
|
34
|
+
names = Array(setting(form, :fields, catalog)).map { |field| field["name"].to_s }
|
|
35
|
+
submitted.to_unsafe_h.slice(*names)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def delivered(form, values, catalog: Pages.catalog)
|
|
39
|
+
converted = Array(setting(form, :fields, catalog)).filter_map do |field|
|
|
40
|
+
kind = FormFieldKinds.find(field["kind"])
|
|
41
|
+
[ field["name"].to_s, kind.delivered.call(values[field["name"].to_s]) ] if kind.delivered
|
|
42
|
+
end
|
|
43
|
+
values.merge(converted.to_h)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def problems(form, values, catalog: Pages.catalog)
|
|
47
|
+
Array(setting(form, :fields, catalog)).each_with_object({}) do |field, found|
|
|
48
|
+
message = problem(field, values[field["name"].to_s].to_s)
|
|
49
|
+
found[field["name"].to_s] = message if message
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private_class_method def problem(field, value)
|
|
54
|
+
kind = FormFieldKinds.find(field["kind"])
|
|
55
|
+
return "Keep this to #{kind.limit} characters or fewer" if value.length > kind.limit
|
|
56
|
+
return "Fill this in" if field["required"].to_s == "true" && value.blank?
|
|
57
|
+
|
|
58
|
+
kind.check&.call(value, field) if value.present?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private_class_method def setting(form, key, catalog)
|
|
62
|
+
form["fields"].fetch(key.to_s) { catalog.find(:form).fields.find { |field| field[:key] == key }[:default] }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
|
|
6
|
+
module KeystoneUi
|
|
7
|
+
module Pages
|
|
8
|
+
module Turnstile
|
|
9
|
+
VERIFY_ADDRESS = URI("https://challenges.cloudflare.com/turnstile/v0/siteverify")
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def on?
|
|
14
|
+
Pages.turnstile_site_key.present? && Pages.turnstile_secret_key.present?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def confirmed?(token, remote_ip)
|
|
18
|
+
answer = Net::HTTP.post_form(VERIFY_ADDRESS, secret: Pages.turnstile_secret_key, response: token.to_s, remoteip: remote_ip.to_s)
|
|
19
|
+
JSON.parse(answer.body)["success"] == true
|
|
20
|
+
rescue StandardError
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/keystone_ui/pages.rb
CHANGED
|
@@ -10,7 +10,8 @@ module KeystoneUi
|
|
|
10
10
|
module Pages
|
|
11
11
|
class << self
|
|
12
12
|
attr_writer :base_controller, :layout, :editor_layout, :pdf_stylesheet, :access_exceptions
|
|
13
|
-
attr_accessor :edit_check_method, :view_check_method, :values_method, :owner_method, :refusal_method, :chrome_path
|
|
13
|
+
attr_accessor :edit_check_method, :view_check_method, :values_method, :owner_method, :refusal_method, :chrome_path,
|
|
14
|
+
:turnstile_site_key, :turnstile_secret_key
|
|
14
15
|
|
|
15
16
|
def base_controller
|
|
16
17
|
@base_controller || "ActionController::Base"
|
|
@@ -37,8 +38,8 @@ module KeystoneUi
|
|
|
37
38
|
@place_settings = {}
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
def register_place(key, label:, default_name: "Page", slug: true)
|
|
41
|
-
place_settings[key.to_s] = { label: label, default_name: default_name, slug: slug }
|
|
41
|
+
def register_place(key, label:, default_name: "Page", slug: true, events: nil, submissions: nil, leaves_out: [])
|
|
42
|
+
place_settings[key.to_s] = { label: label, default_name: default_name, slug: slug, events: events, submissions: submissions, leaves_out: leaves_out.map(&:to_sym) }
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
def place_settings
|
|
@@ -49,6 +50,30 @@ module KeystoneUi
|
|
|
49
50
|
place_settings.dig(place.to_s, :slug) != false
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
def check_places!
|
|
54
|
+
place_settings.each do |place, settings|
|
|
55
|
+
unknown = settings[:leaves_out].reject { |key| catalog.find(key) }
|
|
56
|
+
raise ArgumentError, "The place #{place} leaves out #{unknown.join(", ")}, which no catalog element has" if unknown.any?
|
|
57
|
+
raise ArgumentError, "The place #{place} offers the Form element but has no submissions handler" if offered_catalog(place).find(:form) && !settings[:submissions]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def offered_catalog(place)
|
|
62
|
+
catalog.without(*Array(place_settings.dig(place.to_s, :leaves_out)))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def offered_elements(place)
|
|
66
|
+
offered_catalog(place).elements
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def submission_handler(place)
|
|
70
|
+
place_settings.dig(place.to_s, :submissions)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reported?(place)
|
|
74
|
+
!place_settings.dig(place.to_s, :events).nil?
|
|
75
|
+
end
|
|
76
|
+
|
|
52
77
|
def access_exceptions
|
|
53
78
|
@access_exceptions || {}
|
|
54
79
|
end
|
|
@@ -48,8 +48,8 @@ inherit from the base controller, so define it there.
|
|
|
48
48
|
at `/pages/:slug`. Unset, every published page is shown.
|
|
49
49
|
- `KeystoneUi::Pages.places = { "marketing" => "Marketing site" }` — a hash of
|
|
50
50
|
the places a page can belong to, each stored value to the label editors see.
|
|
51
|
-
When it is not empty, the New page form offers them
|
|
52
|
-
in it is refused, and the page list shows each page's place. The view check
|
|
51
|
+
When it is not empty, the New page form offers them with no blank choice, a
|
|
52
|
+
page with no place or a place not in it is refused, and the page list shows each page's place. The view check
|
|
53
53
|
reads it as `page.place`. Unset, pages have no place.
|
|
54
54
|
- `KeystoneUi::Pages.register_place("client_report", label: "Client report", default_name: "Report template", slug: false)`
|
|
55
55
|
— adds one place with its own settings, so each part of a host app registers
|
|
@@ -58,8 +58,78 @@ inherit from the base controller, so define it there.
|
|
|
58
58
|
starts with, followed by the next number among that owner's pages in that
|
|
59
59
|
place, so "Report template 1", then "Report template 2"; it defaults to
|
|
60
60
|
"Page". `slug:` says whether the place's pages have a URL slug; it defaults to
|
|
61
|
-
true.
|
|
61
|
+
true. A page in a place registered with `slug: false` is never served at
|
|
62
|
+
`/pages/:slug`, whatever the view check says. `events:` takes a handler the
|
|
63
|
+
gem calls for each accepted view of the place's live pages; a place with no
|
|
64
|
+
handler reports nothing. `submissions:` takes a handler the gem calls with a
|
|
65
|
+
`KeystoneUi::Pages::Submission` for each accepted form submission from the
|
|
66
|
+
place's pages; a place with no handler refuses every submission as not found.
|
|
67
|
+
`leaves_out:` takes catalog keys the place does not offer, such as
|
|
68
|
+
`[ :form ]`; every other catalog element is offered there, including ones
|
|
69
|
+
registered later. The builder's element list and adding an element use only
|
|
70
|
+
what the place offers, and moving a page to a place that leaves out an
|
|
71
|
+
element it holds is refused on its settings page. A key no catalog element
|
|
72
|
+
has raises an `ArgumentError` when the app boots, checked after every
|
|
73
|
+
initializer and `after_initialize` block has run, and so does a registered
|
|
74
|
+
place that offers the Form element without a `submissions:` handler.
|
|
75
|
+
Registered places are offered alongside any set with `places =`, and
|
|
62
76
|
`places = nil` clears both.
|
|
77
|
+
- **Form submissions handed to the host**: `POST /pages/:slug/versions/:number/forms/:element_id`
|
|
78
|
+
finds the form in that version, live or older, and calls the place's
|
|
79
|
+
`submissions:` handler with a `Submission` holding `page`, `version`,
|
|
80
|
+
`form_name`, `element_id`, `values` (only the field names that version's form
|
|
81
|
+
lists, with each checkbox as `true` or `false`), `request` and `bot`. Every
|
|
82
|
+
form carries a trap field people cannot see, reach by keyboard, hear or have
|
|
83
|
+
autofilled; a submission that fills it arrives with `bot: true`, and the
|
|
84
|
+
visitor still gets the normal answer, so the handler decides what to drop.
|
|
85
|
+
A handler that raises is reported through `Rails.error` with `page_id` and
|
|
86
|
+
`form_name`, and the visitor gets the form back in place, with their values,
|
|
87
|
+
under a message saying it was not sent, never the thank-you or the address.
|
|
88
|
+
- `KeystoneUi::Pages.turnstile_site_key = "..."` — the Cloudflare Turnstile
|
|
89
|
+
site key. With it set, every form shows Cloudflare's widget and a page holding
|
|
90
|
+
a form loads `https://challenges.cloudflare.com/turnstile/v0/api.js` through
|
|
91
|
+
`content_for :head`, so the host's content security policy must allow it.
|
|
92
|
+
Unset, nothing is loaded from Cloudflare.
|
|
93
|
+
- `KeystoneUi::Pages.turnstile_secret_key = "..."` — the Cloudflare Turnstile
|
|
94
|
+
secret key. With both keys set, each submission's token is confirmed with
|
|
95
|
+
Cloudflare, and one it does not confirm arrives with `bot: true` while the
|
|
96
|
+
visitor gets the normal answer. Unset, no submission is sent to Cloudflare. It keeps the host's forgery protection, refuses a
|
|
97
|
+
visitor the view check refuses the way the page is refused, and answers 404
|
|
98
|
+
when the form is not in the version or the place has no handler. A required
|
|
99
|
+
field left empty, an email field holding no email address, or a choice
|
|
100
|
+
that is not one of the field's choices, or a value longer than its kind
|
|
101
|
+
allows (text and choice 200 characters, email 254, long text 5000, checkbox
|
|
102
|
+
1), is refused with
|
|
103
|
+
422 and the form drawn again in its frame with an error under each failed
|
|
104
|
+
field and the visitor's values, never reaching the handler. It otherwise answers
|
|
105
|
+
with the form's thank-you message in a `turbo-frame` named after the form,
|
|
106
|
+
inside the host's page layout, so a page running Turbo swaps only the form.
|
|
107
|
+
A form set to `address` answers a successful submission with a 303 redirect
|
|
108
|
+
there, after the handler runs. A page whose drawing holds a form adds the
|
|
109
|
+
forms script to `content_for :head`, which turns that redirect into a whole
|
|
110
|
+
page visit on a page running Turbo; refusals still answer in place.
|
|
111
|
+
The gem stores nothing.
|
|
112
|
+
- **Views and clicks handed to the host**: a page opened at `/pages/:slug` whose place has
|
|
113
|
+
a handler loads the reporting script, which sends its view once it loads and
|
|
114
|
+
a click on any link or button with the id of the element it sits in. Each
|
|
115
|
+
element on a live drawing carries its id as `data-page-element`. The script
|
|
116
|
+
also fires `keystone-ui-pages:view` (`detail`: `slug`, `place`) and
|
|
117
|
+
`keystone-ui-pages:click` (`detail`: `slug`, `place`, `element`, `href`) on
|
|
118
|
+
`document`, whatever the event address answers, for a tag in the host layout.
|
|
119
|
+
View live and PDFs send nothing. `POST /pages/:slug/events` calls the page's
|
|
120
|
+
place's `events:` handler with a `KeystoneUi::Pages::Event` holding `page`,
|
|
121
|
+
`place`, `kind` (`"view"` or `"click"`), `element` (the element id for a
|
|
122
|
+
click, nil for a view) and `request`. The gem stores nothing;
|
|
123
|
+
what is kept, counted or shown is the host's to decide. The handler runs inside
|
|
124
|
+
the event request, so slow work belongs in a job it enqueues. A handler that
|
|
125
|
+
raises is reported through `Rails.error` with `page_id` and `kind`, and the
|
|
126
|
+
browser still gets 204. An event whose `kind` is given and is not `view` or
|
|
127
|
+
`click` is refused, and so is a click whose `element` is missing or not on the
|
|
128
|
+
live version; a request with no `kind` is a view. A prerendered page sends its
|
|
129
|
+
view when it is opened. It needs no form
|
|
130
|
+
token. It is refused the way the public page refuses a page when the page has
|
|
131
|
+
no live version, is another owner's, fails the view check, or its place has
|
|
132
|
+
no handler.
|
|
63
133
|
- `KeystoneUi::Pages.access_exceptions = { "public_preview" => "Public preview" }`
|
|
64
134
|
— a hash of the exceptions an editor can set on a page, each stored value to
|
|
65
135
|
its label. When it is not empty, the builder's top bar has an Access exception
|
|
@@ -124,6 +194,10 @@ inherit from the base controller, so define it there.
|
|
|
124
194
|
in every host view, that draws the page's live version with the values given,
|
|
125
195
|
inside a `<div class="@container">`. It returns nil when no version is live.
|
|
126
196
|
`values:` takes value names to values, with string or symbol keys.
|
|
197
|
+
When it draws the live version of a page whose place has an `events:`
|
|
198
|
+
handler, it adds the reporting script to `content_for :head` and the page's
|
|
199
|
+
view is sent from the browser once it loads, so the host layout must call
|
|
200
|
+
`yield :head`. An older version drawn by number sends nothing.
|
|
127
201
|
`version:` takes a published version's number and draws that version
|
|
128
202
|
instead, whether it is live or superseded and whether the page is
|
|
129
203
|
unpublished or archived. It returns nil when the page has no version with
|
|
@@ -155,7 +229,7 @@ inherit from the base controller, so define it there.
|
|
|
155
229
|
|
|
156
230
|
### The element catalog
|
|
157
231
|
|
|
158
|
-
- `KeystoneUi::Pages.catalog.register(key:, name:, component:, fields:, body: nil, icon: nil, group: nil, align: nil)`
|
|
232
|
+
- `KeystoneUi::Pages.catalog.register(key:, name:, component:, fields:, body: nil, icon: nil, group: nil, align: nil, placed: false)`
|
|
159
233
|
— adds an element to the builder's palette, or replaces the one with the same
|
|
160
234
|
key. `key:` is a symbol stored in page documents. `name:` is the label in the
|
|
161
235
|
palette. `component:` is the name of a method the drawing calls with one
|
|
@@ -166,6 +240,18 @@ inherit from the base controller, so define it there.
|
|
|
166
240
|
`:number` `:progress`, and any other value shows a box. `group:` is the
|
|
167
241
|
palette heading it sits under, `"Other"` when unset. `align:` is the default
|
|
168
242
|
placement in its column: `"stretch"`, `"start"`, `"center"` or `"end"`.
|
|
243
|
+
`placed: true` also passes `element_id:` and `version:` (nil in the builder)
|
|
244
|
+
to the method, which the Form element uses to build its address.
|
|
245
|
+
- The built-in Form element (`:form`) holds a form name, a list of fields (each
|
|
246
|
+
a label, a field name, a kind of `text`, `email`, `long_text`, `choice` or
|
|
247
|
+
`checkbox`, the choices of a `choice` field separated by commas, and whether
|
|
248
|
+
it is required), a submit label, a thank-you message, and what happens after a
|
|
249
|
+
successful submission: `thank_you` (the default) or `address`, which sends the
|
|
250
|
+
visitor to the address the editor sets. A published form
|
|
251
|
+
posts to `POST /pages/:slug/versions/:number/forms/:element_id`. Its drawing
|
|
252
|
+
is cached with a placeholder token, and each request puts in the visitor's
|
|
253
|
+
own forgery token, so the host's forgery protection stays on. A published form
|
|
254
|
+
is hidden in print, so no PDF and no page printed from the browser shows it.
|
|
169
255
|
- `KeystoneUi::Pages.catalog.leave_out(:copy_button)` — removes elements from
|
|
170
256
|
the palette by key. Takes several keys.
|
|
171
257
|
|
data/the_local/interface.yml
CHANGED
|
@@ -19,6 +19,8 @@ develop:
|
|
|
19
19
|
- KeystoneUi::Pages.editor_layout
|
|
20
20
|
- KeystoneUi::Pages.chrome_path
|
|
21
21
|
- KeystoneUi::Pages.pdf_stylesheet
|
|
22
|
+
- KeystoneUi::Pages.turnstile_site_key
|
|
23
|
+
- KeystoneUi::Pages.turnstile_secret_key
|
|
22
24
|
- KeystoneUi::Pages::Values.names
|
|
23
25
|
- KeystoneUi::Pages::Values.chooser
|
|
24
26
|
- KeystoneUi::Pages::Values.provider
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: keystone_ui-pages
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tyler Schneider
|
|
@@ -75,8 +75,11 @@ extra_rdoc_files: []
|
|
|
75
75
|
files:
|
|
76
76
|
- LICENSE
|
|
77
77
|
- app/assets/builds/keystone_ui_pages/editor.js
|
|
78
|
+
- app/assets/builds/keystone_ui_pages/forms.js
|
|
79
|
+
- app/assets/builds/keystone_ui_pages/reporting.js
|
|
78
80
|
- app/controllers/concerns/keystone_ui/pages/edits_pages.rb
|
|
79
81
|
- app/controllers/concerns/keystone_ui/pages/host_checks.rb
|
|
82
|
+
- app/controllers/keystone_ui/pages/events_controller.rb
|
|
80
83
|
- app/controllers/keystone_ui/pages/manage/access_exceptions_controller.rb
|
|
81
84
|
- app/controllers/keystone_ui/pages/manage/base_controller.rb
|
|
82
85
|
- app/controllers/keystone_ui/pages/manage/columns_controller.rb
|
|
@@ -93,8 +96,10 @@ files:
|
|
|
93
96
|
- app/controllers/keystone_ui/pages/manage/titles_controller.rb
|
|
94
97
|
- app/controllers/keystone_ui/pages/manage/versions_controller.rb
|
|
95
98
|
- app/controllers/keystone_ui/pages/pages_controller.rb
|
|
99
|
+
- app/controllers/keystone_ui/pages/submissions_controller.rb
|
|
96
100
|
- app/helpers/keystone_ui/pages/drawing_helper.rb
|
|
97
101
|
- app/helpers/keystone_ui/pages/editor_helper.rb
|
|
102
|
+
- app/helpers/keystone_ui/pages/form_helper.rb
|
|
98
103
|
- app/helpers/keystone_ui/pages/host_helper.rb
|
|
99
104
|
- app/javascript/keystone_ui_pages/AccessExceptionChoice.jsx
|
|
100
105
|
- app/javascript/keystone_ui_pages/DeviceWidth.jsx
|
|
@@ -113,7 +118,9 @@ files:
|
|
|
113
118
|
- app/javascript/keystone_ui_pages/ThemeMode.jsx
|
|
114
119
|
- app/javascript/keystone_ui_pages/document.js
|
|
115
120
|
- app/javascript/keystone_ui_pages/editor.jsx
|
|
121
|
+
- app/javascript/keystone_ui_pages/forms.js
|
|
116
122
|
- app/javascript/keystone_ui_pages/operations.js
|
|
123
|
+
- app/javascript/keystone_ui_pages/reporting.js
|
|
117
124
|
- app/jobs/keystone_ui/pages/pdf_job.rb
|
|
118
125
|
- app/models/keystone_ui/pages/page.rb
|
|
119
126
|
- app/models/keystone_ui/pages/pdf.rb
|
|
@@ -126,6 +133,9 @@ files:
|
|
|
126
133
|
- app/views/keystone_ui/pages/manage/versions/show.html.erb
|
|
127
134
|
- app/views/keystone_ui/pages/pages/show.html.erb
|
|
128
135
|
- app/views/keystone_ui/pages/pdfs/show.html.erb
|
|
136
|
+
- app/views/keystone_ui/pages/submissions/create.html.erb
|
|
137
|
+
- app/views/keystone_ui/pages/submissions/failed.html.erb
|
|
138
|
+
- app/views/keystone_ui/pages/submissions/refused.html.erb
|
|
129
139
|
- config/routes.rb
|
|
130
140
|
- db/migrate/20261014120000_create_keystone_ui_pages_pages.rb
|
|
131
141
|
- db/migrate/20261014120001_create_keystone_ui_pages_versions.rb
|
|
@@ -138,11 +148,16 @@ files:
|
|
|
138
148
|
- lib/keystone_ui/pages/catalog.rb
|
|
139
149
|
- lib/keystone_ui/pages/document.rb
|
|
140
150
|
- lib/keystone_ui/pages/engine.rb
|
|
151
|
+
- lib/keystone_ui/pages/event.rb
|
|
152
|
+
- lib/keystone_ui/pages/form_field_kinds.rb
|
|
141
153
|
- lib/keystone_ui/pages/keystone_elements.rb
|
|
142
154
|
- lib/keystone_ui/pages/operations.rb
|
|
143
155
|
- lib/keystone_ui/pages/pdf_document.rb
|
|
144
156
|
- lib/keystone_ui/pages/pdfs.rb
|
|
145
157
|
- lib/keystone_ui/pages/printer.rb
|
|
158
|
+
- lib/keystone_ui/pages/submission.rb
|
|
159
|
+
- lib/keystone_ui/pages/submissions.rb
|
|
160
|
+
- lib/keystone_ui/pages/turnstile.rb
|
|
146
161
|
- lib/keystone_ui/pages/values.rb
|
|
147
162
|
- lib/keystone_ui/pages/version.rb
|
|
148
163
|
- the_local/agents/keystone_ui-pages-develop.md
|