Skip to content

Commit

Permalink
Add turbo stream tag
Browse files Browse the repository at this point in the history
  • Loading branch information
treagod committed Mar 25, 2024
1 parent 7baf887 commit 28f119f
Show file tree
Hide file tree
Showing 6 changed files with 308 additions and 24 deletions.
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,43 @@ Marten Turbo introduces a new template tag `dom_id`, which supports the creation

Identifier will respect your namespace of the model. I.e. if you have an Article model in the app blogging the generated id will be `blogging_article_1`.

Marten Turbo also provides a turbo stream tag helper

```html
{% turbo_stream.append "articles" do %}
<div class="{% dom_id article %}">
{{ article.name }}
</div>
{% end_turbostream %}
<!--
<turbo-stream action="append" target="articles">
<template>
<div class="article_1">
My First Article
</div>
</template>
</turbo-stream>
-->
<!-- or in one line -->
{% turbo_stream.append "articles" template: "articles/article.html" %}
<!--
<turbo-stream action="append" target="articles">
<template>
content of "articles/article.html"
</template>
</turbo-stream>
-->
<!-- dom_id is automatically applied if targeting a record -->
{% turbo_stream.remove article %}
<!--
<turbo-stream action="remove" target="article_1">
</turbo-stream>
-->
```


## Handlers

Expand Down
127 changes: 127 additions & 0 deletions spec/marten-turbo/template/tag/turbo_stream_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
require "../../../spec_helper"

describe MartenTurbo::Template::Tag::TurboStream do
describe "::new" do
it "raises if turbo_stream does not define an action" do
parser = Marten::Template::Parser.new("")

expect_raises(
Marten::Template::Errors::InvalidSyntax,
"Malformed turbo_stream tag: you must define an action"
) do
MartenTurbo::Template::Tag::TurboStream.new(parser, "turbo_stream")
end
end

it "raises if turbo_stream does not define a target_id" do
parser = Marten::Template::Parser.new("{% turbo_stream.append %}")

expect_raises(
Marten::Template::Errors::InvalidSyntax,
"Malformed turbo_stream tag: you must define a target id"
) do
MartenTurbo::Template::Tag::TurboStream.new(parser, "turbo_stream.append")
end
end

it "raises if turbo_stream block is not closed when 'do' is present at the end" do
parser = Marten::Template::Parser.new(
<<-TEMPLATE
<p>some content</p>
TEMPLATE
)

expect_raises(
Marten::Template::Errors::InvalidSyntax,
"Unclosed tags, expected: end_turbostream"
) do
MartenTurbo::Template::Tag::TurboStream.new(parser, "turbo_stream.append 'tags' do")
end
end
end

describe "#render" do
it "properly renders a turbo-stream tag with the correct action and target" do
parser = Marten::Template::Parser.new("")
tag = MartenTurbo::Template::Tag::TurboStream.new(parser, "turbo_stream.remove 'my-id'")

content = tag.render(Marten::Template::Context.new)
content.should contain "<turbo-stream action=\"remove\" target=\"my-id\">"
content.should_not contain "<template>"
end

it "properly renders a turbo-stream tag with the correct action and target when given a Marten::Model" do
tag_model = Tag.create!(name: "Marten Turbo")

parser = Marten::Template::Parser.new("")
tag = MartenTurbo::Template::Tag::TurboStream.new(parser, "turbo_stream.remove tag")

context = Marten::Template::Context{"tag" => tag_model}

tag.render(context).should contain "<turbo-stream action=\"remove\" target=\"tag_#{tag_model.pk!}\">"
end

it "properly renders a turbo-stream tag with correct specified template" do
tag_model = Tag.create!(name: "Marten Turbo")

parser = Marten::Template::Parser.new("")
tag = MartenTurbo::Template::Tag::TurboStream.new(
parser,
"turbo_stream.append \"tags\" template: \"tags/tag.html\""
)

context = Marten::Template::Context{"tag" => tag_model}

content = tag.render(context)
content.should contain "<div class=\"tag_#{tag_model.pk}\">"
content.should contain "Marten Turbo"
end

it "raises if the specified template could not be found" do
tag_model = Tag.create!(name: "Marten Turbo")

parser = Marten::Template::Parser.new("")
tag = MartenTurbo::Template::Tag::TurboStream.new(
parser,
"turbo_stream.append \"tags\" template: \"tags/not_existing_tag.html\""
)

context = Marten::Template::Context{"tag" => tag_model}

expect_raises(
Marten::Template::Errors::TemplateNotFound,
"Template tags/not_existing_tag.html could not be found"
) do
tag.render(context)
end
end

it "raises if the specified template value is not a string" do
tag_model = Tag.create!(name: "Marten Turbo")

parser = Marten::Template::Parser.new("")
tag = MartenTurbo::Template::Tag::TurboStream.new(parser, "turbo_stream.append \"tags\" template: 1")

context = Marten::Template::Context{"tag" => tag_model}

expect_raises(
Marten::Template::Errors::UnsupportedValue,
"Template name must resolve to a string, git a Int32 instead."
) do
tag.render(context)
end
end

it "properly renders a turbo_stream block if 'do' is present as last argument" do
parser = Marten::Template::Parser.new(
<<-TEMPLATE
<p>some content</p>
{% end_turbostream %}
TEMPLATE
)
tag = MartenTurbo::Template::Tag::TurboStream.new(parser, "turbo_stream.append 'tags' do")

