Slide 1

Slide 1 text

the missing parts Radoslav Stankov

Slide 2

Slide 2 text

!

Slide 3

Slide 3 text

Radoslav Stankov @rstankov rstankov.com

Slide 4

Slide 4 text

https://tips.rstankov.com

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

No content

Slide 7

Slide 7 text

- one developer (me) " - management portal # - implement a mobile app $ - for iOS % / Android &

Slide 8

Slide 8 text

No content

Slide 9

Slide 9 text

source: https://refactoring.fm/p/how-to-choose-technology

Slide 10

Slide 10 text

No content

Slide 11

Slide 11 text

No content

Slide 12

Slide 12 text

source "https://rubygems.org" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "~> 8.0.0.rc1" # The modern asset pipeline for Rails [https://github.com/rails/propshaft] gem "propshaft" # Use sqlite3 as the database for Active Record gem "sqlite3", ">= 2.1" # Use the Puma web server [https://github.com/puma/puma] gem "puma", ">= 5.0" # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] gem "importmap-rails" # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] gem "turbo-rails" # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] gem "stimulus-rails" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] # gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] # Use the database-backed adapters for Rails.cache, Active Job, and Action Cable gem "solid_cache" gem "solid_queue" gem "solid_cable" # Reduces boot times through caching; required in config/boot.rb gem "bootsnap", require: false # Deploy this application anywhere as a Docker container [https://kamal-deploy.org] gem "kamal", require: false # Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] gem "thruster", require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] # gem "image_processing", "~> 1.2" group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" # Static analysis for security vulnerabilities [https://brakemanscanner.org/] gem "brakeman", require: false # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false end group :development do # Use console on exceptions pages [https://github.com/rails/web-console] gem "web-console" end group :test do # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] gem "capybara" gem "selenium-webdriver" end

Slide 13

Slide 13 text

No content

Slide 14

Slide 14 text

No content

Slide 15

Slide 15 text

' Rails extensions ( Missing concepts ) Agenda

Slide 16

Slide 16 text

* Rails extensions Active Record Routing Error Handling I18n

Slide 17

Slide 17 text

Active Record

Slide 18

Slide 18 text

gem 'annotate' # == Schema Information # # Table name: building_print_templates # # id :bigint(8) not null, primary key # format :string not null # name :string not null # options :jsonb not null # created_at :datetime not null # updated_at :datetime not null # account_id :bigint(8) not null # user_id :bigint(8) # # Indexes # # index_building_print_templates_on_account_id_and_name (account_id,name) UNIQUE # index_building_print_templates_on_user_id (user_id) # # Foreign Keys # # fk_rails_... (account_id => accounts.id) # fk_rails_... (user_id => users.id) # class Buildings::PrintTemplate < ApplicationRecord

Slide 19

Slide 19 text

gem 'counter_culture' class Apartment < ApplicationRecord belongs :building # will track active and archived apartments separately counter_culture :building, column_name: -> { _1.active? ? :apartments_count : :archived_apartments_count } # can do this for the whole account counter_culture %i(building account), column_name: -> { _1.building.active? && _1.active? ? :apartments_count : :arch # can track a sum of a columns counter_culture :building, column_name: :unpaid_taxes_amount, delta_magnitude: -> { _1.unpaid_taxes_amount }
 counter_culture :building, column_name: :deposit_amount, delta_magnitude: -> { _1.deposit_amount } end

Slide 20

Slide 20 text

class Note < ApplicationRecord belongs_to :user, optional: true belongs_to_polymorphic :resource, allowed_classes: [ Apartment, Building, ImprovementTax, Calendar::Event, Transaction, Tasks::Task ] end

Slide 21

Slide 21 text

...kinda like "delegated_type" +

Slide 22

Slide 22 text

class Issues::Activity < ApplicationRecord extension HasAuditLog, except: %i(notified_at) extension AngrySupport::TimeAsBoolean, field_name: :approved extension AngrySupport::TimeAsBoolean, field_name: :notified extension Notifications::Subject # ... end

Slide 23

Slide 23 text

...kinda like "concerns" +

Slide 24

Slide 24 text

