active_canvas 0.0.5 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +23 -1
- data/app/assets/javascripts/active_canvas/admin/data_panel.js +357 -0
- data/app/assets/javascripts/active_canvas/admin/field_builder.js +84 -0
- data/app/assets/javascripts/active_canvas/admin/media_select_preview.js +11 -0
- data/app/assets/javascripts/active_canvas/editor/ac_bindings.js +81 -0
- data/app/assets/javascripts/active_canvas/editor/ac_chips.js +192 -0
- data/app/assets/javascripts/active_canvas/editor/code_panel.js +10 -7
- data/app/assets/javascripts/active_canvas/editor/directive_traits.js +95 -0
- data/app/assets/javascripts/active_canvas/editor/live_data.js +178 -0
- data/app/assets/javascripts/active_canvas/editor/panels.js +23 -7
- data/app/assets/javascripts/active_canvas/editor/preview_modal.js +112 -0
- data/app/assets/javascripts/active_canvas/editor/template_validator.js +95 -0
- data/app/assets/javascripts/active_canvas/editor.js +21 -3
- data/app/assets/stylesheets/active_canvas/editor.css +626 -0
- data/app/controllers/active_canvas/admin/application_controller.rb +8 -0
- data/app/controllers/active_canvas/admin/collection_items_controller.rb +93 -0
- data/app/controllers/active_canvas/admin/collections_controller.rb +51 -0
- data/app/controllers/active_canvas/admin/form_submissions_controller.rb +51 -0
- data/app/controllers/active_canvas/admin/pages_controller.rb +127 -5
- data/app/controllers/active_canvas/form_submissions_controller.rb +90 -0
- data/app/controllers/active_canvas/pages_controller.rb +8 -0
- data/app/controllers/active_canvas/redirects_controller.rb +10 -0
- data/app/helpers/active_canvas/admin/collection_items_helper.rb +33 -0
- data/app/models/active_canvas/auto_drop.rb +69 -0
- data/app/models/active_canvas/collection.rb +98 -0
- data/app/models/active_canvas/collection_item.rb +66 -0
- data/app/models/active_canvas/collection_item_version.rb +18 -0
- data/app/models/active_canvas/current.rb +2 -0
- data/app/models/active_canvas/form_submission.rb +9 -0
- data/app/models/active_canvas/page.rb +76 -15
- data/app/models/active_canvas/page_redirect.rb +7 -0
- data/app/services/active_canvas/collection_schema.rb +110 -0
- data/app/services/active_canvas/collection_source.rb +81 -0
- data/app/services/active_canvas/content_sanitizer.rb +33 -21
- data/app/services/active_canvas/form_schema.rb +48 -0
- data/app/services/active_canvas/form_stamper.rb +86 -0
- data/app/services/active_canvas/template_renderer/binding_resolver.rb +138 -0
- data/app/services/active_canvas/template_renderer/directive_expander.rb +54 -0
- data/app/services/active_canvas/template_renderer.rb +160 -0
- data/app/views/active_canvas/admin/collection_items/_field_input.html.erb +40 -0
- data/app/views/active_canvas/admin/collection_items/_form.html.erb +28 -0
- data/app/views/active_canvas/admin/collection_items/edit.html.erb +24 -0
- data/app/views/active_canvas/admin/collection_items/history.html.erb +34 -0
- data/app/views/active_canvas/admin/collection_items/index.html.erb +63 -0
- data/app/views/active_canvas/admin/collection_items/new.html.erb +9 -0
- data/app/views/active_canvas/admin/collections/_field_builder.html.erb +35 -0
- data/app/views/active_canvas/admin/collections/_form.html.erb +31 -0
- data/app/views/active_canvas/admin/collections/edit.html.erb +10 -0
- data/app/views/active_canvas/admin/collections/index.html.erb +45 -0
- data/app/views/active_canvas/admin/collections/new.html.erb +9 -0
- data/app/views/active_canvas/admin/form_submissions/index.html.erb +80 -0
- data/app/views/active_canvas/admin/form_submissions/show.html.erb +99 -0
- data/app/views/active_canvas/admin/pages/_data_panel.html.erb +81 -0
- data/app/views/active_canvas/admin/pages/_form.html.erb +10 -0
- data/app/views/active_canvas/admin/pages/editor.html.erb +62 -1
- data/app/views/active_canvas/pages/show.html.erb +4 -1
- data/app/views/layouts/active_canvas/admin/application.html.erb +15 -0
- data/config/routes.rb +20 -0
- data/db/migrate/20260517100001_add_template_enabled_and_bindings_to_active_canvas_pages.rb +6 -0
- data/db/migrate/20260517100002_add_bindings_to_active_canvas_page_versions.rb +6 -0
- data/db/migrate/20260702000001_create_active_canvas_page_redirects.rb +11 -0
- data/db/migrate/20260702000002_create_active_canvas_form_submissions.rb +14 -0
- data/db/migrate/20260702000003_create_active_canvas_collections.rb +12 -0
- data/db/migrate/20260702000004_create_active_canvas_collection_items.rb +16 -0
- data/db/migrate/20260702000005_create_active_canvas_collection_item_versions.rb +17 -0
- data/lib/active_canvas/configuration.rb +32 -1
- data/lib/active_canvas/data_sources/errors.rb +51 -0
- data/lib/active_canvas/data_sources/param_spec.rb +64 -0
- data/lib/active_canvas/data_sources/registration.rb +60 -0
- data/lib/active_canvas/data_sources/source.rb +47 -0
- data/lib/active_canvas/data_sources.rb +54 -0
- data/lib/active_canvas/engine.rb +15 -0
- data/lib/active_canvas/version.rb +1 -1
- data/lib/active_canvas.rb +6 -0
- metadata +85 -3
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
|
|
3
|
+
module ActiveCanvas
|
|
4
|
+
class TemplateRenderer
|
|
5
|
+
# Walks page.bindings JSON, calls each data source, and returns a
|
|
6
|
+
# string-keyed Liquid assigns hash. Nothing reaches Liquid unless it is a
|
|
7
|
+
# Drop, an escaped scalar, or a Hash/Array made of those.
|
|
8
|
+
class BindingResolver
|
|
9
|
+
def initialize(bindings, silent_errors: false)
|
|
10
|
+
@bindings = bindings || {}
|
|
11
|
+
@silent_errors = silent_errors
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve
|
|
15
|
+
@bindings.each_with_object({}) do |(name, spec), acc|
|
|
16
|
+
acc[name.to_s] = resolve_one(spec)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A JSON-friendly peek at one binding for the editor's Data panel: up to
|
|
21
|
+
# `rows` entries, Drops flattened to hashes, escaping undone so the
|
|
22
|
+
# author sees the real text and the real field names.
|
|
23
|
+
def sample(name, rows: 3)
|
|
24
|
+
spec = @bindings[name.to_s] || @bindings[name.to_sym]
|
|
25
|
+
raise DataSources::UnknownSource.new(name) unless spec
|
|
26
|
+
|
|
27
|
+
value = resolve_one(sample_spec(spec, rows))
|
|
28
|
+
value.is_a?(Array) ? value.first(rows).map { |v| plain(v) } : plain(value)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Caps a collection or param-limited source's `limit` param to the row
|
|
34
|
+
# count so `sample` does not load 100 items to show 3.
|
|
35
|
+
def sample_spec(spec, rows)
|
|
36
|
+
return spec unless spec.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
params = spec["params"] || spec[:params]
|
|
39
|
+
return spec unless params
|
|
40
|
+
|
|
41
|
+
source_name = (spec["source"] || spec[:source]).to_s
|
|
42
|
+
cappable = Collection.exists?(slug: source_name) ||
|
|
43
|
+
(DataSources.registered?(source_name) && DataSources.lookup(source_name).params.key?(:limit))
|
|
44
|
+
return spec unless cappable
|
|
45
|
+
|
|
46
|
+
spec.merge("params" => params.to_h.merge("limit" => rows))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def resolve_one(spec)
|
|
50
|
+
raise DataSources::UnknownSource.new(spec.inspect) unless spec.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
source_name = (spec["source"] || spec[:source]).to_s
|
|
53
|
+
raise DataSources::UnknownSource.new(source_name) if source_name.blank?
|
|
54
|
+
|
|
55
|
+
params = spec["params"] || spec[:params] || {}
|
|
56
|
+
params = {} unless params.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
if source_name == "_literal"
|
|
59
|
+
to_liquid_value(spec.key?("value") ? spec["value"] : spec[:value])
|
|
60
|
+
elsif DataSources.registered?(source_name)
|
|
61
|
+
fetch(DataSources.lookup(source_name), params)
|
|
62
|
+
elsif (collection = Collection.find_by(slug: source_name))
|
|
63
|
+
to_liquid_value(CollectionSource.new(collection).resolve(params))
|
|
64
|
+
else
|
|
65
|
+
raise DataSources::UnknownSource.new(source_name)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Silent sources swallow every StandardError, including InvalidParam and UnsafeData; preview mode still raises.
|
|
70
|
+
def fetch(source, params)
|
|
71
|
+
wrap(source.call(params.to_h.transform_keys(&:to_sym)), source)
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
raise unless @silent_errors && source.on_error == :silent
|
|
74
|
+
Rails.logger.warn("[ActiveCanvas] data source #{source.name.inspect} failed silently: #{e.class}: #{e.message}")
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def wrap(result, source)
|
|
79
|
+
if result.is_a?(Array) || result.is_a?(Set) || (result.respond_to?(:to_ary) && !result.is_a?(String))
|
|
80
|
+
result.map { |item| wrap_one(item, source) }
|
|
81
|
+
else
|
|
82
|
+
wrap_one(result, source)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def wrap_one(item, source)
|
|
87
|
+
return item if item.nil? || item.is_a?(::Liquid::Drop)
|
|
88
|
+
return to_liquid_value(source.drop_class.new(item), source: source) if source.drop_class
|
|
89
|
+
|
|
90
|
+
case item
|
|
91
|
+
when ::Liquid::Drop, String, Symbol, Numeric, true, false, nil, Date, Time, Hash, Array
|
|
92
|
+
to_liquid_value(item, source: source)
|
|
93
|
+
else
|
|
94
|
+
if source.auto_drop_config[:attributes].any?
|
|
95
|
+
AutoDrop.new(item, **source.auto_drop_config)
|
|
96
|
+
else
|
|
97
|
+
raise DataSources::UnsafeData.new(source.name, item.class.name)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The only door into Liquid. Strings are escaped unless already html_safe,
|
|
103
|
+
# containers are walked, and anything else is refused.
|
|
104
|
+
def to_liquid_value(value, source: nil)
|
|
105
|
+
case value
|
|
106
|
+
when ::Liquid::Drop, Numeric, true, false, nil, Date, Time then value
|
|
107
|
+
when String then value.html_safe? ? value : ERB::Util.html_escape(value)
|
|
108
|
+
when Symbol then ERB::Util.html_escape(value.to_s)
|
|
109
|
+
when Hash then value.each_with_object({}) { |(k, v), acc| acc[k.to_s] = to_liquid_value(v, source: source) }
|
|
110
|
+
when Array then value.map { |v| to_liquid_value(v, source: source) }
|
|
111
|
+
else raise DataSources::UnsafeData.new(source&.name || :_literal, value.class.name)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Flattens a resolved value for JSON. Drops expose only what Liquid
|
|
116
|
+
# would (`invokable_methods`, zero-arity), depth is capped so a drop
|
|
117
|
+
# that points back at itself cannot recurse, and anything outside the
|
|
118
|
+
# Liquid boundary is rendered as text, never serialized as an object.
|
|
119
|
+
def plain(value, depth = 0)
|
|
120
|
+
case value
|
|
121
|
+
when AutoDrop
|
|
122
|
+
depth > 2 ? value.to_s : plain(value.to_h, depth + 1)
|
|
123
|
+
when ::Liquid::Drop
|
|
124
|
+
return value.to_s if depth > 2
|
|
125
|
+
(value.class.invokable_methods.to_a - [ "to_liquid" ]).sort
|
|
126
|
+
.select { |m| value.method(m).arity.zero? }
|
|
127
|
+
.each_with_object({}) { |m, acc| acc[m.to_s] = plain(value.invoke_drop(m), depth + 1) }
|
|
128
|
+
when Hash then value.each_with_object({}) { |(k, v), acc| acc[k.to_s] = plain(v, depth + 1) }
|
|
129
|
+
when Array then value.first(3).map { |v| plain(v, depth + 1) }
|
|
130
|
+
when String then CGI.unescapeHTML(value.to_s)
|
|
131
|
+
when Symbol then value.to_s
|
|
132
|
+
when Numeric, true, false, nil, Date, Time then value
|
|
133
|
+
else value.to_s
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class TemplateRenderer
|
|
3
|
+
# Turns `data-ac-for="item in list"` and `data-ac-if="cond"` attributes
|
|
4
|
+
# into the Liquid block tags around their element. Attributes survive every
|
|
5
|
+
# HTML parser, so this is how the editor expresses loops and conditions
|
|
6
|
+
# without text tags that tables and sanitizers would move. The loop goes
|
|
7
|
+
# outside the condition. Text nodes inserted through the DOM are never
|
|
8
|
+
# foster-parented, so a loop on a <tr> stays inside its <tbody>.
|
|
9
|
+
class DirectiveExpander
|
|
10
|
+
FOR_RE = /\A\s*[A-Za-z_][\w.]*\s+in\s+\S.*\z/m
|
|
11
|
+
|
|
12
|
+
def initialize(source)
|
|
13
|
+
@source = source.to_s
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def expand
|
|
17
|
+
return @source unless @source.include?("data-ac-for") || @source.include?("data-ac-if")
|
|
18
|
+
|
|
19
|
+
fragment = Nokogiri::HTML5.fragment(@source)
|
|
20
|
+
fragment.css("[data-ac-for], [data-ac-if]").each { |element| expand_element(element) }
|
|
21
|
+
fragment.to_html
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def expand_element(element)
|
|
27
|
+
loop_expr = element.remove_attribute("data-ac-for")&.value
|
|
28
|
+
cond_expr = element.remove_attribute("data-ac-if")&.value
|
|
29
|
+
|
|
30
|
+
[ loop_expr, cond_expr ].compact.each do |expr|
|
|
31
|
+
raise Liquid::SyntaxError, "Invalid directive #{expr.inspect}: \"%}\" is not allowed" if expr.include?("%}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Siblings are inserted adjacent to the element, so the outer tag
|
|
35
|
+
# (for) goes in first on the left and last on the right.
|
|
36
|
+
if loop_expr
|
|
37
|
+
raise Liquid::SyntaxError, "Invalid data-ac-for #{loop_expr.inspect}: expected \"item in list\"" unless loop_expr.match?(FOR_RE)
|
|
38
|
+
element.add_previous_sibling(text("{% for #{loop_expr.strip} %}", element))
|
|
39
|
+
element.add_next_sibling(text("{% endfor %}", element))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if cond_expr
|
|
43
|
+
raise Liquid::SyntaxError, "Invalid data-ac-if: the condition is blank" if cond_expr.strip.empty?
|
|
44
|
+
element.add_previous_sibling(text("{% if #{cond_expr.strip} %}", element))
|
|
45
|
+
element.add_next_sibling(text("{% endif %}", element))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def text(content, element)
|
|
50
|
+
Nokogiri::XML::Text.new(content, element.document)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
|
|
3
|
+
module ActiveCanvas
|
|
4
|
+
class TemplateRenderer
|
|
5
|
+
MODES = %i[public preview].freeze
|
|
6
|
+
PUBLIC_FALLBACK = "<!-- dynamic block unavailable -->".freeze
|
|
7
|
+
|
|
8
|
+
def initialize(page, mode:)
|
|
9
|
+
raise ArgumentError, "mode must be one of #{MODES.inspect}" unless MODES.include?(mode)
|
|
10
|
+
@page = page
|
|
11
|
+
@mode = mode
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render
|
|
15
|
+
return @page.content.to_s unless @page.template_enabled?
|
|
16
|
+
render_dynamic
|
|
17
|
+
rescue StandardError => e
|
|
18
|
+
handle_error(e)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
MAX_LOOP_COPIES = 10
|
|
22
|
+
|
|
23
|
+
# What the editor's live-data view needs: the text each chip displays in
|
|
24
|
+
# its first occurrence, and for every loop and condition element (in
|
|
25
|
+
# document order) how it rendered: the item count, the rendered copies
|
|
26
|
+
# after the first (so the editor can show them as ghosts), and whether a
|
|
27
|
+
# condition holds. Nested elements are measured inside the first copy of
|
|
28
|
+
# their enclosing loop. Lenient render; only text and rendered HTML that
|
|
29
|
+
# the editor treats as display-only leave this method.
|
|
30
|
+
def chip_values(decorated_html)
|
|
31
|
+
source, markers = mark_for_probe(decorated_html)
|
|
32
|
+
source = DirectiveExpander.new(source).expand
|
|
33
|
+
source = decode_entities_in_liquid_tags(source)
|
|
34
|
+
|
|
35
|
+
assigns = BindingResolver.new(@page.bindings, silent_errors: true).resolve
|
|
36
|
+
template = Liquid::Template.parse(source, error_mode: :strict, line_numbers: true)
|
|
37
|
+
template.resource_limits.render_length_limit = ActiveCanvas.config.template_render_length_limit
|
|
38
|
+
template.resource_limits.render_score_limit = ActiveCanvas.config.template_render_score_limit
|
|
39
|
+
template.resource_limits.assign_score_limit = ActiveCanvas.config.template_assign_score_limit
|
|
40
|
+
rendered = template.render(assigns, exception_renderer: ->(e) { raise e if e.is_a?(Liquid::MemoryError); "" })
|
|
41
|
+
|
|
42
|
+
fragment = Nokogiri::HTML5.fragment(rendered)
|
|
43
|
+
values = fragment.css("[data-ac-id]").each_with_object({}) do |chip, acc|
|
|
44
|
+
acc[chip["data-ac-id"]] ||= chip.text.strip.truncate(200)
|
|
45
|
+
end
|
|
46
|
+
loops = markers[:loops].map do |marker|
|
|
47
|
+
copies = occurrences(fragment, marker)
|
|
48
|
+
{ expr: marker[:expr], count: copies.size, copies: copies.drop(1).first(MAX_LOOP_COPIES).map { |c| strip_probe_markers(c).to_html } }
|
|
49
|
+
end
|
|
50
|
+
conds = markers[:conds].map { |marker| { expr: marker[:expr], shown: occurrences(fragment, marker).any? } }
|
|
51
|
+
{ values: values, loops: loops, conds: conds }
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
{ values: {}, loops: [], conds: [], error: e.message }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def preview?
|
|
59
|
+
@mode == :preview
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Preview is strict so authors see every mistake. Public is forgiving: an
|
|
63
|
+
# undefined variable, an unknown filter or a node that raises renders empty
|
|
64
|
+
# and the rest of the page survives; only syntax errors, resource limits
|
|
65
|
+
# and failures before rendering fall back to the comment. The canvas never
|
|
66
|
+
# holds rendered output, so there is nothing to undo here.
|
|
67
|
+
def render_dynamic
|
|
68
|
+
source = DirectiveExpander.new(@page.content.to_s).expand
|
|
69
|
+
source = decode_entities_in_liquid_tags(source)
|
|
70
|
+
|
|
71
|
+
assigns = BindingResolver.new(@page.bindings, silent_errors: !preview?).resolve
|
|
72
|
+
template = Liquid::Template.parse(source, error_mode: :strict, line_numbers: true)
|
|
73
|
+
template.resource_limits.render_length_limit = ActiveCanvas.config.template_render_length_limit
|
|
74
|
+
template.resource_limits.render_score_limit = ActiveCanvas.config.template_render_score_limit
|
|
75
|
+
template.resource_limits.assign_score_limit = ActiveCanvas.config.template_assign_score_limit
|
|
76
|
+
rendered = if preview?
|
|
77
|
+
template.render!(assigns, strict_variables: true, strict_filters: true)
|
|
78
|
+
else
|
|
79
|
+
template.render(assigns, exception_renderer: method(:render_node_error))
|
|
80
|
+
end
|
|
81
|
+
ContentSanitizer.sanitize_html(rendered)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# WYSIWYG editors (GrapeJS) encode `<`, `>`, `&`, `"` as HTML entities when
|
|
85
|
+
# serializing text nodes, including inside Liquid tags the user typed. So
|
|
86
|
+
# `{% if a > b %}` becomes `{% if a > b %}` after editor round-trip,
|
|
87
|
+
# which Liquid rejects with a syntax error. Decode entities inside tags
|
|
88
|
+
# before parsing so user-authored Liquid keeps working.
|
|
89
|
+
def decode_entities_in_liquid_tags(source)
|
|
90
|
+
source.gsub(/\{\{.+?\}\}|\{%.+?%\}/m) do |tag|
|
|
91
|
+
CGI.unescapeHTML(tag)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def handle_error(error)
|
|
96
|
+
if preview?
|
|
97
|
+
raise DataSources::TemplateRenderError.new(
|
|
98
|
+
error.message,
|
|
99
|
+
line: error.try(:line_number),
|
|
100
|
+
column: error.try(:column),
|
|
101
|
+
original: error
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
Rails.error.report(error, handled: true, source: "active_canvas", context: { page_id: @page.id })
|
|
106
|
+
Rails.logger.warn("[ActiveCanvas] dynamic page #{@page.id} render failed: #{error.class}: #{error.message}")
|
|
107
|
+
PUBLIC_FALLBACK
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Public mode: a single broken node renders empty and is reported; only a
|
|
111
|
+
# resource limit stops the whole render (re-raised so `render` falls back).
|
|
112
|
+
def render_node_error(error)
|
|
113
|
+
raise error if error.is_a?(Liquid::MemoryError)
|
|
114
|
+
Rails.error.report(error, handled: true, source: "active_canvas", context: { page_id: @page.id })
|
|
115
|
+
Rails.logger.warn("[ActiveCanvas] dynamic page #{@page.id} node failed: #{error.class}: #{error.message}")
|
|
116
|
+
""
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Chips render their source (their text may hold a stale value in the
|
|
120
|
+
# editor). Loop and condition elements get a marker attribute with a
|
|
121
|
+
# document-order id and remember their nearest enclosing loop, so their
|
|
122
|
+
# copies can be found and scoped after the Liquid tags are consumed.
|
|
123
|
+
def mark_for_probe(html)
|
|
124
|
+
fragment = Nokogiri::HTML5.fragment(html)
|
|
125
|
+
fragment.css("[data-ac-id]").each { |chip| chip.content = chip["data-ac-source"] if chip["data-ac-source"] }
|
|
126
|
+
|
|
127
|
+
markers = { loops: [], conds: [] }
|
|
128
|
+
fragment.css("[data-ac-for], [data-ac-if]").each_with_index do |el, i|
|
|
129
|
+
parent = el.ancestors.find { |a| a["data-ac-probe-loop"] }
|
|
130
|
+
parent_loop = parent && parent["data-ac-probe-loop"]
|
|
131
|
+
if el["data-ac-for"]
|
|
132
|
+
el["data-ac-probe-loop"] = "l#{i}"
|
|
133
|
+
markers[:loops] << { id: "l#{i}", attr: "data-ac-probe-loop", expr: el["data-ac-for"], parent: parent_loop }
|
|
134
|
+
end
|
|
135
|
+
if el["data-ac-if"]
|
|
136
|
+
el["data-ac-probe-cond"] = "k#{i}"
|
|
137
|
+
markers[:conds] << { id: "k#{i}", attr: "data-ac-probe-cond", expr: el["data-ac-if"], parent: parent_loop }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
[ fragment.to_html, markers ]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The rendered copies of a marked element, inside the first copy of its
|
|
144
|
+
# enclosing loop (the whole page when it is not nested).
|
|
145
|
+
def occurrences(fragment, marker)
|
|
146
|
+
scope = marker[:parent] ? fragment.at_css("[data-ac-probe-loop=\"#{marker[:parent]}\"]") : fragment
|
|
147
|
+
return [] unless scope
|
|
148
|
+
scope.css("[#{marker[:attr]}=\"#{marker[:id]}\"]").to_a
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def strip_probe_markers(element)
|
|
152
|
+
copy = element.dup
|
|
153
|
+
([ copy ] + copy.css("[data-ac-probe-loop], [data-ac-probe-cond]").to_a).each do |el|
|
|
154
|
+
el.remove_attribute("data-ac-probe-loop")
|
|
155
|
+
el.remove_attribute("data-ac-probe-cond")
|
|
156
|
+
end
|
|
157
|
+
copy
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<div class="form-group">
|
|
2
|
+
<label for="item_data_<%= field["id"] %>">
|
|
3
|
+
<%= field["label"] %><% if field["required"] %> <span style="color:var(--danger,#c00);">*</span><% end %>
|
|
4
|
+
</label>
|
|
5
|
+
|
|
6
|
+
<% name = "item[data][#{field["id"]}]" %>
|
|
7
|
+
<% dom_id = "item_data_#{field["id"]}" %>
|
|
8
|
+
|
|
9
|
+
<% case field["type"] %>
|
|
10
|
+
<% when "textarea" %>
|
|
11
|
+
<%= text_area_tag name, value, id: dom_id, class: "form-control", rows: 4 %>
|
|
12
|
+
<% when "rich_text" %>
|
|
13
|
+
<%= text_area_tag name, value, id: dom_id, class: "form-control", rows: 8 %>
|
|
14
|
+
<small>Raw HTML. Sanitized on save.</small>
|
|
15
|
+
<% when "number" %>
|
|
16
|
+
<%= number_field_tag name, value, id: dom_id, class: "form-control", step: "any" %>
|
|
17
|
+
<% when "boolean" %>
|
|
18
|
+
<%= hidden_field_tag name, "0" %>
|
|
19
|
+
<%= check_box_tag name, "1", ActiveModel::Type::Boolean.new.cast(value), id: dom_id %>
|
|
20
|
+
<% when "date" %>
|
|
21
|
+
<%= date_field_tag name, value, id: dom_id, class: "form-control" %>
|
|
22
|
+
<% when "select" %>
|
|
23
|
+
<%= select_tag name, options_for_select(Array(field["options"]), value), id: dom_id, class: "form-control", include_blank: true %>
|
|
24
|
+
<% when "media" %>
|
|
25
|
+
<% selected_media = media.find { |m| m.id.to_s == value.to_s } %>
|
|
26
|
+
<select name="<%= name %>" id="<%= dom_id %>" class="form-control" data-media-select>
|
|
27
|
+
<option value="">— none —</option>
|
|
28
|
+
<% media.each do |m| %>
|
|
29
|
+
<option value="<%= m.id %>" data-url="<%= m.url %>" <%= "selected" if value.to_s == m.id.to_s %>><%= m.filename %></option>
|
|
30
|
+
<% end %>
|
|
31
|
+
</select>
|
|
32
|
+
<% if selected_media&.url.present? %>
|
|
33
|
+
<%= image_tag selected_media.url, class: "ac-media-preview", alt: "", style: "max-height:80px;margin-top:.5rem;" %>
|
|
34
|
+
<% else %>
|
|
35
|
+
<img class="ac-media-preview" alt="" style="max-height:80px;margin-top:.5rem;display:none;">
|
|
36
|
+
<% end %>
|
|
37
|
+
<% else %>
|
|
38
|
+
<%= text_field_tag name, value, id: dom_id, class: "form-control" %>
|
|
39
|
+
<% end %>
|
|
40
|
+
</div>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<% form_url = @item.persisted? ? admin_collection_item_path(@collection, @item) : admin_collection_items_path(@collection) %>
|
|
2
|
+
<%= form_with model: @item, url: form_url, method: (@item.persisted? ? :patch : :post) do |form| %>
|
|
3
|
+
<% if @item.errors.any? %>
|
|
4
|
+
<div class="error-messages">
|
|
5
|
+
<ul>
|
|
6
|
+
<% @item.errors.full_messages.each do |message| %>
|
|
7
|
+
<li><%= message %></li>
|
|
8
|
+
<% end %>
|
|
9
|
+
</ul>
|
|
10
|
+
</div>
|
|
11
|
+
<% end %>
|
|
12
|
+
|
|
13
|
+
<div class="form-group">
|
|
14
|
+
<%= form.label :slug, "Item slug (optional)" %>
|
|
15
|
+
<%= form.text_field :slug, class: "form-control" %>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<% @collection.fields.each do |field| %>
|
|
19
|
+
<%= render "field_input", field: field, value: (@item.draft_data || {})[field["id"]], media: @media %>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<div class="form-actions">
|
|
23
|
+
<%= form.submit "Save draft", class: "btn btn-primary" %>
|
|
24
|
+
<%= link_to "Cancel", admin_collection_items_path(@collection), class: "btn btn-secondary" %>
|
|
25
|
+
</div>
|
|
26
|
+
<% end %>
|
|
27
|
+
|
|
28
|
+
<%= javascript_include_tag "active_canvas/admin/media_select_preview" %>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<% content_for :page_title, "Edit #{@collection.name} item" %>
|
|
2
|
+
|
|
3
|
+
<div class="page-header">
|
|
4
|
+
<h2>Edit item</h2>
|
|
5
|
+
<div>
|
|
6
|
+
<%= link_to "History", history_admin_collection_item_path(@collection, @item), class: "btn btn-secondary" %>
|
|
7
|
+
<% if @item.status != "published" %>
|
|
8
|
+
<%= button_to "Publish", publish_admin_collection_item_path(@collection, @item), method: :patch, class: "btn btn-primary", form: { style: "display:inline" } %>
|
|
9
|
+
<% else %>
|
|
10
|
+
<% if @item.pending_changes? %>
|
|
11
|
+
<%= button_to "Publish changes", publish_admin_collection_item_path(@collection, @item), method: :patch, class: "btn btn-primary", form: { style: "display:inline" } %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<%= button_to "Unpublish", unpublish_admin_collection_item_path(@collection, @item), method: :patch, class: "btn btn-secondary", form: { style: "display:inline" } %>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<% if @item.status == "published" && @item.pending_changes? %>
|
|
19
|
+
<p class="help-text" style="margin:0 0 1rem;">This item has draft changes that visitors do not see yet. Publish changes to make them live.</p>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<div class="card" style="padding:1.5rem;">
|
|
23
|
+
<%= render "form" %>
|
|
24
|
+
</div>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<% content_for :page_title, "Item history" %>
|
|
2
|
+
|
|
3
|
+
<div class="page-header">
|
|
4
|
+
<h2>History</h2>
|
|
5
|
+
<%= link_to "Back to item", edit_admin_collection_item_path(@collection, @item), class: "btn btn-secondary" %>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<div class="card">
|
|
9
|
+
<% if @versions.any? %>
|
|
10
|
+
<% labels = @collection.fields.each_with_object({}) { |f, acc| acc[f["id"]] = f["label"] } %>
|
|
11
|
+
<% @versions.each do |version| %>
|
|
12
|
+
<div style="padding:1rem;border-bottom:1px solid var(--border,#eee);">
|
|
13
|
+
<h3>Version <%= version.version_number %></h3>
|
|
14
|
+
<p>
|
|
15
|
+
<small>
|
|
16
|
+
<%= version.created_at.strftime("%Y-%m-%d %H:%M") %>
|
|
17
|
+
<% if version.changed_by.present? %> · by <%= version.changed_by %><% end %>
|
|
18
|
+
</small>
|
|
19
|
+
</p>
|
|
20
|
+
<dl>
|
|
21
|
+
<% (version.data || {}).each do |field_id, value| %>
|
|
22
|
+
<dt><%= labels[field_id] || field_id %><% unless labels.key?(field_id) %> <small>(removed field)</small><% end %></dt>
|
|
23
|
+
<dd><%= truncate(value.to_s, length: 120) %></dd>
|
|
24
|
+
<% end %>
|
|
25
|
+
</dl>
|
|
26
|
+
</div>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% else %>
|
|
29
|
+
<div class="empty-state">
|
|
30
|
+
<h3>No versions yet</h3>
|
|
31
|
+
<p>Publishing this item will record a snapshot here.</p>
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<% content_for :page_title, "#{@collection.name} items" %>
|
|
2
|
+
|
|
3
|
+
<div class="page-header">
|
|
4
|
+
<h2><%= @collection.name %> · Items</h2>
|
|
5
|
+
<%= link_to "New item", new_admin_collection_item_path(@collection), class: "btn btn-primary" %>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<div class="card">
|
|
9
|
+
<%= form_with url: admin_collection_items_path(@collection), method: :get, local: true, style: "padding:1rem;" do %>
|
|
10
|
+
<%= label_tag :status, "Status" %>
|
|
11
|
+
<%= select_tag :status,
|
|
12
|
+
options_for_select([ [ "All", "" ], [ "Draft", "draft" ], [ "Published", "published" ] ], params[:status]),
|
|
13
|
+
class: "form-control", style: "display:inline-block;width:auto;", onchange: "this.form.requestSubmit ? this.form.requestSubmit() : this.form.submit()" %>
|
|
14
|
+
<%= submit_tag "Filter", class: "btn btn-sm btn-secondary" %>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
<% if @items.any? %>
|
|
18
|
+
<table>
|
|
19
|
+
<thead>
|
|
20
|
+
<tr>
|
|
21
|
+
<% @leading_fields.each do |field| %>
|
|
22
|
+
<th><%= field["label"] %></th>
|
|
23
|
+
<% end %>
|
|
24
|
+
<th>Status</th>
|
|
25
|
+
<th>Updated</th>
|
|
26
|
+
<th></th>
|
|
27
|
+
</tr>
|
|
28
|
+
</thead>
|
|
29
|
+
<tbody>
|
|
30
|
+
<% @items.each do |item| %>
|
|
31
|
+
<tr>
|
|
32
|
+
<% @leading_fields.each do |field| %>
|
|
33
|
+
<td><%= collection_cell(item, field) %></td>
|
|
34
|
+
<% end %>
|
|
35
|
+
<td><%= collection_status_badge(item) %></td>
|
|
36
|
+
<td><%= item.updated_at.strftime("%Y-%m-%d %H:%M") %></td>
|
|
37
|
+
<td class="actions" style="white-space:nowrap;">
|
|
38
|
+
<%= link_to "Edit", edit_admin_collection_item_path(@collection, item), class: "btn btn-sm btn-secondary" %>
|
|
39
|
+
<% if item.status != "published" || item.pending_changes? %>
|
|
40
|
+
<%= button_to (item.status == "published" ? "Publish changes" : "Publish"), publish_admin_collection_item_path(@collection, item),
|
|
41
|
+
method: :patch, class: "btn btn-sm btn-primary", form: { style: "display:inline" } %>
|
|
42
|
+
<% end %>
|
|
43
|
+
<% if item.status == "published" %>
|
|
44
|
+
<%= button_to "Unpublish", unpublish_admin_collection_item_path(@collection, item),
|
|
45
|
+
method: :patch, class: "btn btn-sm btn-secondary", form: { style: "display:inline" } %>
|
|
46
|
+
<% end %>
|
|
47
|
+
<%= link_to "History", history_admin_collection_item_path(@collection, item), class: "btn btn-sm btn-secondary" %>
|
|
48
|
+
<%= button_to "Delete", admin_collection_item_path(@collection, item), method: :delete,
|
|
49
|
+
class: "btn btn-sm btn-danger", form: { style: "display:inline" },
|
|
50
|
+
onclick: "return confirm('Delete this item?')" %>
|
|
51
|
+
</td>
|
|
52
|
+
</tr>
|
|
53
|
+
<% end %>
|
|
54
|
+
</tbody>
|
|
55
|
+
</table>
|
|
56
|
+
<% else %>
|
|
57
|
+
<div class="empty-state">
|
|
58
|
+
<h3>No items yet</h3>
|
|
59
|
+
<p>Add an item to populate this collection.</p>
|
|
60
|
+
<%= link_to "New item", new_admin_collection_item_path(@collection), class: "btn btn-primary" %>
|
|
61
|
+
</div>
|
|
62
|
+
<% end %>
|
|
63
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<div class="form-group" id="ac-field-builder">
|
|
2
|
+
<label>Fields</label>
|
|
3
|
+
<p><small>Define the typed columns of this collection. Field IDs are generated from labels and never change once created.</small></p>
|
|
4
|
+
|
|
5
|
+
<script type="application/json" id="ac-fields-data"><%= raw json_escape(@collection.fields_json) %></script>
|
|
6
|
+
<ul id="ac-fields-list" style="list-style:none;padding:0;margin:0;"></ul>
|
|
7
|
+
<button type="button" id="ac-add-field-btn" class="btn btn-sm btn-secondary">Add field</button>
|
|
8
|
+
|
|
9
|
+
<%= form.hidden_field :fields_json, id: "ac-fields-json" %>
|
|
10
|
+
|
|
11
|
+
<template id="ac-field-row-template">
|
|
12
|
+
<li class="ac-field-row card" style="display:flex;gap:.5rem;align-items:center;padding:.5rem;margin-bottom:.5rem;">
|
|
13
|
+
<input type="hidden" data-field="id">
|
|
14
|
+
<span style="flex:2;display:flex;flex-direction:column;gap:2px;">
|
|
15
|
+
<input type="text" data-field="label" class="form-control" placeholder="Label">
|
|
16
|
+
<code data-field-id-display class="ac-field-id" style="font-size:.7rem;opacity:.7;" title="Use this in the page: {{ item.<id> }}"></code>
|
|
17
|
+
</span>
|
|
18
|
+
<select data-field="type" class="form-control" style="flex:1;">
|
|
19
|
+
<option value="text">Text</option>
|
|
20
|
+
<option value="textarea">Textarea</option>
|
|
21
|
+
<option value="rich_text">Rich text (HTML)</option>
|
|
22
|
+
<option value="number">Number</option>
|
|
23
|
+
<option value="boolean">Boolean</option>
|
|
24
|
+
<option value="date">Date</option>
|
|
25
|
+
<option value="select">Select</option>
|
|
26
|
+
<option value="media">Media</option>
|
|
27
|
+
</select>
|
|
28
|
+
<input type="text" data-field="options" class="form-control ac-field-options" placeholder="a, b, c" style="flex:1;" hidden>
|
|
29
|
+
<label style="white-space:nowrap;"><input type="checkbox" data-field="required"> Required</label>
|
|
30
|
+
<button type="button" data-move="up" class="btn btn-sm btn-secondary" title="Move up">↑</button>
|
|
31
|
+
<button type="button" data-move="down" class="btn btn-sm btn-secondary" title="Move down">↓</button>
|
|
32
|
+
<button type="button" data-remove class="btn btn-sm btn-danger" title="Remove">×</button>
|
|
33
|
+
</li>
|
|
34
|
+
</template>
|
|
35
|
+
</div>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<%= form_with model: [ :admin, @collection ] do |form| %>
|
|
2
|
+
<% if @collection.errors.any? %>
|
|
3
|
+
<div class="error-messages">
|
|
4
|
+
<ul>
|
|
5
|
+
<% @collection.errors.full_messages.each do |message| %>
|
|
6
|
+
<li><%= message %></li>
|
|
7
|
+
<% end %>
|
|
8
|
+
</ul>
|
|
9
|
+
</div>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<div class="form-group">
|
|
13
|
+
<%= form.label :name %>
|
|
14
|
+
<%= form.text_field :name, class: "form-control" %>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="form-group">
|
|
18
|
+
<%= form.label :slug %>
|
|
19
|
+
<%= form.text_field :slug, class: "form-control" %>
|
|
20
|
+
<small>The Liquid source name used in <code>{% for x in slug %}</code>. Leave blank to derive from the name.</small>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<%= render "field_builder", form: form %>
|
|
24
|
+
|
|
25
|
+
<div class="form-actions">
|
|
26
|
+
<%= form.submit "Save collection", class: "btn btn-primary" %>
|
|
27
|
+
<%= link_to "Cancel", admin_collections_path, class: "btn btn-secondary" %>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<%= javascript_include_tag "active_canvas/admin/field_builder" %>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<% content_for :page_title, @collection.name %>
|
|
2
|
+
|
|
3
|
+
<div class="page-header">
|
|
4
|
+
<h2>Edit collection</h2>
|
|
5
|
+
<%= link_to "Manage items", admin_collection_items_path(@collection), class: "btn btn-secondary" %>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<div class="card" style="padding:1.5rem;">
|
|
9
|
+
<%= render "form" %>
|
|
10
|
+
</div>
|