tag.render(Marten::Template::Context.new).should contain "<p>some content</p>"
end
end
end
3 changes: 3 additions & 0 deletions spec/test_project/templates/tags/tag.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div class="{% dom_id tag %}">
{{ tag.name }}
</div>
31 changes: 31 additions & 0 deletions src/marten_turbo/template/tag/concerncs/dom_identifier.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module MartenTurbo
module Template
module Tag
module Identifiable
def create_dom_id(value : Marten::Template::Value, prefix : Marten::Template::Value? = nil)
if value.raw.is_a? Marten::Model
generate_id_for_model(value.raw.as(Marten::Model), prefix)
else
dom_id = value.to_s
prefix ? "#{prefix}_#{dom_id}" : dom_id
end
end

private def formatted_prefix(prefix)
prefix ? "#{prefix}_" : ""
end

private def generate_id_for_model(model, prefix)
identifier = model.class.name.downcase.gsub(RE_NAMESPACE_IDENTIFIER, '_')
if model.new_record?
"#{formatted_prefix(prefix)}new_#{identifier}"
else
"#{formatted_prefix(prefix)}#{identifier}_#{model.pk}"
end
end

RE_NAMESPACE_IDENTIFIER = /(?:\:\:|\.)/
end
end
end
end
28 changes: 4 additions & 24 deletions src/marten_turbo/template/tag/dom_id.cr
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
require "./concerncs/dom_identifier"

module MartenTurbo
module Template
module Tag
class DomId < Marten::Template::Tag::Base
include Marten::Template::Tag::CanSplitSmartly
include Identifiable

@instance_name : Marten::Template::Variable
@prefix : Marten::Template::Variable? = nil
Expand All @@ -21,31 +24,8 @@ module MartenTurbo
end

def render(context : Marten::Template::Context) : String
instance = @instance_name.resolve(context)
prefix = @prefix.try(&.resolve(context))

if instance.raw.is_a? Marten::Model
generate_id_for_model(instance.raw.as(Marten::Model), prefix)
else
dom_id = instance.to_s
prefix ? "#{prefix}_#{dom_id}" : dom_id
end
end

private def formatted_prefix(prefix)
prefix ? "#{prefix}_" : ""
create_dom_id @instance_name.resolve(context), @prefix.try(&.resolve(context))
end

private def generate_id_for_model(model, prefix)
identifier = model.class.name.downcase.gsub(RE_NAMESPACE_IDENTIFIER, '_')
if model.new_record?
"#{formatted_prefix(prefix)}new_#{identifier}"
else
"#{formatted_prefix(prefix)}#{identifier}_#{model.pk}"
end
end

RE_NAMESPACE_IDENTIFIER = /(?:\:\:|\.)/
end
end
end
Expand Down
106 changes: 106 additions & 0 deletions src/marten_turbo/template/tag/turbo_stream.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
require "./concerncs/dom_identifier"

module MartenTurbo
module Template
module Tag
class TurboStream < Marten::Template::Tag::Base
include Marten::Template::Tag::CanSplitSmartly
include Marten::Template::Tag::CanExtractKwargs
include Identifiable

@turbo_stream_nodes : Marten::Template::NodeSet?
@action : String
@target_id : Marten::Template::Variable

def initialize(parser : Marten::Template::Parser, source : String)
parts = split_smartly(source)

tag_name_parts = parts[0].split(".")

if tag_name_parts.size != 2
raise Marten::Template::Errors::InvalidSyntax.new(
"Malformed turbo_stream tag: you must define an action"
)
end

@action = tag_name_parts[1]

if parts.size < 2
raise Marten::Template::Errors::InvalidSyntax.new(
"Malformed turbo_stream tag: you must define a target id"
)
end

@target_id = Marten::Template::Variable.new(parts[1])

if parts[-1] == "do"
@turbo_stream_nodes = parser.parse(up_to: {"end_turbostream"})
parser.shift_token
kwargs_parts = parts[2...-2]
else
kwargs_parts = parts[2..]
end

@kwargs = {} of String => Marten::Template::FilterExpression
extract_kwargs(kwargs_parts.join(' ')).each do |key, value|
@kwargs[key] = Marten::Template::FilterExpression.new(value)
end
end

private def render_template_tag(content)
return "" unless content

<<-TEMPLATE_TAG
<template>
#{content}
</template>
TEMPLATE_TAG
end

private def render_turbo_stream_tag(target_id, content)
<<-TURBO_STREAM_TAG
<turbo-stream action="#{@action}" target="#{target_id}">
#{render_template_tag(content)}
</turbo-stream>
TURBO_STREAM_TAG
end

def render(context : Marten::Template::Context) : String
content = if turbo_stream_nodes = @turbo_stream_nodes
turbo_stream_nodes.render(context)
else
nil
end

template = nil

@kwargs.each do |param_name, param_expression|
raw_param_value = param_expression.resolve(context).raw

# Ensure that the raw param value can be used as an URL parameter.
unless raw_param_value.is_a?(Marten::Routing::Parameter::Types)
raise Marten::Template::Errors::UnsupportedType.new(
"#{raw_param_value.class} objects cannot be used as URL parameters"
)
end

if param_name == "template"
unless raw_param_value.is_a?(String)
raise Marten::Template::Errors::UnsupportedValue.new(
"Template name must resolve to a string, git a #{raw_param_value.class} instead."
)
end
template = Marten.templates.get_template(raw_param_value)
end
end

if template
content = template.render(context)
end

render_turbo_stream_tag(create_dom_id(@target_id.resolve(context)), content)
end
end
end
end
end

0 comments on commit 28f119f

Please sign in to comment.