rubyn-code 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Rails: Form Objects
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Use form objects when a form doesn't map cleanly to a single ActiveRecord model. Form objects encapsulate validation, data transformation, and multi-model persistence behind an ActiveModel-compliant interface that works with Rails form helpers.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# app/forms/registration_form.rb
|
|
9
|
+
class RegistrationForm
|
|
10
|
+
include ActiveModel::Model
|
|
11
|
+
include ActiveModel::Attributes
|
|
12
|
+
|
|
13
|
+
attribute :email, :string
|
|
14
|
+
attribute :password, :string
|
|
15
|
+
attribute :password_confirmation, :string
|
|
16
|
+
attribute :company_name, :string
|
|
17
|
+
attribute :plan, :string, default: "free"
|
|
18
|
+
attribute :terms_accepted, :boolean
|
|
19
|
+
|
|
20
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
21
|
+
validates :password, presence: true, length: { minimum: 8 }
|
|
22
|
+
validates :password_confirmation, presence: true
|
|
23
|
+
validates :company_name, presence: true
|
|
24
|
+
validates :terms_accepted, acceptance: true
|
|
25
|
+
validate :passwords_match
|
|
26
|
+
validate :email_not_taken
|
|
27
|
+
|
|
28
|
+
def save
|
|
29
|
+
return false unless valid?
|
|
30
|
+
|
|
31
|
+
ActiveRecord::Base.transaction do
|
|
32
|
+
company = Company.create!(name: company_name, plan: plan)
|
|
33
|
+
user = company.users.create!(email: email, password: password, role: :admin)
|
|
34
|
+
Onboarding::WelcomeService.call(user)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
true
|
|
38
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
39
|
+
errors.add(:base, e.message)
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def passwords_match
|
|
46
|
+
errors.add(:password_confirmation, "doesn't match") unless password == password_confirmation
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def email_not_taken
|
|
50
|
+
errors.add(:email, "is already registered") if User.exists?(email: email)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The controller stays thin:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# app/controllers/registrations_controller.rb
|
|
59
|
+
class RegistrationsController < ApplicationController
|
|
60
|
+
def new
|
|
61
|
+
@form = RegistrationForm.new
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def create
|
|
65
|
+
@form = RegistrationForm.new(registration_params)
|
|
66
|
+
|
|
67
|
+
if @form.save
|
|
68
|
+
redirect_to dashboard_path, notice: "Welcome!"
|
|
69
|
+
else
|
|
70
|
+
render :new, status: :unprocessable_entity
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def registration_params
|
|
77
|
+
params.require(:registration_form).permit(:email, :password, :password_confirmation, :company_name, :plan, :terms_accepted)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The view works with standard form helpers:
|
|
83
|
+
|
|
84
|
+
```erb
|
|
85
|
+
<%= form_with model: @form, url: registrations_path do |f| %>
|
|
86
|
+
<%= f.text_field :email %>
|
|
87
|
+
<%= f.password_field :password %>
|
|
88
|
+
<%= f.password_field :password_confirmation %>
|
|
89
|
+
<%= f.text_field :company_name %>
|
|
90
|
+
<%= f.check_box :terms_accepted %>
|
|
91
|
+
<%= f.submit "Sign Up" %>
|
|
92
|
+
<% end %>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Why This Is Good
|
|
96
|
+
|
|
97
|
+
- **Validates as a unit.** Cross-field validations (password confirmation, terms acceptance) and cross-model checks (email uniqueness) live together in one object rather than scattered across multiple models.
|
|
98
|
+
- **Works with Rails forms.** Including `ActiveModel::Model` gives you `form_with` compatibility, error messages, and all the form helpers for free.
|
|
99
|
+
- **Keeps models clean.** The User model doesn't need a `terms_accepted` virtual attribute or a `password_confirmation` validation that only applies during registration.
|
|
100
|
+
- **Testable.** Instantiate the form with params, call `.save`, assert results. No HTTP, no controllers, no views.
|
|
101
|
+
- **Transactional.** Multi-model persistence wraps in a transaction naturally within the `save` method.
|
|
102
|
+
|
|
103
|
+
## Anti-Pattern
|
|
104
|
+
|
|
105
|
+
Stuffing virtual attributes and context-specific validations into the model:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# app/models/user.rb
|
|
109
|
+
class User < ApplicationRecord
|
|
110
|
+
attr_accessor :company_name, :plan, :terms_accepted, :registering
|
|
111
|
+
|
|
112
|
+
validates :terms_accepted, acceptance: true, if: :registering
|
|
113
|
+
validates :password_confirmation, presence: true, if: :registering
|
|
114
|
+
validates :company_name, presence: true, if: :registering
|
|
115
|
+
|
|
116
|
+
after_create :create_company_and_onboard, if: :registering
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def create_company_and_onboard
|
|
121
|
+
company = Company.create!(name: company_name, plan: plan)
|
|
122
|
+
self.update!(company: company, role: :admin)
|
|
123
|
+
Onboarding::WelcomeService.call(self)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Why This Is Bad
|
|
129
|
+
|
|
130
|
+
- **Conditional validations pollute the model.** Every `if: :registering` is a code smell. The model accumulates flags and conditionals for every context it's used in (registration, profile update, admin edit, API creation).
|
|
131
|
+
- **Virtual attributes bloat the model.** `company_name`, `plan`, `terms_accepted` have nothing to do with the User model — they're registration-specific concerns.
|
|
132
|
+
- **Callbacks hide side effects.** `after_create :create_company_and_onboard` runs silently whenever a user is created with the `registering` flag. Creating a user in the console, a seed file, or a test unexpectedly triggers company creation if someone accidentally sets the flag.
|
|
133
|
+
- **Hard to test.** Testing registration requires setting `user.registering = true` and knowing about the hidden callback chain. The test is coupled to implementation details.
|
|
134
|
+
|
|
135
|
+
## When To Apply
|
|
136
|
+
|
|
137
|
+
Use a form object when ANY of these are true:
|
|
138
|
+
|
|
139
|
+
- The form spans **multiple models** (registration creates a user AND a company)
|
|
140
|
+
- The form has **virtual attributes** that don't exist on any model (terms acceptance, password confirmation for non-Devise setups, promotional codes)
|
|
141
|
+
- **Validations are context-specific** — they apply during this form submission but not when the model is used elsewhere
|
|
142
|
+
- The form requires **data transformation** before persistence (parsing dates, splitting full name into first/last, geocoding an address)
|
|
143
|
+
- The form has **complex conditional logic** about which fields are required based on other field values
|
|
144
|
+
|
|
145
|
+
## When NOT To Apply
|
|
146
|
+
|
|
147
|
+
- The form maps **directly to one model** with no virtual attributes and no context-specific validations. Use the model directly — a form object adds a pointless layer.
|
|
148
|
+
- The form is **read-only** (search, filter). Use a simple parameter object or a query object instead.
|
|
149
|
+
- The **only difference** from the model is one extra validation. Add it to the model with a context (`validates :field, presence: true, on: :registration`) rather than creating an entire form object for one rule.
|
|
150
|
+
|
|
151
|
+
## Edge Cases
|
|
152
|
+
|
|
153
|
+
**The form needs to update existing records, not just create:**
|
|
154
|
+
Add a constructor that accepts an existing record and populates attributes from it. The `save` method checks for `persisted?` and calls `update!` instead of `create!`.
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
def initialize(user: nil, **attributes)
|
|
158
|
+
@user = user
|
|
159
|
+
super(**attributes)
|
|
160
|
+
self.email ||= @user&.email
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**The form has nested attributes (like line items on an order):**
|
|
165
|
+
Form objects can include their own nested form objects or accept arrays. This is where form objects really shine over `accepts_nested_attributes_for`, which is brittle and hard to validate.
|
|
166
|
+
|
|
167
|
+
**The team uses the `reform` or `dry-validation` gem:**
|
|
168
|
+
Follow the team's existing pattern. If they use Reform, write a Reform form. Rubyn adapts to the project's conventions.
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# Rails: Hotwire (Turbo + Stimulus)
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Hotwire delivers fast, reactive UIs by sending HTML over the wire instead of JSON. Turbo handles navigation and page updates without JavaScript. Stimulus adds sprinkles of JS behavior when needed. Together, they replace most SPA complexity.
|
|
6
|
+
|
|
7
|
+
### Turbo Drive (Automatic)
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Turbo Drive is automatic — every link click and form submission
|
|
11
|
+
# is intercepted and fetched via fetch(), replacing the body.
|
|
12
|
+
# No configuration needed. Just use standard Rails link and form helpers.
|
|
13
|
+
|
|
14
|
+
# The key contract: return proper HTTP status codes
|
|
15
|
+
class OrdersController < ApplicationController
|
|
16
|
+
def create
|
|
17
|
+
@order = current_user.orders.build(order_params)
|
|
18
|
+
|
|
19
|
+
if @order.save
|
|
20
|
+
redirect_to @order, notice: "Order created." # 303 redirect → Turbo follows it
|
|
21
|
+
else
|
|
22
|
+
render :new, status: :unprocessable_entity # 422 → Turbo replaces the page
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def update
|
|
27
|
+
@order = current_user.orders.find(params[:id])
|
|
28
|
+
|
|
29
|
+
if @order.update(order_params)
|
|
30
|
+
redirect_to @order, notice: "Updated."
|
|
31
|
+
else
|
|
32
|
+
render :edit, status: :unprocessable_entity # MUST return 422 for Turbo to re-render
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Turbo Frames (Partial Page Updates)
|
|
39
|
+
|
|
40
|
+
```erb
|
|
41
|
+
<%# app/views/orders/index.html.erb %>
|
|
42
|
+
<%# Only the content inside the turbo_frame is replaced on navigation %>
|
|
43
|
+
<h1>Orders</h1>
|
|
44
|
+
|
|
45
|
+
<%= turbo_frame_tag "orders_list" do %>
|
|
46
|
+
<%= render @orders %>
|
|
47
|
+
|
|
48
|
+
<%# Pagination links inside the frame only update the frame %>
|
|
49
|
+
<%= paginate @orders %>
|
|
50
|
+
<% end %>
|
|
51
|
+
|
|
52
|
+
<%# Search form targets the frame %>
|
|
53
|
+
<%= form_with url: orders_path, method: :get, data: { turbo_frame: "orders_list" } do |f| %>
|
|
54
|
+
<%= f.search_field :q, placeholder: "Search orders..." %>
|
|
55
|
+
<%= f.submit "Search" %>
|
|
56
|
+
<% end %>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```erb
|
|
60
|
+
<%# app/views/orders/_order.html.erb %>
|
|
61
|
+
<%= turbo_frame_tag dom_id(order) do %>
|
|
62
|
+
<div class="order-card">
|
|
63
|
+
<h3><%= order.reference %></h3>
|
|
64
|
+
<p><%= order.status %></p>
|
|
65
|
+
<%= link_to "Edit", edit_order_path(order) %>
|
|
66
|
+
</div>
|
|
67
|
+
<% end %>
|
|
68
|
+
|
|
69
|
+
<%# Clicking "Edit" loads the edit form INTO the frame — no full page load %>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```erb
|
|
73
|
+
<%# app/views/orders/edit.html.erb %>
|
|
74
|
+
<%= turbo_frame_tag dom_id(@order) do %>
|
|
75
|
+
<%= render "form", order: @order %>
|
|
76
|
+
<% end %>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Turbo Streams (Real-Time Updates)
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# After creating an order, broadcast updates to the page
|
|
83
|
+
class Order < ApplicationRecord
|
|
84
|
+
after_create_commit -> {
|
|
85
|
+
broadcast_prepend_to "orders",
|
|
86
|
+
target: "orders_list",
|
|
87
|
+
partial: "orders/order",
|
|
88
|
+
locals: { order: self }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
after_update_commit -> {
|
|
92
|
+
broadcast_replace_to "orders",
|
|
93
|
+
target: dom_id(self),
|
|
94
|
+
partial: "orders/order",
|
|
95
|
+
locals: { order: self }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
after_destroy_commit -> {
|
|
99
|
+
broadcast_remove_to "orders", target: dom_id(self)
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```erb
|
|
105
|
+
<%# app/views/orders/index.html.erb %>
|
|
106
|
+
<%= turbo_stream_from "orders" %>
|
|
107
|
+
|
|
108
|
+
<div id="orders_list">
|
|
109
|
+
<%= render @orders %>
|
|
110
|
+
</div>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Inline Turbo Stream responses from controller actions:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
class OrdersController < ApplicationController
|
|
117
|
+
def create
|
|
118
|
+
@order = current_user.orders.build(order_params)
|
|
119
|
+
|
|
120
|
+
if @order.save
|
|
121
|
+
respond_to do |format|
|
|
122
|
+
format.turbo_stream {
|
|
123
|
+
render turbo_stream: turbo_stream.prepend("orders_list",
|
|
124
|
+
partial: "orders/order", locals: { order: @order })
|
|
125
|
+
}
|
|
126
|
+
format.html { redirect_to orders_path }
|
|
127
|
+
end
|
|
128
|
+
else
|
|
129
|
+
render :new, status: :unprocessable_entity
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def destroy
|
|
134
|
+
@order = current_user.orders.find(params[:id])
|
|
135
|
+
@order.destroy!
|
|
136
|
+
|
|
137
|
+
respond_to do |format|
|
|
138
|
+
format.turbo_stream { render turbo_stream: turbo_stream.remove(dom_id(@order)) }
|
|
139
|
+
format.html { redirect_to orders_path }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Stimulus (Sprinkles of JavaScript)
|
|
146
|
+
|
|
147
|
+
```javascript
|
|
148
|
+
// app/javascript/controllers/toggle_controller.js
|
|
149
|
+
import { Controller } from "@hotwired/stimulus"
|
|
150
|
+
|
|
151
|
+
export default class extends Controller {
|
|
152
|
+
static targets = ["content"]
|
|
153
|
+
|
|
154
|
+
toggle() {
|
|
155
|
+
this.contentTarget.classList.toggle("hidden")
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```erb
|
|
161
|
+
<%# Usage in HTML — no inline JS, no jQuery %>
|
|
162
|
+
<div data-controller="toggle">
|
|
163
|
+
<button data-action="click->toggle#toggle">Show Details</button>
|
|
164
|
+
<div data-toggle-target="content" class="hidden">
|
|
165
|
+
<p>Order details here...</p>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```javascript
|
|
171
|
+
// app/javascript/controllers/auto_submit_controller.js
|
|
172
|
+
import { Controller } from "@hotwired/stimulus"
|
|
173
|
+
|
|
174
|
+
export default class extends Controller {
|
|
175
|
+
static targets = ["form"]
|
|
176
|
+
|
|
177
|
+
submit() {
|
|
178
|
+
clearTimeout(this.timeout)
|
|
179
|
+
this.timeout = setTimeout(() => {
|
|
180
|
+
this.formTarget.requestSubmit()
|
|
181
|
+
}, 300)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
```erb
|
|
187
|
+
<%= form_with url: orders_path, method: :get,
|
|
188
|
+
data: { controller: "auto-submit", auto_submit_target: "form", turbo_frame: "orders_list" } do |f| %>
|
|
189
|
+
<%= f.search_field :q, data: { action: "input->auto-submit#submit" }, placeholder: "Search..." %>
|
|
190
|
+
<% end %>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Why This Is Good
|
|
194
|
+
|
|
195
|
+
- **No JavaScript framework needed.** Turbo + Stimulus replaces 90% of what React/Vue do for typical CRUD apps, with far less code.
|
|
196
|
+
- **Server-rendered HTML.** No JSON API, no serializers, no client-side state management. The server renders HTML and Turbo delivers it to the right place.
|
|
197
|
+
- **Progressive enhancement.** Everything works without JavaScript (Turbo Drive degrades gracefully). Stimulus adds interactivity on top.
|
|
198
|
+
- **Turbo Streams enable real-time.** WebSocket-powered live updates without a single line of custom JavaScript. New orders appear instantly for all users.
|
|
199
|
+
- **Stimulus controllers are tiny.** 10-20 lines each, reusable across views, no build step complexity.
|
|
200
|
+
|
|
201
|
+
## Anti-Pattern
|
|
202
|
+
|
|
203
|
+
Disabling Turbo or fighting it:
|
|
204
|
+
|
|
205
|
+
```erb
|
|
206
|
+
<%# BAD: Disabling Turbo because forms don't work %>
|
|
207
|
+
<%= form_with model: @order, data: { turbo: false } do |f| %>
|
|
208
|
+
|
|
209
|
+
<%# The real fix: return the correct status code %>
|
|
210
|
+
def create
|
|
211
|
+
if @order.save
|
|
212
|
+
redirect_to @order # 303 — Turbo follows
|
|
213
|
+
else
|
|
214
|
+
render :new, status: :unprocessable_entity # 422 — Turbo re-renders
|
|
215
|
+
# NOT: render :new (200 status makes Turbo think it succeeded)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## When To Apply
|
|
221
|
+
|
|
222
|
+
- **Every new Rails 7+ app.** Hotwire is the default. Use it.
|
|
223
|
+
- **CRUD-heavy apps.** Forms, lists, search, pagination, inline editing — Hotwire handles all of these with minimal JavaScript.
|
|
224
|
+
- **Real-time features.** Chat, notifications, live dashboards — Turbo Streams over WebSockets.
|
|
225
|
+
|
|
226
|
+
## When NOT To Apply
|
|
227
|
+
|
|
228
|
+
- **Complex client-side interactions.** Drag-and-drop editors, canvas drawing, real-time collaboration — these may need a JavaScript framework.
|
|
229
|
+
- **Offline-first apps.** Turbo requires a server connection. PWAs with offline support need client-side state.
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Rails: Internationalization (i18n)
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Use Rails i18n for all user-facing text, even in English-only apps. It centralizes copy, supports pluralization, enables future translation, and keeps views clean.
|
|
6
|
+
|
|
7
|
+
### Basic Setup
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
# config/locales/en.yml
|
|
11
|
+
en:
|
|
12
|
+
orders:
|
|
13
|
+
index:
|
|
14
|
+
title: "Your Orders"
|
|
15
|
+
empty: "You haven't placed any orders yet."
|
|
16
|
+
count:
|
|
17
|
+
one: "%{count} order"
|
|
18
|
+
other: "%{count} orders"
|
|
19
|
+
show:
|
|
20
|
+
title: "Order %{reference}"
|
|
21
|
+
status:
|
|
22
|
+
pending: "Awaiting confirmation"
|
|
23
|
+
confirmed: "Processing"
|
|
24
|
+
shipped: "On its way"
|
|
25
|
+
delivered: "Delivered"
|
|
26
|
+
cancelled: "Cancelled"
|
|
27
|
+
create:
|
|
28
|
+
success: "Order placed successfully!"
|
|
29
|
+
failure: "Could not place order. Please check the errors below."
|
|
30
|
+
|
|
31
|
+
shared:
|
|
32
|
+
actions:
|
|
33
|
+
edit: "Edit"
|
|
34
|
+
delete: "Delete"
|
|
35
|
+
cancel: "Cancel"
|
|
36
|
+
save: "Save Changes"
|
|
37
|
+
back: "Back"
|
|
38
|
+
confirmations:
|
|
39
|
+
delete: "Are you sure? This cannot be undone."
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Usage in Views
|
|
43
|
+
|
|
44
|
+
```erb
|
|
45
|
+
<%# Views use t() helper %>
|
|
46
|
+
<h1><%= t(".title") %></h1> <%# Lazy lookup — resolves to orders.index.title %>
|
|
47
|
+
|
|
48
|
+
<% if @orders.empty? %>
|
|
49
|
+
<p><%= t(".empty") %></p>
|
|
50
|
+
<% else %>
|
|
51
|
+
<p><%= t(".count", count: @orders.count) %></p> <%# Pluralization %>
|
|
52
|
+
<% end %>
|
|
53
|
+
|
|
54
|
+
<%= link_to t("shared.actions.edit"), edit_order_path(@order) %>
|
|
55
|
+
|
|
56
|
+
<%# Status with translation %>
|
|
57
|
+
<span class="badge"><%= t("orders.show.status.#{@order.status}") %></span>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Usage in Controllers
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
class OrdersController < ApplicationController
|
|
64
|
+
def create
|
|
65
|
+
@order = current_user.orders.build(order_params)
|
|
66
|
+
if @order.save
|
|
67
|
+
redirect_to @order, notice: t(".success")
|
|
68
|
+
else
|
|
69
|
+
flash.now[:alert] = t(".failure")
|
|
70
|
+
render :new, status: :unprocessable_entity
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def destroy
|
|
75
|
+
@order.destroy
|
|
76
|
+
redirect_to orders_path, notice: t("orders.destroy.success", reference: @order.reference)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Model Validations
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
# config/locales/en.yml
|
|
85
|
+
en:
|
|
86
|
+
activerecord:
|
|
87
|
+
errors:
|
|
88
|
+
models:
|
|
89
|
+
order:
|
|
90
|
+
attributes:
|
|
91
|
+
shipping_address:
|
|
92
|
+
blank: "is required for delivery"
|
|
93
|
+
total:
|
|
94
|
+
greater_than: "must be a positive amount"
|
|
95
|
+
user:
|
|
96
|
+
attributes:
|
|
97
|
+
email:
|
|
98
|
+
taken: "is already registered. Did you mean to sign in?"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# These override Rails' default validation messages automatically
|
|
103
|
+
class Order < ApplicationRecord
|
|
104
|
+
validates :shipping_address, presence: true
|
|
105
|
+
# Error message: "Shipping address is required for delivery"
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Organizing Locale Files
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
config/locales/
|
|
113
|
+
├── en.yml # Shared/global translations
|
|
114
|
+
├── models/
|
|
115
|
+
│ ├── en.yml # ActiveRecord model names and attributes
|
|
116
|
+
│ └── errors/
|
|
117
|
+
│ └── en.yml # Validation error messages
|
|
118
|
+
├── views/
|
|
119
|
+
│ ├── orders.en.yml # Order view translations
|
|
120
|
+
│ ├── users.en.yml # User view translations
|
|
121
|
+
│ └── admin.en.yml # Admin panel translations
|
|
122
|
+
└── mailers/
|
|
123
|
+
└── en.yml # Email subject lines and content
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# config/application.rb
|
|
128
|
+
config.i18n.load_path += Dir[Rails.root.join("config/locales/**/*.yml")]
|
|
129
|
+
config.i18n.default_locale = :en
|
|
130
|
+
config.i18n.fallbacks = true # Fall back to :en if translation missing
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Date and Number Formatting
|
|
134
|
+
|
|
135
|
+
```yaml
|
|
136
|
+
# config/locales/en.yml
|
|
137
|
+
en:
|
|
138
|
+
date:
|
|
139
|
+
formats:
|
|
140
|
+
short: "%b %d" # "Mar 20"
|
|
141
|
+
long: "%B %d, %Y" # "March 20, 2026"
|
|
142
|
+
time:
|
|
143
|
+
formats:
|
|
144
|
+
short: "%b %d, %I:%M %p" # "Mar 20, 02:30 PM"
|
|
145
|
+
number:
|
|
146
|
+
currency:
|
|
147
|
+
format:
|
|
148
|
+
unit: "$"
|
|
149
|
+
precision: 2
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```erb
|
|
153
|
+
<%= l(@order.created_at, format: :long) %> <%# March 20, 2026 %>
|
|
154
|
+
<%= l(@order.created_at, format: :short) %> <%# Mar 20 %>
|
|
155
|
+
<%= number_to_currency(@order.total / 100.0) %> <%# $25.00 %>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Why This Is Good
|
|
159
|
+
|
|
160
|
+
- **Single source of truth for copy.** Changing "Place Order" to "Complete Purchase" across the entire app means editing one YAML line, not grep-replacing across 15 files.
|
|
161
|
+
- **Lazy lookup keeps views clean.** `t(".title")` in `orders/index.html.erb` automatically resolves to `en.orders.index.title`. No long key paths in views.
|
|
162
|
+
- **Pluralization is handled.** `t(".count", count: 1)` → "1 order." `t(".count", count: 5)` → "5 orders." Works correctly for languages with complex pluralization rules.
|
|
163
|
+
- **Validation messages are customizable per model.** "Email is already registered. Did you mean to sign in?" is more helpful than "Email has already been taken."
|
|
164
|
+
- **Future-proofs for translation.** Even if you're English-only today, adding Spanish later means adding `es.yml` files — no code changes.
|
|
165
|
+
|
|
166
|
+
## Anti-Pattern
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
# BAD: Hardcoded strings in views
|
|
170
|
+
<h1>Your Orders</h1>
|
|
171
|
+
<p>You have <%= @orders.count %> order<%= @orders.count == 1 ? "" : "s" %></p>
|
|
172
|
+
|
|
173
|
+
# BAD: Hardcoded strings in controllers
|
|
174
|
+
redirect_to @order, notice: "Order placed successfully!"
|
|
175
|
+
flash[:alert] = "Something went wrong"
|
|
176
|
+
|
|
177
|
+
# BAD: Hardcoded validation messages
|
|
178
|
+
validates :email, uniqueness: { message: "is already taken" }
|
|
179
|
+
# Use locale files instead — they're overridable per model
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## When To Apply
|
|
183
|
+
|
|
184
|
+
- **Every user-facing string.** Views, flash messages, mailer subject lines, validation messages, error pages.
|
|
185
|
+
- **Even English-only apps.** Centralizing copy in YAML is valuable for consistency and maintainability regardless of language count.
|
|
186
|
+
- **Date and number formatting.** Use `l()` for dates and `number_to_currency` for money — they respect locale settings.
|
|
187
|
+
|
|
188
|
+
## When NOT To Apply
|
|
189
|
+
|
|
190
|
+
- **Log messages.** Logs are for developers, not users. Log in English, always.
|
|
191
|
+
- **Developer-facing text.** Rake task output, console messages, internal error classes. These stay as plain strings.
|
|
192
|
+
- **API responses.** JSON APIs typically return machine-readable codes, not translated text. Error codes like `"insufficient_credits"` don't need i18n.
|