- <%= image_tag "code_placeholder_3.jpg", class: "w-full h-fit mt-10 mx-auto max-w-[22rem] lg:max-w-[26.25rem] md:mt-0 md:mx-0" %>
+ <%= vite_image_tag "images/code_placeholder_3.jpg", class: "w-full h-fit mt-10 mx-auto max-w-[22rem] lg:max-w-[26.25rem] md:mt-0 md:mx-0" %>
@@ -145,7 +145,7 @@
asdfasdfasdfsdfasdfasdfs
- <%= image_tag "code_placeholder_2.png", class: "hidden lg:block rounded relative h-[29rem] bottom-[29rem] left-[54rem] -mb-[29rem]" %>
+ <%= vite_image_tag "images/code_placeholder_2.png", class: "hidden lg:block rounded relative h-[29rem] bottom-[29rem] left-[54rem] -mb-[29rem]" %>
diff --git a/app/controllers/views_controller.rb b/app/controllers/views_controller.rb
index 34254e2..ecb8e16 100644
--- a/app/controllers/views_controller.rb
+++ b/app/controllers/views_controller.rb
@@ -91,20 +91,20 @@ def api(*) # FIXME: implement
end
- # = image_tag "info_icon.svg"
+ # = vite_image_tag "info_icon.svg"
def info(type: :info, &block)
- box(img: "info_icon.svg", bg: "bg-bg-purple-1/50", &block)
+ box(img: "images/info_icon.svg", bg: "bg-bg-purple-1/50", &block)
end
def warning(&block)
- box(img: "light_bulb_icon.svg", bg: "bg-bg-orange", &block)
+ box(img: "images/light_bulb_icon.svg", bg: "bg-bg-orange", &block)
end
def box(bg:, img:, &block) # TODO: use cell for this.
kramdown_options = @options.fetch(:kramdown_options)
convert_method = kramdown_options.fetch(:converter)
- icon_tag = @options[:controller].helpers.image_tag img
+ icon_tag = @options[:controller].helpers.vite_image_tag img
html = yield
html = Kramdown::Document.new(html, kramdown_options).send(convert_method) # TODO: encapsulate that.
@@ -158,15 +158,15 @@ def capture(&block)
block
end
- My::Cell.delegate_to_controller_helpers(self, :image_tag)
+ My::Cell.delegate_to_controller_helpers(self, :vite_image_tag)
- module ImageTag
- def image_tag(*args, **options)
+ module ViteImageTag
+ def vite_image_tag(*args, **options)
super(*args, **Cms::Config.tailwind.img, **options)
end
end
- include ImageTag
+ include ViteImageTag
module H
class Render < Torture::Cms::Helper::Header::Render
@@ -225,7 +225,15 @@ def initialize(controller:, **options)
@options = options.merge(controller: controller) # TODO: find way how to specify required kws.
end
- My::Cell.delegate_to_controller_helpers(self, :csrf_meta_tags, :csp_meta_tag, :stylesheet_link_tag, :javascript_importmap_tags)
+ My::Cell.delegate_to_controller_helpers(
+ self,
+ :csrf_meta_tags,
+ :csp_meta_tag,
+ :stylesheet_link_tag,
+ :vite_client_tag,
+ :vite_javascript_tag,
+ :vite_stylesheet_tag
+ )
def script_for_page_identifier
%()
@@ -248,7 +256,13 @@ def initialize(controller:, **options)
@options = options.merge(controller: controller)
end
- My::Cell.delegate_to_controller_helpers(self, :link_to, :image_tag) # navbar.erb
+ My::Cell.delegate_to_controller_helpers(
+ self,
+ :link_to,
+ :vite_image_tag,
+ :vite_asset_path,
+ :vite_asset_url
+ ) # navbar.erb
def render(template)
::Cell.({template: template, exec_context: self}) # DISCUSS: does {render} always mean we want the same exec_context?
@@ -282,7 +296,7 @@ def navbar_link_to(text, path, is: nil)
end
def navbar_logo
- "logo_blue_ruby.svg"
+ "images/logo_blue_ruby.svg"
end
def navbar_options
@@ -504,7 +518,7 @@ def navbar_div_options
end
def navbar_logo
- "logo_white_ruby.svg"
+ "images/logo_white_ruby.svg"
end
My::Cell.delegate_to_controller_helpers(self, :asset_path)
diff --git a/app/frontend/entrypoints/application.css b/app/frontend/entrypoints/application.css
new file mode 100644
index 0000000..e737a5a
--- /dev/null
+++ b/app/frontend/entrypoints/application.css
@@ -0,0 +1,5 @@
+@import "../stylesheets/tailwind/application.css";
+@import "../stylesheets/application.css";
+@import "../stylesheets/highlightjs-trb.css";
+@import "../stylesheets/open-sauce.css";
+@import "../stylesheets/anchor-js.css";
diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js
new file mode 100644
index 0000000..1d56420
--- /dev/null
+++ b/app/frontend/entrypoints/application.js
@@ -0,0 +1 @@
+import '../javascript/application.js'
diff --git a/app/assets/fonts/Open Sauce One OFL.txt b/app/frontend/fonts/Open Sauce One OFL.txt
similarity index 100%
rename from app/assets/fonts/Open Sauce One OFL.txt
rename to app/frontend/fonts/Open Sauce One OFL.txt
diff --git a/app/assets/fonts/Open Sauce Sans OFL.txt b/app/frontend/fonts/Open Sauce Sans OFL.txt
similarity index 100%
rename from app/assets/fonts/Open Sauce Sans OFL.txt
rename to app/frontend/fonts/Open Sauce Sans OFL.txt
diff --git a/app/assets/fonts/Open Sauce Two OFL.txt b/app/frontend/fonts/Open Sauce Two OFL.txt
similarity index 100%
rename from app/assets/fonts/Open Sauce Two OFL.txt
rename to app/frontend/fonts/Open Sauce Two OFL.txt
diff --git a/app/assets/fonts/OpenSauceOne-Black.ttf b/app/frontend/fonts/OpenSauceOne-Black.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-Black.ttf
rename to app/frontend/fonts/OpenSauceOne-Black.ttf
diff --git a/app/assets/fonts/OpenSauceOne-BlackItalic.ttf b/app/frontend/fonts/OpenSauceOne-BlackItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-BlackItalic.ttf
rename to app/frontend/fonts/OpenSauceOne-BlackItalic.ttf
diff --git a/app/assets/fonts/OpenSauceOne-Bold.ttf b/app/frontend/fonts/OpenSauceOne-Bold.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-Bold.ttf
rename to app/frontend/fonts/OpenSauceOne-Bold.ttf
diff --git a/app/assets/fonts/OpenSauceOne-BoldItalic.ttf b/app/frontend/fonts/OpenSauceOne-BoldItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-BoldItalic.ttf
rename to app/frontend/fonts/OpenSauceOne-BoldItalic.ttf
diff --git a/app/assets/fonts/OpenSauceOne-ExtraBold.ttf b/app/frontend/fonts/OpenSauceOne-ExtraBold.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-ExtraBold.ttf
rename to app/frontend/fonts/OpenSauceOne-ExtraBold.ttf
diff --git a/app/assets/fonts/OpenSauceOne-ExtraBoldItalic.ttf b/app/frontend/fonts/OpenSauceOne-ExtraBoldItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-ExtraBoldItalic.ttf
rename to app/frontend/fonts/OpenSauceOne-ExtraBoldItalic.ttf
diff --git a/app/assets/fonts/OpenSauceOne-Italic.ttf b/app/frontend/fonts/OpenSauceOne-Italic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-Italic.ttf
rename to app/frontend/fonts/OpenSauceOne-Italic.ttf
diff --git a/app/assets/fonts/OpenSauceOne-Light.ttf b/app/frontend/fonts/OpenSauceOne-Light.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-Light.ttf
rename to app/frontend/fonts/OpenSauceOne-Light.ttf
diff --git a/app/assets/fonts/OpenSauceOne-LightItalic.ttf b/app/frontend/fonts/OpenSauceOne-LightItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-LightItalic.ttf
rename to app/frontend/fonts/OpenSauceOne-LightItalic.ttf
diff --git a/app/assets/fonts/OpenSauceOne-Medium.ttf b/app/frontend/fonts/OpenSauceOne-Medium.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-Medium.ttf
rename to app/frontend/fonts/OpenSauceOne-Medium.ttf
diff --git a/app/assets/fonts/OpenSauceOne-MediumItalic.ttf b/app/frontend/fonts/OpenSauceOne-MediumItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-MediumItalic.ttf
rename to app/frontend/fonts/OpenSauceOne-MediumItalic.ttf
diff --git a/app/assets/fonts/OpenSauceOne-Regular.ttf b/app/frontend/fonts/OpenSauceOne-Regular.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-Regular.ttf
rename to app/frontend/fonts/OpenSauceOne-Regular.ttf
diff --git a/app/assets/fonts/OpenSauceOne-SemiBold.ttf b/app/frontend/fonts/OpenSauceOne-SemiBold.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-SemiBold.ttf
rename to app/frontend/fonts/OpenSauceOne-SemiBold.ttf
diff --git a/app/assets/fonts/OpenSauceOne-SemiBoldItalic.ttf b/app/frontend/fonts/OpenSauceOne-SemiBoldItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceOne-SemiBoldItalic.ttf
rename to app/frontend/fonts/OpenSauceOne-SemiBoldItalic.ttf
diff --git a/app/assets/fonts/OpenSauceSans-Black.ttf b/app/frontend/fonts/OpenSauceSans-Black.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-Black.ttf
rename to app/frontend/fonts/OpenSauceSans-Black.ttf
diff --git a/app/assets/fonts/OpenSauceSans-BlackItalic.ttf b/app/frontend/fonts/OpenSauceSans-BlackItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-BlackItalic.ttf
rename to app/frontend/fonts/OpenSauceSans-BlackItalic.ttf
diff --git a/app/assets/fonts/OpenSauceSans-Bold.ttf b/app/frontend/fonts/OpenSauceSans-Bold.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-Bold.ttf
rename to app/frontend/fonts/OpenSauceSans-Bold.ttf
diff --git a/app/assets/fonts/OpenSauceSans-BoldItalic.ttf b/app/frontend/fonts/OpenSauceSans-BoldItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-BoldItalic.ttf
rename to app/frontend/fonts/OpenSauceSans-BoldItalic.ttf
diff --git a/app/assets/fonts/OpenSauceSans-ExtraBold.ttf b/app/frontend/fonts/OpenSauceSans-ExtraBold.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-ExtraBold.ttf
rename to app/frontend/fonts/OpenSauceSans-ExtraBold.ttf
diff --git a/app/assets/fonts/OpenSauceSans-ExtraBoldItalic.ttf b/app/frontend/fonts/OpenSauceSans-ExtraBoldItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-ExtraBoldItalic.ttf
rename to app/frontend/fonts/OpenSauceSans-ExtraBoldItalic.ttf
diff --git a/app/assets/fonts/OpenSauceSans-Italic.ttf b/app/frontend/fonts/OpenSauceSans-Italic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-Italic.ttf
rename to app/frontend/fonts/OpenSauceSans-Italic.ttf
diff --git a/app/assets/fonts/OpenSauceSans-Light.ttf b/app/frontend/fonts/OpenSauceSans-Light.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-Light.ttf
rename to app/frontend/fonts/OpenSauceSans-Light.ttf
diff --git a/app/assets/fonts/OpenSauceSans-LightItalic.ttf b/app/frontend/fonts/OpenSauceSans-LightItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-LightItalic.ttf
rename to app/frontend/fonts/OpenSauceSans-LightItalic.ttf
diff --git a/app/assets/fonts/OpenSauceSans-Medium.ttf b/app/frontend/fonts/OpenSauceSans-Medium.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-Medium.ttf
rename to app/frontend/fonts/OpenSauceSans-Medium.ttf
diff --git a/app/assets/fonts/OpenSauceSans-MediumItalic.ttf b/app/frontend/fonts/OpenSauceSans-MediumItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-MediumItalic.ttf
rename to app/frontend/fonts/OpenSauceSans-MediumItalic.ttf
diff --git a/app/assets/fonts/OpenSauceSans-Regular.ttf b/app/frontend/fonts/OpenSauceSans-Regular.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-Regular.ttf
rename to app/frontend/fonts/OpenSauceSans-Regular.ttf
diff --git a/app/assets/fonts/OpenSauceSans-SemiBold.ttf b/app/frontend/fonts/OpenSauceSans-SemiBold.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-SemiBold.ttf
rename to app/frontend/fonts/OpenSauceSans-SemiBold.ttf
diff --git a/app/assets/fonts/OpenSauceSans-SemiBoldItalic.ttf b/app/frontend/fonts/OpenSauceSans-SemiBoldItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceSans-SemiBoldItalic.ttf
rename to app/frontend/fonts/OpenSauceSans-SemiBoldItalic.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-Black.ttf b/app/frontend/fonts/OpenSauceTwo-Black.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-Black.ttf
rename to app/frontend/fonts/OpenSauceTwo-Black.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-BlackItalic.ttf b/app/frontend/fonts/OpenSauceTwo-BlackItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-BlackItalic.ttf
rename to app/frontend/fonts/OpenSauceTwo-BlackItalic.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-Bold.ttf b/app/frontend/fonts/OpenSauceTwo-Bold.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-Bold.ttf
rename to app/frontend/fonts/OpenSauceTwo-Bold.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-BoldItalic.ttf b/app/frontend/fonts/OpenSauceTwo-BoldItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-BoldItalic.ttf
rename to app/frontend/fonts/OpenSauceTwo-BoldItalic.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-ExtraBold.ttf b/app/frontend/fonts/OpenSauceTwo-ExtraBold.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-ExtraBold.ttf
rename to app/frontend/fonts/OpenSauceTwo-ExtraBold.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-ExtraBoldItalic.ttf b/app/frontend/fonts/OpenSauceTwo-ExtraBoldItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-ExtraBoldItalic.ttf
rename to app/frontend/fonts/OpenSauceTwo-ExtraBoldItalic.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-Italic.ttf b/app/frontend/fonts/OpenSauceTwo-Italic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-Italic.ttf
rename to app/frontend/fonts/OpenSauceTwo-Italic.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-Light.ttf b/app/frontend/fonts/OpenSauceTwo-Light.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-Light.ttf
rename to app/frontend/fonts/OpenSauceTwo-Light.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-LightItalic.ttf b/app/frontend/fonts/OpenSauceTwo-LightItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-LightItalic.ttf
rename to app/frontend/fonts/OpenSauceTwo-LightItalic.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-Medium.ttf b/app/frontend/fonts/OpenSauceTwo-Medium.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-Medium.ttf
rename to app/frontend/fonts/OpenSauceTwo-Medium.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-MediumItalic.ttf b/app/frontend/fonts/OpenSauceTwo-MediumItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-MediumItalic.ttf
rename to app/frontend/fonts/OpenSauceTwo-MediumItalic.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-Regular.ttf b/app/frontend/fonts/OpenSauceTwo-Regular.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-Regular.ttf
rename to app/frontend/fonts/OpenSauceTwo-Regular.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-SemiBold.ttf b/app/frontend/fonts/OpenSauceTwo-SemiBold.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-SemiBold.ttf
rename to app/frontend/fonts/OpenSauceTwo-SemiBold.ttf
diff --git a/app/assets/fonts/OpenSauceTwo-SemiBoldItalic.ttf b/app/frontend/fonts/OpenSauceTwo-SemiBoldItalic.ttf
similarity index 100%
rename from app/assets/fonts/OpenSauceTwo-SemiBoldItalic.ttf
rename to app/frontend/fonts/OpenSauceTwo-SemiBoldItalic.ttf
diff --git a/app/assets/images/03-new-form-layoutless.webp b/app/frontend/images/03-new-form-layoutless.webp
similarity index 100%
rename from app/assets/images/03-new-form-layoutless.webp
rename to app/frontend/images/03-new-form-layoutless.webp
diff --git a/app/assets/images/03-show-layoutless.webp b/app/frontend/images/03-show-layoutless.webp
similarity index 100%
rename from app/assets/images/03-show-layoutless.webp
rename to app/frontend/images/03-show-layoutless.webp
diff --git a/app/assets/images/Twitter-Facebook-preview-card-image-jungle.webp b/app/frontend/images/Twitter-Facebook-preview-card-image-jungle.webp
similarity index 100%
rename from app/assets/images/Twitter-Facebook-preview-card-image-jungle.webp
rename to app/frontend/images/Twitter-Facebook-preview-card-image-jungle.webp
diff --git a/app/assets/images/Twitter-preview-card-image-jungle.webp b/app/frontend/images/Twitter-preview-card-image-jungle.webp
similarity index 100%
rename from app/assets/images/Twitter-preview-card-image-jungle.webp
rename to app/frontend/images/Twitter-preview-card-image-jungle.webp
diff --git a/app/assets/images/activity-signal.png b/app/frontend/images/activity-signal.png
similarity index 100%
rename from app/assets/images/activity-signal.png
rename to app/frontend/images/activity-signal.png
diff --git a/app/assets/images/activity-signal.webp b/app/frontend/images/activity-signal.webp
similarity index 100%
rename from app/assets/images/activity-signal.webp
rename to app/frontend/images/activity-signal.webp
diff --git a/app/assets/images/advance-domain-ctx.webp b/app/frontend/images/advance-domain-ctx.webp
similarity index 100%
rename from app/assets/images/advance-domain-ctx.webp
rename to app/frontend/images/advance-domain-ctx.webp
diff --git a/app/assets/images/assert-fail-message.png b/app/frontend/images/assert-fail-message.png
similarity index 100%
rename from app/assets/images/assert-fail-message.png
rename to app/frontend/images/assert-fail-message.png
diff --git a/app/assets/images/assert-pass-failing-wtf.png b/app/frontend/images/assert-pass-failing-wtf.png
similarity index 100%
rename from app/assets/images/assert-pass-failing-wtf.png
rename to app/frontend/images/assert-pass-failing-wtf.png
diff --git a/app/assets/images/assert-pass-message.png b/app/frontend/images/assert-pass-message.png
similarity index 100%
rename from app/assets/images/assert-pass-message.png
rename to app/frontend/images/assert-pass-message.png
diff --git a/app/assets/images/assert-pass-wtf.png b/app/frontend/images/assert-pass-wtf.png
similarity index 100%
rename from app/assets/images/assert-pass-wtf.png
rename to app/frontend/images/assert-pass-wtf.png
diff --git a/app/assets/images/back.webp b/app/frontend/images/back.webp
similarity index 100%
rename from app/assets/images/back.webp
rename to app/frontend/images/back.webp
diff --git a/app/assets/images/blog_post_index.webp b/app/frontend/images/blog_post_index.webp
similarity index 100%
rename from app/assets/images/blog_post_index.webp
rename to app/frontend/images/blog_post_index.webp
diff --git a/app/assets/images/blog_post_new_error.webp b/app/frontend/images/blog_post_new_error.webp
similarity index 100%
rename from app/assets/images/blog_post_new_error.webp
rename to app/frontend/images/blog_post_new_error.webp
diff --git a/app/assets/images/book/check-email-fail.webp b/app/frontend/images/book/check-email-fail.webp
similarity index 100%
rename from app/assets/images/book/check-email-fail.webp
rename to app/frontend/images/book/check-email-fail.webp
diff --git a/app/assets/images/book/create-account-01.webp b/app/frontend/images/book/create-account-01.webp
similarity index 100%
rename from app/assets/images/book/create-account-01.webp
rename to app/frontend/images/book/create-account-01.webp
diff --git a/app/assets/images/book/create-account-fail-fast.webp b/app/frontend/images/book/create-account-fail-fast.webp
similarity index 100%
rename from app/assets/images/book/create-account-fail-fast.webp
rename to app/frontend/images/book/create-account-fail-fast.webp
diff --git a/app/assets/images/book/create-account-fail-wrong.webp b/app/frontend/images/book/create-account-fail-wrong.webp
similarity index 100%
rename from app/assets/images/book/create-account-fail-wrong.webp
rename to app/frontend/images/book/create-account-fail-wrong.webp
diff --git a/app/assets/images/book/create-account-fails.webp b/app/frontend/images/book/create-account-fails.webp
similarity index 100%
rename from app/assets/images/book/create-account-fails.webp
rename to app/frontend/images/book/create-account-fails.webp
diff --git a/app/assets/images/book/create-account-wtf.webp b/app/frontend/images/book/create-account-wtf.webp
similarity index 100%
rename from app/assets/images/book/create-account-wtf.webp
rename to app/frontend/images/book/create-account-wtf.webp
diff --git a/app/assets/images/book/create-sub.webp b/app/frontend/images/book/create-sub.webp
similarity index 100%
rename from app/assets/images/book/create-sub.webp
rename to app/frontend/images/book/create-sub.webp
diff --git a/app/assets/images/book/reset-email.webp b/app/frontend/images/book/reset-email.webp
similarity index 100%
rename from app/assets/images/book/reset-email.webp
rename to app/frontend/images/book/reset-email.webp
diff --git a/app/assets/images/book/reset-ok.webp b/app/frontend/images/book/reset-ok.webp
similarity index 100%
rename from app/assets/images/book/reset-ok.webp
rename to app/frontend/images/book/reset-ok.webp
diff --git a/app/assets/images/book/reset-password-fail.webp b/app/frontend/images/book/reset-password-fail.webp
similarity index 100%
rename from app/assets/images/book/reset-password-fail.webp
rename to app/frontend/images/book/reset-password-fail.webp
diff --git a/app/assets/images/book/reset-password.webp b/app/frontend/images/book/reset-password.webp
similarity index 100%
rename from app/assets/images/book/reset-password.webp
rename to app/frontend/images/book/reset-password.webp
diff --git a/app/assets/images/book/reset-sub.webp b/app/frontend/images/book/reset-sub.webp
similarity index 100%
rename from app/assets/images/book/reset-sub.webp
rename to app/frontend/images/book/reset-sub.webp
diff --git a/app/assets/images/book/save-account-error.webp b/app/frontend/images/book/save-account-error.webp
similarity index 100%
rename from app/assets/images/book/save-account-error.webp
rename to app/frontend/images/book/save-account-error.webp
diff --git a/app/assets/images/book/save-account-fail.webp b/app/frontend/images/book/save-account-fail.webp
similarity index 100%
rename from app/assets/images/book/save-account-fail.webp
rename to app/frontend/images/book/save-account-fail.webp
diff --git a/app/assets/images/book/verify-account-fail.webp b/app/frontend/images/book/verify-account-fail.webp
similarity index 100%
rename from app/assets/images/book/verify-account-fail.webp
rename to app/frontend/images/book/verify-account-fail.webp
diff --git a/app/assets/images/book/verify-invalid-id.webp b/app/frontend/images/book/verify-invalid-id.webp
similarity index 100%
rename from app/assets/images/book/verify-invalid-id.webp
rename to app/frontend/images/book/verify-invalid-id.webp
diff --git a/app/assets/images/book/verify-invalid-second.webp b/app/frontend/images/book/verify-invalid-second.webp
similarity index 100%
rename from app/assets/images/book/verify-invalid-second.webp
rename to app/frontend/images/book/verify-invalid-second.webp
diff --git a/app/assets/images/book/verify-invalid-token.webp b/app/frontend/images/book/verify-invalid-token.webp
similarity index 100%
rename from app/assets/images/book/verify-invalid-token.webp
rename to app/frontend/images/book/verify-invalid-token.webp
diff --git a/app/assets/images/book/wtf-password.webp b/app/frontend/images/book/wtf-password.webp
similarity index 100%
rename from app/assets/images/book/wtf-password.webp
rename to app/frontend/images/book/wtf-password.webp
diff --git a/app/assets/images/bpmn-editor-23.png b/app/frontend/images/bpmn-editor-23.png
similarity index 100%
rename from app/assets/images/bpmn-editor-23.png
rename to app/frontend/images/bpmn-editor-23.png
diff --git a/app/assets/images/buildalib-cover.webp b/app/frontend/images/buildalib-cover.webp
similarity index 100%
rename from app/assets/images/buildalib-cover.webp
rename to app/frontend/images/buildalib-cover.webp
diff --git a/app/assets/images/cells-nav-bar.webp b/app/frontend/images/cells-nav-bar.webp
similarity index 100%
rename from app/assets/images/cells-nav-bar.webp
rename to app/frontend/images/cells-nav-bar.webp
diff --git a/app/assets/images/cli/memo-create.png b/app/frontend/images/cli/memo-create.png
similarity index 100%
rename from app/assets/images/cli/memo-create.png
rename to app/frontend/images/cli/memo-create.png
diff --git a/app/assets/images/code.webp b/app/frontend/images/code.webp
similarity index 100%
rename from app/assets/images/code.webp
rename to app/frontend/images/code.webp
diff --git a/app/assets/images/code_placeholder_1.jpg b/app/frontend/images/code_placeholder_1.jpg
similarity index 100%
rename from app/assets/images/code_placeholder_1.jpg
rename to app/frontend/images/code_placeholder_1.jpg
diff --git a/app/assets/images/code_placeholder_2.png b/app/frontend/images/code_placeholder_2.png
similarity index 100%
rename from app/assets/images/code_placeholder_2.png
rename to app/frontend/images/code_placeholder_2.png
diff --git a/app/assets/images/code_placeholder_3.jpg b/app/frontend/images/code_placeholder_3.jpg
similarity index 100%
rename from app/assets/images/code_placeholder_3.jpg
rename to app/frontend/images/code_placeholder_3.jpg
diff --git a/app/assets/images/code_tabs.png b/app/frontend/images/code_tabs.png
similarity index 100%
rename from app/assets/images/code_tabs.png
rename to app/frontend/images/code_tabs.png
diff --git a/app/assets/images/cross-orange-lg.webp b/app/frontend/images/cross-orange-lg.webp
similarity index 100%
rename from app/assets/images/cross-orange-lg.webp
rename to app/frontend/images/cross-orange-lg.webp
diff --git a/app/assets/images/cross-purple-lg.webp b/app/frontend/images/cross-purple-lg.webp
similarity index 100%
rename from app/assets/images/cross-purple-lg.webp
rename to app/frontend/images/cross-purple-lg.webp
diff --git a/app/assets/images/ctx-in.png b/app/frontend/images/ctx-in.png
similarity index 100%
rename from app/assets/images/ctx-in.png
rename to app/frontend/images/ctx-in.png
diff --git a/app/assets/images/ctx-out-all.png b/app/frontend/images/ctx-out-all.png
similarity index 100%
rename from app/assets/images/ctx-out-all.png
rename to app/frontend/images/ctx-out-all.png
diff --git a/app/assets/images/ctx-out.png b/app/frontend/images/ctx-out.png
similarity index 100%
rename from app/assets/images/ctx-out.png
rename to app/frontend/images/ctx-out.png
diff --git a/app/assets/images/debugger-ide-screenshot-august.png b/app/frontend/images/debugger-ide-screenshot-august.png
similarity index 100%
rename from app/assets/images/debugger-ide-screenshot-august.png
rename to app/frontend/images/debugger-ide-screenshot-august.png
diff --git a/app/assets/images/deco1.webp b/app/frontend/images/deco1.webp
similarity index 100%
rename from app/assets/images/deco1.webp
rename to app/frontend/images/deco1.webp
diff --git a/app/assets/images/directory-structure.webp b/app/frontend/images/directory-structure.webp
similarity index 100%
rename from app/assets/images/directory-structure.webp
rename to app/frontend/images/directory-structure.webp
diff --git a/app/assets/images/docu-back.webp b/app/frontend/images/docu-back.webp
similarity index 100%
rename from app/assets/images/docu-back.webp
rename to app/frontend/images/docu-back.webp
diff --git a/app/assets/images/docu-front.webp b/app/frontend/images/docu-front.webp
similarity index 100%
rename from app/assets/images/docu-front.webp
rename to app/frontend/images/docu-front.webp
diff --git a/app/assets/images/docu-middle.webp b/app/frontend/images/docu-middle.webp
similarity index 100%
rename from app/assets/images/docu-middle.webp
rename to app/frontend/images/docu-middle.webp
diff --git a/app/assets/images/double-end.webp b/app/frontend/images/double-end.webp
similarity index 100%
rename from app/assets/images/double-end.webp
rename to app/frontend/images/double-end.webp
diff --git a/app/assets/images/dp.webp b/app/frontend/images/dp.webp
similarity index 100%
rename from app/assets/images/dp.webp
rename to app/frontend/images/dp.webp
diff --git a/app/assets/images/drawing_2.svg b/app/frontend/images/drawing_2.svg
similarity index 100%
rename from app/assets/images/drawing_2.svg
rename to app/frontend/images/drawing_2.svg
diff --git a/app/assets/images/dsl_to_activity.webp b/app/frontend/images/dsl_to_activity.webp
similarity index 100%
rename from app/assets/images/dsl_to_activity.webp
rename to app/frontend/images/dsl_to_activity.webp
diff --git a/app/assets/images/each-failure-trace.png b/app/frontend/images/each-failure-trace.png
similarity index 100%
rename from app/assets/images/each-failure-trace.png
rename to app/frontend/images/each-failure-trace.png
diff --git a/app/assets/images/each-trace.png b/app/frontend/images/each-trace.png
similarity index 100%
rename from app/assets/images/each-trace.png
rename to app/frontend/images/each-trace.png
diff --git a/app/assets/images/editor-september.webp b/app/frontend/images/editor-september.webp
similarity index 100%
rename from app/assets/images/editor-september.webp
rename to app/frontend/images/editor-september.webp
diff --git a/app/assets/images/endpoint-adapter-api.webp b/app/frontend/images/endpoint-adapter-api.webp
similarity index 100%
rename from app/assets/images/endpoint-adapter-api.webp
rename to app/frontend/images/endpoint-adapter-api.webp
diff --git a/app/assets/images/endpoint-adapter-web.webp b/app/frontend/images/endpoint-adapter-web.webp
similarity index 100%
rename from app/assets/images/endpoint-adapter-web.webp
rename to app/frontend/images/endpoint-adapter-web.webp
diff --git a/app/assets/images/endpoint-draft.webp b/app/frontend/images/endpoint-draft.webp
similarity index 100%
rename from app/assets/images/endpoint-draft.webp
rename to app/frontend/images/endpoint-draft.webp
diff --git a/app/assets/images/endpoint-fast-track-rw.png b/app/frontend/images/endpoint-fast-track-rw.png
similarity index 100%
rename from app/assets/images/endpoint-fast-track-rw.png
rename to app/frontend/images/endpoint-fast-track-rw.png
diff --git a/app/assets/images/endpoint-not_authenticated-trace.webp b/app/frontend/images/endpoint-not_authenticated-trace.webp
similarity index 100%
rename from app/assets/images/endpoint-not_authenticated-trace.webp
rename to app/frontend/images/endpoint-not_authenticated-trace.webp
diff --git a/app/assets/images/endpoint-protocol-block.png b/app/frontend/images/endpoint-protocol-block.png
similarity index 100%
rename from app/assets/images/endpoint-protocol-block.png
rename to app/frontend/images/endpoint-protocol-block.png
diff --git a/app/assets/images/endpoint-protocol-railway.png b/app/frontend/images/endpoint-protocol-railway.png
similarity index 100%
rename from app/assets/images/endpoint-protocol-railway.png
rename to app/frontend/images/endpoint-protocol-railway.png
diff --git a/app/assets/images/endpoint-protocol-termini.png b/app/frontend/images/endpoint-protocol-termini.png
similarity index 100%
rename from app/assets/images/endpoint-protocol-termini.png
rename to app/frontend/images/endpoint-protocol-termini.png
diff --git a/app/assets/images/endpoint-protocol-update.png b/app/frontend/images/endpoint-protocol-update.png
similarity index 100%
rename from app/assets/images/endpoint-protocol-update.png
rename to app/frontend/images/endpoint-protocol-update.png
diff --git a/app/assets/images/endpoint-protocol.webp b/app/frontend/images/endpoint-protocol.webp
similarity index 100%
rename from app/assets/images/endpoint-protocol.webp
rename to app/frontend/images/endpoint-protocol.webp
diff --git a/app/assets/images/endpoint-success-trace.webp b/app/frontend/images/endpoint-success-trace.webp
similarity index 100%
rename from app/assets/images/endpoint-success-trace.webp
rename to app/frontend/images/endpoint-success-trace.webp
diff --git a/app/assets/images/endpoint-update.png b/app/frontend/images/endpoint-update.png
similarity index 100%
rename from app/assets/images/endpoint-update.png
rename to app/frontend/images/endpoint-update.png
diff --git a/app/assets/images/fail_fast.png b/app/frontend/images/fail_fast.png
similarity index 100%
rename from app/assets/images/fail_fast.png
rename to app/frontend/images/fail_fast.png
diff --git a/app/assets/images/fast_track.png b/app/frontend/images/fast_track.png
similarity index 100%
rename from app/assets/images/fast_track.png
rename to app/frontend/images/fast_track.png
diff --git a/app/assets/images/favicon.ico b/app/frontend/images/favicon.ico
similarity index 100%
rename from app/assets/images/favicon.ico
rename to app/frontend/images/favicon.ico
diff --git a/app/assets/images/flow.webp b/app/frontend/images/flow.webp
similarity index 100%
rename from app/assets/images/flow.webp
rename to app/frontend/images/flow.webp
diff --git a/app/assets/images/front.webp b/app/frontend/images/front.webp
similarity index 100%
rename from app/assets/images/front.webp
rename to app/frontend/images/front.webp
diff --git a/app/assets/images/ft-failfast.webp b/app/frontend/images/ft-failfast.webp
similarity index 100%
rename from app/assets/images/ft-failfast.webp
rename to app/frontend/images/ft-failfast.webp
diff --git a/app/assets/images/ft-fasttrack.webp b/app/frontend/images/ft-fasttrack.webp
similarity index 100%
rename from app/assets/images/ft-fasttrack.webp
rename to app/frontend/images/ft-fasttrack.webp
diff --git a/app/assets/images/ft-passfast.webp b/app/frontend/images/ft-passfast.webp
similarity index 100%
rename from app/assets/images/ft-passfast.webp
rename to app/frontend/images/ft-passfast.webp
diff --git a/app/assets/images/hec.webp b/app/frontend/images/hec.webp
similarity index 100%
rename from app/assets/images/hec.webp
rename to app/frontend/images/hec.webp
diff --git a/app/assets/images/illus-lp.webp b/app/frontend/images/illus-lp.webp
similarity index 100%
rename from app/assets/images/illus-lp.webp
rename to app/frontend/images/illus-lp.webp
diff --git a/app/assets/images/info_icon.svg b/app/frontend/images/info_icon.svg
similarity index 100%
rename from app/assets/images/info_icon.svg
rename to app/frontend/images/info_icon.svg
diff --git a/app/assets/images/input.webp b/app/frontend/images/input.webp
similarity index 100%
rename from app/assets/images/input.webp
rename to app/frontend/images/input.webp
diff --git a/app/assets/images/intermediate.webp b/app/frontend/images/intermediate.webp
similarity index 100%
rename from app/assets/images/intermediate.webp
rename to app/frontend/images/intermediate.webp
diff --git a/app/assets/images/internals-trace-node.png b/app/frontend/images/internals-trace-node.png
similarity index 100%
rename from app/assets/images/internals-trace-node.png
rename to app/frontend/images/internals-trace-node.png
diff --git a/app/assets/images/internals-trace-tw.png b/app/frontend/images/internals-trace-tw.png
similarity index 100%
rename from app/assets/images/internals-trace-tw.png
rename to app/frontend/images/internals-trace-tw.png
diff --git a/app/assets/images/landing/let_the_operation_handle_it.svg b/app/frontend/images/landing/let_the_operation_handle_it.svg
similarity index 100%
rename from app/assets/images/landing/let_the_operation_handle_it.svg
rename to app/frontend/images/landing/let_the_operation_handle_it.svg
diff --git a/app/assets/images/landing/where_do_i_put_this_code.svg b/app/frontend/images/landing/where_do_i_put_this_code.svg
similarity index 100%
rename from app/assets/images/landing/where_do_i_put_this_code.svg
rename to app/frontend/images/landing/where_do_i_put_this_code.svg
diff --git a/app/assets/images/landing/you_can_focus.svg b/app/frontend/images/landing/you_can_focus.svg
similarity index 100%
rename from app/assets/images/landing/you_can_focus.svg
rename to app/frontend/images/landing/you_can_focus.svg
diff --git a/app/assets/images/light_bulb_icon.svg b/app/frontend/images/light_bulb_icon.svg
similarity index 100%
rename from app/assets/images/light_bulb_icon.svg
rename to app/frontend/images/light_bulb_icon.svg
diff --git a/app/assets/images/list-circle-full.svg b/app/frontend/images/list-circle-full.svg
similarity index 100%
rename from app/assets/images/list-circle-full.svg
rename to app/frontend/images/list-circle-full.svg
diff --git a/app/assets/images/list_checkmark.svg b/app/frontend/images/list_checkmark.svg
similarity index 100%
rename from app/assets/images/list_checkmark.svg
rename to app/frontend/images/list_checkmark.svg
diff --git a/app/assets/images/list_circle.svg b/app/frontend/images/list_circle.svg
similarity index 100%
rename from app/assets/images/list_circle.svg
rename to app/frontend/images/list_circle.svg
diff --git a/app/assets/images/list_white_checkmark.svg b/app/frontend/images/list_white_checkmark.svg
similarity index 100%
rename from app/assets/images/list_white_checkmark.svg
rename to app/frontend/images/list_white_checkmark.svg
diff --git a/app/assets/images/logo-16.webp b/app/frontend/images/logo-16.webp
similarity index 100%
rename from app/assets/images/logo-16.webp
rename to app/frontend/images/logo-16.webp
diff --git a/app/assets/images/logo-dark.webp b/app/frontend/images/logo-dark.webp
similarity index 100%
rename from app/assets/images/logo-dark.webp
rename to app/frontend/images/logo-dark.webp
diff --git a/app/assets/images/logo_blue.svg b/app/frontend/images/logo_blue.svg
similarity index 100%
rename from app/assets/images/logo_blue.svg
rename to app/frontend/images/logo_blue.svg
diff --git a/app/assets/images/logo_blue_ruby.svg b/app/frontend/images/logo_blue_ruby.svg
similarity index 100%
rename from app/assets/images/logo_blue_ruby.svg
rename to app/frontend/images/logo_blue_ruby.svg
diff --git a/app/assets/images/logo_white.svg b/app/frontend/images/logo_white.svg
similarity index 100%
rename from app/assets/images/logo_white.svg
rename to app/frontend/images/logo_white.svg
diff --git a/app/assets/images/logo_white_ruby.svg b/app/frontend/images/logo_white_ruby.svg
similarity index 100%
rename from app/assets/images/logo_white_ruby.svg
rename to app/frontend/images/logo_white_ruby.svg
diff --git a/app/assets/images/lottie/1-A.json b/app/frontend/images/lottie/1-A.json
similarity index 100%
rename from app/assets/images/lottie/1-A.json
rename to app/frontend/images/lottie/1-A.json
diff --git a/app/assets/images/lottie/1_data.json b/app/frontend/images/lottie/1_data.json
similarity index 100%
rename from app/assets/images/lottie/1_data.json
rename to app/frontend/images/lottie/1_data.json
diff --git a/app/assets/images/lottie/2-A.json b/app/frontend/images/lottie/2-A.json
similarity index 100%
rename from app/assets/images/lottie/2-A.json
rename to app/frontend/images/lottie/2-A.json
diff --git a/app/assets/images/lottie/2_data.json b/app/frontend/images/lottie/2_data.json
similarity index 100%
rename from app/assets/images/lottie/2_data.json
rename to app/frontend/images/lottie/2_data.json
diff --git a/app/assets/images/lottie/3-A.json b/app/frontend/images/lottie/3-A.json
similarity index 100%
rename from app/assets/images/lottie/3-A.json
rename to app/frontend/images/lottie/3-A.json
diff --git a/app/assets/images/lottie/3_data.json b/app/frontend/images/lottie/3_data.json
similarity index 100%
rename from app/assets/images/lottie/3_data.json
rename to app/frontend/images/lottie/3_data.json
diff --git a/app/assets/images/macro-ends.webp b/app/frontend/images/macro-ends.webp
similarity index 100%
rename from app/assets/images/macro-ends.webp
rename to app/frontend/images/macro-ends.webp
diff --git a/app/assets/images/magnetic_to.png b/app/frontend/images/magnetic_to.png
similarity index 100%
rename from app/assets/images/magnetic_to.png
rename to app/frontend/images/magnetic_to.png
diff --git a/app/assets/images/memo-create.png b/app/frontend/images/memo-create.png
similarity index 100%
rename from app/assets/images/memo-create.png
rename to app/frontend/images/memo-create.png
diff --git a/app/assets/images/memo-network-error.png b/app/frontend/images/memo-network-error.png
similarity index 100%
rename from app/assets/images/memo-network-error.png
rename to app/frontend/images/memo-network-error.png
diff --git a/app/assets/images/memo-pass.png b/app/frontend/images/memo-pass.png
similarity index 100%
rename from app/assets/images/memo-pass.png
rename to app/frontend/images/memo-pass.png
diff --git a/app/assets/images/middle.webp b/app/frontend/images/middle.webp
similarity index 100%
rename from app/assets/images/middle.webp
rename to app/frontend/images/middle.webp
diff --git a/app/assets/images/model-not-found.png b/app/frontend/images/model-not-found.png
similarity index 100%
rename from app/assets/images/model-not-found.png
rename to app/frontend/images/model-not-found.png
diff --git a/app/assets/images/model_find.png b/app/frontend/images/model_find.png
similarity index 100%
rename from app/assets/images/model_find.png
rename to app/frontend/images/model_find.png
diff --git a/app/assets/images/model_find_trace.png b/app/frontend/images/model_find_trace.png
similarity index 100%
rename from app/assets/images/model_find_trace.png
rename to app/frontend/images/model_find_trace.png
diff --git a/app/assets/images/nested-static-complete.png b/app/frontend/images/nested-static-complete.png
similarity index 100%
rename from app/assets/images/nested-static-complete.png
rename to app/frontend/images/nested-static-complete.png
diff --git a/app/assets/images/nested-static-create.png b/app/frontend/images/nested-static-create.png
similarity index 100%
rename from app/assets/images/nested-static-create.png
rename to app/frontend/images/nested-static-create.png
diff --git a/app/assets/images/nested-static-trace.png b/app/frontend/images/nested-static-trace.png
similarity index 100%
rename from app/assets/images/nested-static-trace.png
rename to app/frontend/images/nested-static-trace.png
diff --git a/app/assets/images/nested-static-vorbiscomment.png b/app/frontend/images/nested-static-vorbiscomment.png
similarity index 100%
rename from app/assets/images/nested-static-vorbiscomment.png
rename to app/frontend/images/nested-static-vorbiscomment.png
diff --git a/app/assets/images/nesting-end.webp b/app/frontend/images/nesting-end.webp
similarity index 100%
rename from app/assets/images/nesting-end.webp
rename to app/frontend/images/nesting-end.webp
diff --git a/app/assets/images/nesting-reconnect.webp b/app/frontend/images/nesting-reconnect.webp
similarity index 100%
rename from app/assets/images/nesting-reconnect.webp
rename to app/frontend/images/nesting-reconnect.webp
diff --git a/app/assets/images/nesting.webp b/app/frontend/images/nesting.webp
similarity index 100%
rename from app/assets/images/nesting.webp
rename to app/frontend/images/nesting.webp
diff --git a/app/assets/images/output.webp b/app/frontend/images/output.webp
similarity index 100%
rename from app/assets/images/output.webp
rename to app/frontend/images/output.webp
diff --git a/app/assets/images/parallax/back.webp b/app/frontend/images/parallax/back.webp
similarity index 100%
rename from app/assets/images/parallax/back.webp
rename to app/frontend/images/parallax/back.webp
diff --git a/app/assets/images/parallax/front.webp b/app/frontend/images/parallax/front.webp
similarity index 100%
rename from app/assets/images/parallax/front.webp
rename to app/frontend/images/parallax/front.webp
diff --git a/app/assets/images/parallax/middle.webp b/app/frontend/images/parallax/middle.webp
similarity index 100%
rename from app/assets/images/parallax/middle.webp
rename to app/frontend/images/parallax/middle.webp
diff --git a/app/assets/images/pass_fast.png b/app/frontend/images/pass_fast.png
similarity index 100%
rename from app/assets/images/pass_fast.png
rename to app/frontend/images/pass_fast.png
diff --git a/app/assets/images/patch-trace.png b/app/frontend/images/patch-trace.png
similarity index 100%
rename from app/assets/images/patch-trace.png
rename to app/frontend/images/patch-trace.png
diff --git a/app/assets/images/patch.png b/app/frontend/images/patch.png
similarity index 100%
rename from app/assets/images/patch.png
rename to app/frontend/images/patch.png
diff --git a/app/assets/images/patching-trace.webp b/app/frontend/images/patching-trace.webp
similarity index 100%
rename from app/assets/images/patching-trace.webp
rename to app/frontend/images/patching-trace.webp
diff --git a/app/assets/images/patching.webp b/app/frontend/images/patching.webp
similarity index 100%
rename from app/assets/images/patching.webp
rename to app/frontend/images/patching.webp
diff --git a/app/assets/images/path-cc.png b/app/frontend/images/path-cc.png
similarity index 100%
rename from app/assets/images/path-cc.png
rename to app/frontend/images/path-cc.png
diff --git a/app/assets/images/path-end.png b/app/frontend/images/path-end.png
similarity index 100%
rename from app/assets/images/path-end.png
rename to app/frontend/images/path-end.png
diff --git a/app/assets/images/path-end.webp b/app/frontend/images/path-end.webp
similarity index 100%
rename from app/assets/images/path-end.webp
rename to app/frontend/images/path-end.webp
diff --git a/app/assets/images/path-join.png b/app/frontend/images/path-join.png
similarity index 100%
rename from app/assets/images/path-join.png
rename to app/frontend/images/path-join.png
diff --git a/app/assets/images/path-macro-join.webp b/app/frontend/images/path-macro-join.webp
similarity index 100%
rename from app/assets/images/path-macro-join.webp
rename to app/frontend/images/path-macro-join.webp
diff --git a/app/assets/images/path-macro-railway.webp b/app/frontend/images/path-macro-railway.webp
similarity index 100%
rename from app/assets/images/path-macro-railway.webp
rename to app/frontend/images/path-macro-railway.webp
diff --git a/app/assets/images/path-macro.webp b/app/frontend/images/path-macro.webp
similarity index 100%
rename from app/assets/images/path-macro.webp
rename to app/frontend/images/path-macro.webp
diff --git a/app/assets/images/path-railway.png b/app/frontend/images/path-railway.png
similarity index 100%
rename from app/assets/images/path-railway.png
rename to app/frontend/images/path-railway.png
diff --git a/app/assets/images/path.png b/app/frontend/images/path.png
similarity index 100%
rename from app/assets/images/path.png
rename to app/frontend/images/path.png
diff --git a/app/assets/images/path.webp b/app/frontend/images/path.webp
similarity index 100%
rename from app/assets/images/path.webp
rename to app/frontend/images/path.webp
diff --git a/app/assets/images/pro-billo.png b/app/frontend/images/pro-billo.png
similarity index 100%
rename from app/assets/images/pro-billo.png
rename to app/frontend/images/pro-billo.png
diff --git a/app/assets/images/pro.webp b/app/frontend/images/pro.webp
similarity index 100%
rename from app/assets/images/pro.webp
rename to app/frontend/images/pro.webp
diff --git a/app/assets/images/protocol-fail-fast.png b/app/frontend/images/protocol-fail-fast.png
similarity index 100%
rename from app/assets/images/protocol-fail-fast.png
rename to app/frontend/images/protocol-fail-fast.png
diff --git a/app/assets/images/protocol-wtf.png b/app/frontend/images/protocol-wtf.png
similarity index 100%
rename from app/assets/images/protocol-wtf.png
rename to app/frontend/images/protocol-wtf.png
diff --git a/app/assets/images/quote-close.webp b/app/frontend/images/quote-close.webp
similarity index 100%
rename from app/assets/images/quote-close.webp
rename to app/frontend/images/quote-close.webp
diff --git a/app/assets/images/quote-open.webp b/app/frontend/images/quote-open.webp
similarity index 100%
rename from app/assets/images/quote-open.webp
rename to app/frontend/images/quote-open.webp
diff --git a/app/assets/images/railway-fail.webp b/app/frontend/images/railway-fail.webp
similarity index 100%
rename from app/assets/images/railway-fail.webp
rename to app/frontend/images/railway-fail.webp
diff --git a/app/assets/images/railway-pass.webp b/app/frontend/images/railway-pass.webp
similarity index 100%
rename from app/assets/images/railway-pass.webp
rename to app/frontend/images/railway-pass.webp
diff --git a/app/assets/images/railway-wire.webp b/app/frontend/images/railway-wire.webp
similarity index 100%
rename from app/assets/images/railway-wire.webp
rename to app/frontend/images/railway-wire.webp
diff --git a/app/assets/images/railway.webp b/app/frontend/images/railway.webp
similarity index 100%
rename from app/assets/images/railway.webp
rename to app/frontend/images/railway.webp
diff --git a/app/assets/images/reform-architecture.webp b/app/frontend/images/reform-architecture.webp
similarity index 100%
rename from app/assets/images/reform-architecture.webp
rename to app/frontend/images/reform-architecture.webp
diff --git a/app/assets/images/s_hero.webp b/app/frontend/images/s_hero.webp
similarity index 100%
rename from app/assets/images/s_hero.webp
rename to app/frontend/images/s_hero.webp
diff --git a/app/assets/images/subprocess-output.png b/app/frontend/images/subprocess-output.png
similarity index 100%
rename from app/assets/images/subprocess-output.png
rename to app/frontend/images/subprocess-output.png
diff --git a/app/assets/images/subprocess-terminus.png b/app/frontend/images/subprocess-terminus.png
similarity index 100%
rename from app/assets/images/subprocess-terminus.png
rename to app/frontend/images/subprocess-terminus.png
diff --git a/app/assets/images/subprocess.png b/app/frontend/images/subprocess.png
similarity index 100%
rename from app/assets/images/subprocess.png
rename to app/frontend/images/subprocess.png
diff --git a/app/assets/images/task-wrap.webp b/app/frontend/images/task-wrap.webp
similarity index 100%
rename from app/assets/images/task-wrap.webp
rename to app/frontend/images/task-wrap.webp
diff --git a/app/assets/images/task_wrap_validate.png b/app/frontend/images/task_wrap_validate.png
similarity index 100%
rename from app/assets/images/task_wrap_validate.png
rename to app/frontend/images/task_wrap_validate.png
diff --git a/app/assets/images/trb-ruby-white.svg b/app/frontend/images/trb-ruby-white.svg
similarity index 100%
rename from app/assets/images/trb-ruby-white.svg
rename to app/frontend/images/trb-ruby-white.svg
diff --git a/app/assets/images/tutorials/fail-output.webp b/app/frontend/images/tutorials/fail-output.webp
similarity index 100%
rename from app/assets/images/tutorials/fail-output.webp
rename to app/frontend/images/tutorials/fail-output.webp
diff --git a/app/assets/images/tutorials/fail-wtf.webp b/app/frontend/images/tutorials/fail-wtf.webp
similarity index 100%
rename from app/assets/images/tutorials/fail-wtf.webp
rename to app/frontend/images/tutorials/fail-wtf.webp
diff --git a/app/assets/images/tutorials/fail.webp b/app/frontend/images/tutorials/fail.webp
similarity index 100%
rename from app/assets/images/tutorials/fail.webp
rename to app/frontend/images/tutorials/fail.webp
diff --git a/app/assets/images/tutorials/find_user.webp b/app/frontend/images/tutorials/find_user.webp
similarity index 100%
rename from app/assets/images/tutorials/find_user.webp
rename to app/frontend/images/tutorials/find_user.webp
diff --git a/app/assets/images/tutorials/github-signin-v1.webp b/app/frontend/images/tutorials/github-signin-v1.webp
similarity index 100%
rename from app/assets/images/tutorials/github-signin-v1.webp
rename to app/frontend/images/tutorials/github-signin-v1.webp
diff --git a/app/assets/images/tutorials/magnetic.webp b/app/frontend/images/tutorials/magnetic.webp
similarity index 100%
rename from app/assets/images/tutorials/magnetic.webp
rename to app/frontend/images/tutorials/magnetic.webp
diff --git a/app/assets/images/tutorials/no_info.webp b/app/frontend/images/tutorials/no_info.webp
similarity index 100%
rename from app/assets/images/tutorials/no_info.webp
rename to app/frontend/images/tutorials/no_info.webp
diff --git a/app/assets/images/tutorials/path-existing.webp b/app/frontend/images/tutorials/path-existing.webp
similarity index 100%
rename from app/assets/images/tutorials/path-existing.webp
rename to app/frontend/images/tutorials/path-existing.webp
diff --git a/app/assets/images/tutorials/path-failure.webp b/app/frontend/images/tutorials/path-failure.webp
similarity index 100%
rename from app/assets/images/tutorials/path-failure.webp
rename to app/frontend/images/tutorials/path-failure.webp
diff --git a/app/assets/images/tutorials/path-success.webp b/app/frontend/images/tutorials/path-success.webp
similarity index 100%
rename from app/assets/images/tutorials/path-success.webp
rename to app/frontend/images/tutorials/path-success.webp
diff --git a/app/assets/images/tutorials/render.webp b/app/frontend/images/tutorials/render.webp
similarity index 100%
rename from app/assets/images/tutorials/render.webp
rename to app/frontend/images/tutorials/render.webp
diff --git a/app/assets/images/tutorials/signup-1.webp b/app/frontend/images/tutorials/signup-1.webp
similarity index 100%
rename from app/assets/images/tutorials/signup-1.webp
rename to app/frontend/images/tutorials/signup-1.webp
diff --git a/app/assets/images/tutorials/signup-2.webp b/app/frontend/images/tutorials/signup-2.webp
similarity index 100%
rename from app/assets/images/tutorials/signup-2.webp
rename to app/frontend/images/tutorials/signup-2.webp
diff --git a/app/assets/images/tutorials/signup-3.webp b/app/frontend/images/tutorials/signup-3.webp
similarity index 100%
rename from app/assets/images/tutorials/signup-3.webp
rename to app/frontend/images/tutorials/signup-3.webp
diff --git a/app/assets/images/tutorials/signup-4.webp b/app/frontend/images/tutorials/signup-4.webp
similarity index 100%
rename from app/assets/images/tutorials/signup-4.webp
rename to app/frontend/images/tutorials/signup-4.webp
diff --git a/app/assets/images/tutorials/signup.webp b/app/frontend/images/tutorials/signup.webp
similarity index 100%
rename from app/assets/images/tutorials/signup.webp
rename to app/frontend/images/tutorials/signup.webp
diff --git a/app/assets/images/tutorials/subprocess-trace.webp b/app/frontend/images/tutorials/subprocess-trace.webp
similarity index 100%
rename from app/assets/images/tutorials/subprocess-trace.webp
rename to app/frontend/images/tutorials/subprocess-trace.webp
diff --git a/app/assets/images/tutorials/subprocess.webp b/app/frontend/images/tutorials/subprocess.webp
similarity index 100%
rename from app/assets/images/tutorials/subprocess.webp
rename to app/frontend/images/tutorials/subprocess.webp
diff --git a/app/assets/images/tutorials/trace-exception.webp b/app/frontend/images/tutorials/trace-exception.webp
similarity index 100%
rename from app/assets/images/tutorials/trace-exception.webp
rename to app/frontend/images/tutorials/trace-exception.webp
diff --git a/app/assets/images/tutorials/trace.webp b/app/frontend/images/tutorials/trace.webp
similarity index 100%
rename from app/assets/images/tutorials/trace.webp
rename to app/frontend/images/tutorials/trace.webp
diff --git a/app/assets/images/tutorials/truefalse.webp b/app/frontend/images/tutorials/truefalse.webp
similarity index 100%
rename from app/assets/images/tutorials/truefalse.webp
rename to app/frontend/images/tutorials/truefalse.webp
diff --git a/app/assets/images/tutorials/validate-3.webp b/app/frontend/images/tutorials/validate-3.webp
similarity index 100%
rename from app/assets/images/tutorials/validate-3.webp
rename to app/frontend/images/tutorials/validate-3.webp
diff --git a/app/assets/images/tutorials/validate.webp b/app/frontend/images/tutorials/validate.webp
similarity index 100%
rename from app/assets/images/tutorials/validate.webp
rename to app/frontend/images/tutorials/validate.webp
diff --git a/app/assets/images/tutorials/voc.webp b/app/frontend/images/tutorials/voc.webp
similarity index 100%
rename from app/assets/images/tutorials/voc.webp
rename to app/frontend/images/tutorials/voc.webp
diff --git a/app/assets/images/upcase.png b/app/frontend/images/upcase.png
similarity index 100%
rename from app/assets/images/upcase.png
rename to app/frontend/images/upcase.png
diff --git a/app/assets/images/wire-output-add.webp b/app/frontend/images/wire-output-add.webp
similarity index 100%
rename from app/assets/images/wire-output-add.webp
rename to app/frontend/images/wire-output-add.webp
diff --git a/app/assets/images/wire-output-end.webp b/app/frontend/images/wire-output-end.webp
similarity index 100%
rename from app/assets/images/wire-output-end.webp
rename to app/frontend/images/wire-output-end.webp
diff --git a/app/assets/images/wire-output-endadd.webp b/app/frontend/images/wire-output-endadd.webp
similarity index 100%
rename from app/assets/images/wire-output-endadd.webp
rename to app/frontend/images/wire-output-endadd.webp
diff --git a/app/assets/images/wire-output.webp b/app/frontend/images/wire-output.webp
similarity index 100%
rename from app/assets/images/wire-output.webp
rename to app/frontend/images/wire-output.webp
diff --git a/app/assets/images/wiring-custom-track.png b/app/frontend/images/wiring-custom-track.png
similarity index 100%
rename from app/assets/images/wiring-custom-track.png
rename to app/frontend/images/wiring-custom-track.png
diff --git a/app/assets/images/wiring-end.png b/app/frontend/images/wiring-end.png
similarity index 100%
rename from app/assets/images/wiring-end.png
rename to app/frontend/images/wiring-end.png
diff --git a/app/assets/images/wiring-explicit-output.png b/app/frontend/images/wiring-explicit-output.png
similarity index 100%
rename from app/assets/images/wiring-explicit-output.png
rename to app/frontend/images/wiring-explicit-output.png
diff --git a/app/assets/images/wiring-id.png b/app/frontend/images/wiring-id.png
similarity index 100%
rename from app/assets/images/wiring-id.png
rename to app/frontend/images/wiring-id.png
diff --git a/app/assets/images/wiring-left.png b/app/frontend/images/wiring-left.png
similarity index 100%
rename from app/assets/images/wiring-left.png
rename to app/frontend/images/wiring-left.png
diff --git a/app/assets/images/wiring-output.png b/app/frontend/images/wiring-output.png
similarity index 100%
rename from app/assets/images/wiring-output.png
rename to app/frontend/images/wiring-output.png
diff --git a/app/assets/images/wiring-path-connect-to.png b/app/frontend/images/wiring-path-connect-to.png
similarity index 100%
rename from app/assets/images/wiring-path-connect-to.png
rename to app/frontend/images/wiring-path-connect-to.png
diff --git a/app/assets/images/wiring-path-helper.png b/app/frontend/images/wiring-path-helper.png
similarity index 100%
rename from app/assets/images/wiring-path-helper.png
rename to app/frontend/images/wiring-path-helper.png
diff --git a/app/assets/images/wiring-subprocess-fast-track.png b/app/frontend/images/wiring-subprocess-fast-track.png
similarity index 100%
rename from app/assets/images/wiring-subprocess-fast-track.png
rename to app/frontend/images/wiring-subprocess-fast-track.png
diff --git a/app/assets/images/wiring-terminus-dashed.png b/app/frontend/images/wiring-terminus-dashed.png
similarity index 100%
rename from app/assets/images/wiring-terminus-dashed.png
rename to app/frontend/images/wiring-terminus-dashed.png
diff --git a/app/assets/images/wiring-terminus-track.png b/app/frontend/images/wiring-terminus-track.png
similarity index 100%
rename from app/assets/images/wiring-terminus-track.png
rename to app/frontend/images/wiring-terminus-track.png
diff --git a/app/assets/images/wiring-terminus.png b/app/frontend/images/wiring-terminus.png
similarity index 100%
rename from app/assets/images/wiring-terminus.png
rename to app/frontend/images/wiring-terminus.png
diff --git a/app/assets/images/wrap-route.png b/app/frontend/images/wrap-route.png
similarity index 100%
rename from app/assets/images/wrap-route.png
rename to app/frontend/images/wrap-route.png
diff --git a/app/assets/images/wrap-timeout.png b/app/frontend/images/wrap-timeout.png
similarity index 100%
rename from app/assets/images/wrap-timeout.png
rename to app/frontend/images/wrap-timeout.png
diff --git a/app/assets/images/wrap-trace.png b/app/frontend/images/wrap-trace.png
similarity index 100%
rename from app/assets/images/wrap-trace.png
rename to app/frontend/images/wrap-trace.png
diff --git a/app/assets/images/wrap.png b/app/frontend/images/wrap.png
similarity index 100%
rename from app/assets/images/wrap.png
rename to app/frontend/images/wrap.png
diff --git a/app/assets/images/wtf-color-map.webp b/app/frontend/images/wtf-color-map.webp
similarity index 100%
rename from app/assets/images/wtf-color-map.webp
rename to app/frontend/images/wtf-color-map.webp
diff --git a/app/assets/images/wtf-exception.png b/app/frontend/images/wtf-exception.png
similarity index 100%
rename from app/assets/images/wtf-exception.png
rename to app/frontend/images/wtf-exception.png
diff --git a/app/assets/images/wtf-exception.webp b/app/frontend/images/wtf-exception.webp
similarity index 100%
rename from app/assets/images/wtf-exception.webp
rename to app/frontend/images/wtf-exception.webp
diff --git a/app/assets/images/wtf-focus.webp b/app/frontend/images/wtf-focus.webp
similarity index 100%
rename from app/assets/images/wtf-focus.webp
rename to app/frontend/images/wtf-focus.webp
diff --git a/app/assets/images/wtf-trace-nice.png b/app/frontend/images/wtf-trace-nice.png
similarity index 100%
rename from app/assets/images/wtf-trace-nice.png
rename to app/frontend/images/wtf-trace-nice.png
diff --git a/app/assets/images/wtf-trace.png b/app/frontend/images/wtf-trace.png
similarity index 100%
rename from app/assets/images/wtf-trace.png
rename to app/frontend/images/wtf-trace.png
diff --git a/app/assets/images/wtf.webp b/app/frontend/images/wtf.webp
similarity index 100%
rename from app/assets/images/wtf.webp
rename to app/frontend/images/wtf.webp
diff --git a/app/javascript/application.js b/app/frontend/javascript/application.js
similarity index 94%
rename from app/javascript/application.js
rename to app/frontend/javascript/application.js
index 469e42c..930bc71 100644
--- a/app/javascript/application.js
+++ b/app/frontend/javascript/application.js
@@ -1,20 +1,19 @@
+// dependencies
import hljs from 'highlight.js/lib/core';
import ruby from 'highlight.js/lib/languages/ruby';
-import anchorJS from 'anchor-js';
-
-import 'navigations'; // FIXME: here, part of the code should only be run when "docs".
-
+import "@lottiefiles/lottie-player";
+import AnchorJS from 'anchor-js';
import jquery from 'jquery';
+import { ParallaxScroll } from "../javascript/jquery.parallax-scroll";
-import { ParallaxScroll } from "jquery.parallax-scroll";
+// custom files
+import '../javascript/navigations'; // FIXME: here, part of the code should only be run when "docs".
hljs.registerLanguage('ruby', ruby);
hljs.highlightAll();
if (pageIdentifier == "landing") {
jquery(document).ready(function() {
- import("lottie-player");
-
ParallaxScroll.init();
// TODO: move to separate function/file.
@@ -37,7 +36,7 @@ if (pageIdentifier == "landing") {
if (pageIdentifier == "docs") {
jquery(document).ready(function() {
- import("docsearch");
+ import("../javascript/docsearch");
let h2_map = [];
@@ -166,7 +165,7 @@ if (pageIdentifier == "docs") {
jquery(window).on("scroll", h2_listener);
h2_listener(null); // init wyof-scrollspy
- var anchors = new anchorJS();
+ var anchors = new AnchorJS();
anchors.add('h2, h3, h4');
// listen for click on tab links
diff --git a/app/javascript/docsearch.js b/app/frontend/javascript/docsearch.js
similarity index 86%
rename from app/javascript/docsearch.js
rename to app/frontend/javascript/docsearch.js
index 15678ed..691ef1f 100644
--- a/app/javascript/docsearch.js
+++ b/app/frontend/javascript/docsearch.js
@@ -1,4 +1,6 @@
-import 'docsearch-3.5.2';
+import docsearch from '@docsearch/js';
+
+import '@docsearch/css';
// TODO: remove, currently unused function.
function my_transformItems(items) {
diff --git a/app/javascript/jquery.parallax-scroll.js b/app/frontend/javascript/jquery.parallax-scroll.js
similarity index 100%
rename from app/javascript/jquery.parallax-scroll.js
rename to app/frontend/javascript/jquery.parallax-scroll.js
diff --git a/app/javascript/navigations.js b/app/frontend/javascript/navigations.js
similarity index 100%
rename from app/javascript/navigations.js
rename to app/frontend/javascript/navigations.js
diff --git a/app/assets/stylesheets/anchor-js.css b/app/frontend/stylesheets/anchor-js.css
similarity index 100%
rename from app/assets/stylesheets/anchor-js.css
rename to app/frontend/stylesheets/anchor-js.css
diff --git a/app/assets/stylesheets/application.css b/app/frontend/stylesheets/application.css
similarity index 100%
rename from app/assets/stylesheets/application.css
rename to app/frontend/stylesheets/application.css
diff --git a/app/assets/stylesheets/highlightjs-trb.css b/app/frontend/stylesheets/highlightjs-trb.css
similarity index 100%
rename from app/assets/stylesheets/highlightjs-trb.css
rename to app/frontend/stylesheets/highlightjs-trb.css
diff --git a/app/assets/stylesheets/open-sauce.css b/app/frontend/stylesheets/open-sauce.css
similarity index 100%
rename from app/assets/stylesheets/open-sauce.css
rename to app/frontend/stylesheets/open-sauce.css
diff --git a/app/assets/tailwind/application.css b/app/frontend/stylesheets/tailwind/application.css
similarity index 70%
rename from app/assets/tailwind/application.css
rename to app/frontend/stylesheets/tailwind/application.css
index ada4eab..ca17332 100644
--- a/app/assets/tailwind/application.css
+++ b/app/frontend/stylesheets/tailwind/application.css
@@ -1,6 +1,14 @@
@import "tailwindcss";
@plugin "@tailwindcss/forms";
+@source "../../../../public/*.html";
+@source "../../../../app/javascript/**/*.js";
+@source "../../../../app/views/**/*.erb";
+@source "../../../../app/concepts/**/*.erb";
+@source "../../../../app/controllers/views_controller.rb";
+@source "../../../../app/concepts/cms/*.rb";
+@source "../../../../section/**/*.erb";
+
@theme {
--font-display: "Open Sauce Sans", "sans-serif";
@@ -30,31 +38,31 @@
/* Typography */
- --font-size-xs: 0.75rem;
- --font-size-sm: 0.875rem;
- --font-size-base: 17px;
- --font-size-lg: 1.125rem;
- --font-size-xl: 1.25rem;
- --font-size-2xl: 1.5rem;
- --font-size-3xl: 1.875rem;
- --font-size-5xl: 2.75rem;
+ --size-xs: 0.75rem;
+ --size-sm: 0.875rem;
+ --size-base: 17px;
+ --size-lg: 1.125rem;
+ --size-xl: 1.25rem;
+ --size-2xl: 1.5rem;
+ --size-3xl: 1.875rem;
+ --size-5xl: 2.75rem;
/* Line Heights */
- --line-height-xs: 1rem;
- --line-height-sm: 1.25rem;
- --line-height-base: 20px;
- --line-height-lg: 1.75rem;
- --line-height-3xl: 1.4;
- --line-height-5xl: 1.3;
+ --leading-xs: 1rem;
+ --leading-sm: 1.25rem;
+ --leading-base: 20px;
+ --leading-lg: 1.75rem;
+ --leading-3xl: 1.4;
+ --leading-5xl: 1.3;
/* Border Radius */
- --border-radius-default: 10px;
- --border-radius-md: 0.375rem;
- --border-radius-lg: 0.5rem;
- --border-radius-xl: 0.75rem;
- --border-radius-xxl: 1.25rem;
- --border-radius-3xl: 1.5rem;
- --border-radius-full: 9999px;
+ --radius: 10px;
+ --radius-md: 0.375rem;
+ --radius-lg: 0.5rem;
+ --radius-xl: 0.75rem;
+ --radius-xxl: 1.25rem;
+ --radius-3xl: 1.5rem;
+ --radius-full: 9999px;
/* Spacing */
--spacing-7: 1.75rem;
@@ -63,13 +71,20 @@
--spacing-22: 5.5rem;
/* List Style Images */
- --list-style-image-disc: url('list-circle-full.svg');
+ --list-image-disc: url('@/images/list-circle-full.svg');
}
@utility mobile-section-width {
@apply w-10/12;
}
+@layer base {
+ button:not(:disabled),
+ [role="button"]:not(:disabled) {
+ cursor: pointer;
+ }
+}
+
@layer components {
/* FIXME: doesn't show up*/
/*.DocSearch-Button {
diff --git a/bin/importmap b/bin/importmap
deleted file mode 100755
index 36502ab..0000000
--- a/bin/importmap
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/usr/bin/env ruby
-
-require_relative "../config/application"
-require "importmap/commands"
diff --git a/bin/vite b/bin/vite
new file mode 100755
index 0000000..5da3388
--- /dev/null
+++ b/bin/vite
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+#
+# This file was generated by Bundler.
+#
+# The application 'vite' is installed as part of a gem, and
+# this file is here to facilitate running it.
+#
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+bundle_binstub = File.expand_path("bundle", __dir__)
+
+if File.file?(bundle_binstub)
+ if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
+ load(bundle_binstub)
+ else
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
+Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
+ end
+end
+
+require "rubygems"
+require "bundler/setup"
+
+load Gem.bin_path("vite_ruby", "vite")
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 68aebab..e759b03 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -56,9 +56,6 @@
# Highlight code that triggered database queries in logs.
# config.active_record.verbose_query_logs = true
- # Suppress logger output for asset requests.
- config.assets.quiet = true
-
# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 871ec3d..99884ff 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -59,9 +59,6 @@
# Highlight code that triggered database queries in logs.
# config.active_record.verbose_query_logs = true
- # Suppress logger output for asset requests.
- config.assets.quiet = true
-
# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true
diff --git a/config/importmap.rb b/config/importmap.rb
deleted file mode 100644
index 21f9201..0000000
--- a/config/importmap.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# Pin npm packages by running ./bin/importmap
-
-pin "application", preload: true
-pin "anchor-js", preload: true # @5.0.0
-pin "navigations"
-
-# bin/importmap pin --download highlight.js/lib/core DOESN'T WORK I downloaded highlight.js/lib/core.js manually and put it in vendor with the --ed name
-# highlight.js/lib/languages/ruby
-pin "highlight.js/lib/core", to: "highlight.js--lib--core.js" # @11.9.0
-pin "highlight.js/lib/languages/ruby", to: "highlight.js--lib--languages--ruby.js" # @11.9.0
-pin "jquery" # @3.7.1
-pin "jquery.parallax-scroll"
-
-pin "lottie-player", preload: false
-
-pin "docsearch", preload: false # the local file we wrote.
-pin "docsearch-3.5.2", preload: false
diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb
deleted file mode 100644
index 9cce19e..0000000
--- a/config/initializers/assets.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# Be sure to restart your server when you modify this file.
-
-# Version of your assets, change this if you want to expire all your assets.
-Rails.application.config.assets.version = "1.0"
-Rails.application.config.assets.paths << Rails.root.join("vendor", "stylesheet")
-
-# Add additional assets to the asset load path.
-# Rails.application.config.assets.paths << Emoji.images_path
-
-# Precompile additional assets.
-# application.js, application.css, and all non-JS/CSS in the app/assets
-# folder are already added.
-# Rails.application.config.assets.precompile += %w( admin.js admin.css )
diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb
index 54f47cf..9bc515d 100644
--- a/config/initializers/content_security_policy.rb
+++ b/config/initializers/content_security_policy.rb
@@ -11,7 +11,16 @@
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
+ # Allow @vite/client to hot reload javascript changes in development
+# policy.script_src *policy.script_src, :unsafe_eval, "http://#{ ViteRuby.config.host_with_port }" if Rails.env.development?
+
+ # You may need to enable this in production as well depending on your setup.
+# policy.script_src *policy.script_src, :blob if Rails.env.test?
+
# policy.style_src :self, :https
+ # Allow @vite/client to hot reload style changes in development
+# policy.style_src *policy.style_src, :unsafe_inline if Rails.env.development?
+
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
diff --git a/config/tailwind.config.js b/config/tailwind.config.js
deleted file mode 100644
index d2aefba..0000000
--- a/config/tailwind.config.js
+++ /dev/null
@@ -1,15 +0,0 @@
-module.exports = {
- content: [
- "./public/*.html",
- // "./app/helpers/**/*.rb",
- "./app/javascript/**/*.js",
- "./app/views/**/*.erb",
- "./app/concepts/**/*.erb",
- "./app/controllers/views_controller.rb",
- "./app/concepts/cms/*.rb", // config.rb
- "section/**/*.erb",
- ],
- plugins: [
- require("@tailwindcss/forms")
- ]
-}
diff --git a/config/vite.json b/config/vite.json
new file mode 100644
index 0000000..5240bd2
--- /dev/null
+++ b/config/vite.json
@@ -0,0 +1,16 @@
+{
+ "all": {
+ "sourceCodeDir": "app/frontend",
+ "watchAdditionalPaths": []
+ },
+ "development": {
+ "autoBuild": true,
+ "publicOutputDir": "vite-dev",
+ "port": 3036
+ },
+ "test": {
+ "autoBuild": true,
+ "publicOutputDir": "vite-test",
+ "port": 3037
+ }
+}
diff --git a/dist/2.1/index.html b/dist/2.1/index.html
index a07deb6..0260aea 100644
--- a/dist/2.1/index.html
+++ b/dist/2.1/index.html
@@ -4,8 +4,8 @@
Trailblazer - The advanced business logic framework - Trailblazer
-
-
+
+
@@ -44,7 +44,7 @@
DocumentationBlogAbout
-
+
Chat with us
@@ -55,7 +55,7 @@
BlogAbout→ 2.0
-
+
@@ -126,7 +126,7 @@
This page contains documentation for deprecated features of the activity gem suite that have been superseded.
+
+
While everything described here is still working, we encourage you to slowly replace your old code with the functions we think are more useful, usually faster, plus better to debug and understand.
+
+
Variable mapping
+
Input / Output
+
+
+
+
+
+
With trailblazer-2.1.1 and the bundled trailblazer-activity-dsl-linear-1.0.0 gems, the recommended way of I/O is using composable variable mapping via In() and Out().
+
+
+
+
+
Before the introduction of the composable In(), Out() and Inject() filters, variable mapping was done with the :input and :output option. This is still supported and not planned to be dropped. However, there are a bunch of drawbacks with using the monolithic, non-composable options.
+
+
+
Once used, the :input, :output and :inject option will overwrite any options set earlier (or later) via In(), Out() and Inject(). This will often lead to problems when using macros.
+
The superseded options are basically impossible to debug, whereas the composable In() approach can nicely display the computed set of variables going in or out of a step.
+
In future versions of Trailblazer we’re planning automatic “contracts” for steps along with type checking. This is not possible with the monolithic :input option.
+
+
+
Input / Output
+ :input
+
+
+
+
The :input option accepts any callable following the option interface.
Very often your activity or one of the steps contained require particular objects and values to get their job done. Instead of hard-wiring those “dependencies” in the code it is good style to allow providing those objects by passing them into the activity at run-time. This is called dependency injection and is a common technique in software engineering.
+
+
One way for using dependency injection is using keyword arguments for variables you need, and defaulting those in the step signature.
In older versions before trailblazer-activity-dsl-linear-1.2.0, connecting the Path() to a separate terminus required you to pass two options :end_task and :end_id.
This is now simplified to be more consistent by introducing the :terminus option.
+
+
+Output(...) => Path(terminus: :valid) do
+ # ...
+end
+
+
If you haven’t updated your code you will see a deprecation warning.
+
+
+[Trailblazer] <file.rb> Using `:end_task` and `:end_id` in Path() is deprecated, use `:terminus` instead.
+ Please refer to https://trailblazer.to/2.1/docs/activity.html#activity-wiring-api-path-end_task-end_id-deprecation
Out of the frustration with Rails’ view layer, its lack of encapsulation and the convoluted code resulting from partials and helpers both accessing global state, the Cells gem emerged.
+
+
The cells gem is completely stand-alone and can be used without Trailblazer.
+
+
A cell is an object that represent a fragment of your UI. The scope of that fragment is up to you: it can embrace an entire page, a single comment container in a thread or just an avatar image link.
+
+
In other words: A cell is an object that can render a template.
+
+
Overview
+
+
Cells are faster than ActionView. While exposing a better performance, you step-wise encapsulate fragments into cell widgets and enforce interfaces.
+
+
View Model
+
+
Think of cells, or view models, as small Rails controllers, but without any HTTP coupling. Cells embrace all presentation and rendering logic to present a fragment of the UI.
+
+
Rendering a Cell
+
+
Cells can be rendered anywhere in your application. Mostly, you want to use them in controller views or actions to replace a complex helper/partial mess.
This will instantiate and invoke the Comment::Cell for each comment. It is completely up to the cell how to return the necessary markup.
+
+
Cell Class
+
+
Following the Trailblazer convention, the Comment::Cell sits in app/concepts/comment/cell.rb.
+
+
class Comment::Cell < Cell::ViewModel
+ def show
+ "This: #{model.inspect}"
+ end
+ end
+
+
+
Per default, the #show method of a cell is called when it is invoked from a view. This method is responsible to compile the HTML (or whatever else you want to present) that is returned and displayed.
+
+
Whatever you pass into the cell via the concept helper will be available as the cell’s #model.
+Whatever you return from the show method will be displayed in the page invoking the cell.
Note that you can pass anything into a cell. This can be an ActiveRecord model, a PORO or an array of attachments. The cell provides access to it via model and it’s your job do use it correctly.
+
+
Cell Views
+
+
While we already have a cleaner interface as compared to helpers accessing to global state, the real power of Cells comes when rendering views. This, again, is similar to controllers.
+
+
class Comment::Cell < Cell::ViewModel
+ def show
+ render # renders app/concepts/comment/views/show.haml
+ end
+ end
+
+
+
Using #render without any arguments will parse and interpolate the app/concepts/comment/views/show.haml template. Note that you’re free to use ERB, Haml, or Slim.
That’s right, you can use Rails helpers in cell views.
+
+
No Helpers
+
+
While you could reference model throughout your view and strongly couple view and model, Cells makes it extremely simple to have logicless views and move presentation code to the cell instance itself.
+
+
- # app/concepts/comment/views/show.haml
+ %li
+ = body
+ By #{author_link}
+
+
+
Every method invoked in the view is called on the cell instance. This means we have to implement #body and #author_link in the cell class. Note that how that completely replaces helpers with clean object-oriented methods.
+
+
class Comment::Cell < Cell::ViewModel
+ def show
+ render
+ end
+
+ private
+ def body
+ model.body
+ end
+
+ def author_link
+ link_to model.author.email, author_path(model.author)
+ end
+ end
+
+
+
What were global helper functions are now instance methods. All Rails helpers like link_to are available on the cell instance.
+
+
Properties
+
+
Delegating attributes to the model is so common it is built into Cells.
+
+
class Comment::Cell < Cell::ViewModel
+ property :body
+ property :author
+
+ def show
+ render
+ end
+
+ private
+ def author_link
+ link_to author.email, author_path(author)
+ end
+ end
+
+
+
The ::property declaration will create a delegating method for you.
+
+
Testing
+
+
The best part about Cells is: you can test them in isolation. If they work in a test, they will work just anywhere.
+
+
describe Comment::Cell do
+ it do
+ html = concept("comment/cell", Comment.find(1)).()
+ expect(html).to have_css("h1")
+ end
+ end
+
+
+
The concept helper will behave exactly like in a controller or view and allows you to write rock-solid test for view components with a very simple API.
+
+
More
+
+
Cells comes with a bunch of helpful features like nesting, caching, view inheritance, and more.
+
+
+
The following happens now.
+
+
+
First, Comment::Cell::Show cell is rendered.
+
Then, Pro::Cell::Layout is invoked and it will yield the content from the show cell.
+
In the layout.haml view, the navigation cell is also rendered. This cell needs a current_user option which is provided by the context object.
+
+
+
Context Object
+
+
When rendering a hierarchy of cells, it’s often necessary to share generic data across all involved cells. This always happens via dependency injection, no global state is allowed in Trailblazer.
+
+
The :context option will create such an object and is automatically being passed to all cells in that render workflow.
+
+
In order to access the current_user from the context object, the navigation cell has to be changed slightly.
+
+
module Pro
+ module Cell
+ class Navigation < Trailblazer::Cell
+ include ::Cell::Hamlit
+
+ def signed_in?
+ context[:current_user]
+ end
+
+ def email
+ context[:current_user].email
+ end
+
+ def avatar_url
+ hexed = Digest::MD5.hexdigest(email)
+ "https://www.gravatar.com/avatar/#{hexed}?s=36"
+ end
+ end
+ end
+end
+
+
+
Note that :current_user is not an option anymore, but comes from the context object.
+
+
Success
+
+
As demonstrated in this guide, it’s not really hard to replace the existing rendering stack, whether that’s ActionView or Hanami::View or Sinatra templates, with Cells.
+
+
API
+
+
A cell is an object that can render views. It represents a fragment of the page, or the entire page.
+
+
Sometimes they’re also called object-oriented partials.
+
+
The object has to define at least one method which in turn has to return the fragment. Per convention, this is #show. In this public method, you may compile arbitrary strings or render a cell view.
+
+
The return value of this public method (also called state) is what will be the rendered in the view using the cell.
+
+
Anatomy
+
+
Cells usually inherit from Cell::ViewModel.
+
+
class CommentCell < Cell::ViewModel
+ def show
+ render # renders app/cells/comment/show.haml
+ end
+end
+
+
+
When the CommentCell cell is invoked, its show method is called, the view is rendered, and returned as a HTML string.
+
+
This snippet illustrates a suffix cell, because it follows the outdated Rails-style naming and file structure. We encourage you to use Trailblazer cells. However, this document mostly describes the generic API.
+
+
Show
+
+
As per convention, #show is the only public method of a cell class.
+
+
The return value of this method is what gets rendered as the cell.
+
+
def show
+ "I don't like templates!"
+end
+
+
+
You’re free to return whatever string you desire, use your own rendering engine, or use cells’ render for templates.
+
+
Manual Invocation
+
+
In its purest form, a cell can be rendered as follows.
+
+
Comment::Cell.new(comment).() #=> "I don't like templates!"
+
+
+
This can be split up into two steps: initialization and invocation.
+
+
Initialize
+
+
You may instantiate cells manually, wherever you want.
+
+
cell = Comment::Cell.new(comment)
+
+
+
This is helpful in environments where the helpers are not available, e.g. a Rails mailer or a Lotus::Action.
+
+
Note that usually you pass in an arbitrary object into the cell, the “model”. Here, this is the comment instance.
+
+
Model
+
+
The model you pass into the cell’s constructor is completely up to you! It could be an ActiveRecord instance, a Struct, or an array of items.
See how you can combine cells with global helpers like content_for?
+
+
You can also provide additional arguments to call.
+
+
cell.(:show, Time.now)
+
+
+
All arguments after the method name are passed to the invoked method.
+
+
def show(time)
+ time #=> Now!
+end
+
+
+
Even blocks are allowed.
+
+
cell.(:show) { "Yay!" }
+
+
+
Again, the block is passed through to the invoked method.
+
+
def show(&block)
+ yield #=> "Yay!"
+end
+
+
+
This is particularly interesting when passing the block to render and using yield in the view. See render’s docs for that.
+
+
Default Show
+
+
Per default, every cell derived from Cell::ViewModel has a built-in show method.
+
+
class CommentCell < Cell::ViewModel
+ # #show is inherited.
+end
+
+
+
The implementation looks as follows.
+
+
def show(&block)
+ render &block
+end
+
+
+
An optional block is always passed to the render method.
+
+
Of course, you’re free to override show to do whatever it needs to do.
+
+
Instantiation Helper
+
+
In most environments you will instantiate cells with the concept or cell helper which internally does exactly the same as the manual invocation.
+
+
cell = cell(:comment, comment)
+
+
+
This is identical to
+
+
cell = CommentCell.new(comment)
+
+
+
Depending on your environment, the cell helper might inject dependencies into the created cell. For example, in Rails, the controller is passed on into the cell behind the scenes. When manually instantiating cells, you must not forget to do so, too.
+
+
The cell helper also allows passing in the cell constant. This means, it won’t try to infer the class constant name.
+
+
cell = cell(CommentCell, comment)
+
+
+
File Structure
+
+
Having a cell to represent a fragment of your page is one thing. The real power, whatsoever, comes when rendering templates in cells. The render method does just that.
+
+
In a suffix environment, Cells expects the following file layout.
Every cell - unless configured otherwise - has its own view directory named after the cell’s name (comment). Views do only have one extension to identify the template’s format (show.haml). Again, you’re free to provide arbitrary additional extensions.
+
+
Note that the suffix style shown above is deprecated, and will be superseded in Cells 5 by the Trailblazer-style naming and file structure.
+
+
Render
+
+
class CommentCell < Cell::ViewModel
+ def show
+ render # renders show.haml.
+ end
+end
+
+
+
A simple render will implicitly figure out the method (or state) name and attempt to render that view. Here, the file will be resolved to app/cells/comment/show.haml.
+
+
Note that render literally renders the template and returns the HTML string. This allows you to call render multiple times, concatenate, and so on.
+
+
def show
+ render + render(:footer) + "<hr/>"
+end
+
+
+
You can provide an explicit view name as the first argument.
+
+
def show
+ render :item # renders item.haml
+end
+
+
+
When providing more than one argument to render, you have to use the :view option.
This instantiates each collection cell as follows.
+
+
CommentCell.new(comment, size: 9)
+
+
+
You can use the join method to customize how each item in the collection is invoked. The return value of the block is automatically inserted in between each rendered item in the collection0
+
+
class CommentCell < Cell::ViewModel
+ def odd
+ "odd comment\n"
+ end
+
+ def even
+ "even comment\n"
+ end
+end
+
+cell(:comment, collection: Comment.all).join do |cell, i|
+ i.odd? ? cell.(:odd) : cell(:even)
+end
+# => "odd comment\neven comment\nodd comment\neven comment"
+
+
+
An optional separator string can be passed to join when it concatenates the item fragments.
Alternatively, if you just want to add some extra content in between each rendered item and don’t need to customize how each item is invokved, you can call join with a separator and no block:
+
+
class PostCell
+ def show
+ 'My post'
+ end
+end
+
+cell(:post, collection: Post.all).join("<hr/>")
+# => "My post<hr/>My post<hr/>My post"
+
+
+
External Layout
+
+
Since Cells 4.1, you can instruct your cell to use a second cell as a wrapper. This will first render your actual content cell, then pass the content via a block to the layout cell.
+
+
Cells desiring to be wrapped in a layout have to include Layout::External.
+
+
class CommentCell < Cell::ViewModel
+ include Layout::External
+end
+
+
+
The layout cell usually can be an empty subclass.
+
+
class LayoutCell < Cell::ViewModel
+end
+
+
+
Its show view must contain a yield to insert the content.
In Rails, this is all you need to do. In other environments, you need to include the respective module into your cells.
+
+
class CommentCell < Cell::ViewModel
+ include ::Cell::Erb # or Cell::Hamlit, or Cell::Haml, or Cell::Slim
+end
+
+
+
HTML Escaping
+
+
Cells per default does no HTML escaping, anywhere. This is one of the reasons why Cells is much faster than Rails’ ActionView.
+
+
Include Escaped to make property readers return escaped strings.
+
+
class CommentCell < Cell::ViewModel
+ include Escaped
+ property :title
+end
+
+song.title #=> "<script>Dangerous</script>"
+Comment::Cell.(song).title #=> <script>Dangerous</script>
+
+
+
Only strings will be escaped via the property reader.
+
+
You can suppress escaping manually.
+
+
def raw_title
+ "#{title(escape: false)} on the edge!"
+end
+
+
+
Of course this works in views too:
+
+
<%= title(escape: false) %>
+
+
+
Context Object
+
+
By default, every cell contains a context object. When nesting cells, this object gets passed in automatically. To add objects to the context, use the :context option.
Often, it’s good practice to replace decider code from views or classes by extracting it out into separate sub-cells. Or in case you want to render a polymorphic collection, builders come in handy.
+
+
Builders allow instantiating different cell classes for different models and options.
+
+
class CommentCell < Cell::ViewModel
+ include ::Cell::Builder
+
+ builds do |model, options|
+ if model.is_a?(Post)
+ PostCell
+ elsif model.is_a?(Comment)
+ CommentCell
+ end
+ end
+end
+
+
+
The #cell helper takes care of instantiating the right cell class for you.
+
+
cell(:comment, Post.find(1)) #=> creates a PostCell. This also works with collections.
+
+cell(:comment, collection: [@post, @comment]) #=> renders PostCell, then CommentCell.
+
+
+
Multiple calls to ::builds will be ORed. If no block returns a class, the original class will be used (CommentCell). Builders are inherited.
+
+
Caching
+
+
Cells allow you to cache per state. It’s simple: the rendered result of a state method is cached and expired as you configure it.
+
+
To cache forever, don’t configure anything
+
+
class CartCell < Cell::Rails
+ cache :show
+
+ def show
+ render
+ end
+
+
+
This will run #show only once, after that the rendered view comes from the cache.
+
+
Cache Options
+
+
Note that you can pass arbitrary options through to your cache store. Symbols are evaluated as instance methods, callable objects (e.g. lambdas) are evaluated in the cell instance context allowing you to call instance methods and access instance variables. All arguments passed to your state (e.g. via render_cell) are propagated to the block.
+
+
cache :show, :expires_in => 10.minutes
+
+
+
If you need dynamic options evaluated at render-time, use a lambda.
+
+
cache :show, :tags => lambda { |*args| tags }
+
+
+
If you don’t like blocks, use instance methods instead.
+
+
class CartCell < Cell::Rails
+ cache :show, :tags => :cache_tags
+
+ def cache_tags(*args)
+ # do your magic..
+ end
+
+
+
Conditional Caching
+
+
The :if option lets you define a condition. If it doesn’t return a true value, caching for that state is skipped.
+
+
cache :show, :if => lambda { |*| has_changed? }
+
+
+
Cache Keys
+
+
You can expand the state’s cache key by appending a versioner block to the ::cache call. This way you can expire state caches yourself.
+
+
class CartCell < Cell::Rails
+ cache :show do
+ order.id
+ end
+
+
+
The versioner block is executed in the cell instance context, allowing you to access all stakeholder objects you need to compute a cache key. The return value is appended to the state key: "cells/cart/show/1".
+
+
As everywhere in Rails, you can also return an array.
+
+
class CartCell < Cell::Rails
+ cache :show do
+ [id, options[:items].md5]
+ end
+
When caching is turned on, you might wanna see notifications. Just like a controller, Cells gives you the following notifications.
+
+
+
write_fragment.action_controller for cache miss.
+
read_fragment.action_controller for cache hits.
+
+
+
To activate notifications, include the Notifications module in your cell.
+
+
class Comment::Cell < Cell::Rails
+ include Cell::Caching::Notifications
+
+
+
Cache Inheritance
+
+
Cache configuration is inherited to derived cells.
+
+
Testing Caching
+
+
If you want to test it in development, you need to update development.rb to see the effect.
+
+
config.action_controller.perform_caching = true
+
+
+
Trailblazer
+
+
This documents the Trailblazer-style cells semantics, brought to you by the trailblazer-cells gem.
+
+
+
+
+
+
This gem can be used stand-alone without Trailblazer, its only dependency is the cells gem.
+
+
+
+
+
A Trailblazer::Cell is a normal cell with Trailblazer semantics added. Naming, file structure, and the way views are resolved follow the TRB style. Note that this will be the standard for Cells 5, which will drop all old “dialects”.
+
+
Installation
+
+
gem "trailblazer-cells"
+gem "cells-slim"
+
+
+
Make sure you also add the view engine. We recommend cells-slim.
+
+
File Structure
+
+
In Trailblazer, cell classes sit in their concept’s cell directory, the corresponding views sit in the view directory.
Note that one cell class can have multiple views, as well as other assets like .scss stylesheets.
+
+
Also, the view names with Trailblazer::Cell are not called show.slim, but named after its corresponding cell class. For instance, Comment::Cell::Index will render comment/view/index.slim.
+
+
Naming
+
+
As always, the Trailblazer naming applies.
+
+
Comment[::SubConcepts]::Cell::[Name]
+
+
+
This results in classes such as follows.
+
+
module Comment::Cell # namespace
+ class New < Trailblazer::Cell # class
+ def show
+ render # renders app/concepts/comment/view/new.slim.
+ end
+ end
+end
+
+
+
This is different to old suffix-cells. While the show method still is the public method, calling render will use the new.slim view, as inferred from the cell’s last class constant segment (New).
+
+
Default Show
+
+
Note that you don’t have to provide a show method, it is created automatically.
+
+
module Comment::Cell
+ class New < Trailblazer::Cell
+ end
+end
+
+
+
This is the recommended way since no setup code should be necessary.
+
+
You’re free to override show, though.
+
+
View Names
+
+
Per default, the view name is computed from the cell’s class name.
If your cells have a controller dependency, you can set it using ::controller.
+
+
describe SongCell do
+ controller SongsController
+
+
+
This will provide a testable controller via #controller.
+
+
Capybara Support
+
+
Per default, Capybara support is enabled in Cell::TestCase when the Capybara gem is loaded. This works for both Minitest and Rspec.
+
+
The only changed behavior will be that the result of Cell#call is wrapped into a Capybara::Node::Simple instance, which allows to call matchers on the result.
+
+
cell(:song, @song).().must_have_selector "b" # example for MiniTest::Spec.
+
+
+
In case you need access to the actual markup string, use #to_s. Note that this is a Cells-specific extension.
+
+
cell(:song, @song).().to_s.must_match "by SNFU" # example for MiniTest::Spec.
+
+
+
You can disable Capybara for Cells even when the gem is loaded.
+
+
Cell::Testing.capybara = false
+
+
+
Capybara with Minitest (Rails)
+
+
With Minitest, the recommended approach is to use the minitest-rails-capybara gem.
+
+
group :test do
+ gem "minitest-rails-capybara"
+end
+
+
+
You also have to include certain Capybara modules into your test case. It’s a good idea to do this in your app-wide test_helper.rb.
+
+
Cell::TestCase.class_eval do
+ include ::Capybara::DSL
+ include ::Capybara::Assertions
+end
+
+
+
If you miss to do so, you will get an exception similar to the following.
+
+
NoMethodError: undefined method `must_have_css' for #<User::Cell::Index:0xb5a6c>
+
After including the Testing module, you’re ready to run specs against cells.
+
+
class NavigationCellTest < Minitest::Spec
+ include Cell::Testing
+
+ it "renders avatar when user provided" do
+ html = cell(Pro::Cell::Navigation, user).()
+
+ html.must_have_css "#avatar-signed-in"
+ html.to_s.must_match "Signed in: nick@trb.to"
+ end
+end
+
+
+
Rendering
+
+
View Paths
+
+
Every cell class can have multiple view paths. However, I advise you not to have more than two, better one, unless you’re implementing a cell in an engine. This is simply to prevent unexpected behavior.
+
+
View paths are set via the ::view_paths method.
+
+
class Cell::ViewModel
+ self.view_paths = ["app/cells"]
+
+
+
Use the setter to override the view paths entirely, or append as follows.
+
+
class Shopify::CartCell
+ self.view_paths << "/var/shopify/app/cells"
+
+
+
The view_paths variable is an inheritable array.
+
+
A trick to quickly find out about the directory lookup list is to inspect the ::prefixes class method of your particular cell.
This is the authorative list when finding templates. It will include inherited cell’s directories as well when you used inheritance. The list is traversed from left to right.
+
+
Partials
+
+
Even considered a taboo, you may render global partials from Cells.
+
+
SongCell < Cell::ViewModel
+ include Partial
+
+ def show
+ render partial: "../views/shared/sidebar.html"
+ end
+
+
+
Make sure to use the :partial option and specify a path relative to the cell’s view path. Cells will automatically add the format and the terrible underscore, resulting in "../views/shared/_sidebar.html.erb".
+
+
Rails
+
+
When using cells in a Rails app there’s several nice features to benefit from.
+
+
Asset Pipeline
+
+
Cells can bundle their own assets in the cell’s view directory. This is a very popular way of writing highly reusable components.
+
+
It works with both engine cells and application cells.
The Asset Pipeline is a complex system. If your assets are not compiled, start debugging in Cells’ railtie and uncomment the puts in the cells.update_asset_paths initializer to see what directories get added.
+
+
Cell classes need to be loaded when precompiling assets! Make sure your application.rb contains the following setting (per default, this is turned on).
+
+
config.assets.initialize_on_precompile = true
+
+
+
You need to compile assets using this command, which is explained here.
Although not recommended, you can also render global partials from a cell. Be warned, though, that they will be rendered using our stack, and you might have to include helpers into your view model.
+
+
This works by including Partial and the corresponding :partial option.
+
+
class Cell < Cell::ViewModel
+ include Partial
+
+ def show
+ render partial: "../views/shared/map.html" # app/views/shared/map.html.haml
+ end
+
+
+
The provided path is relative to your cell’s ::view_paths directory. The format has to be added to the file name, the template engine suffix will be used from the cell.
+
+
You can provide the format in the render call, too.
This was mainly added to provide compatibility with 3rd-party gems like Kaminari and Cells that rely on rendering partials within a cell.
+
+
Generators
+
+
In Rails, you can generate cells and concept cells.
+
+
rails generate cell comment
+
+
+
Or, TRB-style concept cells.
+
+
rails generate concept comment
+
+
+
Engine Cells
+
+
You can bundle cells into Rails engines and maximize a clean, component architecture by making your view models easily distributable and overridable.
+
+
This pretty much works out-of-the-box, you write cells and push them into an engine. The only thing differing is that engine cells have to set their view_paths manually to point to the gem directory.
+
+
Engine View Paths
+
+
Each engine cell has to set its view_paths.
+
+
The easiest way is to do this in a base cell in your engine.
+
+
module MyEngine
+ class Cell < Cell::Concept
+ view_paths = ["#{MyEngine::Engine.root}/app/concepts"]
+ end
+end
+
+
+
The view_paths is inherited, you only have to define it once when using inheritance within your engine.
+
+
module MyEngine
+ class Song::Cell < Cell # inherits from MyEngine::Cell
+
+
+
This will not allow overriding views of this engine cell in app/cells as it is not part of the engine cell’s view_paths. When rendering MyEngine::User::Cell or a subclass, it will not look in app/cells.
+
+
To achieve just that, you may append the engine’s view path instead of overwriting it.
+
+
class MyEngine::User::Cell < Cell::Concept
+ view_paths << "#{MyEngine::Engine.root}/app/concepts"
+end
+
+
+
Engine Render problems
+
+
You might have to include cells’ template gem into your application’sGemfile. This will properly require the extension.
+
+
Engine Namespace Helpers
+
+
If you need namespaced helpers, include the respective helper in your engine cell.
+
+
module MyEngine
+ class CommentCell < Cell::ViewModel
+ include Engine.routes.url_helpers
+
+ def comment_url
+ link_to model.title, engine_specific_path_without_any_namespaces_needed
+ end
+ end
+end
+
+
+# application Gemfile
+gem "cells-erb"
+
+
+
Translation and I18N Helper
+
+
You can use the #t helper.
+
+
require "cell/translation"
+
+class Admin::Comment::Cell < Cell::Concept
+ include ActionView::Helpers::TranslationHelper
+ include ::Cell::Translation
+
+ def show
+ t(".greeting")
+ end
+end
+
+
+
This will lookup the I18N path admin.comment.greeting.
+
+
Setting a differing translation path works with ::translation_path.
+
+
class Admin::Comment::Cell < Cell::Concept
+ include Cell::Translation
+ self.translation_path = "cell.admin"
+
+
+
The lookup will now be cell.admin.greeting.
+
+
Asset Helper
+
+
When using asset path helpers like image_tag that render different paths in production, please simply delegate to the controller.
+
+
class Comment::Cell < Cell::Concept
+ delegates :parent_controller, :image_tag
+
+
+
The delegation fixes the well-known problem of the cell rendering the “wrong” path when using Sprockets. Please note that this fix is necessary due to the way Rails includes helpers and accesses global data.
+
+
Template Engines
+
+
Cells supports various template engines.
+
+
We provide support for Haml, Erb, and Slim. You can also write your own template engine.
+
+
In a non-Rails environment, you need to include the respective module into your cells, so it knows what template to find.
+
+
class SongCell < Cell::ViewModel
+ include Cell::Erb
+ # include Cell::Haml
+ # include Cell::Slim
+
+
+
Note that you can only include one engine per class. This is due to problems with helpers in Rails and the way they have to be fixed in combination with Cells.
+
+
Multiple Template Engines in Rails
+
+
When including more than one engine in your Gemfile in Rails, the last one wins. Since each gem includes itself into Cell::ViewModel, unfortunately there can only be one global engine.
+
+
Currently, there’s no clean way but to disable automatic inclusion from each gem (not yet implemented) and then include template modules into your application cells manually.
+
+
ERB
+
+
Haml
+
+
Slim
+
+
Your Own
+
+
Theoretically, you can use any template engine supported by Tilt.
+
+
To activate it in a cell, you only need to override #template_options_for.
+
+
class SongCell < Cell::ViewModel
+ def template_options_for(options)
+ {
+ template_class: Tilt, # or Your::Template.
+ suffix: "your"
+ }
+ end
+
+
+
This will use Tilt to instantiate a template to be evaluated. The :suffix is needed for Cells when finding the view.
+
+
Troubleshooting
+
+
Helper Inclusion Order
+
+
One of the many problems with Rails helper implementation is that the inclusion order matters. This can lead to problems with the following exception.
+
+
undefined method `output_buffer=' for #<Comment::Cell:0xb518d8cc>
+
+
+
Usually, the problem arises when you have initializers to setup your application cell. When including helpers here, they might be included before the cells gem has a chance to include its fixes.
+
+
Please include your template engine module explicitly then, after your standard helper inclusions.
+
+
# config/initializers/trailblazer.rb
+
+Cell::Concept.class_eval do
+ include ActionView::Helpers::TranslationHelpers # include your helpers here.
+ include Cell::Erb # or Cell::Slim, or Cell::Haml after that
+end
+
+
+
Form helpers
+
+
Sometimes you have to manually include Rails helpers into your cell classes. For instance, the following exception originating from lib/action_view/helpers/form_options_helper.rb might disturb your sunny day.
+
+
undefined method `collection_select' for #<...::Cell::
+
+
+
While we still don’t understand the root of the problem, it can be fixed by including FormOptionsHelper.
+
+
module Comment::Cell
+ class New < Trailblazer::Cell
+ include ActionView::Helpers::FormOptionsHelper
+ # ...
+ end
+end
+
+
+
Likewise, when using simple_form you need to manually include its helper or you will encounter the following exception.
+
+
undefined method `simple_form_for' for #<...::Cell::
+
+
+
It is easily fixed with an include.
+
+
module Transaction::Cell
+ class New < Trailblazer::Cell
+ include SimpleForm::ActionViewExtensions::FormHelper
+ # you can include more than one helper:
+ # include ActionView::Helpers::FormOptionsHelper
+
A twin is an intermediate object that usually sits between persistence layer and your application code. It’s a domain object that helps you model your application domain. Sometimes this is called decorator.
+
+
Often, a twin maps directly to one persistent “model”. However, twins are absolutely not limited to your database layout - the opposite is the case. One twin can be a composition of many underlying models, with renaming, delegating, and more mapping features, that allow you modeling objects for your application, and not objects dictated by your database layout.
+
+
Declarative API
+
+
Every twin is based on a schema which comes in form of a Disposable::Twin class.
+
+
class AlbumTwin < Disposable::Twin
+ property :title
+
+ collection :songs do
+ property :name
+ property :index
+ end
+
+ property :artist do
+ property :full_name
+ end
+end
+
+
+
The self-explaining DSL known from many Trailblazer gems allows to define flat or nested twins.
+
+
Unnest
+
+
To expose a nested property on an outer level, use ::unnest.
+
+
class AlbumTwin < Disposable::Twin
+ property :artist do
+ property :email
+ end
+
+ unnest :email, from: :artist
+end
+
+
+
The email accessors will now be on top-level, hiding the nested structure to the outside world.
#sync writes all configured attributes back to the models using public setters such as album.name= or album.songs=. This works recursively and will sync the entire object graph.
+
+
+
+ Note that sync might unexpectedly trigger saving of the model. Some persistence layers, for instance ActiveRecord, can't deal with collection= [] and instantly persist that.
+
+
+
+
Sync with Block
+
+
You may implement your syncing manually by passing a block to sync. The block argument is a nested hash of property/value pairs.
Invoking sync with a block will not write anything to the models. Note that nil values are included into the hash, too (0.4.0).
+
+
Save
+
+
Calling #save will do sync, then call save on all nested models. This implies that the models need to implement #save. save is unavailable by default; you have to explicitly include it with feature Save:
+
+
class AlbumTwin < DispoableTwin
+ feature Sync
+ feature Save
+
+ property :title
+
+ collection :songs do
+ property :name
+ property :index
+ end
+end
+
+album = Album.find(1)
+twin = AlbumTwin.new(album)
+twin.save
+#=> syncs data back to the models, then calls:
+#=> album.save
+#=> album.songs.each { |song| song.save }
+
+
+
+
Nested Twin
+
+
Nested objects can be declared with an inline twin.
You can also specify nested objects with an explicit class.
+
+
property :artist, twin: TwinArtist
+
+
+
Features
+
+
You can simply include feature modules into twins. If you want a feature to be included into all inline twins of your schema, use ::feature.
+
+
class AlbumTwin < Disposable::Twin
+ feature Coercion
+
+ property :artist do
+ # this will now include Coercion, too.
+
+
+
Coercion
+
+
Twins can use dry-types coercion. This will override the setter in your twin, coerce the incoming value, and call the original setter. Nothing more will happen.
+
+
Disposable already defines a module Disposable::Twin::Coercion::Types with all the Dry::Types built-in types. So you can use any of the documented types.
The :type option defines the coercion type. You may include Setup::SkipSetter, too, as otherwise the coercion will happen at initialization time and in the setter.
+
+
twin.id = "1"
+twin.id #=> 1
+
+
+
Again, coercion only happens in the setter.
+
+
Nilify
+
+
Coercion also supports the conversion of blank strings ("") into nil. This is known as nilify and provided via the :nilify option.
The Property::Hash module also works great with ::unnest and is a fantastic way to get rid of migrations and data that doesn’t need a dedicated column.
+
+
Twin Collections
+
+
To twin a collection of models, you can use ::from_collection.
+
+
SongTwin.from_collection([song, song])
+
+
+
This will decorate every song instance using a fresh twin.
This allows leaving the Group free of implementation, as a pure definition of events, only. Also, note that you don’t have any nesting with a context object, all methods are implemented on the same level in a simple object.
+
+
Options
+
+
You may pass arbitrary options to all callback methods.
The endpoint gem provides abstractions to invoke operations, configure injected options, and gives the invoker a matcher-like interface to deal with the outcome.
+
+
It also introduces a separate layer allowing to maintain “operations around” your actual business operation. This is called protocol.
+
+
Canonical invoke
+
+
In many TRB-driven projects, developers override Operation.call_with_public_method to inject options and tweak the way an operation is invoked on the top-level. For example, adding ctx aliases or setting standard variables in the ctx can be achieved with a monkey-patch.
+
+
However, this patch is limited and not the recommended way to configure the invocation. With endpoint, we’re introducing a canonical abstraction for invoking operations and activities, and customizing their options.
+
+
This has three major advantages.
+
+
+
You don’t have to maintain a monkey-patch.
+
Operations and activities, in controllers, tests, tasks, or on the CLI, are all invoked with the same configuration.
+
You may use a matcher-like block to handle the outcome directly, if needed.
+
+
+
signal, result = __(Memo::Operation::Create, params: {memo: {}}) do
+ failure { |ctx, contract:, **| puts contract.errors }
+end
+
+
TODO:
+ :default_matcher
+ :flow_options
+ how to use in tests via trailblazer-test.
+
+
Configuration
+
+
In order to use the “canonical invoke”, you need to define the #__() method (or whatever name you want) and configure its keyword arguments.
Your entire application can now use #__() and #__?() to invoke operations that have set a ctx alias :record for :model.
+
+
+
+
+
+
This might change in the future as we’re considering overriding #call_with_public_interface directly.
+
+
+
+
+
+
Controller
+
+
The layer helping you in controllers provides the canonical invoke via the #invoke method, and offers a super simple and customizable DSL to configure injected options.
+
+
Installation
+
+
Include the Controller module into your ApplicationController to use the endpoint controller layer across your app.
+
+
class ApplicationController < ActionController::Base
+ # ...
+ include Trailblazer::Endpoint::Controller.module
+ # ...
+end
+
+
+
Your controller and all subclasses are now ready to configure and run endpoints.
+
+
Invoke
+
+
To use the canonical invoke along with all its mechanics, use the #invoke method in your controller actions. It will automatically inject the well-defined options for each operation call, and accepts an optional block to handle different outcomes.
+
+
class MemosController < ApplicationController
+ # ...
+ def create
+ invoke Memo::Operation::Create, params: params, protocol: false do # FIXME: protocol should be false per default.
+ success { |ctx, model:, **| redirect_to memo_path(id: model.id) }
+ failure { |ctx, contract:, **|
+ render partial: "form", locals: {contract: contract}
+ }
+ end
+ end
+ # ...
+end
+
+
+
In the example above, input for the operation, such as :params, needs to be handed manually into #invoke.
+
+
Also, every possible outcome has to be defined by hand in the matcher block, cluttering the simple controller action with verbose code.
+
+
However, it’s actually very simple to move the reappearing options and matchers one level up using the configuration DSL.
+
+
Configuration
+
+
You can predefine endpoint-specific options within the endpoint block of a controller. Usually, that would be done on the ApplicationController level.
+
+
+class ApplicationController < ActionController::Base
+ include Trailblazer::Endpoint::Controller.module
+
+ endpoint do
+ ctx do
+ # ...
+ end
+
+ default_matcher do
+ # ...
+ end
+
+ flow_options do
+ # ...
+ end
+ end
+
+
The various directives are discussed in the following sections.
+
+
Configuration
+ ctx
+
+
+
+
Use the ctx block to define what ctx variables always get passed into the operation invocation.
+
+
endpoint do
+ # ...
+ ctx do |controller:, **|
+ {
+ params: controller.params,
+ }
+ end
+ # ...
+end
+
+
+
Note that you have access to :controller and :activity as block keyword arguments. Any number of ctx variables may be added, such as the current_user.
+
+
Now, the controller action doesn’t need to pass params anymore.
+
+
def create
+ invoke Memo::Operation::Create, protocol: false do
+ # ...
+ end
+end
+
+
+
Variables passed manually to #invoke are merged with the endpoint.ctx hash.
+
+
Configuration
+ default_matcher
+
+
+
+
Using the endpoint DSL, you can keep controller actions lean by defining generic handlers once, on the ApplicationController level using the default_matcher block.
Note that a default success handler is overridden by the block passed to #invoke.
+
+
Configuration
+ invoke
+
+
+
+
TODO
+
+
Configuration
+ Inheritance
+
+
+
+
Per default, all configuration options are inherited, allowing you very lean concrete controllers that only override specific directives.
+
+
class MemosController < ApplicationController
+ # ...
+end
+
+
+
MemosController inherits all endpoint options from ApplicationController and allows overriding those.
+
+
Protocol
+
+
Whenever you need an operation “around” your actual business operation, to add authentication, policy checks, finding models, or other steps before and after your domain code, you want to start using a protocol endpoint. We usually simply call those endpoint.
+
+
Overview
+
+
A protocol endpoint is defined per use case, which usually corresponds to a particular controller action and operation. In this example, an Update operation is embraced by its endpoint. You may add arbitrary steps to the endpoint, such as authentication, authorization or data filtering that is unrelated to your business domain.
+
+
Usually, a bunch of use cases share the same protocol.
+
+
+
+
Here, two exemplary steps authenticate and policy are placed before the actual business operation Memo::Operation::Update. Also note how both “protocol steps” have their failure output connected to their own terminus. The layout of the endpoint, nevertheless, is completely up to you.
+
+
Endpoints define a fixed set of possible outcomes: besides success and failure there might be additional, more fine-grained termini like not_found or even credit_card_invalid. It’s the job of your team to decide which generic, reusable termini to introduce.
+
+
The key idea of a fixed set of outcomes is a fixed set of handlers. You implement the handlers for each terminus once, and override those in specific cases per controller or controller action. In other words, not_found renders a 404 error automatically, but you can customize the rendering in a success outcome manually.
+
+
Overview
+ Protocol
+
+
+
+
Endpoints can be used anywhere, in controllers, tests, background jobs, or wherever you need to invoke an operation. This example showcases a typical Rails controller using an operation Memo::Operation::Create along with our self-tailored protocol. The latter might look like so.
The Protocol, for obvious reasons, needs to be an Activity subclass like Railway or FastTrack.
+
+
+
+
The steps involved, the termini exposed, all that is up to the designer. Only one step named :domain_activity is mandatory as this step gets replaced with the actual business operation.
+
+
Overview
+ Defining
+
+
+
+
Use the controller method ::endpoint to compile an actual endpoint with your MyProtocol and a concrete operation.
A new activity is created where MyProtocol now contains Memo::Operation::Create as its :domain_activity step. This is done on the class level so it’s compiled and stored once.
+
+
Overview
+ Invocation
+
+
+
+
To run the protocol with your operation, use #invoke. This part is (almost) identical to when running an operation without a protocol.
+
+
class MemosController < ApplicationController
+ # ...
+ def create
+ invoke Memo::Operation::Create, protocol: true, request: request, params: params do
+ success { |ctx, model:, **| redirect_to memo_path(id: model.id) }
+ failure { |ctx, params:, **| head 401 }
+ end
+ end
+ # ...
+end
+
+
+
With protocol: true, the invocation logic will grab the endpoint you created earlier. Check the #wtf? trace to understand what happens here.
+
+
+
+
As planned, the business operation is run as one step of the protocol.
+
+
+
+
+
+
This example is very verbose. You can move many options onto the controller level. This is discussed in the Configuration section.
+
+
+
+
+
Layout
+
+
One main reasons to use protocols is a fixed set of termini, which allows you to create generic default matchers to handle outcomes. This reduces application code in your controllers and saves developers from having to think about possible outcomes.
+
+
Layout
+ Terminus
+
+
+
+
When creating a protocol for a segment of your application, most of the thinking will be about which termini to expose and how to connect them. You can use any feature of the Wiring API - in the end, a protocol is just another “operation”.
The above example protocol exposes five termini. Three extra termini are added using #terminus to Railway’s success and failure tuple.
+
+
To re-route the failure output of the authenticate step, the Wiring API is leveraged and connects it directly to the new terminus not_authenticated.
+
+
+
+
Five termini to rule them all.
+
+
+
success obviously indicates everything went fine.
+
failure informs the caller of a validation error.
+
not_found could be terminated on when a model wasn’t found.
+
If the current_user couldn’t be computed, due to invalid credentials, the not_authenticated terminus is hit.
+
Any kind of policy breach could be routed to not_authorized.
+
+
+
Layout
+ Handlers
+
+
+
+
Now, exposing this kind of layout is only helpful if you actually handle each case in a specific way. For a better understanding, here’s an example matcher block.
This #create controller action illustrates nicely how endpoint’s matcher interface allows dealing with outcomes (or different termini). Check the default_matcher directive to learn how to reduce redundancy and be less verbose with your matchers.
+
+
Layout
+ Wiring
+
+
+
+
When compiling an endpoint with MyProtocol, every “known” terminus of the business operation is automatically connected.
For example, if Update was exposing a not_found terminus, it’s linked to its respective terminus in the protocol.
+
+
+
+
In case you need to connect an output to another arbitrary track or terminus, you can use the Wiring API in an optional block when compiling the endpoint.
+
+
Let’s assume the Update operation was terminating on its built-in fail_fast terminus when the updated model couldn’t be found. You can wire that very terminus to the not_found track in the protocol using the Wiring API.
+
+
class MemosController < ApplicationController
+ # ...
+ endpoint Memo::Operation::Update, protocol: MyProtocol do
+ {
+ Output(:fail_fast) => Track(:not_found)
+ }
+ end
+ # ...
+end
+
+
+
The returned hash of wiring instructions is executed when the :domain_activity placeholder step is replaced with the actual business operation at compile time, making the diagram look as follows.
+
+
+
+
This trick allows you to “translate” any kind of operation layout to your protocol’s. For instance, you could even move legacy operations into endpoints and connect their termini to corresponding tracks in the protocol.
+
+
Configuration
+
+
Any configuration directive from the controller layer can be used in combination with a protocol. In addition to that, we do have some extra options to fine-tune the compilation of your protocol endpoints.
+
+
Configuration
+ options
+
+
+
+
Use the options directive to default options when you create a new endpoint on the controller level.
+
+
class MemosController < ApplicationController
+ # ...
+ endpoint do
+ options do
+ {
+ protocol: MyProtocol,
+ }
+ end
+ end
+ # ...
+end
+
+
+
With the above setting, you can skip the :protocol option.
+
+
class MemosController < ApplicationController
+ # ...
+ endpoint Memo::Operation::Create # no :protocol needed!
+ # ...
+end
+
+
+
Note that you can still manually override the class-wide option when defining the endpoint.
+
+
Configuration
+ fast_track_to_railway
+
+
+
+
If you’re not interested in handling your operation’s pass_fast and fail_fast termination separately, you can use the :fast_track_to_railway shortcut in options to wire the two fast track termini to their railway friends.
+
+
class MemosController < ApplicationController
+ # ...
+ endpoint do
+ options do
+ {
+ # ...
+ fast_track_to_railway: true,
+ }
+ end
+ end
+ # ...
+end
+
+
+
This will result in a flow diagram similar to this.
+
+
+
+
Here, the distinction between failure and fail_fast gets lost after the endpoint has been run. In most scenarios, this is desired, as this mimics querying the result object via result.success?.
+
+
In some cases you might want to deal with a fail_fast or pass_fast termination of your operation. Simply override the option when defining the specific endpoint.
It’s even possible to have a dynamic :protocol_block. The block is executed in the context of the domain operation.
+
+
class MemosController < ApplicationController
+ # ...
+ endpoint do
+ options do
+ {
+ protocol_block: -> do
+ if to_h[:outputs].find { |output| output.semantic == :not_found }
+ {Output(:not_found) => End(:not_found)}
+ else
+ {} # no custom wiring.
+ end
+ end,
+ }
+ end
+ end
+ # ...
+end
+
+
+
In this example, the block is evaluated in context of Memo::Operation::Update. Within this block, you may use the Introspect API or any method of the domain operation. Here, to_h translates to Memo::Operation::Update.to_h, allowing you to add custom wiring as it applies.
The INTERNALS chapter is aimed at fellow developers who want to get a deeper understanding of how Trailblazer is built. If you plan to add features, improve or extend Trailblazer, this is your place to learn about details.
+
+
Please note that this chapter is constantly updated and extended. Are you lost? Chat to us on Zulip!
A normalizer is a pipeline of steps, a bit like a simple operation without the railway. The concept of normalizers is used in a lot of places in TRB: for processing DSL options as seen with #step or in Reform 3’s #property, or even in the Developer::Debugger.
+
+
In the Activity DSL, every time #step is called, a normalizer is invoked and its steps eventually produce the task(s) and the wiring. The result is then added to the Sequence instance.
+
+
The basic normalizer resides in trailblazer/activity/dsl/linear/normalizer.rb. All additional normalizers for #fail, #pass and #terminus in both Railway and FastTrack (operation) are built on top of that normalizer.
+
+
Normalizer
+ Extend
+
+
+
+
Normalizer steps usually check for options in ctx and then apply logic. Suppose you want a new option upcase_id: true in your operation DSL that uppercase the precomputed ID.
Note that this function needs :id, so it has to be inserted after the ID computing step. You can extend an existing normalizer using Normalizer.extend!.
+
+
module Song::Activity
+ class Create < Trailblazer::Activity::Railway
+ Trailblazer::Activity::DSL::Linear::Normalizer.extend!(
+ Song::Activity::Create,
+ :step
+ ) do |normalizer|
+ Trailblazer::Activity::DSL::Linear::Normalizer.prepend_to(
+ normalizer,
+ "activity.default_outputs", # few steps after "activity.normalize_id"
+ {
+ "my.upcase_id" => Trailblazer::Activity::DSL::Linear::Normalizer.Task(MyNormalizer.method(:upcase_id)),
+ }
+ )
+ end
+
+ step :create_model, upcase_id: true
+ step :validate
+ pass :save, upcase_id: true # not applied!
+ # ...
+ end
+end
+
+
+
The first argument is the activity to extend. The following arguments name the normalizers to modify. In this example, only #step’s normalizer will contain the #upcase_id step.
+
+
In the trace you can see, as expected, that only the ID for :create_model is uppercased.
+
+
+
+
Normalizer
+ Inherit
+
+
+
+
The dsl supports the inherit: true option to copy over particular recorded options from the replaced step in the superclass. This is implemented in "inherit.recall_recorded_options". In order to instruct the :inherit logic to record and reapply certain options, you need to mark those using Record().
+
+
If you wanted the :upcase_id option to be added automatically to the user’s option when using inherit: true the following function could be added to the above MyNormalizer.
+
+
module MyNormalizer
+ # ...
+ def self.record_upcase_id_flag(ctx, non_symbol_options:, upcase_id: nil, **)
+ ctx.merge!(
+ non_symbol_options: non_symbol_options.merge(
+ Trailblazer::Activity::DSL::Linear::Normalizer::Inherit.Record(
+ {upcase_id: upcase_id}, # what do you want to record?
+ type: :upcase_id_feature, # categorize the recorded data.
+ non_symbol_options: false # this is a real :symbol option.
+ )
+ )
+ )
+ end
+end
+
+
+
As :upcase_id is a symbol option (unlike, for example, Out()), the :non_symbol_option for Record() is `false.
+
+
It is added as another step to the original normalizer using extend!.
The #step DSL allows to save arbitrary data in the activity’s data field.
+
+
module Song::Activity
+ class Create < Trailblazer::Activity::Railway
+ step :create_model,
+ model_class: Song,
+ DataVariable() => :model_class # mark :model_class as data-worthy.
+ # ...
+ end
+end
+
+
+
Using Introspect::Graph, you can now read the :model_class variable from the data field.
+
+
Trailblazer::Activity::Introspect.Nodes(Song::Activity::Create, id: :create_model)
+ .data[:model_class] #=> Song
+
+
+
This is used internally to store and expose data like :extensions (which is part of the Introspect API). It’s implemented in the basic normalizer in "activity.compile_data".
+
+
Wiring API
+
+
As almost every task in the final activity is connected to other tasks, this wiring needs to be computed by the DSL. Two parts are required for that: the outputs the task exposes and the connectors where a particular output is connected to.
The actual work happens in #compile_wirings where the connections from each output are computed from two options: :outputs, which represents all exposed outputs along with their signal, and :output_tuples associating outputs to search strategies.
+
+
One major design guideline is that :outputs is simply a list of available outputs of the step. This option does not imply any outgoing connections. Those are effectivly defined by the :output_tuples.
+
+
Outputs
+
+
Internally, :outputs can be set in two different ways. When using Subprocess() this option is provided by the macro, it retrieves the outputs via the activity interface from the nested task.
+
+
When adding a simple step (e.g. an :instance_method) the Strategy’s defaulting gets invoked, and only then! Again, none of the defaulting described in the following section is executed if the :outputs option is provided by a macro or Subprocess().
+
+
After the outputs part is run, there will always exist an :outputs option for the following steps in the normalizers ctx.
+
+
Outputs
+ Defaulting
+
+
+
+
There is a special outputs pipeline under "activity.default_outputs" which has the purpose to configure and provide :outputs.
+
+
Each Strategy subclass now adds its :outputs defaulting steps (e.g. "path.outputs" or "railway.outputs" to that default_outputs pipeline. Currently, the implementation is a bit confusing as we don’t have nesting in pipeline.
+
+
As an example, Railway will add outputs[:failure].
The FastTrack normalizer conditionally adds outputs, only if the respective option (e.g. pass_fast: true is set.
+
+
Output Tuples
+
+
Once ctx[:outputs] is finalized, the output tuples come into play. Using the Wiring API you can configure which output goes where.
+
+
+step :model,
+ Output(:success) => Track(:ok) # THIS is the Wiring API!
+
+
Associating outputs to connectors is implemented in output_tuples.rb. After steps of this unit have been run, a new option ctx[:output_tuples] exists that connects the :outputs and can be transformed into :connections.
+
+
The running order for computing :output_tuples is as follows.
+
+
+
The generic :inherit logic from "inherit.recall_recorded_options" will copy over all non-generic output tuples from the superclass to :non_symbol_options, as if they had been provided by a user.
+
Then, defaulting from the respective strategy takes place, where default connectors for :success etc are merged before 1.).
+
User tuples are merged on top, potentially overwriting defaulted options, as it should be.
+
+
+
Output Tuples
+ Defaulting
+
+
+
+
Each strategy provides defaulting for the case that no custom wiring is configured.
+Defaulting steps such as "path.step.add_success_connector"are added before"output_tuples.normalize_output_tuples".
+
+
Alternatively, as with "railway.fail.success_to_failure", a particular “inherited” connector step is replaced.
+
+
This assures that the order in :non_symbol_options and the resulting order of :output_tuples is
The Wiring API allows to add outputs along with a new signal to non-nested steps.
+
+
+step :model,
+ Output(Error, :error) => Track(:failure) # When Error is returned, go to failure track.
+
+
When using the two-argument form, a Output::CustomOutput tuple is created. In "output_tuples.register_additional_outputs" this is converted to a Output::Semantic after the new signal is registered as a new output on :outputs.
+
+
The conversion allows all following output tuples code to work with Output::Semantic, only.
+
+
Output Tuples
+ Inherit
+
+
+
+
Several steps in the normalizer supply support for inherit: true. It is important to understand here that only custom output tuples are inherited. The :outputs option is not inherited, and neither are the default output tuples.
+
+
+
Non-generic output tuples are marked for recording in "output_tuples.remember_custom_output_tuples". These are stored via the generic inherit logic.
+
+
Currently, we assume that strict_outputs = false. This means we filter out custom output tuples that are not supported by the new step task or activity in "output_tuples.filter_inherited_output_tuples".
+
+
In order to accomplish this, the set of all inherited custom output tuples have to be explicitely computed. at present, we do that via inherited_recorded_options[:custom_output_tuples].
+
+
+
+
Connections
+
+
Once the :outputs variable is computed, and :output_tuples are set, the actual connections can be compiled in "activity.wirings", which is a step implemented in OutputTuples::Connections.compile_wirings. The returned :wirings array contains Sequence::Search instances that, during compilation, find the next step for a particular output.
+
+
Actually, the term :wirings is misleading and should be renamed to :output_searches.
+
+
Connections
+ Connectors
+
+
+
+
It is also possible to build custom connectors that are able to add any number of steps (actually, sequence rows) via the ADDS interface.
+
+
Finalization
+
+
The actual Sequence::Row is then computed in "activity.create_row". This is where :wirings is required. The :row is pushed onto ctx[:adds] which contains all ADDS additions for this step.
+
+
The Row instance is just one of potentially many ADDS additions that are applied to the Sequence instance.
+
+
Finalization
+ Sequence Row
+
+
+
+
+
+
ADDS Interface
+
+
The ADDS interface is implemented in the activity gem. It defines behavior and structures for adding rows to or altering an array or sequence.
+
+
It’s used for adding steps to the taskWrap pipeline, to normalizers, and to add rows representing steps to the Sequence in the dsl gem.
+
+
An ADDS addition instance is a hash composed like so.
Whatsoever, usually you don’t need to use the ADDS directly but through TaskWrap::Extension.WrapStatic or when working on dsl’s sequence code.
+
+
Activity
+
+
An Activity instance exposes two public methods.
+
+
+
Activity#call to invoke the activity.
+
Activity#to_h that returns the Schema hash which contains all data that was collected
+during compile time.
+
+
+
This instance is usually created via a DSL, the Schema (and Activity) is created when compiled in intermediate.rb.
+
+
An Activity is created by a DSL or other layers, but it’s completely unrelated to any DSL.
+It is the runtime object that actually invokes your steps.
+
+
Schema
+
+
The schema hash can be accessed using Activity#to_h. It consists of four mandatory keys.
+
+
+
activity.to_h[:circuit] The executable `Activity::Circuit that will actually run the task graph.
+
activity.to_h[:nodes] A Schema::Nodes instance with an Attributes instance per activity task. Usually used with Introspect::Nodes().
+
activity.to_h[:outputs] The output instances this activity exposes.
+
activity.to_h[:config] A hash keeping viable data such as :wrap_static. Note that you can add to this structure during compilation.
+
+
+
Schema
+ Nodes
+
+
+
+
Schema
+ Attributes
+
+
+
+
Invocation process
+
+
Invocation process
+ Call method
+
+
+
+
Every step (technically, it’s a task) will be run by invoking its #call method. This was a design decision to simplify building activities without having to wrap each step into some adapter that then dispatches the invocation to the actual step.
+
+
Invocation process
+ Circuit
+
+
+
+
The circuit, when invoked via #call (DISCUSS: maybe that’s not necessary) runs one step after another and figures out the next step.
+
+
It uses a Runner to actually invoke each step.
+
+
Invocation process
+ Runner
+
+
+
+
The idea of the Runner is to provide different ways of invoking a step (which could also be any operation). Currently, we provide a simple #calling implementation, and one that runs each step using a taskWrap.
The trailblazer-operation gem, being ridiculously tiny, provides the following features.
+
+
+
The Trailblazer::Operation class which is a Trailblazer::Activity::FastTrack subclass with additions.
+
A user-friendly public Operation.call version implemented in operation/public_call.rb.
+
The ClassDependencies module to set ctx variables directly on the operation class.
+
Last but not least, Operation::Result that is returned from Operation.call and allows queries such as #success?.
+
+
+
The operation gem is really just a syntactical sugaring on top of Activity::FastTrack. Everything else, from the #step DSL to tracing, is implemented in underlying gems.
+
+
Public call
+
+
Public call
+ call_task hack
+
+
+
+
Future
+
+
Long-term, I’d like to remove this gem. The only useful addition is Operation.call(ctx), and this comes with a high price. The public_call.rb code introduces unnecessary complexity and needs to apply all kinds of tricks to make Operation expose two different #call methods.
Context aka ctx (or plain old options) is a core argument-specific data structure for Trailblazer. It provides a generic, ordered read/write interface that collects mutable runtime-computed data while providing access to any compile-time information. It is extracted into its own gem and can also be used independently.
+
+
ctx
+
+
ctx can be initialized when an operation is invoked at the run time or by defining dependencies at the compile time. Inside the operation, it gets passed down to every step with it’s argument position depending on step’s interface. It will contain whatever the most recently executed step has changed and hopefully contains what you’re expecting.
+
+
+
+
+
+
If you want to see what ctx modifications are being performed per step or at specific steps, you can debug it using developer’s focus_on API.
+
+
+
+
+
ctx’s purpose is to hold the state of your activity which can also be passed down to other nested activities using Subprocess. You can filter what such activities can or can not “see” using variable mapping. After operation’s execution using public call, the Result object wraps the context for convenient access.
+
+
In order to provide the generic interface, scoping and debugging capabilities, the “Hash” argument you provide to an operation is initialized as an instance of Trailblazer::Context::Container to build up the final ctx. This allows us to support more features on top of it like indifferent access, aliasing etc
+
+
ctx
+ Indifferent Access
+
+
+
+
ctx mimics as “Hash” and also allows you to use Strings or Symbols interchangeably as keys; similar to the params hash in Rails.
+
+
result = Memo::Operation::Create.(params: { text: "Enjoy an IPA" })
+
+result[:params] # => { text: "Enjoy an IPA" }
+result['params'] # => { text: "Enjoy an IPA" }
+
+
+
All keys are stored as Symbols by default in order to allow them to be accessible as keyword arguments.
+Note that ctx doesn’t provide interchangeable keys for any nested hashes because of the performance reasons.
+
+
ctx
+ Aliases
+
+
+
+
Most commonly found keys in ctx are 'contract.default', 'contract.default.params', 'policy.default' etc. It sometimes becomes cumbersome to access them from ctx as they can’t be defined as keyword arguments in steps.
+
+
To overcome this, it is possible to define a shorter versions of context keys using aliases. By providing aliases mapping in flow_options[:context_options], context will maintain any mutations being made on the origianl keys with
+the aliases and vice versa.
+
+
flow_options are passed to the nested operations via Subprocess and all given aliases will also be applied in them by default.
Trailblazer::Option is one of the core structure behind operation’s step API, reform’s populator API etc. It makes us possible to accept any kind of callable objects at compile time and execute them at runtime.
Trailblazer::Option() accepts Symbol, lambda and any other type of callable as an argument. It will be wrapped accordingly to make an executable, so you can evaluate it at runtime.
+
+
Passing a Symbol will be treated as a method that’s called on the given exec_context.
Notice the usage of keyword_arguments while calling an Option(). They need to be mentioned explicitly in order for them to be compatible with ruby 2.7+.
+
+
+
+
+
And of course, passing lambdas. They get executed within given exec_context when set.
When using #wtf?, two taskWrap steps Trace.capture_args and Trace.capture_return are injected and applied around every activity step being run during the invocation of the activities (including the top activity itself). Those taskWrap extensions then invoke the snapshooters to produce a snapshot of the ctx variables before invocation of actual task, and after.
+
+
+
+
Trace
+ Snapshooter
+
+
+
+
The snapshot logic is implemented in Trace::Snapshot.before_snapshooter and .after_snapshooter, leveraging the Snapshot::Version.changeset_for method to produce a diffable snapshot of ctx.
+
+
Trace
+ Version
+
+
+
+
Trace
+ Stack
+
+
+
+
Both taskWrap extensions add the created Snapshot::Before or After instance to a Stack instance which collects the snapshots and maintains the Version object.
+
+
The stack is returned to the caller of the operation and can then be used for presentation.
+
+
Trace
+ Trace::Node
+
+
+
+
Once presentation is called, an array of Trace::Node instances is generated from stack, each node comprised either of a Snapshot::Before, and its matching After, or a Node::Incomplete, when the Snapshot::After couldn’t be found (e.g. due to an exception thrown before the tracer was called).
+
+
+
+
Trace
+ Present
+
+
+
+
The Trace::Present.call method accepts two arguments:
+1. the Stack instance
+2. a block that yields this trace_node structure (along with the other options) and allows to return a hash that is then passed on to the render_method. This hash allows to configure the rendering code, and in the core renderers, config per node can be added keyed by Trace::Node instance.
+
+
TODO: add example from node_options.
+
+
Trace
+ Debugger::Trace
+
+
+
+
After trace_nodes is computed, the specific rendering begins, and a Debugger::Trace is generated. It consists of variable versions and Debugger::Node instances, the latter basically decorating a Trace::Node.
+
+
This Debugger::Trace is then passed to either #wtf? or to trailblazer-pro’s rendering.
The #code_tabs helper will render two tabs ACTIVITY and OPERATION. The operation test snippet is retrieved from test/docs/autogenerated/operation_<original file name>. Extracting the snippet is done using torture mechanics both times. Sounds painful but isn’t.
+
+
+< %= code_tabs "create" %>
+
+
It’s beautiful!
+
+
+
+
Convert operation test
+
+
Always write doc tests against Activity::Railway and friends. Use the conversion tool in trailblazer-core-utils to autogenerate an operation test.
From Ruby 3.4 onwards, Hash#inspect looks different and breaks our tests. Since we use a lot of string assertions for safety reasons, please use Trailblazer::Core::Utils.inspect to render the asserted object.
The Utils.inspect will make sure the rendered string contains “old style” hash syntax.
+
+
Deprecate
+
+
Always use Activity::Deprecate.warn when marking a method as deprecated.
+
+
def outdated_method
+ Trailblazer::Activity::Deprecate.warn caller_locations[0], "The `#outdated_method` is deprecated."
+
+ # old code here.
+end
+
+
+
You need to pass one element of caller_locations to #warn. Sometimes the index changes, feel free to apply some searching for a more helpful location. Users will find the old code much faster and hopefully replace it.
+
+
Also, please test that the deprecation is actually visible.
+
+
it "gives a deprecation warning" do
+ _, err = capture_io do
+ outdated_method()
+ end
+ line_no = __LINE__
+
+ assert_equal err, %([Trailblazer] #{__FILE__}:#{line_no - 2} The `#outdated_method` is deprecated.\n)
+end
+
Macros provide shorthand methods to create and configure a particular step in your operation. Trailblazer ships with a handful of macros. Those are implemented in trailblazer-macro and trailblazer-macro-contract.
+
+
Writing you own macros is a very simple thing to do if you follow the macro API.
+
+
General notes
+
+
General notes
+ Variable mapping
+
+
+
+
Most macros internally use variable mapping to allow injection of configuration variables or to limit their own scope. Macros do so by leveraging the composable API using In(), Inject() and Out().
+
+
This allows you to add your own variable mapping to a macro, as illustrated in the example below.
When running the above operation, the exemplary Model() macro will pick the ID from :my_id.
+
+
result = Create.(my_id: 1)
+
+
+
Please note that you shall not use :input to configure your macro as it will override internal filters and breaks the macro’s configuration. Go with the new composable variable mapping which is available from trailblazer 2.1.1 and onwards.
+
+
Nested
+
+
Only use the Nested() macro if you’re planning to nest an activity that can only be chosen at runtime.
+
+
+
If you know the nested activity upfront, use Subprocess().
+
If you know the activities upfront, and need only need to choose at run-time, use the :auto_wire option.
+
+
+
Dynamic
+
+
Use Nested() to dynamically decide which nested activity to invoke, but when you don’t know all activities to pick from. This is sometimes the case in polymorphic setups, when a part of an operation needs to be “decided” at runtime, with no way to know what this will look like when writing the operation class.
+
+
Consider the following activity to create a song. Depending on the input in params, you either want to run a nested Id3Tag processor activity for an MP3 song, or VorbisComment at a specific point during the song creation.
+
+
module Song::Activity
+ class Create < Trailblazer::Activity::Railway
+ step :model
+ step Nested(:decide_file_type) # Run either {Id3Tag} or {VorbisComment}
+ step :save
+ # ...
+ def decide_file_type(ctx, params:, **)
+ params[:type] == "mp3" ? Id3Tag : VorbisComment
+ end
+ end
+end
+
+
+
When using Nested() with only one argument, the decider, we call this the “dynamic style”. Since we don’t know the possible nested activities upfront, the macro needs to compile several internals at runtime, which will be slower than its static equivalent.
+
+
Dynamic
+ Decider
+
+
+
+
The decider can be any “option style” callable: an instance method in the hosting Create activity, any lambda or proc, or a callable object or class. In our example, it’s an instance method.
+
+
The decider receives ctx and keyword arguments just like any other step. It is run directly before the nested activity is run, it’s return value decides about which one that will be: Id3Tag or VorbisComment.
+
+
Dynamic
+ Nested activity
+
+
+
+
The nested activity (or operation) will, per default, receive the same ctx that a step in place of Nested() would receive.
+
+
module Song::Activity
+ class Id3Tag < Trailblazer::Activity::Railway
+ step :parse
+ step :encode_id3
+ # ...
+ end
+end
+
+
+
Auto_wire
+
+
The recommended way of maintaining dynamically nested operations (or activities) is to use Nested() with the :auto_wire option. It works exactly like its purely dynamic counterpart but requires you to specify all possible nested activities at compile-time.
The :auto_wire option expects an array of nested activities. While this might seem a bit clumsy, this brings several benefits. First of all, execution is much faster since the entire activity graph is created at compile-time. Second, you can use the Debugging API to introspect things.
+
+
You can use any type of decider step. In this example, it’s a instance method #decide_file_type on the Nested()-hosting class.
+
+
+
+
Internally, the “auto-wire” Nested() macro simply returns a small activity as illustrated above in gray. It has a decide step to figure out which nested activity to run, then runs the actual nested activity. All well-known termini (success, failure, pass_fast, fail_fast) of the nested activities are automatically connected to the Nested()’s activity’s termini.
+
+
Auto_wire
+ Output Wiring
+
+
+
+
Given we had a nested activity VorbisComment implemented like so.
If the #encode_cover step fails, the activity will stop in the End.unsupported_file_format terminus - an end event very specific to the VorbisComment activity.
+
+
+
+
This new terminus has to be wired explicitely in the nesting activity.
+
+
module Song::Activity
+ class Create < Trailblazer::Activity::Railway
+ step :model
+ step Nested(
+ :decide_file_type,
+ auto_wire: [Id3Tag, VorbisComment]
+ ),
+ # Output and friends are used *after* Nested().
+ # Connect VorbisComment's {unsupported_file_format} to our {failure} track:
+ Output(:unsupported_file_format) => Track(:failure)
+
+ step :save
+ # ...
+ end
+end
+
+
+
Using Output(:unsupported_file_type) from the Wiring API you can connect this specific terminus to wherever you need it, here it’s routed to the failure track in Create. Note that Wiring API options are used after Nested(...), not within its parenthesis.
+
+
The complete activity graph would look a bit like the following diagram.
The trace is fully compatible with the Debugging API and allows compile-time introspection. For example, you can specify the path to a nested activity Id3Tag and inspect it.
+
+
Trailblazer::Developer.render(Song::Activity::Create,
+ path: [
+ "Nested/decide_file_type", # ID of Nested()
+ Song::Activity::Id3Tag # ID of the nested {Id3Tag} activity.
+ ]
+)
+
+
+
Note that the ID of the nested activities are the class constants.
+
+
Compliance
+ Patch
+
+
+
+
Wrap
+
+
Sometimes you need to run a sequence of steps within some block you provide. Often, this is required when certain steps must be wrapped in a database transaction. The Wrap() macro does just that.
+
+
module Song::Activity
+ class Upload < Trailblazer::Activity::FastTrack
+ step :model
+ step Wrap(MyTransaction) {
+ step :update # this changes the database.
+ step :transfer # this might even break!
+ }
+ step :notify
+ left :log_error
+ # ...
+ end
+end
+
+
+
In an imaginary song Upload operation that transfers a music file to some streaming service platform while updating fields on the database, needs the steps #update and #transfer being run inside a transaction. The code running and interpreting this block is provided via the custom transaction MyTransaction.
+
+
Wrap handler
+
+
The most simple “transaction” (we call it wrap handler) could look as follows.
+
+
class MyTransaction
+ def self.call((ctx, flow_options), **, &block)
+ signal, (ctx, flow_options) = yield # calls the wrapped steps
+
+ return signal, [ctx, flow_options]
+ end
+end
+
+
+
Your transaction handler can be any type of [callable] exposing the circuit interface.
+
+
In the most simple transaction ever written, we simply run the wrapped steps by calling yield. This will return a circuit-interface return set. The interesting parts are the returned ctx, which is the ctx that left the wrapped steps, and the signal, which indicates the outcome of running the wrapped steps.
+
+
Here, we simply return the original signal of the wrapped activity.
+
+
Note that internally #update and #transfer are put into a separate activity. The terminus reached in that activity is the signal.
+
+
+
+
Given that both #update and #transfer are railway steps, the wrapped code can terminate in a success terminus, or a failure.
+Now, it’s your handler that is responsible to interpret that. In the example above, we simply pass on the wrapped activity’s terminal signal, making the Upload activity either continue from success or from failure.
+
+
Wrap handler
+ Routing
+
+
+
+
Let’s assume the #transfer step had a custom output that goes to a super-special terminus End.timeout.
+
+
step Wrap(MyTransaction) {
+ step :update # this changes the database.
+ step :transfer,
+ Output(:failure) => End(:timeout) # creates a third terminus.
+},
+
+
+
The resulting wrapped activity now has three termini.
+
+
+
+
With our handler, which simply returns the wrapped activity’s signal, it’s possible to route the :timeout signal in Upload. For instance, if a :timeout should be resulting in Upload jumping to the fail_fast track after #transfer terminated in timeout, you can do so.
+
+
step Wrap(MyTransaction) {
+ step :update # this changes the database.
+ step :transfer,
+ Output(:failure) => End(:timeout) # creates a third terminus.
+},
+ Output(:timeout) => Track(:fail_fast) # any wiring is possible here.
+
+
+
Here’s what the circuit would look like.
+
+
+
+
Transaction
+
+
Wrapping steps into a real database transaction, and rolling back in case of a failure outcome in one of the steps, could look like so.
+
+
class MyTransaction
+ def self.call((ctx, flow_options), **)
+ handler_signal = nil # this is the signal we decide to return from Wrap().
+
+ ActiveRecord::Base.transaction do
+ signal, (ctx, flow_options) = yield # call the Wrap block
+
+ # It's now up to us to interpret the wrapped activity's outcome.
+ terminus_semantic = signal.to_h[:semantic]
+
+ if [:success, :pass_fast].include?(terminus_semantic)
+ handler_signal = Trailblazer::Activity::Right
+ else # something in the wrapped steps went wrong:
+ handler_signal = Trailblazer::Activity::Left
+
+ raise ActiveRecord::Rollback # This is the only way to tell ActiveRecord to rollback!
+ end
+ end # transaction
+
+ return handler_signal, [ctx, flow_options]
+ end
+end
+
+
+
This might look a bit clumsy, but most of the code is very straight-forward.
+
+
+
Your main intent, wrapping a sequence of steps into an ActiveRecord::Base.transaction block, contains the yield invocation that runs the wrapped steps.
+
Having the signal of the wrapped activity returned, you can decide how you’d like to interpret that. Usually, looking at the signals :semantic field indicates the outcome.
+
In case you’re not happy, a raise ActiveRecord::Rollback will make ActiveRecord undo whatever database commits happened in the block.
+
Instead of returning the nested activity’s signal, which is completely legit, Wrap() also allows you to return a Right or Left signal to communicate the outcome of the entire Wrap() component.
+
+
+
Exception handling
+
+
Catching and intercepting exceptions in Wrap() works identical to transactions. In case you don’t want to use our canonical Rescue() macro here is a sample wrap handler that uses rescue for intercepting exceptions thrown in #update or #transfer.
With any exception being thrown, the original signal from the wrapped activity is returned, as if the steps were part of the Upload operation flow.
+
+
The code below rescue is only run when an exception was thrown. Here, we write a new variable :exception to the ctx and return Left, indicating an error.
The returned new activity upload_with_upsert is a copy of Upload with the respective step replace. Note that in this very example, the method #upsert has to be included in the nested activity.
The Each() macro allows to loop over a dataset while invoking a particular operation per iteration, as if multiple identical operations were serially connected, but receiving different input ctxs.
module Song::Activity
+ class Cover < Trailblazer::Activity::Railway
+ step :model
+ step Each(dataset_from: :composers_for_each) {
+ step :notify_composers
+ }
+ step :rearrange
+
+ # "decider interface"
+ def composers_for_each(ctx, model:, **)
+ model.composers
+ end
+
+ def notify_composers(ctx, index:, item:, **)
+ Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
+ end
+ def model(ctx, params:, **)
+ ctx[:model] = Song.find_by(id: params[:id])
+ end
+
+ include T.def_steps(:rearrange)
+ end
+end
+
module Song::Operation
+ class Cover < Trailblazer::Operation
+ step :model
+ step Each(dataset_from: :composers_for_each) {
+ step :notify_composers
+ }
+ step :rearrange
+
+ # "decider interface"
+ def composers_for_each(ctx, model:, **)
+ model.composers
+ end
+
+ def notify_composers(ctx, index:, item:, **)
+ Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
+ end
+ def model(ctx, params:, **)
+ ctx[:model] = Song.find_by(id: params[:id])
+ end
+
+ include T.def_steps(:rearrange)
+ end
+end
+
+
+
You can either pass a block with steps to iterate, or an entire operation/activity. In this example, a block is used. Note that you need to use the {} curly braces due to Ruby’s precedence, just as it’s done in Wrap().
+
+
dataset_from
+
+
While you could simply assign a ctx variable :dataset in a step preceding the Each() macro, the recommended way is using :dataset_from and implementing a dedicated dataset provider.
Note that our dataset provider is an instance method #composers_for_each defined on the operation class hosting Each(). It exposes a step signature with ctx and handy keyword arguments and simply returns the dataset. It explicitely does not write to ctx!
+
+
The only requirement to the dataset provider’s return value is that it returns an enumerable object - usually, that’s an array.
+
+
Iterated Block
+
+
Note that the iterated block’s :instance_methods are defined in the Each()-hosting activity, too.
+
+
def notify_composers(ctx, index:, item:, **)
+ Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
+end
+
+
+
Per default, Each() will loop over the dataset using #each and pass the iterated item and its index into each step of the block, named :item and :index.
+
+
Iterated Block
+ item key
+
+
+
+
If you don’t like the :item keyword argument in your steps, you can configure it using the :item_key option for Each().
module Song::Activity
+ class Cover < Trailblazer::Activity::Railway
+ # ...
+ def notify_composers(ctx, index:, composer:, **)
+ Mailer.send(to: composer.email, message: "#{index}) You, #{composer.full_name}, have been warned about your song being copied.")
+ end
+ end
+end
+
module Song::Operation
+ class Cover < Trailblazer::Operation
+ # ...
+ def notify_composers(ctx, index:, composer:, **)
+ Mailer.send(to: composer.email, message: "#{index}) You, #{composer.full_name}, have been warned about your song being copied.")
+ end
+ end
+end
+
+
+
Iterated Block
+ operation
+
+
+
+
If you would like the iterated steps to be within the “block”, use a dedicted activity or operation.
module Song::Activity
+ class Notify < Trailblazer::Activity::Railway
+ step :send_email
+
+ def send_email(ctx, index:, item:, **)
+ Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
+ end
+ end
+end
+
module Song::Operation
+ class Notify < Trailblazer::Operation
+ step :send_email
+
+ def send_email(ctx, index:, item:, **)
+ Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
+ end
+ end
+end
+
module Song::Activity
+ class Cover < Trailblazer::Activity::Railway
+ step :model
+ step Each(dataset_from: :composers_for_each,
+ Inject(:messages) => ->(*) { {} },
+
+ # all filters called before/after each iteration!
+ Out() => [:messages]
+ ) {
+ step :write_to_ctx
+ }
+ step :rearrange
+
+ def write_to_ctx(ctx, item:, messages:, index:, **)
+ ctx[:messages] = messages.merge(index => item.full_name)
+ end
+ include EachTest::CoverMethods
+ include EachTest::ComposersForEach
+ end
+end
+
module Song::Operation
+ class Cover < Trailblazer::Operation
+ step :model
+ step Each(dataset_from: :composers_for_each,
+ Inject(:messages) => ->(*) { {} },
+
+ # all filters called before/after each iteration!
+ Out() => [:messages]
+ ) {
+ step :write_to_ctx
+ }
+ step :rearrange
+
+ def write_to_ctx(ctx, item:, messages:, index:, **)
+ ctx[:messages] = messages.merge(index => item.full_name)
+ end
+ include EachTest::CoverMethods
+ include EachTest::ComposersForEach
+ end
+end
+
+
+
+
When starting the iteration, the ctx variable :messages will be initialized to an empty hash (unless you provide it in the outside ctx).
+
The #write_to_ctx step within Each() sees that :messages variable and can override it, adding its non-sense message to the hash.
+
Since you configured Out() => [:messages], the following iteration will see that very :messages variable from the last iteration.
+
+
+
This allows to collect outgoing variables from the iterations, even in case of failures.
+
+
+
+
+
+
Note how the Inject() and Out() calls are within the parenthesis of Each().
+
+
+
+
+
Failure
+
+
If not configured, a failing iterated step leads the entire iteration to be stopped. The Each() activity will terminate on its failure terminus.
+
+
You can still access ctx[:collected_from_each] for each iterated block. Note that you can even see which iteration failed in the trace!
+
+
+
+
In combination with TRB’s debugger, this gives you a powerful tool for finding bugs in your code or understanding the flow, without having to jump around through iterations using pry or other tools.
If the ID extraction is more complicated, maybe you need to look into a deeply nested hash in params, use the block style to implement your own extraction.
The lamdba you implement will be called directly on the model class, mimickin scope from ActiveRecord.
+
+
The lambda receives ctx and keyword options just like any other step in the operation, except for the :id variable, which is the extracted ID from previous Find() step.
+
+
Model::Find
+ Not Found
+
+
+
+
You can wire the Model::Find() step to a dedicated terminus :not_found in case of a missing model (instead of connecting it to the default failure track). The new terminus represents an explicit outcome and could, for instance, be used in an endpoint to render a 404 response without having to check if ctx[:model] is present or not.
+
+
+
+
To add the explicit End.not_foundterminus, pass the :not_found_terminus option to the macro.
module Song::Activity
+ class Update < Trailblazer::Activity::Railway
+ step Model(Song, :find_by, :title) # third positional argument.
+ step :validate
+ step :save
+ include T.def_steps(:validate, :save)
+ end
+end
+
module Song::Operation
+ class Update < Trailblazer::Operation
+ step Model(Song, :find_by, :title) # third positional argument.
+ step :validate
+ step :save
+ include T.def_steps(:validate, :save)
+ end
+end
+
+
+
Model
+ find
+
+
+
+
Note that, instead of find_by, you may also use :find. This is not recommended, though, since it raises an exception, which is not the preferred way of flow control in Trailblazer.
+
+
Model
+ Arbitrary Finder
+
+
+
+
It’s possible to specify any finder method, which is helpful with ROMs such as Sequel.
result = Song::Operation::Update.(params: {id: 1}, seq: [])
+result[:model] #=> #<struct Song id=1, title="Roxanne">
+
+
+
Model
+ Not Found
+
+
+
+
You can wire the Model() step to a dedicated terminus :not_found in case of a missing model, instead of connecting it to the default failure track. The new terminus represents an explicit outcome and could, for instance, be used in an endpoint to render a 404 response without having to check if ctx[:model] is present or not.
+
+
+
+
To add the explicit End.not_foundterminus, pass the :not_found_terminus option to the macro.
While you could be implementing your own begin/rescue/end mechanics using Wrap, Trailblazer offers you the Rescue() macro to catch and handle exceptions that might occur while running any series of steps.
Note that you don’t have to create the model via the Model macro - you can use any logic you want. The Pundit macro will grab the model from ["model"], though.
+
+
This policy will only pass when the operation is invoked as follows.
+
+
+Create.(current_user: User.find(1))
+
+
Any other call will cause a policy breach and stop the pipe from executing after the Policy::Pundit step.
+
+
Pundit
+ API
+
+
+
+
Add your polices using the Policy::Pundit macro. It accepts the policy class name, and the rule method to call.
The step will create the policy instance automatically for you and passes the "model" and the "current_user" skill into the policies constructor. Just make sure those dependencies are available before the step is executed.
You can inject it using "policy.#{name}.eval". It can be any object responding to call.
+
+
Guard
+
+
A guard is a step that helps you evaluating a condition and writing the result. If the condition was evaluated as falsey, the pipe won’t be further processed and a policy breach is reported in Result["result.policy.default"].
result = Create.(:current_user => true)
+result[:"result.policy.user"].success? #=> true
+
+
+
Guard
+ Dependency Injection
+
+
+
+
Instead of using the configured guard, you can inject any callable object that returns a Result object. Do so by overriding the policy.#{name}.eval path when calling the operation.
This is helpful if you maintain modules for operations with generic steps.
+
+
Contract
+
+
A contract is an abstraction to handle validation of arbitrary data or object state. It is a fully self-contained object that is orchestrated by the operation.
+
+
The actual validation can be implemented using Reform with ActiveModel::Validation or dry-validation, or a Dry::Schema directly without Reform.
+
+
The Contract macros helps you defining contracts and assists with instantiating and validating data with those contracts at runtime.
+
+
overview: reform
+
+
Most contracts are Reform objects that you can define and validate in the operation. Reform is a fantastic tool for deserializing and validating deeply nested hashes, and then, when valid, writing those to the database using your persistence layer such as ActiveRecord.
As you can see, using contracts consists of five steps.
+
+
+
Define the contract class (or multiple of them) for the operation.
+
Plug the contract creation into the operation’s pipe using Contract::Build.
+
Run the contract’s validation for the params using Contract::Validate.
+
If successful, write the sane data to the model(s). This will usually happen in the Contract::Persist macro.
+
After the operation has been run, interpret the result. For instance, a controller calling an operation will render a erroring form for invalid input.
+
+
+
+
+
+
+
You don’t have to use any of the TRB macros to deal with contracts, and do everything yourself. They are an abstraction that will save code and bugs, and introduce strong conventions. However, feel free to use your own code.
+
+
+
+
+
Here’s what the result would look like after running the Create operation with invalid data.
+
+
result = Song::Create.(params: { title: "A" })
+result.success? #=> false
+result[:"contract.default"].errors.messages
+#=> {:title=>["is too short (minimum is 2 characters)"], :length=>["is not a number"]}
+
+
+
Definition
+
+
Trailblazer offers a few different ways to define contract classes and use them in an operation.
+
+
Definition
+ Explicit
+
+
+
+
The preferred way of defining contracts is to use a separate file and class, such as the example below.
Defining the contract happens via the contract block. This is called an inline contract. Note that you need to extend the class with the Contract::DSL module. You don’t have to specify anything in the Build macro.
+
+
While this is nice for a quick example, this usually ends up quite convoluted and we advise you to use the explicit style.
+
+
Build
+
+
The Contract::Build macro helps you to instantiate the contract.
+It is both helpful for a complete workflow, or to create the contract, only, without validating it, e.g. when presenting the form.
This macro will grab the model from options["model"] and pass it into the contract’s constructor. The contract is then saved in options["contract.default"].
+
+
result = Song::New.(params: {})
+result["model"] #=> #<struct Song title=nil, length=nil>
+result["contract.default"]
+#=> #<Song::Contract::Create model=#<struct Song title=nil, length=nil>>
+
+
+
The Build macro accepts the :name option to change the name from default.
+
+
Build
+ Dependency Injection / Contract class
+
+
+
+
Instead of defining the contract class in the Build() macro the very option can be injected at run-time, when calling the operation. The operation class doesn’t need any hard-wired reference to a contract class at all.
When invoking the operation, you now have to provide the default contract class as a variable (or dependency) using the :"contract.default.class" option. The Build() step will use the passed class constant for instantiating the contract.
The Contract::Validate macro is responsible for validating the incoming params against its contract. That means you have to use Contract::Build beforehand, or create the contract yourself. The macro will then grab the params and throw then into the contract’s validate (or call) method.
Note that Validate really only validates the contract, nothing is written to the model, yet. You need to push data to the model manually, e.g. with Contract::Persist.
Validate will use options["params"] as the input. You can change the nesting with the :key option.
+
+
+
+
+
+
Internally, this macro will simply call Form#validate on the Reform object.
+
+
Note that Reform comes with sophisticated deserialization semantics for nested forms, it might be worth reading a bit about Reform to fully understand what you can do in the Validate step.
+
+
+
+
+
Validate
+ Key
+
+
+
+
Per default, Contract::Validate will use ctx[:params] as the data to be validated. Use the :key option if you want to validate a nested hash from the original params structure.
Note that string vs. symbol do matter here since the operation will simply do a hash lookup using the key you provided.
+
+
Validate
+ Dependency Injection / Key
+
+
+
+
The Validate() macro allows to injecting the :key option at run-time instead of providing it when using the macro on the class level. You may omit the :key option in the Validate() macro call as below.
+
+
class Song::Create < Trailblazer::Operation
+ # ...
+ step Contract::Validate() # we don't define a key here! E.g. {key: "song"}
+ step Contract::Persist()
+end
+
+
+
Defining the :key option in Operation.call is now achieved by passing the "contract.default.extract_key" option.
Instead of using Contract::Build() to let the macro create the contract instance used for Validate(), an arbitrary contract object may be injected at run-time. You may omit Model() and Contract::Build() in that case.
+
+
class Song::Create < Trailblazer::Operation
+ # we omit the {Model()} call as the run-time contract contains the model.
+ # we don't have a {Contract::Build()} step here.
+ step Contract::Validate(key: "song") # you could use an injection here, too!
+ step Contract::Persist()
+end
+
+
+
In order for Validate() to work you have to inject a contract via the :"contract.default" option.
+
+
res = Song::Create.(
+ params: params,
+ "contract.default": Song::Contract::Create.new(Song.new) # we build the contract ourselves!
+)
+
+
+
As always, the .default part might differ depending on the name of your contract.
+
+
Invalid Termini
+
+
If the Contract::Validate() deviates on a failure track, it is possible to emit a new signal suggesting contract failure.
+
+
+
+
This becomes really handy when used along with the endpoint.
+It avoids any conditional checks and can be wired to render 422 response without accessing the ctx.
+In order to add this new termini in your operation’s terminuses, you need to pass invalid_data_terminus kwarg.
You have to use the name: option to tell each step what contract to use. The contract and its result will now use your name instead of default.
+
+
result = Song::Create.(params: { title: "A" })
+result[:"contract.form"].errors.messages #=> {:title=>["is too short (minimum is 2 ch...
+
+
+
Use this if your operation has multiple contracts.
+
+
Dry-Validation
+
+
Instead of using ActiveModel::Validation you may use the very popular dry-validation gem for validations in your Reform class.
+
+
require "reform/form/dry"
+class Create < Trailblazer::Operation
+ # contract to verify params formally.
+ class MyContract < Reform::Form
+ feature Dry
+ property :id
+ property :title
+
+ validation name: :default do
+ params do
+ required(:id).filled
+ end
+ end
+
+ validation name: :extra, if: :default do
+ params do
+ required(:title).filled(min_size?: 2)
+ end
+ end
+ end
+
+ step Model( Song, :new ) # create the op's main model.
+ step Contract::Build( constant: MyContract ) # create the Reform contract.
+ step Contract::Validate() # validate the Reform contract.
+ step Contract::Persist( method: :sync) # persist the contract's data via the model.
+end
+
+
+
All you need to do is including the feature Dry extension and applying dry-validation’s syntax within the validation blocks. The operation’s macros will work seamlessly with dry’s logic.
+
+
+
+
+
+
(TODO Jan 2021) We are rewriting the Dry-validation documentation shortly and will link to a more concise reference here.
+
+
+
+
+
Dry-Validation
+ Dry-Schema
+
+
+
+
It is possible to use Dry’s Validation::Contract directly as a contract in an operation, using the Contract::Validate() macro. You don’t need a model here, and the contract cannot be persisted. However, this is great for formal validations, e.g. to make sure the params have the correct format.
+
+
module Song::Operation
+ class Archive < Trailblazer::Operation
+ Schema = Dry::Validation.Contract do
+ params do
+ required(:id).filled
+ end
+ end
+
+ # step Model(Song, :new) # You don't need {ctx[:model]}.
+ step Contract::Validate(constant: Schema, key: :song) # Your validation.
+ # ...
+ end
+end
+
+
+
Invoking the operation works exactly as it did with a “normal” contract.
+
+
result = Song::Operation::Archive.(params: {song: {id: nil}})
+
+
+
Note that if you don’t use the :song key in your incoming data, you can configure the Validate() macro to refrain from looking for that key.
+
+
The “errors” object after running the contract validation can be found at `ctx[:”result.contract.default”]
+
+
result[:"result.contract.default"].errors[:id] #=> ["must be filled"]
+
+
+
+
+
+
+
(TODO Jan 2021) We are working on an operation-wide Errors object which will be available at ctx[:errors].
+
+
+
+
+
Manual Extraction
+
+
You can plug your own complex logic to extract params for validation into the pipe.
+
+
class Create < Trailblazer::Operation
+ class MyContract < Reform::Form
+ property :title
+ end
+
+ def type
+ "evergreen" # this is how you could do polymorphic lookups.
+ end
+
+ step Model( Song, :new )
+ step Contract::Build(constant: MyContract)
+ step :extract_params!
+ step Contract::Validate( skip_extract: true )
+ step Contract::Persist( method: :sync )
+
+ def extract_params!(options, **)
+ options[:"contract.default.params"] = options[:params][type]
+ end
+end
+
+
+
Note that you have to set the self["params.validate"] field in your own step, and - obviously - this has to happen before the actual validation.
+
+
Keep in mind that & will deviate to the left track if your extract_params! logic returns falsey.
+
+
Manual Build
+
+
To manually build the contract instance, e.g. to inject the current user, use builder:.
Note how the contract’s class and the appropriate model are offered as kw arguments. You’re free to ignore these options and use your own assets.
+
+
As always, you may also use a proc.
+
+
Result Object
+
+
The operation will store the validation result for every contract in its own result object.
+
+
The path is result.contract.#{name}.
+
+
result = Create.(params: { length: "A" })
+
+result[:"result.contract.default"].success? #=> false
+result[:"result.contract.default"].errors #=> Errors object
+result[:"result.contract.default"].errors.messages #=> {:length=>["is not a number"]}
+
+
+
+
Each result object responds to success?, failure?, and errors, which is an Errors object. TODO: design/document Errors. WE ARE CURRENTLY WORKING ON A UNIFIED API FOR ERRORS (FOR DRY AND REFORM).
An operation is the central building block in a Trailblazer architecture. It’s an abstraction that helps structuring your business code while taking over the control flow for you.
+
+
Operations are often confused as god objects that “do everything”. However, operations are nothing but orchestrators. Where to implement the actual code and when to call it is up to the developer.
+
+
+
+
+
+
You’re confused about Operation and Activity? It’s simple: an operation is a very thin high-level API around an activity, providing you a less clumsy Operation.call() along with the result object.
+
+
Both come with the exact same DSL.
+
+
+
+
+
Step DSL
+
+
Most of the times, operations are defined via our “step DSL”.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :validate
+ step :save
+ left :handle_errors
+ step :notify
+ # ...
+ end
+end
+
+
+
It’s a convenient way for you to define what happens. The operation takes care of the when - which reduces your application code while providing a great amount of beautiful features, such as tracing.
+
+
Railway
+
+
Internally, the above operation compiles to some kind of “circuit” which looks as follows.
+
+
+
+
As visible, your “steps” are placed onto two tracks, forming a “railway”.
+
+
This is a great concept stolen from functional programming! The railway is your mental model of the code flow. If a step fails, the execution will deviate to the “failure track”, skipping the remaining “success” steps, and eventually stopping on the “failure terminus”. The terminus is how you communicate the outcome of your operation run to the outer world.
+
+
Invocation
+
+
Now, that’s why when running your operation, the outcome will always be either “success” or “failure”.
This is how you invoke an operation. Note how the input data goes into the operation’s #call method. It returns the terminus and the ctx object that was passed from step to step within the operation.
+
+
Step
+
+
In an operation your application code is structured into steps. These steps can be any callable Ruby object, but very often, developers use instance methods.
The hash given into the operation invocation is passed from step to step as ctx. Contained variables, such as :params can be accessed using Ruby’s lovely keyword arguments feature.
+
+
Steps can instruct the operation about the step outcome by returning a truthy or falsey value from the step method.
+
+
Step
+ State
+
+
+
+
Since we’re dealing with business logic in steps, you’re allowed to write state to the ctx object. The subsequential steps can access the new variables just as any other variable on ctx.
+
+
def save(ctx, params:, **)
+ memo = Memo.new(params[:memo])
+ memo.save
+
+ ctx[:model] = memo # you can write to the {ctx}.
+end
+
+
+
After running the operation, ctx variables can also be retrieved, for example for rendering in a controller view, or in your unit tests.
+
+
result = Memo::Operation::Create.(
+ params: {memo: {text: "Do not forget!"}}
+)
+
+puts result[:model] #=> #<Memo id: 1 text: "Do not forget!">
+
+
+
And that’s it, you just learnt the basic mechanics of operations.
+
+
Features
+
+
Activities not only help structuring, they are an entirely new development experience that provide a rich and stable API that allows to model almost any business use case.
+
+
Features
+ Debugging
+
+
+
+
Operations can trace themselves when being run. This allows you to see which step had been invoked when, whether or not there was a failure, and so on.
In your operation, you can define any circuit you need in order to accomplish your task using the Wiring API. You’re not limited to a linear railway!
+
+
+
+
You may add additional termini to your operations to indicate state, go back and retry a particular step, error-out early, or even nest operations to solve more complex business logic.
+
+
Features
+ Variable mapping
+
+
+
+
For even cleaner code and maximized reusability, you can control what variables come in and out of steps. → VARIABLE MAPPING docs
Using our PRO debugger, you can even trace this variable flow.
+
+
Features
+ BPMN and state machines
+
+
+
+
We are (finally, and definitely!) releasing PRO in 2024 which will bring us the workflow gem and our BPMN editor to model and implement complex, long-running workflows.
+
+
+
+
This will be pretty exciting and introduces “enterprise” concepts that haven’t been around in Ruby so far.
+
+
Mechanics
+
+
This chapter discusses the fundamental building blocks and concepts of operations: steps, signals, the ctx object and how to run operations.
+
+
Invocation
+
+
Operations represent functions in your application, both in a domain-oriented way and from a programmatical point of view. That is why there’s only one way to run an operation.
+
+
Invocation
+ Call
+
+
+
+
The #call class method is how you run your operation along with its steps.
+
+
result = Memo::Operation::Create.call(params: {memo: nil})
+
+
+
And that’s literally the only way to invoke your steps. It is not allowed to run a subset of steps, only. If you need that, you might have to introduce a new operation, or change the wiring to achieve the same effect.
+
+
Invocation
+ Implicit call
+
+
+
+
In Ruby, it’s possible to skip the word call. That’s why you might often see invocations of operations that might look a bit cryptic in the beginning.
+
+
result = Memo::Operation::Create.(params: {memo: nil})
+
+
+
Anyhow, the implicit call style underlines the functional aspect of an operation. Since, per convention, the operation name is a verb (e.g. Create) there’s no need to think of another verb to actually run that “verb”. That’s why we love the implicit call style!
+
+
Invocation
+ Input variables
+
+
+
+
Any variables you pass into the invocation will be readable by all steps of the operation (unless configured otherwise).
+
+
result = Memo::Operation::Create.(params: {memo: nil})
+
+
+
Here, the :params variable can be accessed in each step via the ctx object, which is the first positional argument passed to each step.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :validate
+ # ...
+ def validate(ctx, **)
+ p ctx[:params] #=> {:memo=>nil}
+ end
+ end
+end
+
+
+
While this is a breathtaking feature of Trailblazer, it’s a bit clumsy and we got a more convenient way to read from ctx.
+
+
Invocation
+ Keyword arguments
+
+
+
+
Instead of manually checking ctx for the presence of a particular variable, you can add a keyword argument to your step’s signature.
Once an operation run finished, variables added to the ctx are readable by the caller on the outside via the result object.
+
+
Keep in mind that the above is the default behavior. You may configure what comes in and goes out of a step using variable mapping.
+
+
Flow control
+
+
When being invoked with #call, the operation works off its steps in the order they were defined with #step. This is called the “happy path”, or the success track.
+
+
+
+
Once a step returns a “failure”, the operation will deviate to the “failure track” and skip the remaining steps on the success track. Instead, the remaining steps added using #left (formerly #fail) will be executed.
+
+
Steps can communicate their outcome to the operation’s flow control with their return value.
+
+
Flow control
+ Success
+
+
+
+
The #validate step in our example does a simple check to see if params[:memo] is present.
Since this is the only line in the method, the return value will be either true, signalizing a successful validation, or false, indicating there’s a formal problem with the incoming user data.
+
+
The return value doesn’t have to true or false, but can be any “truthy” or “falsey” (e.g. nil) value.
+
+
Flow control
+ Failure
+
+
+
+
A falsey return value will deviate the operation’s path to the failure track.
+
+
def validate(ctx, params:, **)
+ nil
+end
+
+
+
This depressive step will always return a falsey value (nil is evaluated to false), the remaining success path steps will always be skipped. Don’t do this!
+
+
Flow control
+ Signal
+
+
+
+
The binary true/false outcome is useful for many scenarios. However, sometimes a step might have a third outcome - for example, to indicate a network error that needs to be handled off the failure track.
In this step, we’re returning a “third” possible value. The trick is: the NetworkError needs to be a subclass of Signal.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ class NetworkError < Trailblazer::Activity::Signal
+ end
+ # ...
+ end
+end
+
+
+
That way, the operation’s routing will recognize this third outcome. Speaking of, the third scenario needs to be properly connected using the Wiring API.
Using tools the Wiring API provides, this adds new connections plus an entire new terminus of our operation!
+
+
Terminus
+
+
Here, #notify encountering a disastrous network error will make the operation stop the execution in a third terminus called network_error.
+
+
+
+
Terminus
+ Semantic
+
+
+
+
In general, a terminus is a way to communicate an outcome state of a particular operation to the user. This can be as simple as manually running an operation and checking which terminus it stopped in.
You can ask the terminus for its semantic by calling #to_h. The semantic can be any meaningful term you want, for instance, we named the success path terminus :success.
+
+
Terminus
+ Nesting
+
+
+
+
Those termini start making even more sense when nesting operations.
+
+
module Endpoint
+ class API < Trailblazer::Operation
+ step Subprocess(Memo::Operation::Create),
+ Output(:network_error) => Track(:failure)
+ # ...
+ end
+end
+
+
+
It is possible (and required!) to wire termini of a nested operation into the container circuit - this is much cleaner than handling different outcomes by hand, and allows for tracing and debugging.
+
+
Check out the Wiring API to learn how to leverage multiple outcomes in operations.
+
+
Result
+
+
As the ctx is being passed from step to step, it accumulates data. In order to access this data on the outside, the ctx is converted to a result object after the run is finished.
You can use Ruby’s inheritance to derive subclasses from operations.
+
+
Inheritance
+ Subclass
+
+
+
+
Inheritance allows you to share logic and behavior across any number of subclasses.
+
+
module Memo::Operation
+ class Update < Create
+ end
+end
+
+
+
Subclassing will not only copy over methods, but the entire circuit of Create along with its three steps.
+
+
Inheritance
+ Customize
+
+
+
+
You can now insert, replace or even delete new steps in the Update class.
+
+
module Memo::Operation
+ class Update < Create
+ step :find_model, replace: :create_model
+ # ...
+ end
+end
+
+
+
This will replace the create_model step with new find_model logic. Obviously, you have to provide the #find_model method in the Update class.
+
+
When inserting steps into subclasses you can use any step DSL options. You might even drill into deeply nested, inherited class compounds using #patch.
When writing operations, most of the time you will use #step and #left to configure your activity’s circuit. While it is possible to model any kind of flow, the operation’s “Step DSL” is optimized for building a railway layout.
+
+
Railway
+
+
In operations, the Railway layout is used to build circuits with “automatic” error handling. You arrange your actual chain of logic on the “success” track, if a problem occurs, the processing jumps to the parallel “failure” track, skipping the rest of the tasks on the success track.
+
+
Once on the failure track, it stays there (unless you instruct not to do so!).
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :validate
+ step :save
+ left :handle_errors
+ step :notify
+ # ...
+ end
+end
+
+
+
This operation will look as follows.
+
+
+
+
Four possible execution paths this operation might take.
+
+
+
No errors: First validate, then save, then notify, then terminate in End.success. The operation was successful.
+
Validation error: First validate, which returns a Left (failure) signal, leading to handle_errors, then terminate in End.failure.
+
DB error: First validate, then save, which deviates to the failure track, leading to End.failure.
+
Notify error: validate, save, then notify fails, terminating in End.failure. Note that this doesn’t hit the error handler due to the sequence order.
+
+
+
Railway
+ Step
+
+
+
+
For placing a step onto the success track, use #step.
This simply puts one step after another, in the order you define, on the success track.
+
+
+
+
+
+
Note that it’s possible to insert steps by using inheritance or modules. # FIXME
+
+
+
+
+
Without any options, the step’s interface is expected to follow the “step interface”. # FIXME
+
+
Railway
+ Left
+
+
+
+
To place tasks on the failure track, use #left. Again, the order and position defines where this particular step will sit on the failure track.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :validate
+ step :save
+ left :handle_errors
+ # ...
+
+
+
Obviously, you may use as many tasks as you need on this track - there are no limitations.
+
+
+
+
+
+
Historically, the success path is called “right” whereas the error handling track is “left”. The signals Right and Left in Trailblazer are still named following this convention.
+
+
+
+
+
Railway
+ Fail
+
+
+
+
Before we introduced #left, the DSL method to place steps onto the failure track was named #fail.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :validate
+ step :save
+ fail :handle_errors # just like {#left}
+ # ...
+ end
+end
+
+
+
Both methods are identical. It is not deprecated, feel free to use it instead of #left, should your editor allow it.
+
+
Railway
+ Pass
+
+
+
+
If the return value of a task on the success path shouldn’t matter, use #pass.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :validate
+ pass :save # no output goes to the failure track here.
+ left :handle_errors
+ # ...
+ end
+end
+
+
+
#save’s return value is ignored in the operation run, the next step will always be #notify on the success track.
+
+
+
+
+
Step
+
+
An operation can be composed of any number of steps. Steps can be callable Ruby objects or instance methods of the operation itself.
+
+
When adding a step using #step or #left, the step’s interface is considered to be exposing the “step interface”. Note that you can also implement steps with a low-level interface called the “circuit interface” that is clumsier but also richer.
+
+
Step
+ Instance method
+
+
+
+
A step declared with a :symbol, as done here with #validate, will be considered an instance method of the operation class. This style allows to define the operation layout before implementing the steps in the same class.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :validate
+
+ def validate(ctx, params:, **)
+ params.key?(:memo) ? true : false # return value matters!
+ end
+ end
+end
+
+
+
Signature and return value are identical to all other step types.
+
+
Step
+ Class method
+
+
+
+
A class method can come in handy if you want to reuse this particular step in an entirely different operation.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ # Define {Memo::Operation::Create.validate}
+ def self.validate(ctx, params:, **)
+ params.key?(:memo) ? true : false # return value matters!
+ end
+
+ step method(:validate)
+ end
+end
+
+
+
Note that the actual class method has to be defined before retrieving the method object via method(:validate). Again, signature and return value are identical to other styles.
+
+
Step
+ Module method
+
+
+
+
Just like a class method, steps can come from any module - this makes it straight-forward to create reusable chunks of logic.
+
+
# Reusable steps in a module.
+module Steps
+ def self.validate(ctx, params:, **)
+ params.key?(:memo) ? true : false # return value matters!
+ end
+end
+
+
+
The mechanics to use a “foreign” method as a step are identical to using a class method - they simply rely on Ruby’s #method function.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step Steps.method(:validate)
+ end
+end
+
+
+
This is a common technique in complex Trailblazer applications for creating reusable steps with a high degree of maintainability.
+
+
Step
+ Callable
+
+
+
+
As a matter of fact, any object, module or class exposing a #call method can be a step!
+
+
module Validate
+ def self.call(ctx, params:, **)
+ valid?(params) ? true : false # return value matters!
+ end
+
+ def valid?(params)
+ params.key?(:memo)
+ end
+end
+
+
+
Callable objects are often used when the step’s logic exceeds a certain threshold of complexity and you want to introduce additional methods, as the above #valid? function.
+
+
+
+
+
+
Keep in mind that a step can also be implemented using a second operation. You can use the Subprocess() macro to nest operations.
+
+
+
+
+
Step
+ Lambda
+
+
+
+
Another trick, mostly used for debugging, is to use a lambda as a step.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step ->(ctx, params:, **) { p params.inspect }
+ end
+end
+
+
+
Obviously, this is not really sustainable and should be replaced with one of the above options.
+
+
Sequence Options
+
+
In addition to wiring options, there are a handful of other options known as sequence options. They configure where a task goes when inserted, and helps with introspection and tracing.
+
+
Sequence Options
+ id
+
+
+
+
The DSL will provide default names for tasks.
+You can name explicitely using the :id option.
Assigning explicit IDs to steps is advisable when you’re planning use patching or inheritance. Keep in mind that this ID will be part of your internal (but public) API.
+
+
Naming also shows up in tracing and introspection.
+
+
If no :id is provided, defaults names are given to steps - these might be awkward sometimes.
+
+
Sequence Options
+ Delete
+
+
+
+
When it’s necessary to remove a task, you can use :delete.
+
+
module Memo::Operation
+ class Admin < Create
+ step nil, delete: :validate
+ end
+end
+
+
+
The :delete option can be helpful when using modules or inheritance to build concrete operations from base operations. In this example, a very poor one, the validate task gets removed, assuming the Admin won’t need a validation.
Working with Subprocess and nested activities is a great way to encapsulate and create reusable code especially with complex logic. However, it can be a PITA if you want to customize one of those deeply nested components and add, replace or remove a certain step.
+
+
Suppose the following 3-level nested activity.
+
+
+
+
The public operation Destroy contains Delete as a nested activity, which itself contains DeleteAssets. In order to customize the latter one and add another step tidy_storage, you’d normally have to subclass all three activities and override steps.
+
+
Patching
+ Patch
+
+
+
+
Using #patch, you can modify nested activities from the uppermost activity and let Trailblazer do the legwork.
+
+
As #patch is mostly used when leveraging inheritance we introduce a subclass of Destroy which is called Erase and introduces the #tidy_storage step. As illustrated above, this new step should be inserted in DeleteAssets activity that itself is nested in Delete, which again is one step of Destroy.
+
+
class Erase < Destroy # we're inheriting from Song::Operation::Destroy
+ # ...
+ def self.tidy_storage(ctx, **)
+ # delete files from your amazing cloud
+ end
+ # ...
+ # These steps are inherited:
+ # step :policy
+ # step :find_model
+ # step Subprocess(Delete), id: :delete
+
+ extend Trailblazer::Activity::DSL::Linear::Patch::DSL
+
+ # Note the path you pass to #patch.
+ patch(:delete, :delete_assets) {
+ step Erase.method(:tidy_storage), after: :rm_images
+ }
+end
+
+
+
The patching logic accepts a path to the particular activity that you want to modify.
+
+
+patch(:delete, :delete_assets) { ... }
+
+
The provided block is executed within that targeted activity and executed as if you’d extend that class. However, the entire modification will only change Erase, all other traversed activities are copied and then modified, leaving the original implemenation unchanged.
+
+
+
+
The #patch method is perfect when using inheritance to first copy over behavior and control flow, and then fine-tune it for the specific use case.
+
+
Patching
+ Patch Option
+
+
+
+
If you’re not using inheritance and want to tweak a nested activity ad-hoc the Subprocess() helper accepts a :patch option.
This works just like the #patch function but returns the patched activity.
+
+
Subprocess() accepts the :patch option which consists of a hash of the path to the customized activity, and its patch.
+
+
This will result in an identical operation as in the above example with #patch. However, Delete is now the operation containing the customization, not a new class Erase.
+
+
Patching can be also done at the top-level activity by passing :patch as a block (Take Delete from above example).
Without additional configuration, when using #step the following happens.
+
+
+
The new step is put either on the success or failure track, depending on whether you used #step or #left for insertion (internally, this happens via the :magnetic_to option).
+
The respective predecessor step is connected to the new step.
+
As, in a railway, a step typically has two logical outcomes, the step’s success output is connected to the following step on the success track. Usually, this would be the success terminus.
+
The exact same thing happens for the step’s failure output.
+
+
+
+
+
Looking closer at a step, the orange circle on a step’s edge represents the failure output. The outgoing line without any circle is the success output.
+
+
+
+
+
+
Technically, this diagram is incorrect since steps sitting on the failure track have two outputs as well. However, as both outputs are linked to the next failure step, this is usually depicted as a single line.
+
+
+
+
+
Now, let’s see how we can customize this operation using the Wiring API.
+
+
Railway
+ Debugging
+
+
+
+
When modeling more complex circuits and becoming familiar with the Wiring API, do not get frustrated! Use our trailblazer-developer gem to quickly visualize what you’ve just built.
The render method returns the actual circuit in a more or less human-readable form.
+
+
Output()
+
+
Rewiring a particular step always starts with Output(). This DSL helper allows you to specify which outgoing connection you’re planning to reconnect.
+
+
Output()
+ Implicit output
+
+
+
+
In a railway-based activity, such as Trailblazer::Operation, each step exposes two outputs per default: :success and :failure. This is why you can simply reference that well-known output by its semantic. The one-argument form of Output() we call “implicit output”.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :validate
+ step :save,
+ Output(:failure) => Track(:success)
+ left :handle_errors
+ step :notify
+ # ...
+ end
+end
+
+
+
With this customization, you’re building a bullet-proof #save step, that even in case of failure goes back to the success track. Hopefully, your #save logic is failsafe.
+
+
+
+
This, by the way, is how #pass is implemented.
+
+
Output()
+ Explicit output
+
+
+
+
If needed, a step can have more than two outcomes. For instance, #save could detect and report a database error in addition to its binary outcomes of success and failure.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ class DbError < Trailblazer::Activity::Signal; end
+
+ step :validate
+ step :save,
+ Output(DbError, :database_error) => Track(:failure)
+ left :handle_errors
+ step :notify
+ # ...
+ end
+end
+
+
+
To add an output, you call Output() with two arguments: the signal to indicate, and the semantic of that new output. Note that the signal has to be derived from Trailblazer::Activity::Signal.
+
+
+
+
In this example, the new output :database_error is routed straight to the failure track. When the operation teminates, you might not be able to see why it failed: it might be better to introduce a separate terminus.
+
+
Output()
+ Failure
+
+
+
+
It might be worth noting that all steps in a railway have two standard outputs, even those put on the failure track using #left! That’s why you don’t have to stay on the failure track, you could divert anywhere you want.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :validate
+ step :save
+ left :handle_errors,
+ Output(:success) => Track(:success)
+ step :notify
+ include T.def_steps(:validate, :save, :handle_errors, :notify)
+ end
+end
+
+
+
If the #handle_errors step returns a truthy value, the operation goes back to the success track.
+
+
+
+
This pattern can be used for error handling, deciders, or alternate routes.
+
+
End()
+
+
A terminus represents an outcome of an operation run. It’s not only helpful when running an operation manually, but also when nesting operations.
+
+
You can use the End() helper to connect an output directly to either an existing terminus (most of the times, that’d be :success or :failure), or you can wire it to a new terminus.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :validate
+ step :save,
+ Output(:failure) => End(:db_error)
+ left :handle_errors
+ step :notify
+ # ...
+ end
+end
+
+
+
Two things happen here: first, the new terminus :db_error is added to the operation as it represents an unknown, new terminus. Then, the failure output of #save gets connected to it.
+
+
+
+
You may use End() multiple times in one operation. This will connect the steps to the same terminus.
+
+
Terminus
+
+
Instead of End() you may add additional termini to an operation using #terminus.
Note that there are more inheritance mechanics for building template operations, such as the :inherit option.
+
+
Track()
+
+
Use Track() when you want a step’s output to connect to the next
+step that is “magnetic to” the desired track.
+
+
Track()
+ Helper
+
+
+
+
For instance, a step (or terminus) sitting on the success track will be attracting the predecessor step to connect to it.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :validate
+ step :save,
+ Output(:failure) => Track(:success)
+ left :handle_errors
+ step :notify
+ # ...
+ end
+end
+
+
+
Track() expects the track name, or the “polarization”, as we call it sometimes.
+
+
+
+
Here, the wiring algorith will connect the :failure output of the #save step to the next available step on the :success track, which is #notify.
+
+
Track()
+ Custom Track
+
+
+
+
You can build entire new tracks using a combination of Track() and :magnetic_to.
+
+
module Memo::Operation
+ class Charge < Trailblazer::Operation
+ terminus :paypal # add a custom terminus (if you need it)
+ step :validate
+ step :find_provider,
+ Output(:failure) => Track(:paypal)
+ step :charge_paypal,
+ magnetic_to: :paypal, Output(:success) => Track(:paypal)
+ step :charge_default
+ # ...
+ end
+end
+
+
+
When adding a terminus via #terminus, its “polarization” will be the same name as its semantic, :paypal in this example (this is achieved using the :magnetic_to option behind the scenes).
+
+
Next, we add #find_provider and reference Track(:paypal) for one of its outputs. This will be connected to the next possible step “magnetic to” :paypal.
+
+
To manually place a step onto that new track, you need to use :magnetic_to and Output() as it’s done with #charge_paypal.
+
+
This will compile the following operation circuit.
+
+
+
+
Id()
+
+
To explicitly connect an output to a particular step, use Id().
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :validate,
+ Output(:failure) => Id(:notify)
+ step :save
+ left :handle_errors
+ step :notify
+ include T.def_steps(:validate, :save, :handle_errors, :notify)
+ end
+end
+
+
+
This will, for whatever reasons, connect the failure output of #validate straight to #notify.
+
+
+
+
Keep in mind that using Id() won’t allow inserting another step between the two just connected. If you want to insert more steps on that connection at a later point, or in a subclass, use Track().
+
+
Magnetic_to
+
+
Most of the times, the step DSL will wire your steps automatically. Sometimes it might be necessary to make one or several steps “snap to” a particular track.
+
+
The :magnetic_to option allows to configure what incoming connections a step attracts.
For branching out a separate path in an operation, use the Path() macro. It’s a convenient, simple way to declare alternative routes, even if you could do everything it does manually.
+
+
Path()
+ Terminus
+
+
+
+
You can use the :terminus option for Path() to lead the path to an existing or new terminus.
+
+
module Memo::Operation
+ class Charge < Trailblazer::Operation
+ step :validate
+ step :find_provider,
+ Output(:failure) => Path(terminus: :paypal) do
+ # step :authorize # you can have multiple steps on a path.
+ step :charge_paypal
+ end
+ step :charge_default
+ # ...
+ end
+end
+
+
+
If the #find_provider steps returns false, the steps on the “paypal” path will be executed. Since we are using the :terminus option, the run stops in the new terminus :paypal .
+
+
+
+
You don’t necessarily have to use the :failure output in order to branch out a new path. You might as well use an additional signal, or any other predefined output of the task.
+
+
When using Path(), the steps on the path will only have one :success output. Note that there’s no connection going to the failure track from #charge_paypal.
+
+
Path()
+ Failure
+
+
+
+
+
+
+
+
We’re working on a :failure option for Path() to allow steps with a failure output.
+
+
+
+
+
Path()
+ Connect_to
+
+
+
+
If you want the path to reconnect and join the railway at some point, use the :connect_to option.
+
+
module Memo::Operation
+ class Charge < Trailblazer::Operation
+ step :validate
+ step :find_provider,
+ Output(:failure) => Path(connect_to: Id(:finalize)) do
+ # step :authorize # you can have multiple steps on a path.
+ step :charge_paypal
+ end
+ step :charge_default
+ step :finalize
+ # ...
+ end
+end
+
+
+
Instead of creating an additional terminus, the last path step will be connected to whatever step is specified via the Id() helper.
+
+
+
+
You can easily branch out and handle specific edge cases using this helper.
+
+
+
Subprocess
+
+
While you could manually nest an operation into another, the Subprocess() macro helps to embed an operation as a step of a container operation.
+
+
Consider the following operation implementing a more complex validation. By extracting that logic to a separate operation, you’re making this code better testable, and reusable across your application.
+
+
module Memo::Operation
+ class Validate < Trailblazer::Operation
+ step :check_params
+ step :text_present?
+ # ...
+ end
+end
+
+
+
Use Subprocess() to nest it into the Create operation.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step Subprocess(Validate)
+ step :save
+ left :handle_errors
+ step :notify
+ # ...
+ end
+end
+
+
+
The macro automatically wires all of Validate’s termini to the known counter-part tracks.
+
+
+
+
The Subprocess macro will go through all outputs of the nested operation, fetch their semantics and search for tracks with the same semantic - it’s that simple.
+
+
Note how the nested operation really acts as if it’s a plain step - in fact, the execution code doesn’t even know this is a “real” separate operation.
+
+
Subprocess
+ Output
+
+
+
+
You can use the familiar Wiring DSL to reconnect termini of the nested operation.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step Subprocess(Validate),
+ Output(:failure) => Id(:notify)
+ step :save
+ left :handle_errors
+ step :notify
+ # ...
+ end
+end
+
+
+
For demonstration purposes, the nested’s failure output now goes straight to #notify.
+
+
+
+
This is identical to connecting a “normal” step’s output and allows to treat particular outcomes of a nested operation in a non-standard way - for example, routing a “failed” terminus to a success path.
+
+
Subprocess
+ Terminus
+
+
+
+
A nested operation doesn’t have to have two termini, only.
The Validate operation could expose a third terminus :invalid to indicate that params wasn’t formatted properly.
+
+
Subprocess() will try to match the nested termini semantics to the tracks it knows. You may wire a custom terminus like :invalid using Output().
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step Subprocess(Validate),
+ Output(:invalid) => Track(:failure)
+ step :save
+ left :handle_errors
+ step :notify
+ # ...
+ end
+end
+
+
+
The new special terminus is now wired to the failure track of the containing Create operation.
+
+
+
+
You may connect the custom terminus using any of the Wiring’s API helpers.
+
+
+
+
+
+
There will be an IllegalSignalException exception thrown at runtime time when not connecting a custom terminus. Use the :strict option to avoid runtime problems.
+
+
+
+
+
Subprocess
+ Strict
+
+
+
+
The :strict option is still experimental: https://github.com/trailblazer/trailblazer-activity-dsl-linear/issues/59
+
+
Subprocess
+ Fast-track
+
+
+
+
When nesting another operation (or Activity::FastTrack), its fast-track temini won’t be connected automatically. Just like with a “normal” step, you need to instruct the Wiring API.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step Subprocess(Validate), fast_track: true
+ step :save
+ left :handle_errors
+ step :notify
+ # ...
+ end
+end
+
+
+
By providing fast_track: true, the nested termini will be connected to the matching outer tracks.
+
+
+
+
Omitting this option is usually the source for an IllegalSignalError at run-time.
+
+
Fast-track
+
+
The operation is based on the FastTrack layout and provides three more options for configuring a step’s connections.
+
+
Fast-track
+ pass_fast
+
+
+
+
The :pass_fast option wires the :success output straight to the new pass_fast terminus.
A falsey return value from #validate will deviate the flow and go straight to the fail_fast terminus.
+
+
+
+
Even though #handle_errors is on the failure track, it won’t be reached from #validate.
+
+
Fast-track
+ fast_track
+
+
+
+
When setting the :fast_track option, a step will have four outputs and, obviously, can have four outcomes. The additional fast-track outputs you can indicate by returning a well-defined signal.
class Create < Trailblazer::Activity::FastTrack
+ module Memo::Activity
+ class Create < Trailblazer::Activity::FastTrack
+ step :validate, fast_track: true
+ step :save
+ fail :handle_errors
+
+ def validate(ctx, params:, **)
+ return Trailblazer::Activity::FastTrack::FailFast if params.nil? #!hint return Railway.fail_fast! if params.nil?
+
+ params.key?(:memo)
+ end
+ # ...
+ end
+ end
+end
+
class Create < Trailblazer::Operation
+ module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :validate, fast_track: true
+ step :save
+ fail :handle_errors
+
+ def validate(ctx, params:, **)
+ return Railway.fail_fast! if params.nil?
+
+ params.key?(:memo)
+ end
+ # ...
+ end
+ end
+end
+
+
+
The validate step now has four outputs. You can instruct the two new fast-track outputs by returning either Railway.fail_fast! or Railway.pass_fast!.
+
+
+
+
+
+
When using the slimmer Activity::FastTrack layout, you won’t have access to Railway.pass_fast!. Either use the fully-qualified constant Trailblazer::Operation::Railway.pass_fast! to reference the signal, or return Trailblazer::Activity::FastTrack::PassFast (see “Activity” code tab above).
+
+
+
+
+
Note that you don’t have to use both outputs.
+
+
+
+
The fast-track layout is an attempt to implement best practices. Most of the times, however, you can achieve a similar result using a separate track or terminus.
Since TRB 2.1 it is possible to define the input and output variables for each step. This is called variable mapping, or I/O in short. It provides an interface to define what variables go in and come out of a task, enabling you to limit what steps “see” and what “output” they can add to the context.
+
+
It’s one of the most frequently used features in Trailblazer.
+
+
Overview
+
+
Imagine a complex application where policies are protecting your operation code from unsolicited access. This code component - the policy - sits as a step in every business operation and decides whether or not the current user is permitted to execute this very operation.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :create_model
+ step Policy::Create # an imaginary policy step.
+ # ...
+ end
+end
+
+
+
The Policy::Create implementation is a simple callable class following the step interface.
+
+
module Policy
+ # Explicit policy, not ideal as it results in a lot of code.
+ class Create
+ def self.call(ctx, model:, user:, **)
+ decision = ApplicationPolicy.can?(model, user, :create) # FIXME: how does pundit/cancan do this exactly?
+ # ...
+ end
+ end
+end
+
+
+
Note that it requires two variables :model and :user from the ctx. For whatever reasons, the author of this class dictated that the “current user” must be passed named :user, not, as it’s a convention in Trailblazer, named :current_user.
+
+
Last, depending on the policy decision, the step code returns true or false.
+
+
When executing the Create operation using the :current_user variable, an ArgumentError is raised.
Since the “current user” is handed into the operation as the :current_user variable, and no other step preceding Policy::Create is setting this variable, the step expecting :user crashes.
+
+
And this is why we need variable mapping in Trailblazer.
+
+
Composable I/o
+
+
Variable mapping (short: i/o) can be done manually, with ugly “helper” steps before or after the respective step, or by using In(), Out() and Inject(). Before these helpers got introduced, we used the :input and :output option - both works, the latter one coming with several drawbacks.
+
+
Helpers can be used multiple times, depending on how complex the incoming or outcoming variables are, forming a pipeline of filters around the actual task.
In() allows to configure variables going into the step. The helper accepts either a mapping hash, a limiting array or a callable object (often a lambda), to compute variables at runtime.
+
+
+
+
+
+
Be wary that once you use In() your are white-listing: only the variables defined in your filters will be passed into the step. All other variables from ctx are invisible in the step.
+
+
+
+
+
In()
+ Mapping hash
+
+
+
+
Picking up the example from above, here’s how a mapping hash “translates” the selected variables from the original ctx object to a new ctx, one that is compatible with Policy::Create’s interface.
The In() filter will result in :current_user being renamed to :user. Since the policy step also needs :model we need to mention this variable as well, no renaming happening here. The beauty of I/O: this is only visible to Policy::Create!
+
+
+
+
To instantly see what new ctx is passed into the configured step, you could replace the original policy step with a #show_ctx method.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :create_model
+ step :show_ctx,
+ In() => {
+ :current_user => :user, # rename {:current_user} to {:user}
+ :model => :model # add {:model} to the inner ctx.
+ }
+
+ def show_ctx(ctx, **)
+ p ctx.to_h
+ #=> {:user=>#<User email:...>, :model=>#<Memo name=nil>}
+ end
+ # ...
+ end
+end
+
+
+
You should use the mapping hash when variables need to be renamed. If variables need to be added without renaming, a limiting array is your friend.
+
+
In()
+ Limiting array
+
+
+
+
In() accepts an array, listed variables are passed into the new ctx (whether they exist in the original ctx or not!).
However, if :current_user is nil, Policy::Create will raise an exception complaining about the :user keyword missing.
+
+
In()
+ Filter method
+
+
+
+
Following the TRB option standard, an In() filter may even be implemented as an instance method. All you need to do is pass a symbol to In().
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :create_model
+ step Policy::Create,
+ In() => :input_for_policy, # You can use an {:instance_method}!
+ In() => [:model]
+
+ def input_for_policy(ctx, **)
+ # only rename {:current_user} if it's there.
+ ctx[:current_user].nil? ? {} : {user: ctx[:current_user]}
+ end
+ # ...
+ end
+end
+
+
+
The method needs to expose a step interface just like any other callable.
+
+
In()
+ Keyword arguments
+
+
+
+
Both callables and filter methods for In() can receive ctx variables as keyword arguments, making it a convenient access and have Ruby perform a loose existance test automatically.
Keep in mind that when not defaulting the keyword argument your filter might crash at runtime when the expected variables were not passed.
+
+
Out()
+
+
Without any output configuration on the exemplary policy step, any variable written to ctx will be automatically set on the outer ctx, passing along internal variabes to the following step.
+
+
+
+
Here, both :status and :message variables that were written in Policy::Create are passed into the outer ctx. The behavior is identical to the way before you were using i/o.
+
+
However, it is often necessary to rename or limit the outgoing variables of a particular step. Especially when using nested operations you probably don’t want the entire nested ctx to be copied into the outer context. This is where output filters enter the stage.
+
+
Consider the following updated Policy::Create step.
+
+
module Policy
+ # Explicit policy, not ideal as it results in a lot of code.
+ class Create
+ def self.call(ctx, model:, user:, **)
+ decision = ApplicationPolicy.can?(model, user, :create) # FIXME: how does pundit/cancan do this exactly?
+
+ if decision.allowed?
+ return true
+ else
+ ctx[:status] = 422 # we're not interested in this field.
+ ctx[:message] = "Command {create} not allowed!"
+ return false
+ end
+ end
+ end
+end
+
+
+
Both ctx[:status] and ctx[:message] will be visible in all steps following Policy::Create. This might lead to “misunderstandings” and bugs in more complex applications.
+
+
As soon as you use Out(), only variables specified through the filters will be merged with the original (outer) ctx and passed on to the next step.
+
+
Out()
+ Limiting array
+
+
+
+
In order to limit variables added to the outer ctx, Out() accepts an array similar to In(). Consider this as a whitelisting to specify exposed variables.
This single Out() usage will result in only the :message variable being written to the outer ctx that is passed on. The :status variable is discarded.
+
+
+
+
You may pass any number of variables in the limiting array.
+
+
Out()
+ Mapping hash
+
+
+
+
Renaming variables from the inner to the outer ctx works by providing a mapping hash, where the “old” inner name points to the outer name that you want to use in the operation hosting that step.
Here, steps following Policy::Create will see a variable :message_from_policy merged into the ctx - which is the original :message, renamed.
+
+
Out()
+ Callable
+
+
+
+
An Out() filter can be any callable object following the step interface.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :create_model
+ step Policy::Create,
+ In() => {:current_user => :user},
+ In() => [:model],
+ Out() => ->(ctx, **) do
+ return {} unless ctx[:message]
+
+ { # you always have to return a hash from a callable!
+ :message_from_policy => ctx[:message]
+ }
+ end
+ # ...
+ end
+end
+
+
+
The callable receives the inner ctx that just left the actual step, here Policy::Create. You may run any Ruby code in the callable, even ifs.
+
+
Note that a callable always must return a hash, which is then merged with the original outer ctx.
+
+
Be adviced that it is usually a better idea to maintain multiple smaller Out() callables for different variables. You might later decide to override them, debugging will be easier and the code is more maintainable. This was different when :output was the only way to filter outgoing variables and you had to create one big hash in a one single filter.
+
+
+
+
+
+
You may also use an :instance_method to filter outgoing variables, similar to how it’s done with In().
+
+
+
+
+
Out()
+ Keyword arguments
+
+
+
+
Just as with In() callables can receive keyword arguments.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :create_model
+ step Policy::Create,
+ In() => {:current_user => :user},
+ In() => [:model],
+ Out() => ->(ctx, message: nil, **) do
+ return {} if message.nil?
+
+ { # you always have to return a hash from a callable!
+ :message_from_policy => message
+ }
+ end
+ # ...
+ end
+end
+
+
+
Any variable readable on the inner ctx that just left Policy::Create is available as a keyword argument for a callable. Note that you need to default it if its presence is not guaranteed.
+
+
Out()
+ Outer context
+
+
+
+
You can access the outer, original ctx by passing the :with_outer_ctx option to Out().
While the callable still needs to return a hash that is then merged with the original ctx, it’s possible to access variables from the outer ctx through the :outer_ctx keyword argument. This allows for merging deeper data structures, such as error objects.
+
+
Inject()
+
+
An Inject() filter, as opposed to In(), does an existance check on the ctx using ctx.key?(:variable) before performing its logic. It is helpful in combination with In() filters, when using defaulted keyword arguments in a step or in nested operations.
+
+
+
It allows defaulting a variable when it’s absent in the ctx.
+
It can pass-through a variable when it is present in the ctx, and only then.
+
You can also statically set a variable, whether or not it is present using the :override option.
+
+
+
Note that Inject() can be used without In().
+
+
Check the following exemplary policy code.
+
+
module Policy
+ class Check
+ # vvvvvvvvvvvvvvv-- defaulted keyword arguments
+ def self.call(ctx, model:, user:, action: :create, **)
+ decision = ApplicationPolicy.can?(model, user, action) # FIXME: how does pundit/cancan do this exactly?
+ # ...
+ end
+ end
+end
+
+
+
This policy implementation uses keyword arguments to automatically extract :model, :user and :action from the ctx. Note that the latter is defaulted to :create. Defaulting kwargs only works when the keyword variable is not passed into the step - if it’s nil, the defaulting will not get triggered.
+
+
You could now use In() filters to embed this policy step into your operation.
However, this will break because the action variable will never be defaulted to :create. The In() filter will always pass :action through when calling the policy, even when it’s absent.
+
+
The Inject() helper is designed to handle this case.
+
+
Inject()
+ Array style
+
+
+
+
Use Inject() in combination with In() to add variables to the filtered ctx, but only when they’re present in the outer ctx.
We call this qualified pass-through, it means the :action variable will only be passed into the filtered ctx if it exists on ctx when the filter is invoked.
+
+
Inject()
+ Defaulting
+
+
+
+
Instead of hard-wiring defaulted keyword arguments into your step implementations, you can configure Inject() to set a default value to variables, if they’re absent in the ctx.
+
+
Here’s an example policy without any defaulting in the signature.
+
+
module Policy
+ class Check
+ # vvvvvvv-- no defaulting!
+ def self.call(ctx, model:, user:, action:, **)
+ decision = ApplicationPolicy.can?(model, user, action) # FIXME: how does pundit/cancan do this exactly?
+ # ...
+ end
+ end
+end
+
+
+
Defaulting the :action variable via Inject() will improve the policy component’s reusability.
The lambda is executed at runtime, just before the actual step is invoked. It provides access to the ctx object and allows extracting keyword arguments.
+
+
Inject()
+ Override
+
+
+
+
Use the :override option to always set a variable, even if it is already present in the incoming ctx.
This is helpful to set configuration variables for an activity while still using the well-established keyword argument mechanics. The Policy::Create class defines :action as a kwarg. This doesn’t necessarily mean the user can always inject and dictate the very value. Instead, we can override any injected value with the “hard-coded” :create value.
The call as below will always use action: :create, even if something else is injected.
+
+
result = Memo::Operation::Create.(
+ current_user: current_user,
+ action: :update # this is always overridden.
+)
+puts result[:model] #=> #<Memo id: 1, ...>
+
+
+
Macro
+
+
As all DSL options the In(), Out() and Inject() helpers can be used from macros, providing the macro author a convenient way to define default filters.
In the options hash that a macro must return, you can use the helpers by referencing Trailblazer::Activity::Railway. Except for the prefixed constant, there is no difference or limitation to their usage.
+
+
They can be extended with options the macro user provides.
+
+
module Memo::Operation
+ class Create < Trailblazer::Operation
+ step :create_model
+ step Policy::Create(),
+ Out() => {:message => :copied_message} # user options!
+ # ...
+ end
+end
+
+
+
The user options will be merged into the macro options, resulting in :message being renamed to :message_from_policy and copied to :copied_message.
+
+
+
+
+
+
Before trailblazer-activity-dsl-linear-1.0.0 and the In() and Out() helper shipped with it, any :input from the user would always override the macro’s :input option.
+
+
+
+
+
Inheritance
+
+
Subclasses can add and remove input and output filters - hence the term composable. This is a great tool when inherited operations replace particular steps and need to fine-tune ingoing or returned variables.
A sub operation could now replace the policy step. However, instead of redefining the i/o filters, they can be inherited and extended.
+
+
Here’s a potential inheriting operation.
+
+
module Memo::Operation
+ class Admin < Create
+ step Policy::Create,
+ Out() => {:message => :raw_message_for_admin},
+ inherit: [:variable_mapping],
+ id: :policy, # you need to reference the :id when your step
+ replace: :policy
+ end
+end
+
+
+
This configuration is adding another Out() filter, resulting in a total filter setup as follows in the introspection.
In i/o context, the interesting branches here are task_wrap.input and task_wrap.output. Sandwiched between generic library steps are your filter steps. The visualizer even renders filter configuration where possible.
+
+
+
+
+
+
We’re planning improvements on this part of trailblazer-developer. If you want to help out with better rendering, please come chat to us.
+
+
+
+
+
Macro API
+
+
Macros are short-cuts for inserting a task along with options into your activity.
+
+
Definition
+
+
They’re simple functions that return a hash with options described here.
+
+
module MyMacro
+ def self.NormalizeParams(name: :myparams, merge_hash: {})
+ task = ->((ctx, flow_options), _) do
+ ctx[name] = ctx[:params].merge(merge_hash)
+
+ return Trailblazer::Activity::Right, [ctx, flow_options]
+ end
+
+ # new API
+ {
+ task: task,
+ id: name
+ }
+ end
+end
+
+
+
Two required options are :id and :task, the latter being the actual task you want to insert. The callable task needs to implement the [circuit interface].
+
+
+
+
+
+
Please note that the actual task doesn’t have to be a proc! Use a class, constant, object, as long as it exposes a #call method it will flow.
+
+
+
+
+
Usage
+
+
To actually apply the macro you call the function in combination with step, pass, fail, etc.
There’s no additional logic from Trailblazer happening here. The function returns a well-defined hash which is passed as an argument to step.
+
+
Options
+
+
In the returned hash you may insert any valid DSL [step option], such as sequence options like :before, Output() and friends from the wiring API or even :extensions.
+
+
The following FindModel macro retrieves a configured model just like trailblazer-macro’s Model() and automatically wires the step’s failure output to a new terminus not_found.
+
+
module MyMacro
+ def self.FindModel(model_class)
+ # the inserted task.
+ task = ->((ctx, flow_options), _) do
+ model = model_class.find_by(id: ctx[:params][:id])
+
+ return_signal = model ? Trailblazer::Activity::Right : Trailblazer::Activity::Left
+ ctx[:model] = model
+
+ return return_signal, [ctx, flow_options]
+ end
+
+ # the configuration needed by Trailblazer's DSL.
+ {
+ task: task,
+ id: :"find_model_#{model_class}",
+ Trailblazer::Activity::Railway.Output(:failure) => Trailblazer::Activity::Railway.End(:not_found)
+ }
+ end
+end
+
+
+
See how you can simply add Output wirings by using the well-established mechanics from the wiring API? Remember you’re not in an Activity or Operation namespace and hence need to use the fully-qualified constant reference Trailblazer::Activity::Railway.Output().
+
+
To insert that step and its extended wiring, simply call the macro.
+
+
class Create < Trailblazer::Activity::Railway
+ step MyMacro::FindModel(User)
+end
+
+
+
When running the activity without a valid model ID, it will now terminate on End.not_found.
Using the wiring API in your own macros gives you a powerful tool for harnessing extended wiring without requiring the user to know about the details - the crucial point for a good API.
+
+
Options
+ Subprocess
+
+
+
+
You can even use other macros in custom macros, such as the Subprocess() helper for nesting activities.
Along with the nested Logger step should also go :input and :output configuration. When using the Logger in multiple operation, you would need to repeat the options, so why not pack the entire configuration in a macro?
The nesting activity can now elegantly use the macro without inconvenient options.
+
+
class Create < Trailblazer::Activity::Railway
+ step Macro::Logger(logged_name: :model) # we want to log {ctx[:model]}
+end
+
+
+
TaskWrap
+
+
The taskWrap is the “around_filter” of Trailblazer. It allows adding steps before and after actual tasks without having to change the activity, and without having to introduce ifs.
+
+
Some prominent examples for taskWrap usage in the wild are variable mapping and tracing happening in #wtf?. Those are features completely separated from the core code. You can use the taskWrap mechanics to inject static wraps at compile time (as it’s done for, say, the In() feature), or at runtime, the way #wtf? is built.
+
+
+
+
It’s possible to configure a specific taskWrap for a particular step, or run the same one for all steps encountered while running an activity, even with nesting.
+
+
Example
+
+
Let’s discuss the taskWrap with the following simple Create activity that you’ve probably met before.
+
+
module Song::Activity
+ class Create < Trailblazer::Activity::Railway
+ step :create_model
+ step :validate
+ left :handle_errors
+ step :notify
+ # ...
+ end
+end
+
+
+
We’re using an imaginary MyAPM gem that provides a convenient interface for starting and stopping instrumentation.
+
+
span = MyAPM.start_span("validate", payload: {time: Time.now})
+# do whatever you have to...
+span.finish(payload: {time: Time.now})
+
+
+
Step
+
+
In order to execute the instrumentation, we have to write two taskWrap steps, one to start, one for finishing the instrumentation. Those steps look similar to “ordinary” activity steps, but expose the taskWrap interface.
A taskWrap step receives two positional arguments, the wrap_ctx that is used to transport taskWrap-specific data across taskWrap steps, and the original_args that represent how the circuit called the currently invoked task.
+
+
Above, you can see how it’s possible to retrieve the currently invoked task and its activity.
+
+
We then store the span object in the wrap_ctx so the finishing code can close that span in the second taskWrap step #finish_instrumentation.
As you can see, the span object can be fetched from wrap_ctx as the second step is run somewhere after start_instrumentation.
+
+
call_task
+
+
Each taskWrap has at least one element, the call_task step that calls the actual task. Sometimes this task is a method, such as #validate, sometimes a nested activity, and so on.
+
+
Runtime
+
+
The taskWrap can be extended at runtime, when calling an activity. This allow for adding features to steps without having to change the actual business code.
+
+
Runtime
+ Extension
+
+
+
+
To actually run the Song::Activity::Create activity with this new taskWrap, we need to build a runtime extension.
You can configure where to insert the taskWrap steps using :append or :prepend.
+
+
Runtime
+ Invoke
+
+
+
+
We’re now all set to inject that new taskWrap into the execution of our business activity. In the following example, the my_wrap hash is configured in a way that our extension is run around every task in Create, even for Create itself.
This will result in the same taskWrap being applied to every step of the activity execution path, and for #validate, looks like so.
+
+
+
+
Runtime
+ Step filter
+
+
+
+
You don’t have to run a taskWrap extension around every step. It is possible to configure an extension only being applied to a particular step in your activity. Let’s invoke the new APM taskWrap only around #validate.
+
+
validate_task = Trailblazer::Activity::Introspect
+ .Nodes(Song::Activity::Create, id: :validate) # returns Node::Attributes
+ .task # and the actually executed task from the circuit.
+
+my_wrap = {validate_task => apm_extension}
+
+
+
Note that my_wrap can map specific tasks to their taskWrap extension.
+
+
When running Create with the new configuration, the APM code is only called for #validate.
It’s important to understand that even the top activity Song::Activity::Create is run using a taskWrap, so if you want to apply APM only to this particular “step”, you could do so as follows.
Instead of dynamically adding taskWrap extensions at runtime, in some cases you might want to do so when defining the activity class, at compile-time. For example, the In() and Out() filter DSL uses a static taskWrap extension to add steps around a specific task.
+
+
This can be achieved with the WrapStatic() helper.
You can use our web-based debugger to introspect the static taskWrap of any step.
+
+
+
+
+
+
Internals
+
+
This section discusses low-level structures and is intended for engineers interested in changing or adding their own DSLs, the activity build process, or who want to optimize the Trailblazer internals (which is always appreciated!).
It also accepts a :task option if you need to find attributes for a step where you only know the code component, not the ID. This is, for example, used in the tracing code.
Activities and all tasks (or “steps”) are required to expose a circuit interface. This is the low-level interface. When an activity is executed, all involved tasks are called with that very signature.
+
+
Most of the times it is hidden behind the task interface that you’re probably used to from your operations when using step. Under the hood, however, all callable circuit elements operate through that very interface.
+
+
The circuit interface consists of three things.
+
+
+
A circuit element has to expose a call method.
+
The signature of the call method is call((ctx, flow_options), **circuit_options).
+
Return value of the call method is an array of format [signal, [new_ctx, new_flow_options]].
+
+
+
Do not fear those syntactical finesses unfamiliar to you, young padawan.
Both the Create activity itself and the validate step expose the circuit interface. Note that the :task option for step configures this element as a low-level circuit interface, or in other words, it will skip the wrapping with the task interface.
+
+
Circuit Interface
+ Invocation
+
+
+
+
Maybe it makes more sense now when you see how an activity is called manually? Here’s how to invoke Create.
Note that both ctx and flow_options can be just anything. Per convention, they respond to a hash interface, but theoretically it’s up to you how your network of activities and tasks communicates.
+
+
Check the implementation of validate to understand how you return a different signal or a changed ctx.
Make sure to always stick to the return signature on the circuit interface level.
+
+
Circuit Interface
+ Signature
+
+
+
+
The circuit interface is a bit more clumsy but it gives you unlimited power over the way the activity will be run. And trust us, we’ve been playing with different APIs for two years and this was the easiest and fastest outcome.
The alienating signature uses Ruby’s decomposition feature. This only works because the first argument for call is actually an array.
+
+
Using this interface empowers you to fully take control of the flow™.
+
+
+
You can return any signal you want, not only the binary style in steps. Do not forget to wire that signal appropriately to the next task, though.
+
If needed, the ctx object might be mutated or, better, replaced and a new version returned. This is the place where you’d start implementing an immutable version of Trailblazer’s ctx, for instance.
+
Advanced features like tracing, input/output filters or type checking leverage the framework argument flow_options, which will be passed onwards through the entire activities flow. Know what you’re doing when using flow_options and always return it even if you’re not changing it.
+
The circuit_options is another framework argument needed to control the start task and more. It is immutable and you don’t have to return it. The same circuit_options are guaranteed to be passed to all invoked tasks within one activity.
+
+
+
Since in 99% the circuit_options are irrelevant for you, it’s nicer and faster to discard them instantly.
The last positional argument when calling an activity or task is called circuit options. It’s a library-level hash that is guaranteed to be identical for all tasks of an activity. In other words, all tasks of one activity will be called with the same circuit_options hash.
+
+
The following options are available.
+
+
Circuit Interface
+ Start_task
+
+
+
+
You can instruct the activity where to start - it doesn’t have to be the default start event! Use the :start_task option.
Inject the :start_task option via the circuit options. The value has to be the actual callable task object. You can use the [introspection API] to grab it.
Starting with :validate, the :create task will be skipped and only :validate and then :save will be executed.
+
+
Note that this is a low-level option that should not be used to build “reuseable” activities. If you want different behavior for differing contexts, you should compose different activities.
+
+
Circuit Interface
+ Exec_context
+
+
+
+
When using the step :method_name DSL style, the :exec_context option controls what object provides the method implementations at runtime.
+
+
Usually, Activity#call will automatically set this, but you can invoke the circuit instead, and inject your own exec_context. This allows you to have a separate structure and implementation.
+
+
The following activity is such an “empty” structure.
While this bypasses Activity#call, it gives you a powerful tool for advanced activity design.
+
+
Circuit Interface
+ DSL
+
+
+
+
When using the DSL, use the :task option if you want your added task to be called directly with the circuit interface. This skips the TaskBuilder::Binary wrapping.
The convenient high-level interface for a task implementation is - surprisingly - called task interface. It’s the one you will be working with 95% of your time when writing task logic.
+
+
This interface comprises of two parts.
+
+
+
The signature receives a mutable ctx object, and an optional list of keywords, often seen as (ctx, **).
+
The return value can be true, false, or a subclass of Activity::Signal to dictate the control flow.
+
+
+
The return value does not control what is the next task. Instead, it informs the circuit runner about its outcome, and the circuit runner will find the task executed next.
+
+
module Memo::Operation
+ class Create < Trailblazer::Activity::Railway
+ def self.create_model(ctx, **)
+ attributes = ctx[:attrs] # read from ctx
+
+ ctx[:model] = Memo.new(attributes) # write to ctx
+
+ ctx[:model].save ? true : false # return value matters
+ end
+
+ step method(:create_model)
+ # ...
+ end
+end
+
+
+
Components (such as methods or callable objects) exposing the step interface always receive the ctx as the first (and only) positional argument. Keyword arguments may be used to extract variables from the ctx.
+
+
Depending on the step’s logic, you can write variables to the ctx object.
+
+
The return value can be either a subclass of Trailblazer::Activity::Signal or it will be evaluated to true or false.
+
+
Step Interface
+ Keyword Arguments
+
+
+
+
A cleaner way to access data from the ctx object is to use keyword arguments in the method signature. Trailblazer makes all ctx options available as kw args.
You may use as many keyword arguments as you need - it will save you reading from ctx manually, gives you automatic presence checks, and allows defaulting, too.
+
+
+
+
Step Interface
+ Return Value
+
+
+
+
Using the DSL, your task will usually be wrapped in a TaskBuilder::Binary object, which translates a nil and false return value to an Activity::Left signal, and all other return values to Activity::Right.
In a Railway activity, a true value will usually result in the flow staying on the “success” path, where a falsey return value deviates to the “failure” track. However, eventually it’s the developer’s decision how to wire signals to connections.
+
+
Step Interface
+ Return Signal
+
+
+
+
You are not limited to true and falsey return values. Any subclass of Activity::Signal will simply be passed through without getting “translated” by the Binary wrapper. This allows to emit more than two possible states from a task.
+
+
module Memo::Operation
+ class Create < Trailblazer::Activity::Railway
+ DatabaseError = Class.new(Trailblazer::Activity::Signal) # subclass Signal
+
+ def create_model(ctx, attrs:, **)
+ ctx[:model] = Memo.new(attrs)
+
+ begin
+ return ctx[:model].save ? true : false # binary return values
+ rescue
+ return DatabaseError # third return value
+ end
+ end
+ # ...
+
+ step :create_model,
+ Output(DatabaseError, :handle_error) => Id(:handle_db_error)
+ step :handle_db_error,
+ magnetic_to: nil, Output(:success) => Track(:failure)
+ end
+end
+
+
+
The exemplary DatabaseError is being passed through to the routing and interpreted. It’s your job to make sure this signal is wired to a following task, track, or end (line 16).
+
+
+
+
Note that you don’t have to use the default binary signals at all (Left and Right).
+
+
+
+
Path Layout
+
+
Most of your code will be using Trailblazer::Operation, which is nothing more than a Activity::FastTrack layout.
+
+
However, if you only need a linear layout of steps, where each step is connected to its follower, you can use the Activity::Path layout.
+
+
Per default, this activity will only expose one success terminus but you’re free to customize it using the Wiring API.
+
+
module Memo::Activity
+ class Create < Trailblazer::Activity::Path
+ step :validate
+ step :create
+ # ...
+ end
+end
+
+
+
Without any additional DSL options, this results in a straight path.
+
+
+
+
In turn, this means that only true return values in your tasks will work. The DSL will, per default, wrap every task with the Binary interface, meaning returning true will result in Activity::Right, and false in Activity::Left. Currently, only Right signals are wired up.
+
+
Path Layout
+ Output
+
+
+
+
You may add as many outputs to a task as you need. The DSL provides the Output() helper to do so.
The Path strategy only maintains the :success/Activity::Right semantic/signal combination. Any other combination you need to define explicitly using Output(signal, semantic).
+
+
Path Layout
+ End
+
+
+
+
The End() helper allows creating a new end event labelled with the specified semantic.
The validate task now has a success and a failure output. Since it’s wrapped using Binary it may return true or false to dictate the used output (or Activity::Right/Activity::Left since it’s the [task interface]).
The Activity::Railway layout is great for simple railway activities. It’s used in many places in our gems for two-track code flow where additional features such as :fast_track are not needed.
+
+
module Memo::Activity
+ class Create < Trailblazer::Activity::Railway
+ step :validate
+ step :save
+ left :handle_errors
+ step :notify
+ # ...
+ end
+ end
+
+
+
It has two tracks and two termini leading to success and failure.
+
+
+
+
This layout is perfect for internal “mini activities” in your applications.
+
+
FastTrack layout
+
+
Based on the Railway strategy, the FastTrack pattern allows to “short-circuit” steps and exit the circuit at particular events.
+
+
+
+
This layout provides up to four outputs for any step.
+
+
FastTrack layout
+ Operation
+
+
+
+
It’s important to understand that an Operation is really just a thin public API around Activity::FastTrack. All it adds is a more convenient #call method for public use, and the Result object. The DSL and features are identical.
+
+
Please refer to the operation docs to learn how to leverage the fast-track tracks.
+
+
FastTrack layout
+ Notes
+
+
+
+
+
All options (:pass_fast, :fail_fast and :fast_track) may be used with step, pass or fail. If in doubt, [render the circuit].
+
:pass_fast and :fail_fast can be used in combination.
+
+
+
Troubleshooting
+
+
Even though tracing and wtf? attempt to make your developer experience as smooth as possible, sometimes there are annoying issues.
+
+
Type Error
+
+
It’s a common error to use a bare Hash (with string keys!) instead of a Trailblazer::Context object when running an activity. While symbolized hashes are not a problem, string keys will fail.
The infamous TypeError means your context object can’t convert strings into symbol keys. This is required when calling your steps with keyword arguments.
+
+
TypeError: wrong argument type String (expected Symbol)
+
The Context object automatically converts string keys to symbols.
+
+
Wrong circuit
+
+
When using the same task multiple times in an activity, you might end up with a wiring you’re not expecting. This is due to Trailblazer internally keying tasks by their object identity.
+
+
class Update < Trailblazer::Activity::Railway
+ class CheckAttribute < Trailblazer::Activity::Railway
+ step :valid?
+ end
+
+ step :find_model
+ step Subprocess(CheckAttribute), id: :a
+ step Subprocess(CheckAttribute), id: :b # same task!
+ step :save
+end
+
+
+
When introspecting this activity, you will see that the CheckAttribute task is present only once.
+
+
You need to create a copy of the method or the class of your callable task in order to fix this and have two identical steps.
+
+
class Update < Trailblazer::Activity::Railway
+ class CheckAttribute < Trailblazer::Activity::Railway
+ step :valid?
+ end
+
+ step :find_model
+ step Subprocess(CheckAttribute), id: :a
+ step Subprocess(Class.new(CheckAttribute)), id: :b # different task!
+ step :save
+end
+
+
+
Illegal Signal Error
+
+
As the name suggests, the IllegalSignalError exception is raised when a step returns a signal that is not registered at compile time. The routing algorithm is not able to find a connection for the returned signal and raises an error at run-time.
+
+
Usually, you encounter this beautiful exception when using the circuit interface signature for a step, and returning a “wrong” signal that is not wired to an on-going next task.
+
+
Other common cases may be
+
+
+
Steps which are not wrapped by [TaskBuilder], for example: step task: method(:validate)
Trailblazer PRO is a collection of additional tools for working with Trailblazer, such as the following.
+
+
+
A web debugger that integrates with our tracing tools like #wtf.
+
Our BPMN editor for designing long-running workflows that are executed by the trailblazer-workflow process engine.
+
+
+
+
+
It is free of charge for low-volume users. Please consider purchasing a subscription in order to support Trailblazer development.
+
+
Rails support
+
+
Using our web debugger in your project is as easy as the following five steps.
+
+
+
+
In your Rails’ Gemfile, add the gem.
+
+
+ gem "trailblazer-pro-rails"
+
+
+
Get your API key from https://pro.trailblazer.to/settings. You have to sign up with Github if you haven’t before. We’re not asking for a credit card number, upgrading is always possible.
+
+
In your Rails app, run our generator and enter your API key.
+
+
+ $ rails g trailblazer:pro:install
+
+
+
+
+
You’re now ready to trace and debug on the web.
+
+
+
+
Run your operation via #WTF?.
+
+
+ result = API::V1::Diagram::Operation::Update.wtf?(params: params)
+
+
+
+
+
Click the [TRB PRO] link in your terminal and start debugging.
+
+
+
+
Rails support
+ Explicit tracing
+
+
+
+
You can use the new method Operation.WTF? to explicitly trace using the web debugger.
+
+
+ result = API::V1::Diagram::Operation::Update.wtf?(params: params)
+
+
This will always send the trace to the web, regardless of any configuration.
+
+
Rails support
+ Automatic tracing
+
+
+
+
It’s possible to web-trace operation that’re invoked with the generic Operation.().
+
+
For example, you could automatically web-trace specific operation run from a controller with the following configuration in config/environments/development.rb.
The trace_operations directive allows you to enable web-tracing for particular operations.
+
+
If needed, you can enable it for all operations.
+
+
# TODO
+
+
Rails support
+ Planned
+
+
+
+
Please note that trailblazer-pro and trailblazer-pro-rails are still experimental, and we’re presently collecting “power user” experiences and feature requests.
+
+
Planned features are
+
+
+
End-to-end encryption for traces, so we won’t be able to see anything your app is doing.
+
Activity tracing.
+
More configuration of the tracing process.
+
Better production mode: collect the trace (which is fast), and only render it if something went wrong. Add the web-trace link to your exception handlers.
+
Please contact us if you need more: https://github.com/trailblazer/trailblazer-pro-rails/issues or https://trailblazer.zulipchat.com
Trailblazer runs with any Ruby web framework - Rails, Hanami or Grape. Why? Because it’s a set of additional abstraction layers like forms, service objects (“operations”) or view components. Those are designed to be completely isolated and decoupled from the hosting infrastructure framework.
+
+
Nevertheless, you’re lucky if your project is using Rails since we provide convenient glue code in the trailblazer-rails gem.
+
+
+gem "trailblazer-rails"
+
+
This will automatically pull the trailblazer gem along with its commonly used dependencies. The Trailblazer::Rails::Railtie railtie will activate all necessary convenience methods for you. You don’t have to do anything manually.
+
+
+
+
+
+
There’s a beautiful QUICK START tutorial introducing you to Trailblazer in Rails.
invoking operations and dealing with the outcome in controller actions.
+
rendering cells that access variables from the operation’s result object.
+
+
+
Please note, though, that we’re slowly moving towards using endpoints in controllers. Once this new layer becomes stable we will support both approaches.
+
+
Manual invoke
+
+
In order to run an operation in a controller you may simply invoke it manually.
+
+
class SongsController < ApplicationController
+ def create
+ result = Song::Operation::Create.(params: params) # manual invocation.
+ # ...
+ end
+end
+
+
+
Note how you have to pass all data into the #call, even variables such as params, that are “magically everywhere” in Rails. This is per design - we want encapsulation!
+
+
After running the operation, you need to handle different outcomes.
+
+
class SongsController < ApplicationController
+ def create
+ result = Song::Operation::Create.(params: params) # manual invocation.
+
+ if result.success?
+ redirect_to song_path(result[:model].id)
+ else # failure
+ @form = result[:"contract.default"]
+
+ render
+ end
+ end
+end
+
+
+
In a bit more complex situations, with more data being passed into the operation, this might become repetitive and quite complex.
+
+
+
+
+
Run
+
+
The trailblazer-rails gem provides #run to simplify the task of invoking operations.
+
+
def new
+ ctx = run Song::Operation::New
+
+ @form = ctx[:"contract.default"]
+
+ render
+end
+
+
+
The #run method will always return the result object (or ctx) from the operation.
+
+
While it invokes the specified operation it does three things for you.
+
+
+
Without any additional configuration, #run passes the controller’s params hash into the operation call as in the example above.
+
It automatically assigns @model and, if available, @form for you, after invoking the operation.
+
The ctx object is assigned to @_result.
+
+
+
Run
+ Block syntax
+
+
+
+
To handle success and failure cases, run accepts an optional block.
+
+
def create
+ _ctx = run Song::Operation::Create do |ctx|
+ # success!
+ return redirect_to song_path(ctx[:model].id) # don't forget the return.
+ end
+
+ # failure
+ @form = _ctx[:"contract.default"]
+
+ render
+end
+
+
+
The block is only run for a successful outcome of the operation - or in other words, when ctx.success? is true. Into the block is passed the result object (or ctx) as a block argument.
+
+
+
+
+
+
Do not forget to use return in the block, otherwise Ruby will execute the remaining code under the block, too.
+
+
+
+
+
After the block, you usually add code for handling a failing outcome of the operation run. Here, you can use the ctx object returned from #run.
+
+
Run
+ Keyword arguments
+
+
+
+
The success block of #run can also hand you variables from ctx as keyword arguments, just as you know it from [operation steps].
+
+
def create
+ _ctx = run Song::Operation::Create do |ctx, model:,**|
+ return redirect_to song_path(model.id) # see how model is available?
+ end
+
+ # failure
+ @form = _ctx[:"contract.default"]
+
+ render
+end
+
+
+
This is convenient shortcut to access ctx variables, and gives you a runtime error should that variable be absent.
+
+
+
+
+
+
Keyword arguments for #run are available from trailblazer-rails >= 2.4.2.
+
+
+
+
+
Run
+ Runtime variables
+
+
+
+
If your operation needs more variables in the ctx you can hand in those via #run.
+
+
class SongsController < ApplicationController
+ # ...
+ def create
+ # vvvvvvvvvvvvvvvvvvvvvvvvvv
+ _ctx = run Song::Operation::Create, current_user: current_user do |ctx, model:, **|
+ return redirect_to song_path(model.id)
+ end
+
+ @form = _ctx[:"contract.default"]
+
+ render
+ end
+end
+
+
+
This will add :current_user to the ctx passed into the Song::Operation::Create operation, allowing you to access it in the steps, as done in the example step below.
+
+
+class Song::Operation::Create < Trailblazer::Operation
+ # ...
+ step :user_allowed?
+
+ def user_allowed?(ctx, current_user:, **)
+ # do something with {current_user}
+ end
+end
Often, several operations across an application slice have generic variables, such as the current_user. Instead of redundantly passing them to #run in every controller action, you can configuring variables on the controller-level.
+
+
Override #_run_options to do that automatically for all run calls in a controller.
The overridden #_run_options method will result in :current_user being passed into the operation when using #run without having to specify that particular parameter.
+
+
def create
+ _ctx = run Song::Operation::Create do |ctx, model:, **|
+ return redirect_to song_path(model.id)
+ end
+
+ @form = _ctx[:"contract.default"]
+
+ render
+end
+
+
+
When mixing both styles, options passed directly to #run will win over possible same-named options in #_run_options.
During the past few years of Trailblazer a new layer emerged, we call it endpoint. An endpoint is literally an “operation” getting invoked straight from the controller and running your business operation (like Song::Operation::Create) at some point. Before and after that, you can place code steps you’d normally use in before_filter and friends.
+
+
We will document this new (completely optional!) layer once it is fully presentable. Until then, check out the API docs.
Cells is a separate gem, it provides view components to maintain strong encapsulation in your partial world.
+
+
The trailblazer-rails gem makes it very simple to use the Cells gem along with TRB. It overrides ActionController#render and allows to render a cell.
+
+
File structure
+
+
In the early, adventurous days of Cells - the project was kicked off in 2007 - both naming of cell classes and their file structure was following the Rails Way™ style. In a Rails app, you could find a structure as below.
While this is still possible by simply using the cells gem stand-alone, most projects using Trailblazer prefer the new file structure implemented in the trailblazer-cells gem.
This results in your cells being named Song::Cell::Show, adhering to the Trailblazer convention. Refer to the to learn more about Trailblazer cells.
+
+
Render
+
+
The trailblazer-rails gem extends the #render method and allows you to render cells in controllers and views.
+
+
+class SongsController < ApplicationController
+ def create
+ run Song::New # optional
+
+ render cell(Song::Cell::New, @model)
+ end
+end
+
+
You simply invoke #cell the way you did it before, and pass it to render. If the first argument to #render is not a cell, the original Rails render version will be used, allowing you to use serializers, partials or whatever else you need.
+
+
Render
+ Skip controller layout
+
+
+
+
Per default, #render will add layout: true to render the controller’s ActionView layout.
+
+
The controller’s layout can be disabled using layout: false. These are pure Rails mechanics.
For instance, you can instruct the cell to use a separate layout cell. Note that this option is passed to #cell, not to #render.
+
+
Configuration
+
+
You can set controller-wide options passed to every #cell call by overriding the #options_for_cell method.
+
+
+class SongsController < ApplicationController
+ private def options_for_cell(model, options)
+ {
+ layout: Song::Cell::DarkLayout # used for all #cell calls.
+ }
+ end
+end
+
+
This is extremely helpful for providing controller-wide options like :layout, allowing you to skip this specific option in all actions.
+
+
+class SongsController < ApplicationController
+ def create
+ # ..
+ render cell(Song::Cell::New, model) # no need to pass {layout: Song::Cell::DarkLayout}
+ end
+
+
+
+
+
+
This feature was added in trailblazer-rails version 2.2.0.
+
+
+
+
+
Reform
+
+
Reform is a form object library that helps to move validations out of your models.
+
+
It has excellent Rails support which will soon be summarized here. Until then, you can find API docs here.
+
+
Loader
+
+
The trailblazer-loader gem implements a very simple way to load all files in your concepts directory in a heuristically meaningful order. It can be used in any environment.
+
+
+
+
+
+
In trailblazer-rails 2.3.0 this component has been removed in favor of Rails autoloading. You can safely skip this section!
+
+
+
+
+
+
+
Loader with Rails
+
+
The trailblazer-loader gem comes pre-bundled with trailblazer-rails for historical reasons: in the early days of Trailblazer, the conventional file name concepts/product/operation/create.rb didn’t match the short operation name, such as Product::Create.
+
+
The trailblazer-loader gem’s duty was to load all concept files without using Rails’ autoloader, overcoming the latter’s conventions.
+
+
Over the years, and with the emerge of controller helpers or our workflow engine calling operations for you, the class name of an operation more and becomes a thing not to worry about.
+
+
Many projects use Trailblazer along with the Rails naming convention now. This means you can disable the loader gem, and benefit from Rails auto-magic behavior such as faster loading in the “correct” order, reloading and all the flaws that come with this non-deterministic behavior.
+
+
As a first step, add Operation to your operation’s class name, matching the Rails naming convention.
+
+
+# app/concepts/product/operation/create.rb
+
+module Product::Operation
+ class Create < Trailblazer::Operation
+ # ...
+ end
+end
+
+
It’s a Trailblazer convention to put [ConceptName]::Operation in one line: it will force Rails to load the concept name constant, so you don’t have to reopen the class yourself.
+
+
This will result in a class name Product::Operation::Create.
+
+
Next, disable the loader gem, in config/initializers/trailblazer.rb.
Trailblazer files will now be loaded by Rails - you need to follow the Rails autoloading file naming from here on, and things should run smoothly. A nice side-effect here is that in bigger projects (with hundreds of operations), the start-up time in development accelerates significantly.
+
+
+
+
+
+
The infamous warning: toplevel constant Cell referenced by Notification::Cell warning is a bug in Ruby. You should upgrade to Ruby >= 2.5.
The Reform-3 (trailblazer-form) section is still WIP (as of November 2023).
+
+
+
+
+
We’re going to announce a presentable release of R3 in public. Currently, we’re collecting doc notes here.
+
+
Internals
+
+
+
Form.deserialize
+
+
+
returns Form::Deserialized
+
+
+
Form::Deserialized
+
+
+
created in [NestedDeserializer]
+
+
+
NestedDeserializer
+
+
+
+generic_input = # input going into the nested "form"
+ ->(ctx, value:, **) {
+ {
+ populated_instance: DeserializedFields.new,
+ form_instance: nested_form.new, # we use this as the {exec_context} for filters.
+ input: value,
+ }
+ }
+
+
Railway.Subprocess(nested_deserializer).merge
+
+
+Railway.Out() => ->(ctx, populated_instance:, form_instance:, **) {
+ # FIXME: use kw for {:model_from_populator}
+ {
+ value: Form::Deserialized.new(nested_schema, form_instance, populated_instance, ctx, paired_model: ctx[:paired_model]), # this is used in {set}.
+ # Here we would have to return the mutated twin
+ }
Virtual fields come in handy when there’s no direct mapping to a model attribute or when you plan on displaying but not processing a value.
+
+
Virtual
+
+
Often, fields like password_confirmation should neither be read from nor written back to the model. Reform comes with the :virtual option to handle that case.
Here, the model won’t be queried for a password_confirmation field when creating and rendering the form. When saving the form, the input value is not written to the decorated model. It is only readable in validations and when saving the form manually.
The nested hash in the block-#save provides the same value.
+
+
form.save do |nested|
+ nested[:password_confirmation] #=> "321"
+
+
+
Read-Only
+
+
Use writeable: false to display a value but skip processing it in validate.
+
+
property :country, writeable: false
+
+
+
+
The form will invoke model.country to read the initial value.
+
It will invoke form.country= in validate.
+
The model’s setter model.countrywon’t be called in sync.
+
+
+
Non-writeable values are still readable in the nested hash and through the form itself.
+
+
form.save do |nested|
+ nested[:country] #=> "Australia"
+
+
+
Write-Only
+
+
Use readable: false to hide a value but still write it to the model.
+
+
property :credit_card_number, readable: false
+
+
+
+
The form won’t invoke model.credit_card_number and will display an empty field.
+
In validate, the form calls form.credit_card_number=.
+
In sync, the setter model.credit_card_number= is called and the value written to the database.
+
+
+
Access Protection
+
+
Use parse: false to protect the form setters from being called in validate.
+
+
property :uuid, parse: false
+
+
+
+
This will call model.uuid to display the field via the form.
+
In validate, the form’s setter won’t be called, leaving the value as it is.
+
In sync, the setter model.uuid is called and restored to the original value.
+
+
+
Note that the :parse option works by leveraging :deserializer.
+
+
Coercion
+
+
Incoming form data often needs conversion to a specific type, like timestamps. Reform uses dry-types for coercion. The DSL is seamlessly integrated with the :type option.
+
+
Be sure to add dry-types to your Gemfile when requiring coercion.
+
+
gem "dry-types"
+
+
+
To use coercion, you need to include the Coercion module into your form class.
To filter values manually, you can override the setter in the form.
+
+
class SongForm < Reform::Form
+ property :title
+
+ def title=(value)
+ super sanitize(value) # value is raw form input.
+ end
+end
+
+
+
Again, setters are only called in validate, not during construction.
+
+
Deserializer
+
+
A form object is just a twin. In validate, a representer is used to deserialize the incoming hash and populate the form twin graph. This means, you can use any representer you like and process data like JSON or XML, too.
+
+
Populator
+
+
When deserializing the incoming input in validate, advanced logic might be necessary to find nested objects from the database, or populate the form with arbitrary nested objects.
+
+
The :populator and its short-hand :populate_if_empty options provide custom deserialization logic and are documented here.
+
+
Inheritance
+
+
Forms can be derived from other forms and will inherit all properties and validations.
+
+
class AlbumForm < Reform::Form
+ property :title
+
+ collection :songs do
+ property :title
+
+ validates :title, presence: true
+ end
+end
+
+
+
Now, a simple inheritance can add fields.
+
+
class CompilationForm < AlbumForm
+ property :composers do
+ property :name
+ end
+end
+
+
+
This will addcomposers to the existing fields.
+
+
You can also partially override fields using :inherit.
+
+
class CompilationForm < AlbumForm
+ property :songs, inherit: true do
+ property :band_id
+ validates :band_id, presence: true
+ end
+end
+
This works for both properties and entire nested forms. The property will simply be ignored when deserializing, as if it had never been in the incoming hash/document.
+
+
For nested properties you can use :skip_if: :all_blank as a macro to ignore a nested form if all values are blank.
+
+
Note that this still runs validations for the property.
+
+
Multiparameter Dates
+
+
Composed multi-parameter dates as created by the Rails date helper are processed automatically when multi_params: true is set for the date property and the MultiParameterAttributes feature is included. As soon as Reform detects an incoming release_date(i1) or the like it is gonna be converted into a date.
The hash is now keyed by composee name with the private property names.
+
+
Composition: ActiveModel
+
+
With ActiveModel, the form needs to have a main object configured. This is where ActiveModel-methods like #persisted? or ‘#id’ are delegated to. Use ::model to define the main object.
+
+
class AlbumForm < Reform::Form
+ include Composition
+
+ property :id, on: :album
+ property :title, on: :album
+ property :songs, on: :cd
+ property :cd_id, on: :cd, from: :id
+
+ model :album # only needed in ActiveModel context.
+
+ validates :title, presence: true
+end
+
+
+
Hash Fields
+
+
Reform can also handle deeply nested hash fields from serialized hash columns. This is documented here.
+
+
Populators
+
+
Reform has two completely separated modes for form setup. One when rendering the form and one when populating the form in validate.
+
+
Prepopulating is helpful when you want to fill out fields (aka. defaults) or add nested forms before rendering. Populating is invoked in validate and will add nested forms depending on the incoming hash.
+
+
This page discusses the latter.
+
+
Populators, matching by IDs, deleting items, and much more, is discussed in detail in the chapters Nested Forms and Mastering Forms of the Trailblazer book.
+
+
The Problem
+
+
Populators in Reform are only involved when validating the form.
+
+
In #validate, you pass a nested hash to the form. Reform per default will try to match nested hashes to nested forms. But often the incoming hash and the existing object graph are not matching 1-to-1. That’s where populators enter the stage.
+
+
Let’s say you have the following model.
+
+
album = Album.new(songs: [])
+
+
+
The album contains an empty songs collection.
+
+
Your form looks like this.
+
+
class AlbumForm < Reform::Form
+ collection :songs do
+ property :name
+ end
+end
+
+
+
Here’s how you’d typically validate an incoming hash.
+
+
form = AlbumForm.new(album)
+form.validate({songs: [{name: "Midnight Rendezvous"}]})
+
+
+
Reform will now try to deserialize every nested songs item to a nested form. So, in pseudo-code, this happens in validate.
Intuitively, you will expect Reform to create an additional song with the name “Midnight Rendezvous”. However, this is not how it works and will crash, since songs[0] doesn’t exist. There is no nested form to represent that fragment, yet, since the original songs collection in the model was empty!
+
+
Reform per design makes no assumptions about how to create nested models. You have to tell it what to do in this out-of-sync case.
+
+
You need to configure a populator to engage Reform in the proper deserialization.
+
+
Declarative DSL
+
+
You have to declare a populator when the form has to deserialize nested input. This can happen via :populate_if_empty or the generic :populator option.
+
+
Both options accept either a proc, a method symbol, or a Callable instance.
Reform now creates a Song instance and nests it in the form since it couldn’t find form.songs[0].
+
+
Note that the matching from fragment to form works by index, any additional matching heuristic has to be implemented manually.
+
+
Populate_if_empty
+ Custom
+
+
+
+
You can also create the object yourself and leverage data from the traversed fragment, for instance, to try to find a Song object by name, first, before creating a new one.
+
+
class AlbumForm < Reform::Form
+ collection :songs,
+ populate_if_empty: ->(fragment:, **) do
+ Song.find_by(name: fragment["name"]) or Song.new
+ end
+
+
+
The result from this block will be automatically added to the form graph.
+
+
You can also provide an instance method on the respective form.
+
+
class AlbumForm < Reform::Form
+ collection :songs, populate_if_empty: :populate_songs! do
+ property :name
+ end
+
+ def populate_songs!(fragment:, **)
+ Song.find_by(name: fragment["name"]) or Song.new
+ end
+
+
+
Populate_if_empty
+ Arguments
+
+
+
+
The only argument passed to :populate_if_empty block or method is an options hash. It contains currently traversed :fragment, the :index (collections, only) and several more options.
+
+
The result of the block will be automatically assigned to the form for you. Note that you can’t use the twin API in here, for example to reorder a collection. If you want more flexibility, use :populator.
+
+
Populator
+
+
While the :populate_if_empty option is only called when no matching form was found for the input, the :populator option is always invoked and gives you maximum flexibility for population. They’re exclusive, you can only use one of the two.
+
+
Again, note that populators won’t be invoked if there’s no incoming fragment(s) for the populator’s property.
+
+
Populator
+ Collections
+
+
+
+
A :populator for collections is executed for every collection fragment in the incoming hash.
class AlbumForm < Reform::Form
+ collection :songs,
+ populator: -> (collection:, index:, **) do
+ if item = collection[index]
+ item
+ else
+ collection.insert(index, Song.new)
+ end
+ end
+
+
+
This populator checks if a nested form is already existing by using collection[index]. While the index keyword argument represents where we are in the incoming array traversal, collection is a convenience from Reform, and is identical to self.songs.
+
+
Note that you manually have to check whether or not a nested form is already available (by index or ID) and then need to add it using the form API writers.
+
+
BTW, the :populator option accepts blocks and instance method names.
+
+
Populator
+ Return Value
+
+
+
+
It is very important that each :populator invocation returns the form that represents the fragment, and not the model. Otherwise, deserialization will fail.
+
+
Here are some return values.
+
+
populator: -> (collection:, index:, **) do
+ songs[index] # works, unless nil
+ collection[index] # identical to above
+ songs.insert(1, Song.new) # works, returns form
+ songs.append(Song.new) # works, returns form
+ Song.new # crashes, that's no form
+ Song.find(1) # crashes, that's no form
+
+
+
Always make sure you return a form object, and not a model.
+
+
Populator
+ Avoiding Index
+
+
+
+
In many ORMs, the order of has_many associations doesn’t matter, and you don’t need to use the index for appending.
+
+
collection :songs,
+ populator: -> (collection:, index:, **) do
+ if item = collection[index]
+ item
+ else
+ collection.append(Song.new)
+ end
+ end
+
+
+
Often, it is better to match by ID instead of indexes.
+
+
Populator
+ Single Property
+
+
+
+
Naturally, a :populator for a single property is only called once.
+
+
class AlbumForm < Reform::Form
+ property :composer,
+ populator: -> (model:, **) do
+ model || self.composer= Artist.new
+ end
+
+
+
A single populator works identical to a collection one, except for the model argument, which is equally to self.composer.
+
+
Populator
+ Match by ID
+
+
+
+
[This is described in chapter Authentication in the Trailblazer book.]
+
+
Per default, Reform matches incoming hash fragments and nested forms by their order. It doesn’t know anything about IDs, UUIDs or other persistence mechanics.
+
+
You can use :populator to write your own matching for IDs.
+
+
collection :songs,
+ populator: ->(fragment:, **) {
+ # find out if incoming song is already added.
+ item = songs.find { |song| song.id == fragment["id"].to_i }
+
+ item ? item : songs.append(Song.new)
+ }
+
+
+
Note that a :populator requires you to add/replace/update/delete the model yourself. You have access to the form API here since the block is executed in form instance context.
collection :songs,
+ populator: ->(fragment:, **) {
+ # find out if incoming song is already added.
+ item = songs.find { |song| song.id.to_s == fragment["id"].to_s }
+
+ if fragment["delete"] == "1"
+ songs.delete(item)
+ return skip!
+ end
+
+ item ? item : songs.append(Song.new)
+ }
+
+
+
You can delete items from the graph using delete. To avoid this fragment being further deserialized, use return skip! to stop processing for this fragment.
+
+
Note that you can also use the twin’s Collection API for finding nested twins by any field.
Since Reform 2.1, populators can skip processing of a fragment by returning skip!. This will ignore this fragment as if it wasn’t present in the incoming hash.
+
+
collection :songs,
+ populator: ->(fragment:, **) do
+ return skip! if fragment["id"]
+ # ..
+ end
+
+
+
To skip from a Uber::Callable-marked object, return Representable::Pipeline::Stop
+
+
class SongsPopulator
+ def call(options)
+ return Representable::Pipeline::Stop if fragment["id"]
+ # ...
+ end
+end
+
+collection :songs, populator: SongsPopulator.new
+
+
+
This won’t process items that have an "id" field in their corresponding fragment.
+
+
Uninitialized Collections
+
+
A problem with populators can be an uninitialized collection property.
+
+
class AlbumForm < Reform::Form
+ collection :songs, populate_if_empty: Song do
+ property :title
+ end
+end
+
+album = Album.new
+form = AlbumForm.new(album)
+
+album.songs #=> nil
+form.songs #=> nil
+
+form.validate(songs: [{title: "Friday"}])
+#=> NoMethodError: undefined method `original' for nil:NilClass
+
+
+
What happens is as follows.
+
+
+
In validate, the form can’t find a corresponding nested songs form and calls the populate_if_empty code.
+
The populator will create a Song model and assign it to the parent form via form.songs << Song.new.
+
This crashes, as form.songs is nil.
+
+
+
The solution is to initialize your object correctly. This is per design. It is your job to do that as Reform/Disposable is likely to do it wrong.
+
+
album = Album.new(songs: [])
+form = AlbumForm.new(album)
+
+
+
With ORMs, the setup happens automatically, this only appears when using Struct or other POROs as models.
+
+
Prepopulating
+
+
Reform has two completely separated modes for form setup. One when rendering the form and one when populating the form in validate.
+
+
Prepopulating is helpful when you want to fill out fields (aka. defaults) or add nested forms before rendering.
+
+
Populating is invoked in validate and will add nested forms depending on the incoming hash.
+
+
This page explains prepopulation used to prepare the form for rendering.
+
+
Configuration
+
+
You can use the :prepopulator option on every property or collection.
+
+
class AlbumForm < Reform::Form
+ property :artist, prepopulator: ->(options) { self.artist = Artist.new } do
+ property :name
+ end
+
+
+
The option value can be a lambda or an instance method name.
+
+
In the block/method, you have access to the form API and can invoke any kind of logic to prepopulate your form. Note you need to assign models for nested form using their writers.
This explicit call must happen before the form gets rendered. For instance, in Trailblazer, this happens in the controller action.
+
+
Prepopulate is not Populate
+
+
:populator and :populate_if_empty will be run automatically in validate. Do not call prepopulate! before validate if you use the populator options. This will usually result in “more” nested forms being added as you wanted (unless you know what you’re doing).
+
+
Prepopulators are a concept designed to prepare a form for rendering, whereas populators are meant to set up the form in validate when the input hash is deserialized.
+
+
This is explained in the Nested Forms chapter of the Trailblazer book. Please read it first if you have trouble understanding this, and then open an issue.
+
+
Options
+
+
Options may be passed. They will be available in the :prepopulator block.
The arguments passed to the prepopulate! call will be passed straight to the block/method.
+
+
This call will be applied to the entire nested form graph recursively after the currently traversed form’s prepopulators were run.
+
+
Execution
+
+
The blocks are run in form instance context, meaning you have access to all possible data you might need. With a symbol, the same-named method will be called on the form instance, too.
+
+
Note that you have to assign the pre-populated values to the form by using setters. In turn, the form will automatically create nested forms for you.
+
+
This is especially cool when populating collections.
+
+
property :songs,
+ prepopulator: ->(*) { self.songs << Song.new if songs.size < 3 } do
+
+
+
This will always add an empty song form to the nested songs collection until three songs are attached. You can use the Twin::Collection API when adding, changing or deleting items from a collection.
+
+
Note that when calling #prepopulate!, your :prepopulate code for all existing forms in the graph will be executed . It is up to you to add checks if you need that.
+
+
Overriding
+
+
You don’t have to use the :prepopulator option. Instead, you can simply override #prepopulate! itself.
+
+
class AlbumForm < Reform::Form
+ def prepopulate!(options)
+ self.title = "Roxanne"
+ self.artist = Artist.new(name: "The Police")
+ end
+
+
+
Defaults
+
+
There’s different alternatives for setting a default value for a formerly empty field.
+
+
+
Use :prepopulator as described here. Don’t forget to call prepopulate! before rendering the form.
+
+
Override the reader of the property. This is not recommended as you might screw things up. Remember that the property reader is called for presentation (in the form builder) and for validation in #validate.
+
+
property :title
+
+def title
+ super or "Unnamed"
+end
+
+
+
+
+
Rails
+
+
Reform works with any framework, but comes with additional Rails glue code.
+
+
Reform-Rails
+
+
The reform gem itself doesn’t contain any Rails-specific code but will still work, e.g. for JSON APIs. For extensive Rails support, add the reform-rails gem.
+
+
gem "reform", ">= 2.2.0"
+gem "reform-rails"
+
+
+
Per default, reform-rails will assume you want ActiveModel::Validations as the validation engine. This will include the following into Reform::Form.
+
+
+
Form::ActiveModel for form builder compliance so your form works with form_for and friends.
+
Reform::Form::ActiveModel::FormBuilderMethods to make Reform consume Rails form builder’s weird parameters, e.g. {song_attributes: { number: 1 }}.
Make sure you use the API when writing dry validations.
+
+
Uniqueness Validation
+
+
Both ActiveRecord and Mongoid modules will support “native” uniqueness support from the model class when you use validates_uniqueness_of. They will provide options like :scope, etc.
+
+
You’re encouraged to use Reform’s non-writing unique: true validation, though. Learn more
+
+
ActiveModel Compliance
+
+
Forms in Reform can easily be made ActiveModel-compliant.
+
+
Note that this step is not necessary in a Rails environment.
+
+
class SongForm < Reform::Form
+ include Reform::Form::ActiveModel
+end
+
+
+
If you’re not happy with the model_name result, configure it manually via ::model.
+
+
class CoverSongForm < Reform::Form
+ include Reform::Form::ActiveModel
+
+ model :song
+end
+
+
+
::model will configure ActiveModel’s naming logic. With Composition, this configures the main model of the form and should be called once.
+
+
This is especially helpful when your framework tries to render cover_song_path although you want to go with song_path.
+
+
FormBuilder Support
+
+
To make your forms work with all Rails form gems like simple_form or Rails form_for don’t forget to include the rails-reform gem in your Gemfile.
+
+
gem "reform-rails"
+
+
+
When using ActiveModel validations, this is all you have to do.
+
+
FormBuilder Support
+ Dry-validations
+
+
+
+
However, if you’ve configured dry-validation as your validation framework you have to include at least the FormBuilderMethods module. This is necessary to translate Rails’ suboptimal songs_attributes weirdness back to normal songs: naming in #validate.
+
+
You can configure reform-rails do enable form builder support with Dry-backed forms.
+
+
# config/development.rb
+Rails.application.configure do
+ config.reform.enable_active_model_builder_methods = true
+end
+
+
+
The manual way would be as follows.
+
+
class SongForm < Reform::Form
+ include Reform::Form::ActiveModel
+ include Reform::Form::ActiveModel::FormBuilderMethods
+end
+
+
+
Simple Form
+
+
If you want full support for simple_form do as follows.
+
+
class SongForm < Reform::Form
+ include Reform::Form::ActiveModel::ModelReflections
+
+
+
Including this module will add #column_for_attribute and other methods need by form builders to automatically guess the type of a property.
Sometimes when you still keep validations in your models (which you shouldn’t) copying them to a form might not feel right. In that case, you can let Reform automatically copy them.
+
+
class SongForm < Reform::Form
+ property :title
+
+ extend ActiveModel::ModelValidations
+ copy_validations_from Song
+end
+
+
+
Note how copy_validations_from copies over the validations allowing you to stay DRY.
+
+
This also works with Composition.
+
+
class SongForm < Reform::Form
+ include Composition
+ # ...
+
+ extend ActiveModel::ModelValidations
+ copy_validations_from song: Song, band: Band
+end
+
+
+
ActiveRecord Compatibility
+
+
Reform provides the following ActiveRecord specific features. They’re mixed in automatically in a Rails/AR setup.
+
+
+
Uniqueness validations. Use validates_uniqueness_of in your form.
+
+
+
As mentioned in the Rails Integration section some Rails 4 setups do not properly load.
+
+
You may want to include the module manually then.
+
+
class SongForm < Reform::Form
+ include Reform::Form::ActiveRecord
+
+
+
Mongoid Compatibility
+
+
Reform provides the following Mongoid specific features. They’re mixed in automatically in a Rails/Mongoid setup.
+
+
+
Uniqueness validations. Use validates_uniqueness_of in your form.
+
+
+
You may want to include the module manually then.
+
+
class SongForm < Reform::Form
+ include Reform::Form::Mongoid
+
+
+
Troubleshooting
+
+
+
In case you explicitly don’t want to have automatic support for ActiveRecord or Mongoid and form builder: require reform/form, only.
+
In some setups around Rails 4 the Form::ActiveRecord module is not loaded properly, usually triggering a NoMethodError saying undefined method 'model'. If that happened to you, require 'reform/rails' manually at the bottom of your config/application.rb.
+
Mongoid form gets loaded with the gem if Mongoid constant is defined.
+
+
+
Upgrading Guide
+
+
We try to make upgrading as smooth as possible. Here’s the generic documentation, but don’t hesitate to ask for help on Zulip.
+
+
2.2 to 2.3
+
+
If you have been using dry-validation and you want to upgrade to version 1.x, get ready to change a lot of your code, unfortunately dry-validation API has been completely rewritten so we had to adapt. If instead you have ActiveModel/ActiveRecord the upgrade from 2.2 to 2.3 should be nice and easy.
+Please refer to the CHANGES in the repo.
+
+
2.1 to 2.2
+
+
In a Rails environment with ActiveModel/ActiveRecord, you have to include the reform-rails gem.
+
+
gem "reform"
+gem "reform-rails"
+
+
+
1.2 to 2.0
+
+
Validations
+
+
Validations like validates_acceptance_of are not available anymore, you have to use the new syntax.
+
+
validates acceptance: true
+
+
+
Form#valid?
+
+
Using form.valid? is a private concept and was never publicly documented. It is still available (private) but you are strongly recommended to use #validate instead.
+
+
Form#update!
+
+
Apparently, some people used form.update!({..}) to pre-fillout forms. #update! has never been publicly documented and got removed in Reform 2. However, you can achieve the same behavior using the following hack.
+
+
Reform::Form.class_eval do
+ alias_method :update!, :deserialize
+ public :update!
+
+
+
Validation Backend
+
+
This only is necessary when not using reform/rails, which is automatically loaded in a Rails environment.
+
+
In an initializer, e.g. config/initializers/reform.rb.
+
+
require "reform/form/active_model/validations"
+Reform::Form.class_eval do
+ include Reform::Form::ActiveModel::Validations
+end
+
Representable maps objects to documents (rendering) and documents to objects (parsing) using representers. Representers define the document structure and the transformation to/from objects.
+
+
Representers can define deeply nested object graphs….
+
+
It is very popular amongst REST API developers as it tackles both sides of exposing APIs: rendering documents and deserializing incoming documents to object graphs using a very generic approach.
+
+
But it’s also very helpful as a generic data transformer. …
+
+
In case you’re looking towards implementing a REST API, check out Roar first, which adds hypermedia semantics, media formats and more to Representable
+
+
API
+
+
In Representable, we differentiate between three APIs.
+
+
The declarative API is how we define representers. You can learn how to use those representers by reading about the very brief public API. Representable is extendable without having to hack existing code: the function API documents how to use its options to achieve what you need.
+
+
Declarative API
+
+
To render objects to documents or parse documents to objects, you need to define a representer.
+
+
A representer can either be a class (called decorator) or a module (called representer module). Throughout the docs, we will use decorators as they are cleaner and faster, but keep in mind you can also use modules.
A representer simply defines the fields that will be mapped to the document using property or collection. You can then decorate an object and render or parse. Here’s an example.
+
+
# Given a Struct like this
+Song = Struct.new(:id, :title) #=> Song
+
+# You can instantiate it with the following
+song = Song.new(1, "Fallout") #=> #<struct Song id=1, title="Fallout">
+
+# This object doesn't know how to represent itself in JSON
+song.to_json #=> NoMethodError: undefined method `to_json'
+
+# But you can decorate it with the above defined representer
+song_representer = SongRepresenter.new(song)
+
+# Relax and let the representer do its job
+song_representer.to_json #=> {"id":1,"title":"Fallout"}
+
+
+
The details are being discussed in the public API section.
+
+
Representer Modules
+
+
Instead of using classes as representers, you can also leverage modules which will then get mixed into the represented object.
+
+
A representer module is also a good way to share configuration and logic across decorators.
When parsing, per default Representable will want to instantiate an object for every nested, typed fragment.
+
+
You have to tell Representable what object to instantiate for the nested artist: fragment.
+
+
class SongRepresenter < Representable::Decorator
+ # ..
+ property :artist, decorator: ArtistRepresenter, class: Artist
+end
+
+
+
This happens via the :class option. Now, the document can be parsed and a nested Artist will be created by the parsing.
+
+
song = Song.new # nothing set.
+
+SongRepresenter.new(song).
+ from_json('{"id":1,"title":"Fallout","artist":{"id":2,"name":"The Police"}}')
+
+song.artist.name #=> "The Police"
+
+
+
The default behavior is - admittedly - very primitive. Representable’s parsing allow rich mapping, object creation and runtime checks.
+
+
Document Nesting
+
+
Not always does the structure of the desired document map to your objects. The ::nested method allows structuring properties within a separate section while still mapping the properties to the outer object.
However, in the Song class, there’s no such concept as details.
+
+
Song = Struct.new(:title, :track, :length)
+
+song = Song.new #=> #<struct Song title=nil, track=nil, length=nil>
+
+
+
Both track and length are properties of the song object itself. Representable gives you ::nested to map the virtual details section to the song instance.
+
+
class SongRepresenter < Representable::Decorator
+ include Representable::JSON
+
+ property :title
+
+ nested :details do
+ property :track
+ property :length
+ end
+end
+
+song_representer = SongRepresenter.new(song)
+song_representer.from_json(json_fragment)
+
+
+
Accessors for the nested properties will still be called on the song object. And as always, this works both ways - for rendering and parsing.
+
+
Wrapping
+
+
You can automatically wrap a document.
+
+
class SongRepresenter < Representable::Decorator
+ include Representable::JSON
+
+ self.representation_wrap= :song
+
+ property :title
+ property :id
+end
+
+
+
This will add a container for rendering and parsing.
Otherwise it would respect the representation_wrap= set in the nested decorator (SongRepresenter) and will render:
+
+
{"songs":[{"song":{"title":"Fallout","id":1}}]}
+
+
+
Note that this only works for JSON and Hash at the moment.
+
+
Inheritance
+
+
Properties can be inherited across representer classes and modules.
+
+
class SongRepresenter < Representable::Decorator
+ include Representable::JSON
+
+ property :id
+ property :title
+end
+
+
+
What if you need a refined representer to also add the artist. Use inheritance.
+
+
class SongWithArtistRepresenter < SongRepresenter
+ property :artist do
+ property :name
+ end
+end
+
+
+
All configuration from SongRepresenter will be inherited, making the properties on SongWithArtistRepresenter: id, title, and artist. The original SongRepresenter will stay as it is.
+
+
Artist = Struct.new(:name)
+SongWithArtist = Struct.new(:id, :title, :artist)
+
+artist = Artist.new("Ivan Lins")
+song_with_artist = SongWithArtist.new(1, "Novo Tempo", artist)
+
+# Using the same object with the two representers
+song_representer = SongRepresenter.new(song_with_artist)
+song_with_artist_representer = SongWithArtistRepresenter.new(song_with_artist)
+
+song_representer.to_json
+#=> {"id":1,"title":"Novo Tempo"}
+
+song_with_artist_representer.to_json
+#=> {"id":1,"title":"Novo Tempo","artist":{"name":"Ivan Lins"}}
+
+
+
Composition
+
+
You can also use modules and decorators together to compose representers.
This can be included in other representers and will extend their configuration.
+
+
class SongRepresenter < Representable::Decorator
+ include GenericRepresenter
+
+ property :title
+end
+
+
+
As a result, SongRepresenter will contain the good old id and title property.
+
+
Overriding Properties
+
+
You might want to override a particular property in an inheriting representer. Successively calling property(name) will override the former definition - exactly as you know it from overriding methods in Ruby.
+
+
class CoverSongRepresenter < SongRepresenter
+ include Representable::JSON
+
+ property :title, as: :name # overrides that definition.
+end
+
+
+
Partly Overriding Properties
+
+
Instead of fully replacing a property, you can extend it with :inherit. This will add your new options and override existing options in case the one you provided already existed.
+
+
class SongRepresenter < Representable::Decorator
+ include Representable::JSON
+
+ property :title, as: :name, render_nil: true
+end
+
+
+
You can now inherit properties but still override or add options.
+
+
class CoverSongRepresenter < SongRepresenter
+ include Representable::JSON
+
+ property :title, as: :songTitle, default: "n/a", inherit: true
+end
+
+
+
Using the :inherit, this will result in a property having the following options.
+
+
property :title,
+ as: :songTitle, # overridden in CoverSongRepresenter.
+ render_nil: true # inherited from SongRepresenter.
+ default: "n/a" # defined in CoverSongRepresenter.
+
+
+
The :inherit option works for both inheritance and module composition.
+
+
Inherit With Inline Representers
+
+
:inherit also works applied with inline representers.
+
+
class SongRepresenter < Representable::Decorator
+ include Representable::JSON
+
+ property :title
+ property :artist do
+ property :name
+ end
+end
+
+
+
You can now override or add properties within the inline representer.
+
+
class HitRepresenter < SongRepresenter
+ include Representable::JSON
+
+ property :artist, inherit: true do
+ property :email
+ end
+end
+
+
+
Results in a combined inline representer as it inherits.
Naturally, :inherit can be used within the inline representer block.
+
+
Note that the following also works.
+
+
class HitRepresenter < SongRepresenter
+ include Representable::JSON
+
+ property :artist, as: :composer, inherit: true
+end
+
+
+
This renames the property but still inherits all the inlined configuration.
+
+
Basically, :inherit copies the configuration from the parent property, then merges in your options from the inheriting representer. It exposes the same behaviour as super in Ruby - when using :inherit the property must exist in the parent representer.
+
+
Feature
+
+
If you need to include modules in all inline representers automatically, register it as a feature.
+
+
class AlbumRepresenter < Representable::Decorator
+ include Representable::JSON
+ feature Link # imports ::link
+
+ link "/album/1"
+
+ property :hit do
+ link "/hit/1" # link method imported automatically.
+ end
+end
+
+
+
Nested representers will include the provided module automatically.
+
+
Execution Context
+
+
Readers and Writers for properties will usually be called on the represented object. If you want to change that, so the accessors get called on the decorator instead, use :exec_context.
+
+
class SongRepresenter < Representable::Decorator
+ property :title, exec_context: :decorator
+
+ def title
+ represented.name
+ end
+end
+
+
+
Callable Options
+
+
While lambdas are one option for dynamic options, you might also pass a “callable” object to a directive.
+
+
class Sanitizer
+ include Uber::Callable
+
+ def call(represented, fragment, doc, *args)
+ fragment.sanitize
+ end
+end
+
+
+
Note how including Uber::Callable marks instances of this class as callable. No respond_to? or other magic takes place here.
+
+
property :title, parse_filter: Santizer.new
+
+
+
This is enough to have the Sanitizer class run with all the arguments that are usually passed to the lambda (preceded by the represented object as first argument).
+
+
Read/Write Restrictions
+
+
Using the :readable and :writeable options access to properties can be restricted.
+
+
property :title, readable: false
+
+
+
This will leave out the title property in the rendered document. Vice-versa, :writeable will skip the property when parsing and does not assign it.
+
+
Coercion
+
+
If you need coercion when parsing a document you can use the Coercion module which uses virtus for type conversion.
+
+
Include Virtus in your Gemfile, first.
+
+
gem 'virtus', ">= 0.5.0"
+
+
+
Use the :type option to specify the conversion target. Note that :default still works.
+
+
class SongRepresenter < Representable::Decorator
+ include Representable::JSON
+ include Representable::Coercion
+
+ property :recorded_at, type: DateTime, default: "May 12th, 2012"
+end
+
+
+
Coercing values only happens when rendering or parsing a document. Representable does not create accessors in your model as virtus does.
+
+
Note that we think coercion in the representer is wrong, and should happen on the underlying object. We have a rich coercion/constraint API for twins.
+
+
Symbol Keys
+
+
When parsing, Representable reads properties from hashes using their string keys.
+
+
song.from_hash("title" => "Road To Never")
+
+
+
To allow symbol keys also include the AllowSymbols module.
+
+
class SongRepresenter < Representable::Decorator
+ include Representable::Hash
+ include Representable::Hash::AllowSymbols
+ # ..
+end
+
+
+
This will give you a behavior close to Rails’ HashWithIndifferentAccess by stringifying the incoming hash internally.
+
+
Defaults
+
+
The defaults method allows setting options that will be applied to all property definitions of a representer.
You can then use this hash decorator on instances of Hash.
+
+
hash = {"Nick" => "Hyper Music", "El" => "Blown In The Wind"}
+SongsRepresenter.new(hash).to_json
+#=> {"Nick":"Hyper Music","El":"Blown In The Wind"}
+
+
+
This works both ways.
+
+
A lonely hash starts to make sense especially when the values are nested objects that need to be represented, too. You can configure the nested value objects using the values method. This works exactly as if you were defining an inline representer, accepting the same options.
+
+
class SongsRepresenter < Representable::Decorator
+ include Representable::JSON::Hash
+
+ values class: Song do
+ property :title
+ end
+end
+
+
+
You can now represents nested objects in the hash, both rendering and parsing-wise.
Another trick to represent collections is using a normal representer with exactly one collection property named to_a.
+
+
class SongsRepresenter < Representable::Decorator
+ include Representable::JSON # note that this is a plain representer.
+
+ collection :to_a, class: Song do
+ property :title
+ end
+end
+
+
+
You can use this representer the way you already know and appreciate, but directly on an array.
Note: the implicit collection representer internally is implemented using a lonely collection. Everything you pass to ::collection_representer is simply provided to the ::items call in the lonely collection. That allows you to use :populator and all the other goodies, too.
+
+
Automatic Singular and Collection
+
+
In case you don’t want to know whether or not you’re working with a collection or singular model, use represent.
represent figures out the correct representer for you. This works for parsing, too.
+
+
Public API
+
+
When decorating an object with a representer, the object needs to provide readers for every defined property - and writers, if you’re planning to parse.
+
+
Accessors
+
+
In our small SongRepresenter example, the represented object has to provide #id and #title for rendering.
+
+
Song = Struct.new(:id, :title)
+song = Song.new(1, "Fallout")
+
+
+
Rendering
+
+
You can render the document by decorating the object and calling the serializer method.
When parsing, the read fragment is written to the represented object using the setter (e.g. Song#id=).
+
+
For other format engines, the deserializing method is named analogue to the serializing counterpart, where to becomes from. For example, Representable::XML#from_xml will parse XML if the format engine is mixed into the representer.
+
+
User Options
+
+
You can provide options when representing an object using the user_options: option.
Note that the :user_options will be accessible on all levels in a nested representer. They act like a “global” configuration and are passed to all option functions.
+
+
Here’s an example where the :if option function evaluates a dynamic user option.
The options hash is passed to all options and has the following members.
+
+
{doc: doc, options: options, represented: represented, decorator: self}
+
+options[:doc] | When rendering, the document as it gets created. When parsing, the entire document.
+options[:fragment] | When parsing, this is the fragment read from the document corresponding to this property.
+options[:input] | When rendering, this is the value read from the represented object corresponding to this property.
+options[:represented] | The currently represented object.
+options[:decorator] | The current decorator instance.
+options[:binding] | The current binding instance. This allows to access the currently used definition, e.g. `options[:binding][:name]`.
+options[:options] | All options that have been passed into the render or parse method.
+options[:user_options] | The `:user_options` for the current representer. These are only the [nested options](#nested-user-options) from the user, for a particular representer.
+
+
+
In your option function, you can either receive the entire options hash and use it in the block.
+
+
if: ->(options) { options[:fragment].nil? }
+
+
+
Or, and that is the preferred way, use Ruby’s keyword arguments.
If your property name doesn’t match the name in the document, use the :as option.
+
+
property :title, as: :name
+
+
+
This will render using the :as value. Vice-versa for parsing
+
+
song.to_json #=> {"name":"Fallout","track":1}
+
+
+
Getter
+
+
When rendering, Representable calls the property’s getter on the represented object. For a property named :id, this will result in represented.id to retrieve the value for rendering.
+
+
You can override this, and instead of having Representable call the getter, run your own logic.
As helpful as this option is, please do not overuse it. A representer is not a data mapper, but a document transformer. If your underlying data model and your representers diverge too much, consider using a twin to simplify the representer.
+
+
Setter
+
+
After parsing has happened, the fragment is assigned to the represented object using the property’s setter. In the above example, Representable will call represented.id=(fragment).
With :reader, parsing is completely up to you. Representable will only invoke the function and do nothing else.
+
+
Writer
+
+
To override the entire rendering process, use :writer. You won’t have access to :input here since the value query to the represented object hasn’t happened, yet.
Instead of using :class you can directly instantiate the represented object yourself using :instance.
+
+
property :artist,
+ instance: ->(fragment) do
+ fragment["type"] == "rockstar" ? Rockstar.new : Artist.new
+ end
+
+
+
Render Nil
+
+
In Representable, false values are considered as a valid value and will be rendered into the document or parsed.
+
+
If you want nil values to be included when rendering, use the :render_nil option.
+
+
property :title,
+ render_nil: true
+
+
+
Render Empty
+
+
Per default, empty collections are rendered (unless they’re nil). You can suppress rendering.
+
+
collection :songs,
+ render_empty: false
+
+
+
XML
+
+
If you’re enjoying the pleasure of working with XML, Representable can help you. It does render and parse XML, too, with an almost identical declarative API.
You can also map properties to tag attributes in Representable. This works only for the top-level node, though (seen from the representer’s perspective).
+
+
class SongRepresenter < Representable::Decorator
+ include Representable::XML
+
+ property :title, attribute: true
+ collection :composers
+end
+
+SongRepresenter.new(song).to_xml
+
The same concept can also be applied to content. If you need to map a property to the top-level node’s content, use the :content option. Again, top-level refers to the document fragment that maps to the representer.
+
+
class SongRepresenter < Representable::Decorator
+ include Representable::XML
+
+ property :title, content: true
+end
+
+SongRepresenter.new(song).to_xml
+
+
+
<song>Fallout</song>
+
+
+
Wrapping Collections
+
+
It is sometimes unavoidable to wrap tag lists in a container tag.
Namespaces in XML allow the use of different vocabularies, or set of names, in one document. Read this great article to share our fascination about them.
The Namespace module is available in Representable >= 3.0.4. It doesn’t work with JRuby due to Nokogiri’s extremely complex implementation. Please wait for Representable 4.0 where we replace Nokogiri.
+
+
For future-compat: Namespace only works in decorator classes, not modules.
+
+
+
+
+
Namespace: Default
+
+
You can define one namespace per representer using ::namespace to set the section’s default namespace.
If your incoming document has namespaces, please do use and specify them properly.
+
+
Namespace: Remove
+
+
If an incoming document contains namespaces, but you don’t want to define them in your representers, you can automatically remove them.
+
+
class AlbumRepresenter < Representable::Decorator
+ include Representable::XML
+
+ remove_namespaces!
+end
+
+
+
This will ditch the namespace prefix and parse all properties as if they never had any prefix in the document, e.g. lib:author becomes author.
+
+
+
+
+
+
Removing namespaces is a Nokogiri hack. It’s absolutely not recommended as it defeats the purpose of XML namespaces and might result in wrong values being parsed and interpreted.
+
+
+
+
+
YAML
+
+
Representable also comes with a YAML representer. Like XML, the declarative API is almost identical.
+
+
Flow Style Lists
+
+
A nice feature is that #collection also accepts a :style option which helps having nicely formatted inline (or “flow”) arrays in your YAML - if you want that!
Representable is a generic mapper using recursions, pipelines and things that might be hard to understand from the outside. That’s why we got the Debug module which will give helpful output about what it’s doing when parsing or rendering.
+
+
You can extend objects on the run to see what they’re doing.
Once your code is migrated to 2.4, you should upgrade to 3.0, which does not have deprecations anymore and only supports Ruby 2.0 and higher.
+
+
If you can’t upgrade to 3.0, you can disable slow and annoying deprecations as follows.
+
+
Representable.deprecations = false
+
+
+
Positional Arguments
+
+
For dynamic options like :instance or :getter we used to expose a positional API like instance: ->(fragment, options) where every option has a slightly different signature. Even worse, for collections this would result in a differing signature plus an index like instance: ->(fragment, index, options).
+
+
From Representable 2.4 onwards, only one argument is passed in for all options with an identical, easily memoizable API. Note that the old signatures will print deprecation warnings, but still work.
+
+
For parsing, this is as follows (:instance is just an example).
+
+
property :artist, instance: ->(options) do
+ options[:input]
+ options[:fragment] # the parsed fragment
+ options[:doc] # the entire document
+ options[:result] # whatever the former function returned,
+ # usually this is the deserialized object.
+ options[:user_options] # options passed into the parse method (e.g. from_json).
+ options[:index] # index of the currently iterated fragment (only with collection)
+end
+
+
+
We highly recommend to use keyword arguments if you’re using Ruby 2.1+.
+
+
property :artist, instance: ->(fragment:, user_options:, **) do
+
+
+
User Options
+
+
When passing dynamic options to to_hash/from_hash and friends, in older version you were allowed to pass in the options directly.
+
+
decorator.to_hash(is_admin: true)
+
+
+
This is deprecated. You now have to use the :user_options key to make it compatible with library options.
The :pass_options option is deprecated and you should simply remove it, even though it still works in < 3.0. You have access to all the environmental object via options[:binding].
JSON API Resource Objects must contain an id and type member. You specify
+the type when you include Roar::JSON::JSONAPI.resource(type):
+
+
class SongsRepresenter < Roar::Decorator
+ include Roar::JSON::JSONAPI.resource :songs
+
+ # ...
+end
+
+
+
You do not need to specify an id. An id property will be created for you
+automatically. However, if your represented object uses a method other than id
+to represent its id, you must specify this with the id_key: option on
+inclusion:
+
+
class Song
+ attr_accessor :song_id
+end
+
+class SongsRepresenter < Roar::Decorator
+ include Roar::JSON::JSONAPI.resource :songs, id_key: :song_id
+
+ # ...
+end
+
By default, member names will be rendered according to JSON API recommendations:
+only non-reserved, URL safe characters specified in RFC 3986 will be used.
+Following JSON API conventions, underscores will also be replaced by hyphens.
+
+
class SongsRepresenter < Roar::Decorator
+ include Roar::JSON::JSONAPI.resource :songs
+
+ attributes do
+ property :song_title # rendered as song-title
+ property :lyric_writer, as: :lyricist
+ end
+
+ has_one :album, class: Album do
+ property :release_date # rendered as release-date
+ end
+end
+
+
+
If you want less-strict behaviour (such as allowing non-ASCII Unicode
+characters), you can override this default:
+
+
class SongsRepresenter < Roar::Decorator
+ include Roar::JSON::JSONAPI.resource :songs
+
+ defaults do |name, _|
+ { as: JSONAPI::MemberName.(name, strict: false) }
+ end
+
+ attributes do
+ property :titel_des_Liedes # rendered as titel_des_Liedes
+ property :Klavierstück # rendered as Klavierstück
+ end
+end
+
Meta information can be included into rendered singular and collection documents in two ways.
+
+
You can define meta information on your collection object and then let Roar compile it.
+
+
class SongsRepresenter < Roar::Decorator
+ include Roar::JSON::JSONAPI.resource :songs
+
+ meta toplevel: true do
+ property :page
+ property :total
+ end
+end
+
+
+
Your collection object must expose the respective methods.
+
+
collection.page #=> 1
+collection.total #=> 12
+
+
+
This will render the {"meta": {"page": 1, "total": 12}} hash into the JSON API document.
+
+
Alternatively, you can provide meta information as a hash when rendering. Any values also defined on your object will be overriden.
Note that the author fragment only contains the email in its attributes.
+
+
The to_json API is designed to be able to process params directly, where JSON API parameters like fields from the request URL are parsed into hashes and arrays.
Looking for the old trailblazer-test-0.1.1 docs? They’re here, but don’t forget to upgrade to 1.0 - it’s worth it!
+
+
+
+
+
Testing a Trailblazer project is very simple. Your test suite usually consists of two separate layers.
+
+
+
Integration tests or system tests covering the full stack, and using Capybara to “click through” the happy path and possible edge-cases such as an erroring form.
+Smoke tests make sure of the integrity of your application, and assert that controllers, views and operations play well together. We will provide more documentation about system tests shortly.
+
Operation unit tests guarantee that your operations, data processing and validations do what they’re supposed to. As they’re much faster and easier to write than full stack “smoke tests” they can cover any possible input to your operation and help quickly asserting the created side-effects. The trailblazer-test gem is here to help with that.
+
+
+
There’s no need to test controllers, models, service objects, etc. in isolation - unless you want to do so for a better documentation of your internal APIs. As operations are the single entry-point for your functions, your entire stack is covered with the two test types.
The trailblazer-test gem allows simple, streamlined unit tests for operations.
+
+
Whenever you introduce a new operation class to your application, you have four choices for testing.
+
+
+
You could skip testing and program the next feature - then, you shouldn’t be reading this.
+
If the operation is simple enough, cover its behavior in a smoke test and test both the successful invocation and its invalid state in a UI test. Nevertheless, this can be cumbersome and slow.
+
Write your own operation unit tests.
+
Use #assert_pass and #assert_fail to quickly test all possible inputs and outcomes in a unit test.
+
+
+
The goal of trailblazer-test is to provide an API for writing extremely robust tests for operations (or activities) with a minimal amount of written test code. Asserting edge cases - such as specific validations or possible breaking scenarios - should be a one-liner with this library.
+
+
Installation
+
+
Add this line to your application’s Gemfile:
+
+
gem 'trailblazer-test', ">= 1.0.0"
+
+
+
The provided assertions and helpers work with Minitest. For RSpec support use rspec-trailblazer which provides matchers such as pass_with and fail_with around our assertions.
+
+
+
+
+
+
We’re working on RSpec matchers. The current implementation is documented here. Please chat with us if you want to help.
+
+
+
+
+
Base class
+
+
It is a good idea to maintain a slim OperationTest or OperationSpec base class in your test_helper.rb.
+
+
By including the assertion modules your tests can use our assertions such as assert_pass and assert_exposes.
+
+
Assert_pass
+
+
The very basic version of #assert_pass runs an operation and asserts the reached terminus was :success.
+
+
it "just check if operation passes" do
+ input = {params: {memo: {title: "Todo", content: "Buy beer"}}}
+
+ assert_pass Memo::Operation::Create, input
+end
+
+
+
You need to pass the operation constant and the entire input hash yourself.
+
+
Note that if the assertion fails, it will help debugging right away by giving you the contract errors.
+
+
+
+
Assert_pass
+ Return value
+
+
+
+
The assertion helper returns the operation’s result object (or ctx when testing an Activity) allowing you to write your own assertions per test case.
+
+
it "returns result" do
+ # ...
+ result = assert_pass Memo::Operation::Create, input
+
+ assert_equal result[:model].title, "Todo" # your own assertion
+end
+
+
+
Assert_pass
+ Block style
+
+
+
+
To implement assertions in addition to the default ones, you can also use the block style.
You can combine your own assertions with the model assertions provided by #assert_pass.
+
+
Assert_pass
+ Model assertions
+
+
+
+
In many cases, you may want to assert attributes on the model the operation produced. After the input hash, #assert_pass takes keyword arguments of attributes that must match the model’s.
Here, the internal logic will check attributes against result[:record].
+
+
Assert_pass
+ WTF?
+
+
+
+
Whenever you’re unsure what’s happening in your operation, or a test fails that shouldn’t, add a question mark and use assert_pass?.
+
+
assert_pass? Memo::Operation::Create, input
+
+
+
Now, run the test case.
+
+
+
+
The operation will be called with wtf? and the trace is printed on the terminal.
+
+
Assert_fail
+
+
Just like your new best friend #assert_pass, its counterpart is designed to help you with deliberately failing operations, for instance, to test validation errors.
+
+
In the basic form, #assert_fail runs an operation and checks if it terminates on :failure.
Due to unmet validations the Create operation fails. In turn, this assertion is valid. It simply checks if the operation terminates on the :failure terminus.
+
+
Assert_fail
+ Return value
+
+
+
+
The assertion helper returns the result of the failed operation.
Testing validation errors has just become much simpler. This assertion checks if the title and content field of the default contract contains errors.
+
+
If you want to test the exact error messages, use the hash form.
+
+
assert_fail Memo::Operation::Create, {params: {memo: {}}},
+ # error messages from contract:
+ {
+ title: ["must be filled"],
+ content: ["must be filled", "size cannot be less than 8"]
+ }
+
+
+
The assertion will check the error messages for both fields.
+
+
Note that the helper will tell you if the errors did not match.
+
+
+
+
+
+
+
+
Please let us know if you need anything else! Every app has a slightly different testing style and we’re depending on your input to provide the best testing helpers possible.
+
+
+
+
+
Assert_fail
+ WTF?
+
+
+
+
If tests fail and you have no clue, add a question mark and use #assert_fail?.
The operation’s trace is printed on the console for you, so you can quickly debug.
+
+
Suite
+
+
In addition to the above assertion helpers, the “Suite” test mode helps you with configuring defaults, so there’s even less code.
+
+
The “Suite” test is designed to
+
+
+
simplify the merging of the input arguments
+
merge expected model attributes automatically without having to repeat those
+
set defaults, such as the tested operation
+
+
+
A typical operation test case might look as follows.
+
+
# ...
+ Trailblazer::Test.module!(self, suite: true)
+
+ # The default ctx passed into the tested operation.
+ let(:default_ctx) do
+ {
+ params: {
+ memo: { # Note the {:memo} key here!
+ title: "Todo",
+ content: "Stock up beer",
+ }
+ }
+ }
+ end
+
+ # What will the model look like after running the operation?
+ let(:expected_attributes) do
+ {
+ title: "Todo",
+ content: "Stock up beer",
+ }
+ end
+
+ let(:operation) { Memo::Operation::Create }
+ let(:key_in_params) { :memo }
+
+ it "accepts {tag_list} and converts it to an array" do
+ assert_pass(
+ {tag_list: "fridge,todo"}, # input + default_ctx
+ {tag_list: ["fridge", "todo"]}) # what's expected on the model.
+ end
+ # ...
+end
+
+
+
Here, we’re assuming you are using Minitest::Spec, which is our preferred test style as it’s super concise, fast and readable. We also got Minitest::Test and RSpec helpers to offer.
+
+
Suite
+ Installation
+
+
+
+
If you want to use the Suite logic to save typing, instruct our Test.module using the :suite option (you could’ve guessed that!).
This will initialize merging logic along with default_ctx, expected_attributes, operation and key_in_params. However, you still need to configure those defaults yourself.
+
+
Suite
+ default_ctx
+
+
+
+
You can define a default_ctx using let() (and maintain different versions in describe blocks). default_ctx defines what to pass into the operation per default.
Note that default_ctx is always the full, complete context you’d normally pass into Operation.call().
+
+
You have two ways to customize this ctx in your actual test cases:
+
+
+
Pass a hash to the assertion helpers, which will be merged with the model-specific values.
+
Ctx() for more extensive changes, or to entirely overwrite the default structure.
+
+
+
The default_ctx is used as the input argument for #assert_pass and #assert_fail.
+
+
Suite
+ Expected attributes
+
+
+
+
The counterpart of default_ctx defines what the attributes of the operation’s model will look like when the operation was run successfully.
+
+
# What will the model look like after running the operation?
+let(:expected_attributes) do
+ {
+ title: "Todo",
+ content: "Stock up beer",
+ }
+end
+
+
+
This hash is used only with #assert_pass and represents the third positional argument.
+
+
Suite
+ operation
+
+
+
+
Instead of having to pass the asserted operation constant each time you use an assertion helper, you can conveniently define it using let().
+
+
let(:operation) { Memo::Operation::Create }
+
+
+
Again, you may use any level of describe to fine-tune your tests. You may also override it using the [:operation option].
+
+
Suite
+ key_in_params
+
+
+
+
As typical for a Rails app, the actual incoming form fields are nested under a specific key, such as params[:memo][...]. The key_in_params sets this key to :memo. This is necessary for the automatic merging logic in Suite.
+
+
Note that, if your app doesn’t nest attributes under a “root” key, you can set key_in_params to false.
+
+
let(:key_in_params) { false }
+
+
+
This will instruct the merging logic to omit that model-named key. In turn, the default_ctx shall have one nesting level less.
The assertion helpers from Suite are bringing together operation, default_ctx and expected_attributes. As a matter of fact, #assert_pass in “Suite style” doesn’t need any arguments at all.
+
+
it "passes with valid input, {tag_list} is optional" do
+ assert_pass( {}, {} )
+end
+
As you can see, the Suite helper uses your defaults wherever possible. Now, the real power becomes visible when testing specific cases.
+
+
it "accepts {tag_list} and converts it to an array" do
+ assert_pass(
+ {tag_list: "fridge,todo"}, # input + default_ctx
+ {tag_list: ["fridge", "todo"]}) # what's expected on the model.
+end
+
+
+
The first hash passed here represents a chunk of the params you want to test. It is merged into default_ctx[:params][:memo] for you.
+
+
The second hash is merged with expected_attributes. In addition to all default assumptions, it checks whether the model’s tag_list property is an array of two elements.
Suite’s #assert_pass is aiming at very typical use cases when writing tests with a huge cover rate. Nevertheless, it saves you from having to repeat params hashes or expected attributes over and over again.
+
+
Suite
+ assert_fail
+
+
+
+
The assertion helper for testing validation errors also uses the default_ctx.
+
+
it "fails with invalid {tag_list}" do
+ assert_fail({tag_list: []}, [:tag_list])
+end
+
+
+
This makes testing a specific edge case very straight-forward. Here, we test if passing an invalid tag_list in params[:memo] will yield the expected contract validation error.
+
+
Note that only tag_list is invalid! The required field title is still set via the default_ctx.
+
+
Suite
+ Minitest::Test
+
+
+
+
You don’t have to use Minitest::Spec. If you fancy the rather cumbersome Minitest::Test style, pass the spec: false option when adding helpers.
+
+
class MemoCreateTest < Minitest::Test
+ Trailblazer::Test.module!(self, suite: true, spec: false)
+ # ...
+ def operation; Memo::Operation::Create end
+ def default_ctx; {params: {memo: {title: "Note to self", content: "Remember me!"}}} end
+ def expected_attributes; {title: "Note to self", content: "Remember me!"} end
+ def key_in_params; :memo end
+
+ def test_our_assertions
+ assert_pass({}, {})
+ end
+end
+
+
+
Instead of let(...) you now have to use methods to define default structures.
+
+
Ctx Helper
+
+
If the rather simple auto-merging of the ctx in Suite is not enough, and you’re in need of a custom-tailored ctx to pass into the tested operation, use Ctx(). It mostly goes as the first argument to #assert_pass and #assert_fail.
+
+
it "passes with correct tag_list for user" do
+ ctx = Ctx(
+ {
+ current_user: yogi,
+ # this is deep-merged with default_ctx!
+ params: {memo: {title: "Reminder"}}
+ }
+ )
+
+ assert_pass ctx, {title: "Reminder"}
+
+
+
The Ctx() helper will, per default, grab the default_ctx and deep-merge it with the hash you’re providing. The merge results in the following data-structure to be passed into an operation.
The helper brings some nice API especially for testing edge cases and failures.
+
+
it "fails with missing key {:title}" do
+ assert_fail( Ctx(exclude: [:title]), [:title] ) do |result|
+ assert_equal ["must be filled"], result[:"contract.default"].errors[:title]
+ end
+end
+
+
+
Whenever you use Ctx() the computed hash is passed through directly into the operation, so you have full control over what’s going in. Both assert_pass and assert_fail are not altering your ctx anymore.
+
+
Ctx Helper
+ Merge
+
+
+
+
The standard behavior of Ctx() is to merge the passed hash with default_ctx.
+
+
it "passes with correct {current_user}" do
+ ctx = Ctx({current_user: yogi} )
+ puts ctx
+ #=> {:params=>{:memo=>{:title=>"Todo", :content=>"Stock up beer"}},
+ # :current_user=>"Yogi"}
+
+ assert_pass ctx, {}
+ # ...
+end
+
+
+
This allows to quickly add variables such as the :current_user to the ctx.
+
+
Ctx Helper
+ Params Merge
+
+
+
+
Anything under the :params key is deep-merged with your default_ctx, which is helpful for customizing form field values.
+
+
it "passes with correct tag_list for user" do
+ ctx = Ctx(
+ {
+ current_user: yogi,
+ # this is deep-merged with default_ctx!
+ params: {memo: {title: "Reminder"}}
+ }
+ )
+
+ assert_pass ctx, {title: "Reminder"}
+
+
+
Note how :title is added to the existing :memo hash, effectively overwriting the default :title.
+
+
Ctx Helper
+ Exclude
+
+
+
+
If you want to delete a certain form field from the input, you can use :exclude. This is great for testing presence or required validations.
+
+
it "provides {Ctx()}" do
+ ctx = Ctx(exclude: [:title])
+ #=> {:params=>{:memo=>{:content=>"Stock up beer"}}}
+
+ assert_fail ctx, [:title]
+ # ...
+end
+
+
+
The :title field under :song is now removed from the input.
+
+
You may also use :exclude in combination with the params merging.
We’re still working on RSpec matchers. Please chat with us if you want to help.
+
+
+
+
+
All eyes on the edge case: Unit tests in Trailblazer
+
+
by Nick Sutterer @apotonick
+
+
When programming software, writing and maintaining automated tests is the most hated part. Everyone reading this is nodding right now.
+
+
It’s not only the pain to set up the environment needed for testing a specific case, but also the amount of work and actually written lines of code to make sure you’re covering “everything” that could be affected by the new chunk of code you introduced.
+
+
In this post, I want to focus on the second part of this bold statement and quickly show you how the just released trailblazer-test gem allows writing very concise unit tests for Trailblazer operations. A lot of work has been put into it to make covering edge cases as simple as possible.
+
+
What is an operation?
+
+
Trailblazer provides a service object called operation. It’s the place where you put the business logic for a particular use case you are working on. With it’s very simple DSL, logic can be organized in chunks and executed step-wise.
In short, when invoking the Memo::Operation::Create operation, the three examplary steps will be executed in the order you defined them. If a step fails (by returning false), the remaining steps are skipped and the operation terminates, indicating a failure.
+
+
If you’re keen to learn more about operations and the internal railway model, check our [extensive docs] or simply watch a [5 minute video].
+
+
Minitest or RSpec?
+
+
These examples are using Minitest, as we think it’s much more readable than RSpec. RSpec on the other hand is providing a great toolset but, whatsoever, is putting too much effort into a test DSL that’s extremely verbose.
+
+
As a matter of fact, this is a matter of taste - no holy wars here: we also have [RSpec support].
+
+
Asserting success
+
+
Now, to show you a very simple test case, let’s create a test file, configure it, and run the Create operation with a particular input to assert that it terminates successfully.
+
+
# test/operation/memo_test.rb
+
+require "test_helper"
+
+class MemoOperationTest < Minitest::Spec
+ Trailblazer::Test.module!(self) # install our helpers.
+
+ it "passes with valid input" do
+ input = {params: {memo: {content: "Stock up beer"}}}
+
+ assert_pass Memo::Operation::Create, input
+ end
+end
+
+
+
Using Test.module! you include the #assert_pass assertion into the test class. In its simplest form, it takes the operation constant, and any kind of input hash.
+
+
Internally, the assertion runs the operation with the specified input and then tests if the outcome was successful. This roughly translates to the following snippet, which I’ve seen throughout many TRB projects.
+
+
it "passes with valid input" do
+ # ...
+ result = Memo::Operation::Create.(input)
+
+ assert_equal result.success?, true
+end
+
+
+
There is nothing wrong with doing the above manually, but our assertions bring a (hopefully!) much better developer experience that we’re about to discover.
+
+
Debugging? You're welcome!
+
+
A typical issue for developers when writing or changing tests is that an operation supposed to be passing actually fails. Most of the times, this is due to validation errors. Given that you’re using a contract with an errors object, a failing #assert_pass will automatically give the the validation errors.
+
+
+
+
If that is not enough, you can simply add a question mark to the assertion.
+
+
it "passes with valid input" do
+ # ...
+ assert_pass? Memo::Operation::Create, input
+end
+
+
+
This gives you the highly popular [#wtf? trace] on the console
+
+
+
+
Those two incredibly helpful features for debugging have been suggested by several TRB users over the years, as both checking the contract errors as well as turning on tracing (#wtf?) are the first things immediately done manually by many developers when hitting an issue.
+
+
Testing the model
+
+
While checking if an operation ran successfully is a great thing to do, bringing joy and happiness to the team, the product managers, and the clients, a good test needs to do a bit more.
+
+
In most cases, an operation produces or alters a model, which is usually found under ctx[:model]. After running, you may want to check if model attributes match your high expectations.
+
+
One way would be to use the block style and do the testing yourself.
+
+
it "passes with valid input" do
+ # ...
+ assert_pass Memo::Operation::Create, input do |result|
+ assert_equal result[:model].content, "Stock up beer"
+ # ...
+end
+
+
+
The block simply yields the Result object (or, when using an Activity, the ctx), and it’s up to you what gets asserted inside.
+
+
Alternatively, you can use the built-in attributes test of #assert_pass.
+
+
it "passes with valid input" do
+ # ...
+ assert_pass Memo::Operation::Create, input,
+ content: "Stock up beer",
+ persisted?: true,
+ id: ->(asserted:, **) { asserted.id > 0 }
+end
+
+
+
Your new best friend #assert_pass takes keywords as its third argument. Those are automatically matched against result[:model]. As you can see for :id, even dynamic assertions are possible.
+
+
The combination of the block style and the built-in model assertions provides a rich interface for testing any successful outcome of your operations.
+
+
When things go wrong
+
+
So far, we learned how to test scenarios when things work out. And for the other part, when things don’t play and operations are supposed to actually fail, we provide #assert_fail - you already guessed that method name, right?
+
+
In many cases, an operation will fail if its validations aren’t met. You can simply check if the operation terminated on the failure terminus by using the new assertion with one argument, only.
+
+
it "fails with invalid input" do
+ invalid_input = {params: {memo: {}}}
+
+ assert_fail Memo::Operation::Create, invalid_input
+end
+
+
+
In rare cases, this might be a sufficient test, but most of the times you want to assert errors more detailed.
+
+
Testing error messages
+
+
Given that you’re using a [contract in the operation], you can ask #assert_fail to check for specific validation error messages.
+
+
it "fails with invalid input" do
+ # ...
+ assert_fail Memo::Operation::Create, invalid_input,
+ [:title, :content] # erroring fields.
+end
+
+
+
The assertion will now check if the internal contract errors object contains the erroring fields you provided, resulting in a manual test that could look like so.
+
+
it "fails with invalid input" do
+ # ...
+ assert_equal result["contract.default"].errors.messages.keys,
+ [:title, :content]
+end
+
+
+
To write an even stricter test, you can provide the error messages as an additional constraint.
+
+
it "fails with invalid input" do
+ # ...
+ assert_fail Memo::Operation::Create, invalid_input,
+ {
+ title: ["must be filled"],
+ content: ["must be filled", "size cannot be less than 8"]
+ }
+end
+
+
+
When expected error messages do not match the actual ones, the assertion automatically shows you the latter.
+
+
+
+
Again, optimizing your experience and shortcutting ways to help you debug.
+
+
What about extendability?
+
+
Both assertions shipped with trailblazer-test provide the block syntax and return the result, in case you need to add more test code.
+
+
Also, keep in mind that the assertions described here are what we needed to minimize time, code and brain when writing tests. Feel free to ping us for discussing any extensions of the gem.
+
+
Suite: Minimizing code
+
+
The assertions described so far are designed to take away pain in your testing, but they require you to repeat arguments over and over again. The “Suite” mode targets defaulting, so the written code is even less.
+
+
Imagine you’re testing our Create operation and you want to make sure that all validations are actually working, each one in a separate test case. Here’s how that could look using the suite feature.
+
+
# test/memo/operation_test.rb
+class MemoOperationTest < Minitest::Spec
+ Trailblazer::Test.module!(self, suite: true)
+
+ describe "Create" do
+ # insert defaulting here, see below...
+
+ it "{content} works" do
+ assert_pass({content: "chill beer"}, {content: "chill beer"})
+ end
+
+ it "{tag_list} is converted to array" do
+ assert_pass(
+ {tag_list: "fridge,todo"}, # input
+ {tag_list: ["fridge", "todo"]} # model value.
+ )
+ end
+ end
+end
+
+
+
In suite mode, assertion arguments such as operation, the incoming ctx and expected attributes on the model can be set (and overwritten!) on the class and describe level.
+
+
Defaulting over verbosity
+
+
You can default arguments by simply defining special-named let() blocks on any level.
+
+
# test/memo/operation_test.rb
+class MemoOperationTest < Minitest::Spec
+ # ...
+ describe "Create" do
+ let (:operation) { Memo::Operation::Create }
+ let(:default_ctx) do
+ {
+ params: {
+ memo: { # Note the {:memo} key here!
+ title: "Todo",
+ content: "Stock up beer",
+ }
+ }
+ }
+ end
+ let(:expected_attributes) { ... }
+ end
+
+
+
Instead of having to repeat those values, the suite-enabled assertions will use and accordingly merge arguments for you. A desired side-effect is that #assert_pass always checks all attributes on the model as it merges expected_attributes with the second hash you provided.
+
+
Check the docs to dive into this simple yet helpful feature.
+
+
Stop mocking me!
+
+
While it’s usually good practice to test the entire stack of logic, meaning your tests also cover complex system parts like external services, sometimes it’s necessary to stub a component.
+
+
Replacing a particular step can easily be done using #mock_step. You are correct when objecting that this method should be named #stub_step, but that’s too close to dub_step and we haven’t added an alias, yet. Note that a “step” could be an entire, nested operation using Subprocess(), anything modeled as a step can be stubbed.
+
+
it "runs fine" do
+ stubbed_create = mock_step(Memo::Operation::Create, path: [:save]) do |ctx, **|
+ # new logic for {save}.
+ ctx[:saved] = true
+ end
+
+ assert_pass stubbed_create, ...
+end
+
+
+
The :path option allows targeting either a first-level step sitting directly in Create, or a deeply nested step somewhere 6 levels down in your nested operation graph. Check the docs for some more detailed examples.
+
+
The #mock_step helper returns a new operation class which can then be passed to the assertions, or even returned from let(:operation) if using suite mode.
+
+
Maybe this post is a good place to mention that the entire stubbing logic is simply using the patch feature of Trailblazer internally - implementing this for the trailblazer-test gem was nothing more but applying the patching mechanics with three lines of code.
+
+
RSpec and more
+
+
In the next post we’re going to introduce the RSpec matchers that are based on this gem.
+
+
it "passes with manual attributes" do
+ input = {params: {memo: {title: "Reminder", content: "Do not forget"}}}
+
+ expect(run(Memo::Operation::Create, input)).
+ to pass_with(title: "Reminder")
+end
+
+
+
Being a bit more verbose, they provide the exact same behavior that we ship for Minitest. If you have suggestions or ideas, never hesitate to discuss those with us! Now, have fun testing. Or at least, try to!
Trailblazer is a collection of gems to help you structure growing Ruby applications. It does so by providing a higher level of architecture through new abstractions.
+
+
Abstractions
+
+
The layers we provide are designed to be stand-alone and do not require you buying the full TRB stack.
+
+
+
Form objects to move validation and processing logic out of models into dedicated and renderable objects. → REFORM docs
+
Service objects that embrace your business code, orchestrate different domain layers, and allow for a solid, testable architecture. → OPERATION docs
+
View components for better encapsulation in your HTML views. → CELLS docs
+
Representers to parse incoming JSON or XML documents, and to serialize outgoing data. → REPRESENTABLE docs
+
Trailblazer does not come with SQL abstractions or routing as those layers are provided by exisiting frameworks such as Rails or Hanami.
+
+
+
+
+
+
+
Are you new to TRB? Then it’s probably best to start with our LEARN section and find out more about this framework!
+
+
+
+
+
Currently, the framework consists of around 40 gems. The main gem trailblazer is an umbrella gem with the sole reason to pull all default dependencies for you. Feel free to pick and choose what you need for your applications.
+
+
Our mission
+
+
Trailblazer empowers developers to build better applications. By “better” we mean maintainable, stable and, yes, fun to work with!
+
+
Our approach enforces encapsulation by providing new abstraction layers. It maximizes testability and reusability, and simplifies onboarding of new developers as we have strong conventions, thorough docs and screencasts, and a vivid community.
+
+
Unlike other “modern architecture” approaches in the Ruby community (as seen in numerous blog posts and books) neither do we tell you how to apply a new, fancy design pattern in your project by giving you suggestions for vague “best practices”, nor do we instruct how to introduce that new, self-tailored service object. No. we give you battle-tested functions, abstractions and classes that enforce a clean architecture.
+
+
We firmly believe that the utilization of ready-to-use layers will always prevail over a documentation-driven approach. In the latter, reliance on constantly outdated “architecture docs” creates a continuous struggle for a designated senior to document the process of creating a specific type of object.
+
+
+
Learn
+
+
Believe it or not, but you can learn about Trailblazer step-wise! It’s usually smart to start checking out operations, then form gems using Reform, and after that macros and Rails integration.
+
+
Trailblazer Tales
+
+
For a while we’ve been pushing very short 5-min videos teaching you how operations work, what testing looks like and which features they bring for your daily development.
+
+
+
+
Producing those instructional screencasts turned out to be a lot of fun (and a bit of work), so expect a new episode every few weeks. This is definitely much simpler than writing books!
+
+
Grab a coffee and watch the first few episodes to quickly grasp how to master this indispensable tool to structure your business logic.
+
+
Books
+
+
The Trailblazer - A new architecture for Rails book from 2017 is very outdated and discusses Trailblazer 1.x! However, the first two chapters are worth a read, if you want to pick up the spirit.
The first argument (params) was merged into the second argument using the key “params”. You now pass one hash to call and hence do the merging yourself.
By convention, we renamed option to ctx, but it is completely up to you to adopt this.
+
+
Nevertheless, the “old style” signatures won’t work anymore.
+
+
+class Memo::Create < Trailblazer::Operation
+ def create_model(options)
+ # ..
+ end
+
+ # or
+
+ def create_model(model:, **)
+ # ..
+ end
+end
+
+
Neither a single options nor keyword-arguments-only are gonna fly as the new step signature is more restrictive and always requires you to maintain the ctx (or options, if you will) as the first positional argument, then keyword arguments.
+
+
Double splat operator ** at the end will be required to in order to discard unused kw args.
+
+
+
+
+
+
You can introduce this change before actual migration to 2.1.
+
+
+
+
+
Call API
+ pass/fail
+
+
+
+
Steps declared as success or failure are now renamed to pass and fail respectively.
If you are using Rubocop it will probably start complaining about unreachable code because it just so happens that fail is also a Ruby Kernel’s method. One solution to this could be to add a custom rule to .rubocop.yml like this:
This way you could introduce this change before actual migration to 2.1. Just don’t forget to remove it after updating gems to 2.1.
+
+
Call API
+ pass_fast/fail_fail
+
+
+
+
Now every step that may end up in pass_fast or fail_fast signals need an extra option that indicates fast track usage. Change this operation:
+
+
+class Memo::Create < Trailblazer::Operation
+ step :create
+
+ def create(ctx, **)
+ Railway.pass_fast! if ctx[:model].save
+ end
+end
+
+
Use the :fast_track option to let Trailblazer know about the potential new signal being emitted.
+
+
+class Memo::Create < Trailblazer::Operation
+ step :create, fast_track: true # notice the extra option :fast_track
+
+ def create(ctx, **)
+ Railway.pass_fast! if ctx[:model].save
+ end
+end
+
+
Context
+
+
The keys for ctx used to be mixed up, some where "longer.strings", some were :symbols. The new context implementation Context::IndifferentAccess now allows to use both.
+
+
+ctx["model"]
+ctx[:model] # => same!
+
+
This also works for namespaced keys, which you still might find helpful.
On the core level, we use symbol keys, only (e.g. :"contract.default").
+
+
+
+
+
The default implementation of the context object can be set by overriding Context.implementation. For example, if you want the old behavior back.
+
+
+class Trailblazer::Context
+ def self.implementation
+ Context # old behavior.
+ end
+end
+
+
Note that the override might be deprecated in favor of a dependency injection.
+
+
Nested
+
+
The Nested macro allows to, well, nest activities or operations, providing a neat way to encapsulate and reuse logic.
+
+
Nested
+ Deprecation
+
+
+
+
In 2.1, the [Subprocess macro] is the standard way for nesting. The Nested macro should only be used when you use the dynamic version where the nested operation is computed at runtime using :builder.
+
+
An exception will warn you about the inappropriate usage.
+
+
+[Trailblazer] Using the `Nested()` macro with operations and activities is deprecated. Replace `Nested(Create)` with `Subprocess(Create)`.
+
+
Nested
+ input and output
+
+
+
+
Both the :input and :output options that used to go with Nested(Present, :input: ...) are now a generic option in Trailblazer. Move them behind the
+macro parenthesis.
An exception will stop compilation if you fail to obey.
+
+
+ArgumentError: unknown keyword: input
+
+
Behold of another tiny API change! The :output filter signature has changed for a better.
+
+
In Trailblazer 2.0, the following signature was allowed.
+
+
+# 2.0
+Nested(Present,
+ output: ->(ctx, mutable_data:, **) {
+ {model: mutable_data[:article]} # you used the {mutable_data} keyword argument.
+ }
+)
+
+
Mind the arguments being passed to :output. The first positional is the original outer context, the context from the nested operation comes as a mutable_data: keyword argument.
+
+
In 2.1, those two have swapped.
+
+
+# 2.1
+Nested(Present),
+ output: ->(inner_ctx, article:, **) {
+ # {inner_ctx} is {mutable_data}
+ {model: article} # you used the {mutable_data} keyword argument.
+ }
+
+
The inner context from the nested operation comes first, as a positional argument. Note how you can conveniently use keyword arguments to access variables from this inner ctx (e.g. article:). Keep in mind, the naming is completely up to you. We use inner_ctx/ctx and original_ctx in our code.
+
+
If you also need the original context in the :output filter, use the :output_with_outer_ctx option.
Another difference is that in 2.0, when you were using pass_fast in nested operations, it would stop only the nested operation from executing. After this the outer one would continue executing.
+
+
Now returning pass_fast in nested operation will stop both, inner and outer operation with success as a final result. If you rely on old behaviour you can still have it with proper signals mapping:
+
+
+Nested(Present), input: :my_input,
+ output: :my_output,
+ fast_track: true,
+ Output(:pass_fast) => Track(:success), # pass_fast now mapped to `just` a success
+ Output(:fail_fast) => End(:failure)
+
+
Macro API
+
+
Macros are functions that add a task along with specific options to the activity. In TRB 2.0, those (historically camel-cased) functions returned an array with two elements.
+
+
+module MyMacro
+ def self.NormalizeParams(name: :myparams, merge_hash: {})
+ task = ->((ctx, flow_options), _) do
+ ctx[name] = ctx[:params].merge(merge_hash)
+
+ return Trailblazer::Activity::Right, [ctx, flow_options]
+ end
+
+ [task, name: name] # old API
+ end
+end
+
+
In 2.1, a hash is returned. Note that :name is :id now.
+
+
module MyMacro
+ def self.NormalizeParams(name: :myparams, merge_hash: {})
+ task = ->((ctx, flow_options), _) do
+ ctx[name] = ctx[:params].merge(merge_hash)
+
+ return Trailblazer::Activity::Right, [ctx, flow_options]
+ end
+
+ # new API
+ {
+ task: task,
+ id: name
+ }
+ end
+end
+
+
+
This allows for a much richer macro experience where you might add additional steps via a macro, use DSL options such as :before and :after and add taskWrap extensions. [macro API]
+
+
A common error if you don’t return a hash from your macro is a call on Array.
It was possible to define contracts on the operation level using a DSL.
+
+
+class Create < Trailblazer::Operation
+ contract do
+ property :id
+ end
+
+ step Contract::Build()
+ step Contract::Validate()
+end
+
+
Since the usability doesn’t outweigh the complexity needed to implement such DSL, we decided to remove that functionality for now.
+
+
Instead, use an explicit inline class and the :constant option.
+
+
+class Create < Trailblazer::Operation
+ class Form < Reform::Form
+ property :id
+ end
+
+ step Contract::Build(constant: Form)
+ step Contract::Validate()
+end
+
+
Trailblazer loader
+
+
Usage of a trailblazer-loader is now discouraged as it’s slower than the ones provided by Rails and it’s error prone.
+
+
In short, we decided to adopt the Rails naming scheme and change operation names from User::Create to User::Operation::Create, so the file name and class path are in sync.
Trailblazer provides a rich set of developer tools to ease debugging and make modelling processes a pleasant experience.
+
+
The developer gem contains tools to help you visualize and debug Trailblazer code. Its development features such as tracing or exception handling are advanced tools and will become your best friends very quickly.
+
+
Constant
+
+
We advise you to alias the Developer constant to something handy, such as ::Dev in your project. For instance, in Rails you’d have the following line in a config/initializers/trailblazer.rb initializer.
The iconic wtf? method is one way to invoke an operation with tracing turned on. During the execution, invoked steps are traced and printed in the console as a tree-view, helping you to understand the code path the operation took. It is one of Trailblazer’s most-loved features and was introduced in Trailblazer 2.1.
+
+
When working with a Trailblazer::Operation simply invoke its #wtf? method.
+
+
result = Memo::Operation::Create.wtf?(params: {title: "Remember me..."})
+
+
+
On the console, you can see the printed trace in all its beauty.
+
+
+
+
This method is especially helpful if you want to
+
+
+
Debug an exception happening somewhere deep in your code.
+
Find out which step changed the track to failure.
+
Focus on specific step(s) to find out what ctx mutations are happening inside them.
+
+
+
Wtf?
+ Exception
+
+
+
+
In case of an exception somewhere in the operation or its nested activities, wtf? will print the trace path to the closest point where the exception was thrown.
+
+
+
+
The original exception will be visible on the console, too.
+
+
+
+
+
+
Please note that this is a higher-level debugging tool that does not confront you with a 200-lines stack trace the way Ruby does it, but pinpoints the exceptional code and locates the problem on a task level.
+
+
This is possible due to you structuring code into higher abstractions, tasks and activities.
+
+
+
+
+
Wtf?
+ Activity
+
+
+
+
The #wtf? class method is only available for Trailblazer::Operation subclasses. You will get an exception if you try to use it with Trailblazer::Activity::Railway and other activities.
+
+
+NoMethodError: undefined method `wtf?' for Memo::Operation::Create:Class
+
+
However, tracing can be used with low-level Activity classes, too.
+
+
module Memo::Operation
+ class Create < Trailblazer::Activity::Railway # Note that this is not an {Operation}!
+ step :extract_markdown
+ step :model
+ # ...
+ end
+end
+
+
+
You have to use the clumsier Developer.wtf? method for tracing.
As always with Activity subclasses, it operates with the circuit interface.
+
+
Wtf?
+ color_map
+
+
+
+
The color_map option allows you to customize default coloring scheme being used to render passed or failed steps.
+
+
+
+
Render
+
+
It often is helpful to visualize an activity. One way is the render helper.
+
+
+puts Trailblazer::Developer.render(Memo::Create)
+
+
The rendered output prints each task, its outputs and where they’re connected to.
+
+
+
+
+
+
We’re working on improved visualization for the console, and for the web editor. If you want to help, please ping us on our chat.
+
+
+
+
+
Render Linear
+
+
Client
+
+
The Developer::Client module provides functions to work with activities and workflows created in the PRO editor.
+
+
Client
+ Import
+
+
+
+
Notes
+
+
+
IDs are extracted from the label of the element. They get chomped to remove a potential newline at the end and reduce confusion.
+
It is currently not possible to assign semantics to arrows via the label. In the meantime, use the :query option. # TODO
+
+
+
Graph
+
+
+
+
+
+
+
+
With trailblazer-activity 0.16.0, the Introspect::Graph API has been moved to trailblazer-developer and might get replaced. Please refer to the Introspect::Nodes API.
+
+
+
+
+
To find out the structure and composition details about any activity, use the Introspect API.
+
+
Graph
+ Find
+
+
+
+
You may use Graph#find with a block for a more complex search.
The returned node instance exposes several inspect helpers.
+
+
The ID of the element is retrieved by using #id.
+
+
# puts node.id.inspect #=> :validate
+
+
+
To see what actual task sits behind this circuit’s node, use #task. This is always the low-level task that responds to a circuit interface. It might be a wrapper around your actual logic, provided by the DSL.
The Output#signal field returns the signal object the task returns to trigger that connection. To see the semantic of this output, use Output#semantic.
+
+
Graph
+ Outputs
+
+
+
+
The Node#outputs method returns an array of Output objects defining each output of outgoing connections of a specific node.
+ A mature and battle tested framework
+
+
+ used in thousands of applications
+
+
+
+
+
+
+
+
+
+
+
+ We streamline
+ code organization
+
+
+ Trailblazer
+ is a framework-agnostic set of gems helping to improve your app's code structure.
+ It is used in hundreds of commercial Rails apps and was often introduced later on, after many years of extensive development, to reduce technical debt.
+
+
+
+
+
+
+
+
+
+
+
+
+ Missing
+ abstractions
+
+
+ Most web frameworks have excellent abstractions for routing, rendering, and databases.
+ When it comes to organizing business logic, however, you're left alone.
+
+
+ The existing places, such as controller actions, models, callbacks and hand-tailored service objects, lead to codebases hard to maintain.
+
+
+
+
+
+
+
+
+
+ Our
+ toolbox
+
+
+ Instead of cluttering your code over the entire MVC framework,
+ Trailblazer introduces a bunch of new abstractions, with a standard service object leading the way: the operation.
+
+
+
+ Code is organized into small chunks we call steps. The operation helps to structure your logic and takes over flow control.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bullet-proof
+ conventions
+
+
+ While you can focus on developing your app and writing code that matters,
+ Trailblazer gives you automatic error handling by letting you apply the Railway pattern to your logic.
+
+
+
+ This not only saves you tons of if and elses but provides clever ways to reuse huge parts of your code by supplying inheritance and composition mechanics to structure your application.
+
Developers who work on Ruby applications, especially those built using frameworks like Rails, Hanami, Roda, Grape, or Sinatra.
+
Development teams working on complex applications with intricate business logic who don't want to reinvent the wheel.
+
Teams that value code organization, maintainability, and testability enforced by a library and not by internal documentation.
+
+
+
+
+
Companies with Legacy Codebase
+
+
Trailblazer is designed to be introduced in existing, complex projects. Most TRB gems support Ruby 2.1 and above.
+
Step-wise, old code can be replaced with much cleaner operations, new functions can be built using TRB.
+
Missing tests are easily added around our new abstractions. No need to write slow integration tests for every edge case.
+
+
+
+
Startups and Fast-Growing Companies
+
+
Using more abstractions doesn't contradict rapid prototyping. In fact, more conventions mean less guessing and faster decisions.
+
With a solid architecture, new features and changes are implemented faster and much more consistent than following the vanilla Rails Way.
+
TRB's robust conventions empower developers to concentrate on feature development, while the Rails Way often presents an abundance of divergent approaches.
+
+
+
+
Growing Teams
+
+
The learning curve of TRB is quickly outweighed by the onboarding speed for new developers.
+
Our architecture encourages strong interfaces and encapsulation which help big teams designing big applications and keeping those maintainable.
+
Trailblazer is built for reusability. Most our abstractions encourage inheritance and composition. Complex components can be shared across teams or even apps.
+
+
+
+
Companies Emphasizing Test-Driven Development
+
+
Encapsulation in TRB is key. That makes unit-tests a walk in the park.
+
We maintain test gems that help writing bullet-proof assertions.
+
Operations are single entry points for functions. That makes it much easier to design tests before you write code.
+
+
+
+
Companies Prioritizing Long-Term Support
+
+
We've been around for more than a decade. And we will stay for at least another one.
+
With TRB 2.1, we provide long-term support (LTS) and don't change public API without thorough deprecation.
+
And if you're still stuck: we also offer consulting services! Engage us for a day or two, we are passionate about helping your team and learning about problems you're facing. This often contributes to the enhancement of Trailblazer itself.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Our
+ abstraction
+ layers
+
+
+ Trailblazer's main objective: keep software maintainable. We do so by providing a range of additional abstraction layers, where code is moved from conservative "MVC" to new layers and objects.
+
+
+
+
+
+
class MemosController < ApplicationController
+ def create
+ if Memo::Operation::Create.(params: params)
+ render
+ end
+ end
+ Move your
+ business logic
+ out of controllers and models.
+
+
+ Let controllers do routing and rendering, let models be SQL abstractions instead
+ of being dumps for all sorts of application code. Your messy callback chains go to operations.
+
+
+
+
+
+
+
+
+
+ A place for
+ business logic
+
+
+
+ Our service object operation takes over control flow, maximizes reusability with inheritance and composition, and lets your team focus on writing features.
+
+ In Trailblazer, we provide a set of solutions aiming for different problems. Our stand-alone gems range from structuring business logic to form objects, view components and much more. You pick what you need.
+
+
+
+
+
Legacy Ready
+
+ You can start using TRB in complex existing applications today. Refactorings can be applied step-wise,
+ legacy code can be minimized as you go. Remember: Rome wasn't build in one day, either.
+
+
+
+
+
Long-Term Support (LTS)
+
+ TRB is in use in thousands of production applications. That's why we promise:
+ Trailblazer 2.1 is long-term supported.
+ Easy upgrades and stable APIs allow us to innovate and keep you covered.
+
+
+
+
+
+
+
+
+
+ What others say about
+
+ Trailblazer?
+
+
+
+
+ Trailblazer has been a game-changer for our development team.
+ It has revolutionized the way we handle complex business logic in our Ruby applications. With Trailblazer,
+ we've experienced a significant improvement in code organization, maintainability, and overall productivity.
+
+
+ Frankie, CEO Codebase
+
+
+
+
+ The architecture provided by Trailblazer has allowed us to separate concerns effectively, making our codebase cleaner and easier to understand while providing an intuitive way to handle errors using the railway pattern.
+
+
+ It's refreshing to see business logic extracted from controllers and models into dedicated operations, resulting in more focused and testable code.
+
+
+ Richard Böhme, Ruby Developer, webit! GmbH
+
+
+
+
+ One of the standout features of Trailblazer is its workflow- oriented approach.
+ The ability to define and orchestrate activities in a clear and structured manner has greatly simplified our application's complex workflows.
+ We can easily visualize and manage the sequence of steps, making it easier to reason about and maintain.
+
+ Our new tutorial book is published!
+ Building an authentication library similar to Devise we discuss everything from software design
+ basics to tools and library functions of Trailblazer 2.1.
+
+ Benefit from
+ TRAILBLAZER PRO
+ features that speed up your code development
+
+ by simplified code snippets.
+
+
+
+
+
+ Debugging can be fun!
+
+
+ Our web debugger takes you up where {#wtf?} left off: browse through operation traces,
+ inspect ctx variables at any time and fix bugs within minutes, not hours.
+
+ We're bringing the industry-standard language BPMN to Ruby! Model complex, long-running business processes
+ with our web editor and let {trailblazer-workflow} do the flow control.
+
+ Sign up for free, check out how to leverage BPMN and our diagram editor in your Ruby code, and trace your
+ code on the web. Then you decide how much PRO you need.
+