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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/keystone_ui_pages/forms.js +1 -0
  3. data/app/assets/builds/keystone_ui_pages/reporting.js +1 -0
  4. data/app/controllers/concerns/keystone_ui/pages/host_checks.rb +6 -0
  5. data/app/controllers/keystone_ui/pages/events_controller.rb +41 -0
  6. data/app/controllers/keystone_ui/pages/manage/elements_controller.rb +1 -1
  7. data/app/controllers/keystone_ui/pages/pages_controller.rb +2 -7
  8. data/app/controllers/keystone_ui/pages/submissions_controller.rb +47 -0
  9. data/app/helpers/keystone_ui/pages/drawing_helper.rb +25 -3
  10. data/app/helpers/keystone_ui/pages/editor_helper.rb +1 -1
  11. data/app/helpers/keystone_ui/pages/form_helper.rb +56 -0
  12. data/app/helpers/keystone_ui/pages/host_helper.rb +1 -1
  13. data/app/javascript/keystone_ui_pages/forms.js +6 -0
  14. data/app/javascript/keystone_ui_pages/reporting.js +24 -0
  15. data/app/models/keystone_ui/pages/page.rb +17 -1
  16. data/app/views/keystone_ui/pages/pages/show.html.erb +1 -3
  17. data/app/views/keystone_ui/pages/submissions/create.html.erb +1 -0
  18. data/app/views/keystone_ui/pages/submissions/failed.html.erb +1 -0
  19. data/app/views/keystone_ui/pages/submissions/refused.html.erb +1 -0
  20. data/config/routes.rb +2 -0
  21. data/lib/keystone_ui/pages/catalog.rb +13 -3
  22. data/lib/keystone_ui/pages/engine.rb +4 -0
  23. data/lib/keystone_ui/pages/event.rb +7 -0
  24. data/lib/keystone_ui/pages/form_field_kinds.rb +35 -0
  25. data/lib/keystone_ui/pages/keystone_elements.rb +15 -0
  26. data/lib/keystone_ui/pages/submission.rb +7 -0
  27. data/lib/keystone_ui/pages/submissions.rb +66 -0
  28. data/lib/keystone_ui/pages/turnstile.rb +25 -0
  29. data/lib/keystone_ui/pages/version.rb +1 -1
  30. data/lib/keystone_ui/pages.rb +28 -3
  31. data/the_local/agents/keystone_ui-pages-develop.md +90 -4
  32. data/the_local/interface.yml +2 -0
  33. metadata +16 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebfd45f732eefa22bd08b4a5abd3c13ff88a2fa65c55a5309c89c8b21468c69c
4
- data.tar.gz: bb309abcbff6e6d5a12aa1096db65c1a3d298ca1db781ed4c3110a5b4e6334a3
3
+ metadata.gz: 4b2fa7cf313f2a2a1683f81cf2596fd0a8ad2cb2054a0f3deb54909f0e9fd34c
4
+ data.tar.gz: 8b35ba167349c28f76a01d1f24b0934e2f1e9aa336267380a3b8ad7a0c86db88
5
5
  SHA512:
6
- metadata.gz: 845983515d8a34e9d1c4ef3faff043e2ec832461b4fbf9be78b246d9fc9aba12c9823b69f3b573879df2755dd30e7041a7b1865939cab16d53ee1dad64f6a94e
7
- data.tar.gz: dc35e48a37f83ed5b6e61d0d41b22e92139017c8b0fa9cb1cabb458e33bb61a69b38d625800a6f7f7113fe5f3ae80689691f5c2b4a2eafbef3f2db8753b3c4cf
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
- Rails.cache.fetch(key) { drawn_document(version.document, values: values).to_str }.html_safe
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.catalog.elements.map { |element| element.to_h.slice(:key, :name, :fields, :icon, :group, :align) },
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,6 @@
1
+ document.addEventListener("turbo:frame-missing", (event) => {
2
+ if (!event.target.id.startsWith("keystone-ui-pages-form-")) return
3
+
4
+ event.preventDefault()
5
+ event.detail.visit(event.detail.response)
6
+ })
@@ -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 } }, allow_nil: true, if: -> { KeystoneUi::Pages.places.any? }
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
- <div data-live-page class="@container print:bg-white print:[print-color-adjust:exact]">
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ Event = Data.define(:page, :place, :kind, :element, :request)
6
+ end
7
+ end
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ Submission = Data.define(:page, :version, :form_name, :element_id, :values, :request, :bot)
6
+ end
7
+ end
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module KeystoneUi
4
4
  module Pages
5
- VERSION = "0.8.0"
5
+ VERSION = "0.9.0"
6
6
  end
7
7
  end
@@ -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, a page with a place not
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. Registered places are offered alongside any set with `places =`, and
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
 
@@ -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.8.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