module AngrySupport::TimeAsBoolean extend self def define(model, field_name:, reverse: nil) column_name = "#{field_name}_at" model.scope field_name, -> { where(arel_table[column_name].lteq(Time.current)) } model.define_method(:"#{field_name}?") do value = public_send(column_name) value.present? && !value.future? end model.alias_method field_name, "#{field_name}?" model.define_method(:"#{field_name}=") do |value| if value.present? if value.acts_like?(:time) public_send(:"#{column_name}=", value) elsif !public_send(field_name) public_send(:"#{column_name}=", Time.current) end else public_send(:"#{column_name}=", nil) end end if reverse model.scope reverse, -> { where(column_name => nil).or(where(arel_table[column_name].gteq(Time.current))) } model.define_method(:"#{reverse}?") { !public_send(field_name) } else model.scope "not_#{field_name}", -> { where(column_name => nil) } end end end

Slide 25

Slide 25 text

module AngrySupport::ActiveRecordExtension def extension(extension_module, *args, **kwargs) extension_module.define(self, *args, **kwargs) if extension_module.respond_to?(:define) extend(extension_module.const_get(:ClassMethods)) if extension_module.const_defined?(:ClassMethods) include(extension_module.const_get(:InstanceMethods)) if extension_module.const_defined?(:InstanceMethods) end end

Slide 26

Slide 26 text

Routing

Slide 27

Slide 27 text

How to use routes helpers outside ActionPack / ActionMailer? ,

Slide 28

Slide 28 text

module Routes class << self include Rails.application.routes.url_helpers include Routes::Custom def default_url_options Rails.application.default_url_options end end end

Slide 29

Slide 29 text

How to use "url_for" my models that don't have proper urls setup? -

Slide 30

Slide 30 text

module Routes::Custom def record_path(record) case record when Accounts::Member then account_member_path(record) when Print::BuildingTemplate then print_template_path(record) when Calendar::Event then event_path(record) when Finance::BulkWithdraw then bulk_withdraw_path(record) when Issues::Type then issue_type_path(record) when Messaging::Bulk then bulk_message_path(record) when Messaging::Message then message_path(record) when MoneyTransfer then transaction_path(record) when PaymentImport::Entry then payment_import_path(record) when Tasks::RecurringTask then recurring_task_path(record) when Tasks::Task then task_path(record) when Taxation::Payment then payment_path(record) when Taxation::PaymentDocument then payment_document_path(record) else url_for(record) end end def record_url(record) #.... end
 
 # ... end

Slide 31

Slide 31 text

Error Handling

Slide 32

Slide 32 text

module ErrorReporting extend self def assign_user(user) return unless Rails.env.production? Sentry.set_user(...) end def capture_exception(error) if Rails.env.production? if error.is_a?(String) Sentry.capture_message(error) else Sentry.capture_exception(error) end else raise error end end def exception_context(prop, hash) Sentry.set_context(prop, hash) if Rails.env.production? end def graphql_path(path) exception_context('graphql_info', path: path) end def job_discarded(job, exception) Rails.logger.error "Discarded #{job.class} due to #{exception.cause.inspect}." exception_context('job', name: job.class.name, arguments: job.arguments) capture_exception(exception) end end

Slide 33

Slide 33 text

https://speakerdeck.com/rstankov/living-without-exceptions

Slide 34

Slide 34 text

I18n

Slide 35

Slide 35 text

. / 0

Slide 36

Slide 36 text

def t_display(to_display) if to_display.is_a?(Symbol) t(to_display) elsif to_display.is_a?(String) to_display elsif to_display.respond_to?(:display_name) to_display.display_name elsif to_display.respond_to?(:name) to_display.name elsif to_display.respond_to?(:title) to_display.title else to_display.to_s end end def t_options(collection) collection.map { [t_display(_1), _1.id] } end def t_enum(enum, i18n_key) enum.keys.map { [t("#{i18n_key}_#{_1}"), _1] } end

Slide 37

Slide 37 text

rails translate:check_translations 1. Check for missing translations 2. Raises errors rails translate:fill_in_missing_translations 1. Compare all config/locales/[lang].yaml 2. Find missing keys 3. Prompt OpenAI API for translations 4. Add new translations 5. Format YAML (sort keys)

Slide 38

Slide 38 text

• Raise error in development • Don't use nested keys! • Use prefixes • "table_column_#{name}" • "page_title_#{controller_name}" • Use defaults • t("table_column_#{name}", default: :"form_label_#{name}") 1 Rado's tips

Slide 39

Slide 39 text

2 Missing concepts View Components External services Policy objects Service objects Form objects Query objects

Slide 40

Slide 40 text

2 Missing concepts View Components External services Policy objects Service objects Form objects Query objects

Slide 41

Slide 41 text

<%= render FieldsetComponent.new(fieldset, title: :bank_account) do %> <%= form.input :bank_account_bank %> <%= form.input :bank_account_iban %> <%= form.input :bank_account_bic %> <% end %>

Slide 42

Slide 42 text

No content

Slide 43

Slide 43 text

https://speakerdeck.com/rstankov/component-driven-ui-with-viewcomponent-gem

Slide 44

Slide 44 text

https://tips.rstankov.com/p/tips-for-using-viewcomponents-in

Slide 45

Slide 45 text

2 Missing concepts View Components External services Policy objects Service objects Form objects Query objects

Slide 46

Slide 46 text

No content

Slide 47

Slide 47 text

3

Slide 48

Slide 48 text

module Handle::NetworkErrors extend self ERRORS = [ # ... Faraday::ConnectionFailed, Faraday::TimeoutError, RestClient::BadGateway, RestClient::BadRequest, # ... ] def ===(error) ERRORS.any? { |error_class| error_class === error } end end

Slide 49

Slide 49 text

No content

Slide 50

Slide 50 text

# Taken from: [url to where those credentials are taken] [sevice_name]_token: [...] [sevice_name]_secret: [...]

Slide 51

Slide 51 text

# Documentation # # - service: [url to service] # - manage: [url to manage tokens] # - api: [url to api documentation] # - gem: [gem we are using (optional)] # [- ... other links] module External::[Service]Api extend self # documentation: [documentation] def perform_action(args) # use HTTParty or gem for this external service end end

Slide 52

Slide 52 text

# Documentation # # - service: https://unsplash.com/ # - api: https://unsplash.com/documentation # - gem: https://github.com/unsplash/unsplash_rb # - portal: https://unsplash.com/oauth/applications module External::UnsplashApi extend self # documentation: https://unsplash.com/documentation#search-photos def search(query) Unsplash::Photo.search(query, 1, 12, 'landscape') end # documentation: https://unsplash.com/documentation#get-a-photo # documentation: https://unsplash.com/documentation#track-a-photo-download def track_download(id) photo = Unsplash::Photo.find(id) photo.track_download rescue Unsplash::NotFoundError nil end end

Slide 53

Slide 53 text

✅ Store credentials in "config/credentials.yml.enc" ✅ Facade around every network call in "External" module ✅ Add logging and error handling around network calls https://tips.rstankov.com/p/tips-for-dealing-with-external-services

Slide 54

Slide 54 text

2 Missing concepts View Components External services Policy objects Service objects Form objects Query objects

Slide 55

Slide 55 text

KittyPolicy https://github.com/producthunt/kitty-policy

Slide 56

Slide 56 text

module ApplicationPolicy extend self def can_access_account?(user, account) return false if user.nil? user.admin? || account.member?(user) end end

Slide 57

Slide 57 text

module ApplicationPolicy extend KittyPolicy::DSL can :access, Account do |user, account| user.admin? || account.member?(user) end end # generates ApplicationPolicy.can_access_account?(user, account) # can be accessed as ApplicationPolicy.can?(user, :access, account) ApplicationPolicy.authorize!(user, :access, account) # raise KittyPolicy::AccessDenied

Slide 58

Slide 58 text

module ApplicationPolicy extend KittyPolicy::DSL extend ApplicationPolicy::RolesDSL # Role stacking. # Upper role can do everything lower role can do
 # # - director > supervisor > operator > cashier # - chief technician > technician # - chief accountant > accountant building_cashier_can :view, Withdraw building_operator_can :create, Withdraw building_supervisor_can :update, Withdraw building_director_can :destroy, Withdraw # ... end

Slide 59

Slide 59 text

wc -l < app/lib/application_policy.rb 273

Slide 60

Slide 60 text

class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event = find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end
 # ... end

Slide 61

Slide 61 text

class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event = find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end
 # ... end

Slide 62

Slide 62 text

class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event = find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end
 # ... end

Slide 63

Slide 63 text

render template: 'system/feature_unavailable' end rescue_from KittyPolicy::AccessDenied do |error| ErrorReporting.capture_exception(error) render template: 'system/unauthorized' end end class_methods do def require_account_feature(feature_name) self.required_account_feature = feature_name end end private def find_record(scope, id = :none, authorize: :view) record = scope.find(id == :none ? params[:id] : id) ApplicationPolicy.authorize!(current_user, authorize, record) if self.class.required_account_feature.present? Accounts.feature_available!(record, self.class.required_account_feature) end

Slide 64

Slide 64 text

private def find_record(scope, id = :none, authorize: :view) record = scope.find(id == :none ? params[:id] : id) ApplicationPolicy.authorize!(current_user, authorize, record) if self.class.required_account_feature.present? Accounts.feature_available!(record, self.class.required_account_feature) end record end end

Slide 65

Slide 65 text

module Accounts extend self def account_of(record) if record.is_a?(Account) record elsif record.respond_to?(:account) record.account elsif record.respond_to?(:building) record.building.account else raise "Can't find account for #{record.class}##{record.id}" end end def feature_available?(record, feature_name) account_of(record).features.available?(feature_name) end def feature_available!(record, feature_name) return if feature_available?(record, feature_name) raise FeatureUnavailable.new(record, feature_name) end end

Slide 66

Slide 66 text

KittyPolicy https://github.com/producthunt/kitty-policy

Slide 67

Slide 67 text

module ApplicationPolicy extend self def can_access_account?(user, account) return false if user.nil? user.admin? || account.member?(user) end end

Slide 68

Slide 68 text

module ApplicationPolicy extend KittyPolicy::DSL can :access, Account do |user, account| user.admin? || account.member?(user) end end # generates ApplicationPolicy.can_access_account?(user, account) # can be accessed as ApplicationPolicy.can?(user, :access, account) ApplicationPolicy.authorize!(user, :access, account) # raise KittyPolicy::AccessDenied

Slide 69

Slide 69 text

module ApplicationPolicy extend KittyPolicy::DSL extend ApplicationPolicy::RolesDSL # Role stacking. # Upper role can do everything lower role can do
 # # - director > supervisor > operator > cashier # - chief technician > technician # - chief accountant > accountant building_cashier_can :view, Withdraw building_operator_can :create, Withdraw building_supervisor_can :update, Withdraw building_director_can :destroy, Withdraw # ... end

Slide 70

Slide 70 text

wc -l < app/lib/application_policy.rb 273

Slide 71

Slide 71 text

class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event = find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end
 # ... end

Slide 72

Slide 72 text

class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event = find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end
 # ... end

Slide 73

Slide 73 text

class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event = find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end
 # ... end

Slide 74

Slide 74 text

render template: 'system/feature_unavailable' end rescue_from KittyPolicy::AccessDenied do |error| ErrorReporting.capture_exception(error) render template: 'system/unauthorized' end end class_methods do def require_account_feature(feature_name) self.required_account_feature = feature_name end end private def find_record(scope, id = :none, authorize: :view) record = scope.find(id == :none ? params[:id] : id) ApplicationPolicy.authorize!(current_user, authorize, record) if self.class.required_account_feature.present? Accounts.feature_available!(record, self.class.required_account_feature) end

Slide 75

Slide 75 text

private def find_record(scope, id = :none, authorize: :view) record = scope.find(id == :none ? params[:id] : id) ApplicationPolicy.authorize!(current_user, authorize, record) if self.class.required_account_feature.present? Accounts.feature_available!(record, self.class.required_account_feature) end record end end

Slide 76

Slide 76 text

module Accounts extend self def account_of(record) if record.is_a?(Account) record elsif record.respond_to?(:account) record.account elsif record.respond_to?(:building) record.building.account else raise "Can't find account for #{record.class}##{record.id}" end end def feature_available?(record, feature_name) account_of(record).features.available?(feature_name) end def feature_available!(record, feature_name) return if feature_available?(record, feature_name) raise FeatureUnavailable.new(record, feature_name) end end

Slide 77

Slide 77 text

2 Missing concepts View Components External services Policy objects Service objects Form objects Query objects

Slide 78

Slide 78 text

module Taxation::PaymentCreate extend self def call(arguments) # ... end private # ... end

Slide 79

Slide 79 text

I don't have many "service objects". In most cases, the "service object", I inline it in a "form object".

Slide 80

Slide 80 text

2 Missing concepts View Components External services Policy objects Service objects Form objects Query objects

Slide 81

Slide 81 text

MiniForm (Syntax sugar around ActiveModel::Model) https://github.com/RStankov/MiniForm

Slide 82

Slide 82 text

class Calendar::AttendeeForm < ApplicationForm main_model( :attendee, attributes: %i(user_id), read: %i(event building), save: true ) validate :ensure_user_is_allowed def initialize(event) @attendee = event.attendees.new end def user_options @user_options ||= building.account.member_users end def perform Notifications.notify_about(@attendee) end private def ensure_user_is_allowed

Slide 83

Slide 83 text

class ApplicationForm include MiniForm::Model def submit(params) update(params.require(:form).permit(self.class.attribute_names)) end def submit!(params) update!(params.require(:form).permit(self.class.attribute_names)) end end

Slide 84

Slide 84 text

class ApplicationForm include MiniForm::Model def submit(params) update(params.require(:form).permit(self.class.attribute_names)) end def submit!(params) update!(params.require(:form).permit(self.class.attribute_names)) end end module ApplicationHelper def app_form(record, options = {}) options[:as] ||= :form 
 # ... end # ...

Slide 85

Slide 85 text

class CalendarEventAttendeesController < ApplicationController require_account_feature :calendar def new @attendee = Calendar::AttendeeForm.new(find_event) end def create @attendee = Calendar::AttendeeForm.new(find_event) @attendee.submit(params) respond_with @attendee, location: event_path(@attendee.event) end private def find_event find_record Calendar::Event, params[:event_id] end end

Slide 86

Slide 86 text

class CalendarEventAttendeesController < ApplicationController require_account_feature :calendar def new @attendee = Calendar::AttendeeForm.new(find_event) end def create @attendee = Calendar::AttendeeForm.new(find_event) @attendee.submit(params) respond_with @attendee, location: event_path(@attendee.event) end private def find_event find_record Calendar::Event, params[:event_id] end end gem 'responders'

Slide 87

Slide 87 text

I keep business logic in the form object, except if it is going to be used somewhere else. I follow the same logic for ActiveJob objects.

Slide 88

Slide 88 text

2 Missing concepts View Components External services Policy objects Service objects Form objects Query objects

Slide 89

Slide 89 text

No content

Slide 90

Slide 90 text

Search form

Slide 91

Slide 91 text

Search form Statistics based on search

Slide 92

Slide 92 text

Search form Statistics based on search Search results

Slide 93

Slide 93 text

SearchObject https://github.com/RStankov/SearchObject

Slide 94

Slide 94 text

search = Finance::TransactionsSearch.new(filters: params[:filters]) search.results

Slide 95

Slide 95 text

class Finance::TransactionsSearch def initialize(filters: {}) @filters = filters end def results scope = Transaction.all scope = scope.where(user_id: @filters[:user_id]) if @filters[:user_id] scope = scope.where("amount > ?", @filters[:min_amount]) if @filters[:min_amount] scope = scope.where("amount < ?", @filters[:max_amount]) if @filters[:max_amount] scope = scope.where("created_at > ?", @filters[:start_date]) if @filters[:start_d scope = scope.where("created_at < ?", @filters[:end_date]) if @filters[:end_date] scope end end

Slide 96

Slide 96 text

class Finance::TransactionsSearch def initialize(filters: {}, page: 0) @filters = filters @page = page end def results @results ||= fetch_results end def fetch_results scope = Transaction.all scope = scope.where(user_id: @filters[:user_id]) if @filters[:user_id] scope = scope.where("amount > ?", @filters[:min_amount]) if @filters[:min_amount] scope = scope.where("amount < ?", @filters[:max_amount]) if @filters[:max_amount] scope = scope.where("created_at > ?", @filters[:start_date]) if @filters[:start_d scope = scope.where("created_at < ?", @filters[:end_date]) if @filters[:end_date] scope.page(@page) end end

Slide 97

Slide 97 text

class Finance::TransactionsSearch include SearchObject.module(:kaminary) scope { Transaction.all } option(:user_id) option(:min_amount) { |scope, v| scope.where("amount > ?", v) } option(:max_amount) { |scope, v| scope.where("amount < ?", v) } option(:start_date) { |scope, v| scope.where("created_at > ?", v) } option(:end_date) { |scope, v| scope.where("created_at < ?", v) } end

Slide 98

Slide 98 text

class Finance::TransactionsSearch include SearchObject.module(:kaminary) scope { Transaction.all } option :user_id option :min_amount, with: :filter_by_min_amount option :max_amount, with: :filter_by_max_amount option :start_date, with: :filter_by_start_date option :end_date, with: :filter_by_end_date private def filter_by_min_amount(scope, value) scope.where("amount > ?", value) end def filter_by_max_amount(scope, value) scope.where("amount < ?", value) end def filter_by_start_date(scope, value) scope.where("created_at > ?", value) end

Slide 99

Slide 99 text

search = Finance::TransactionsSearch.new(filters: params[:filters]) search.results 
 search.balance
 search.income
 search.expense
 search.count
 
 search.cashier_options search.source_options search.type_options

Slide 100

Slide 100 text

, Where do all those objects live?

Slide 101

Slide 101 text

app/policies app/services app/forms app/queries

Slide 102

Slide 102 text

No content

Slide 103

Slide 103 text

app/policies app/services app/forms app/queries

Slide 104

Slide 104 text

app/policies app/services app/forms app/queries app/network

Slide 105

Slide 105 text

app/policies app/services app/forms app/queries app/network app/validators

Slide 106

Slide 106 text

app/policies app/services app/forms app/queries app/network app/validators
 app/decorators

Slide 107

Slide 107 text

app/policies app/services app/forms app/queries app/network app/validators
 app/decorators app/presenters

Slide 108

Slide 108 text

app/services app/forms app/queries app/network app/validators
 app/decorators app/presenters app/notifiers

Slide 109

Slide 109 text

app/forms app/queries app/network app/validators
 app/decorators app/presenters app/notifiers app/values

Slide 110

Slide 110 text

app/queries app/network app/validators
 app/decorators app/presenters app/notifiers app/values app/support

Slide 111

Slide 111 text

app/network app/validators
 app/decorators app/presenters app/notifiers app/values app/support app/others

Slide 112

Slide 112 text

No content

Slide 113

Slide 113 text

No content

Slide 114

Slide 114 text

put everything in one folder put everything in one folder put every object type in its own folder

Slide 115

Slide 115 text

app/lib
 app/lib/[domain]/*.rb
 app/lib/[domain].rb

Slide 116

Slide 116 text

No content

Slide 117

Slide 117 text

You only get special folder if you called in special way. Those are the special folders app/components app/controllers app/jobs app/models app/graph app/mailers

Slide 118

Slide 118 text

No content

Slide 119

Slide 119 text

No JavaScript (1030 lines without any libraries)

Slide 120

Slide 120 text

require 'features_helper' feature 'Feature: Calendar' do scenario 'attendee - create > list > delete' do building = create :building sign_in_operator building account_member = create :account_member, account: building.account event = create :calendar_event, building: building visit event_path(event) click_on I18n.t(:action_new_attendee) # test: validation submit_form expect_form_errors :blank # test: create submit_form!( user_id: account_member.user_id, )

Slide 121

Slide 121 text

submit_form expect_form_errors :blank # test: create submit_form!( user_id: account_member.user_id, ) expect(page).to have_content account_member.name attendee = event.attendees.last! expect(attendee.user).to eq account_member.user # test: destroy click_on_destroy expect_flash_message :destroy expect_to_be_destroyed attendee end end

Slide 122

Slide 122 text

sign_in_operator submit_form submit_form! click_on_destroy expect_form_errors expect_flash_message expect_to_be_destroyed 5 Custom test helpers

Slide 123

Slide 123 text

Recap

Slide 124

Slide 124 text

https://tips.rstankov.com/p/my-testing-tips