diff --git a/README.md b/README.md index b11ad9f..d826b89 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -## Web-palvelinohjelmointi Ruby on Rails 2023, Tietojenkäsittelytieteen osasto, Helsingin Yliopisto +## Web-palvelinohjelmointi Ruby on Rails 2024, Tietojenkäsittelytieteen osasto, Helsingin Yliopisto Kurssisivu https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/wadror.md @@ -11,8 +11,9 @@ Kurssisivu https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/wadr - [viikko 5](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/web/viikko5.md) - [viikko 6](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/web/viikko6.md) - [viikko 7](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/web/viikko7.md) +- [viikko 8 (in English)](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/english/week8.md) -## Web-development Ruby on Rails 2023, Department of Computer Science, University of Helsinki +## Web-development Ruby on Rails 2024, Department of Computer Science, University of Helsinki Course page https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/wadror-english.md @@ -25,3 +26,4 @@ Course page https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/wad - [week 5](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/english/week5.md) - [week 6](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/english/week6.md) - [week 7](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/english/week7.md) +- [week 8](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/english/week8.md) diff --git a/english/week1.md b/english/week1.md index 1f9d231..482a4c5 100644 --- a/english/week1.md +++ b/english/week1.md @@ -1317,7 +1317,7 @@ Go to the application directory and create a Fly.io application with `fly launch Deploy your application to production with `fly deploy`. Use this command everytime you wish to push the current version of your application to the internet. -You can open your application in a browser with `fly open`. +You can open your application in a browser with `fly apps open`. **Note** that (currently) there is nothing at the root of the application, eg. in my case in https://ratebeer.fly.dev/. Our beers can be found at https://ratebeer.fly.dev/beers and breweries at https://ratebeer.fly.dev/breweries. diff --git a/english/week2.md b/english/week2.md index 1dee75c..281462d 100644 --- a/english/week2.md +++ b/english/week2.md @@ -1165,7 +1165,7 @@ If you remove beers with ratings from your application, the ratings which belong > > The error is caused by the fact that it tries to call beer.name from the method to_s of the rating object. > -> Delete the orphan ratings by hand from the console. Try to think first of a command/some commands, which can help you to make a list of the orphan ratings. If you can't think of it yourself, you can find a ready-made answer for the exercise above in this page. +> Delete the orphan ratings by hand from the console. Try to think first of a command/some commands, which can help you to make a list of the orphan ratings. If you can't think of it yourself, you can find a ready-made answer for the exercise below in this page. The ratings which belong to a beer can be deleted easily automatically. Alongside the beer model code has_many :ratings, you should mark that ratings are dependent on beers, and that they should be destroyed if beers are destroyed: @@ -1189,7 +1189,7 @@ The orphan issue is solved now. > >If you can't yet access individual breweries from the all breweries page, fix it now! -## Inderect object connection +## Indirect object connection Your application is created in a way so that ratings belong to beers and that beers belong to breweries. This means that a set of ratings belong to each brewery, indirectly. Rails provides you with a simple way to go from the breweries to the ratings directly: @@ -1227,7 +1227,7 @@ You will see, that beer and brewery both a method called average_ratingaverage_rating that also work identically. It is not acceptable to leave our code this way. > ## Exercise 15 > -> Ruby provides you with a way to share methods between two classes with the help of modules, see https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/web/rubyn_perusteita.md#moduuli +> Ruby provides you with a way to share methods between two classes with the help of modules, see https://ruby-doc.com/docs/ProgrammingRuby/html/tut_modules.html > > Modules have different uses – forming namespaces, for instance. However, now we are interested in the _mixin_ inheritance which can be implemented with modules. > @@ -1242,7 +1242,7 @@ We notice that beer and brewery both have an identically named method aver > end > ``` > -> - Attention: if the name of your module is RatingAverage, exactly like in the example, because of Ruby naming conventions it has to be placed in the file app/models/concerns/rating_average.rb. In fact, even though classes names are CamelCase and start with capital letters, their files names follow the snake_case.rb style. +> - Attention: if the name of your module is RatingAverage, exactly like in the example, because of Ruby naming conventions it has to be placed in the file app/models/concerns/rating_average.rb. In fact, even though classes names are PascalCase and start with capital letters, their files names follow the snake_case.rb style. After you have done the exercise, the class Brewery should look more or less like below (assuming your module is called RatingAverage): @@ -1390,7 +1390,7 @@ http://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Digest.ht >``` >and try what values variables _admin_accounts_, _username_ and _password_ contain ja form the right command. > -> HINT 2: The code block should be evaluated either as true or untrue depending on whether the password is correct. The value doesn't however necessarily have to be either _true_ or _false_ because Ruby interprets also other values as either true (truthy) or untrue (falsy). For example _nil_ is interpreted as untrue/falsy. See more eg. at https://learn.co/lessons/truthiness-in-ruby-readme. +> HINT 2: The code block should be evaluated either as true or untrue depending on whether the password is correct. The value doesn't however necessarily have to be either _true_ or _false_ because Ruby interprets also other values as either true (truthy) or untrue (falsy). For example _nil_ is interpreted as untrue/falsy. See more at [Truth value - Wikipedia](https://en.wikipedia.org/wiki/Truth_value#Computing), [class TrueClass](https://docs.ruby-lang.org/en/master/TrueClass.html) and [class FalseClass](https://docs.ruby-lang.org/en/master/FalseClass.html) . ## Application to Internet @@ -1511,7 +1511,7 @@ Because it is a program in production, resetting the database (rails db:dr Commit all your changes and push the code to Github. Deploy to the newest version to Heroku or Fly.io, as well. -Mark the exercises you have done at https://studies.cs.helsinki.fi/stats/courses/rails2023. +Mark the exercises you have done at https://studies.cs.helsinki.fi/stats/courses/rails2023/submissions. And let's continue coding: [week 3](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/english/week3.md). diff --git a/english/week3.md b/english/week3.md index b1fee35..92905b0 100644 --- a/english/week3.md +++ b/english/week3.md @@ -332,7 +332,11 @@ The styling rules monitored by Rubocop are defined in _.rubocop.yml_ that is pla The rules defined there are based on the [Relaxed Ruby](https://relaxed.ruby.style/) style, but they are a bit stricter on some points. The file contents also define that some files are to be left out of any style checks. -A code style check is executed with the command _rubocop_ on the command line. +A code style check is executed with the command +``` +rubocop +``` +on the command line. There are quite a few problems in the code, for example: @@ -480,7 +484,7 @@ Create a controller for sessions (in the file app/controllers/sessions_controlle ```ruby class SessionsController < ApplicationController def new - # render the signing up page + # render the signing up page end def create @@ -988,7 +992,7 @@ http://guides.rubyonrails.org/active_record_validations.html and https://apidock > > Add the following validations to your program > * beer and brewery names are not empty -> * the brewery founding year is an integer between 1040-2022 +> * the brewery founding year is an integer between 1040-2023 > * the length of the username attribute of the User class is 3 – 30 characters If you try to create a beer with an empty name, you get an error message diff --git a/english/week4.md b/english/week4.md index f125b6f..986fa7b 100644 --- a/english/week4.md +++ b/english/week4.md @@ -227,7 +227,7 @@ You can initialize rspec in your application running the following from command The initialization creates a folder /spec in the application and the various tests – or specs – will be placed in its subfolders. -According to Rails standard but currently less common testing framework, the test are place in the folder /test. The folder will be useless after taking rspec, and you can delete it. +According to Rails standard but currently less common testing framework, the tests are placed in the folder /test. The folder will be useless after taking up rspec as the only testing tool, and you can delete it. The tests – the correct words would be specs or specifications when it comes to rspec, we will be using the word test in the future however – can be written at different levels: unit tests for models and controllers, view tests, and integration tests for controllers. In addition to these, the application can be tested using a simulated browser with the help of the capybara gem https://github.com/jnicklas/capybara. @@ -663,7 +663,7 @@ FactoryBot.create(:user) FactoryBot.create(:user, username: 'Vilma') ``` -More instructions for using FactoryBot at https://www.rubydoc.info/gems/factory_bot/file/GETTING_STARTED.md +More instructions for using FactoryBot at https://thoughtbot.github.io/factory_bot/ ## Users favourite beers, breweries, and styles @@ -744,7 +744,7 @@ end Your test will not succeed, because your method does not do anything so far, and its return value is always nil. -Use [in the spirit of TDD](https://stanislaw.github.io/2016/01/25/notes-on-test-driven-development-by-example-by-kent-beck.html) a "fake solution", without trying to make the final working version yet: +Use [in the spirit of TDD](https://stanislaw.github.io/2016-01-25-notes-on-test-driven-development-by-example-by-kent-beck.html) a "fake solution", without trying to make the final working version yet: ```ruby class User < ApplicationRecord @@ -758,7 +758,15 @@ class User < ApplicationRecord end ``` -Make another test which will force you to make a real implementation [(see triangulation)](https://stanislaw.github.io/2016/01/25/notes-on-test-driven-development-by-example-by-kent-beck.html): +Make another test which will force you to make a real implementation [(see triangulation)](https://stanislaw.github.io/2016-01-25-notes-on-test-driven-development-by-example-by-kent-beck.html#triangulation): + +> How do you most conservatively drive abstraction with tests? Abstract only when you have two or more examples. (p.153) +> +> If two receiving stations at a known distance from teach other can both measure the direction of a radio signal, then there is enough information to calculate the range and bearing of the signal. This calculation is called Triangulation. +> +> By analogy when we triangulate, we only generalize code when we have two examples or more... When the second example demands a more general solution, then and only then do we generalize (p.16). +> +> I only use Triangulation when I'm really, really unsure about the correct abstraction for the calculation. Otherwise I rely on either Obvious Implementation or Fake It. (p.154) ```ruby it "is the one with highest rating if several rated" do @@ -817,6 +825,7 @@ If you look at the documentation (http://guides.rubyonrails.org/active_record_qu ```ruby def favorite_beer return nil if ratings.empty? + ratings.order(score: :desc).limit(1).first.beer end ``` @@ -1126,7 +1135,7 @@ If/when you ran into problems: > >Add information about the user's favourite style to their page. > -> Do not do everything with one method (unless you solve the problem at database level with ActiveRecord or another elegantly compact solution), instead, define the suitable auxiliary methods! If you notice that you method is more than 6 lines long, you are doing either too much or something too complicated, so refactor your code. Ruby's collections have various auxiliary methods which might be useful for the exercise, see http://http://ruby-doc.org/core-2.5.1/Enumerable.html +> Do not do everything with one method (unless you solve the problem at database level with ActiveRecord or another elegantly compact solution), instead, define the suitable auxiliary methods! If you notice that you method is more than 6 lines long, you are doing either too much or something too complicated, so refactor your code. Ruby's collections have various auxiliary methods which might be useful for the exercise, see https://docs.ruby-lang.org/en/3.2/Enumerable.html > ## Exercise 4 > @@ -1360,7 +1369,7 @@ The test expects that clicking the _Create user_ button will cause the number of You will have to take into consideration a small detail, that is, the method expect can be given parameters in two ways. If the method has to test a value, the value is given between brackets, like expect(current_path).to eq(signin_path). Instead, if it tests the impact of an operation (like the one above, click_button('Create User')) on the value of an application object (User.count), the operation to execute is given to expect in a code chunk. -Read more about this in Rspec documentation https://relishapp.com/rspec/rspec-expectations/docs/built-in-matchers +Read more about this in Rspec documentation https://rspec.info/features/3-12/rspec-expectations/built-in-matchers/ So the last test checked whether the operation executed at browser level created an object in the database. Should you make a separate test to see whether a username can sign in the system? Maybe. After all, the previous test did not questioned whether the user object was saved in the database correctly. @@ -1403,7 +1412,7 @@ end The test builds its brewery, two beers and a user with the method let! instead of let which we used earlier. In fact, the version without exclamation mark does not execute the operation immediately, but only once the code refers to the object explicitly. The object beer1 is mentioned only at the end of the code, so if you had created it with the method let, you would have run into a problem creating the rating, because its beer would have not existed in the database yet, and the corresponding select element would not have been found. -The code contained in the before chunk of the test helps users to sign in the system. Most probably, the same code chunk will be useful in various different test files. You had better extract the test code needed in various different places and make a [module](https://relishapp.com/rspec/rspec-core/docs/helper-methods/define-helper-methods-in-a-module), which can be included in all test files which need it. Create a module Helpers in a file named _helpers.rb_ in the _specs_ directory and put the sign-in code there: +The code contained in the before chunk of the test helps users to sign in the system. Most probably, the same code chunk will be useful in various different test files. You had better extract the test code needed in various different places and make a [module](https://rspec.info/features/3-12/rspec-core/helper-methods/modules/), which can be included in all test files which need it. Create a module Helpers in a file named _helpers.rb_ in the _specs_ directory and put the sign-in code there: ```ruby module Helpers @@ -1795,4 +1804,4 @@ Commit all your changes and push the code to Github. Deploy to the newest versio Mark the exercises you have done at https://studies.cs.helsinki.fi/stats/courses/rails2023. -And towards next week: [week 5](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/english/week5.md) \ No newline at end of file +And towards next week: [week 5](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/english/week5.md) diff --git a/english/week5.md b/english/week5.md index 622e546..02b6f15 100644 --- a/english/week5.md +++ b/english/week5.md @@ -5,9 +5,7 @@ You will continue to develop your application from the point you arrived at the A great part of modern Internet services makes use of open interfaces which provides useful data to enrich applications functionality. -Interfaces for beers are also available, try to search for beer at http://www.programmableweb.com/ - -The best interface among the ones available seems to be Beermapping API (see http://www.programmableweb.com/api/beer-mapping ja http://beermapping.com/api/), which makes it possible to search for beer restaurants. +The best interface among the ones available seems to be Beermapping API , which makes it possible to search for beer restaurants. Applications which make use of beermaping API need a singular API key. You can retrieve a key at https://beermapping.com/api/, after logging in to the page (after logging in edit the url in your browser's address bar back to https://beermapping.com/api/). This is a common procedure in use for the larger part of modern free interfaces. @@ -501,7 +499,7 @@ response = HTTParty.get "#{url}#{ERB::Util.url_encode(params[:city])}" ## Refactoring your Places controller -Rails controllers should not include application logic. It is a best practice to put external APIs in their own class. A good place for such class is in the _lib_ folder. Place the following code into the file _lib/beermapping_api.rb_: +Rails controllers should not include application logic. It is a best practice to put external APIs in their own class. A good place for such class is in the _services_ folder under the _app_ folder. Even though this folder is not automatically created, starting from Rails 5 the _services_ folder is autoloaded in boot sequence. Place the following code into the file _app/services/beermapping_api.rb_: ```ruby class BeermappingApi @@ -527,26 +525,7 @@ end So the class defines a static method which returns a table of the beer restaurants which have been found in the towns defined by the parameter. If no restaurant is found, the table will be empty. The API class is not in its best format yet, because you cannot know completely what other methods you need. -To ensure that code in the _lib_ folder will work (not only on your computer but also in Heroku and Fly.io), you must add these two lines into the _config/application.rb_ file: - -```ruby -config.autoload_paths << Rails.root.join("lib") -config.eager_load_paths << Rails.root.join("lib") -``` - -The addition should be placed inside _Application_ class definition - -```ruby -module Ratebeer - class Application < Rails::Application - # ... - - # add here - end -end -``` - -Restart the application for the changes to take effect. +If needed, restart the application. The controller will be looking neat, by now: @@ -665,7 +644,7 @@ mluukkai@melkki$ curl http://beermapping.com/webservice/loccity/731955affc547174 Now you could copy-paste the information returned in XML form by the HTTP reqest to your test. If you want to be sure you place the XML right in the string, you should use a quite particular syntax see http://blog.jayfields.com/2006/12/ruby-multiline-strings-here-doc-or.html where the string is placed between <<-END_OF_STRING and END_OF_STRING. -You find below the test code which should be placed into spec/lib/beermapping_api_spec.rb (deciding to place the code in the lib subfolder because the test destination is an auxiliary class in the lib folder): +You find below the test code which should be placed into spec/services/beermapping_api_spec.rb (deciding to place the code in the services subfolder because the test destination is an auxiliary class in the services folder): ```ruby require 'rails_helper' diff --git a/english/week7.md b/english/week7.md index be4517e..889e277 100644 --- a/english/week7.md +++ b/english/week7.md @@ -1661,9 +1661,9 @@ Write a small description of your strategy to speed up the page in the ind ## An application made of various services -You can scale your application performance only till a certain point if your application is a monolithic entity using a single database and running on a single server. The application can be optimised so that it is scaled horizontally – that is, increasing the physical resources of its server. +You can scale your application performance only till a certain point if your application is a monolithic entity using a single database and running on a single server. The application can be optimised so that it is scaled vertically – that is, increasing the physical resources of its server. -You will have better scaling results if you scale vertically, meaning that instead of improving the physical resources of one server, you start to use various servers, all executing your application actions at the same time. Vertical scaling is not necessarily trivial, you'll have to change the application architecture. If your application works with only one database, you may run into troubles despite vertical scaling, as that one database becomes a bottleneck. Especially so if that is a relational database, that is not easy to distribute and scale vertically. +You will have better scaling results if you scale horizontally, meaning that instead of improving the physical resources of one server, you start to use various servers, all executing your application actions at the same time. Horizontal scaling is not necessarily trivial, you'll have to change the application architecture. If your application works with only one database, you may run into troubles despite horizontal scaling, as that one database becomes a bottleneck. Especially so if that is a relational database, that is not easy to distribute and scale horizontally. Scaling an application (and sometimes also updating and extending it) is easier if the application is made of various different services that work independently and communicate with each other for instance with an HTTP protocol. In fact, your application is already making use of another service, that is BeermappingAPI. In the same way, your application functionality could be expanded if new services were integrated. @@ -1752,6 +1752,4 @@ If Rails seems interesting you could dig more into it in the following ways - https://www.ruby-toolbox.com/ help for finding gems - [Eloquent Ruby](http://www.amazon.com/Eloquent-Ruby-Addison-Wesley-Professional-Series/dp/0321584104) an excellent book on Ruby -You can also dive into [week 8 beta](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/english/week8.md), upcoming addition for the course, if you want to already learn about Hotwire, Rails way of creating reactive applications with minimal Javascript. - - +You can also dive into [week 8](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/english/week8.md), upcoming addition for the course, if you want to already learn about Hotwire, Rails way of creating reactive applications with minimal Javascript. diff --git a/english/week8.md b/english/week8.md index af60a1f..4e554de 100644 --- a/english/week8.md +++ b/english/week8.md @@ -1,10 +1,16 @@ -**Note: This part of the course is still in beta testing. You can already try the material and exercises, but they cannot be submitted yet and the exercises can change before they are released.** +You will continue to develop your application from the point you arrived at the end of week 7. The material that follows comes with the assumption that you have done all the exercises of the previous week. In case you have not done all of them, you can take the sample answer of the previous week from the submission system. -You will continue to develop your application from the point you arrived at the end of week 6. The material that follows comes with the assumption that you have done all the exercises of the previous week. In case you have not done all of them, you can take the sample answer to the previous week from the submission system. +This part is graded separately from the "base course". The part has 17 exercises. You need to complete 16 of those to get the ECTS credit registered. + +This part is provided by four awesome developers from Kisko Labs: [Eetu Mattila](https://github.com/zHarrowed), [Teemu Palokangas](https://github.com/palokangas), [Teemu Tammela](https://github.com/teemutammela), and [Kimmo Salonen](https://github.com/KimmoSalonen). [Kisko Labs](https://www.kiskolabs.com/en/) is a consultancy firm based in Helsinki, which has successfully used Ruby on Rails in various customer products for more than a decade. Check out [this video](https://www.youtube.com/watch?v=qyWdcRQfqI4&t=1s) for more! + +## Prerequisites + +Two of the three topics covered in this part are using just Ruby. The last topic (Stimulus) uses JavaScript and also some browser DOM APIs. If you have no experience using JavaScript on the browser side, the last topic might be quite challenging. ## Hotwire -Ruby on Rails version 7.x introduces a new functionality called [Hotwire](https://hotwired.dev/), aimed at simplifying the creation of dynamic views with minimal reliance on Javascript. Hotwire empowers Rails developers to incorporate partial reloading of user interface elements in a similar fashion to popular Javascript libraries like [React](https://react.dev/), all while leveraging the familiar syntax of the Ruby language. +Ruby on Rails version 7.x introduces a new functionality called [Hotwire](https://hotwired.dev/), aimed at simplifying the creation of dynamic views with minimal reliance on JavaScript. Hotwire empowers Rails developers to incorporate partial reloading of user interface elements in a similar fashion to popular JavaScript libraries like [React](https://react.dev/), all while leveraging the familiar syntax of the Ruby language. ### Why Hotwire? @@ -12,7 +18,7 @@ Throughout its history, the Rails framework has been renowned for its ability to To meet these expectations, developers have had to rely on additional software tools, such as the React library, to build the necessary functionality. Unfortunately, this approach adds complexity to the applications and often diminishes the role of the View component within the Rails [MVC](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller) architecture, reducing it to merely serving as a [REST](https://en.wikipedia.org/wiki/Representational_state_transfer) or [GraphQL](https://en.wikipedia.org/wiki/GraphQL) API. -With the introduction of Hotwire, Rails aims to tackle the challenges posed by the rapidly evolving Javascript landscape. This is in contrast to the more steadily-paced Ruby ecosystem, which tends to favor incremental and conservative evolution. Hotwire offers a more streamlined and cohesive approach to fulfilling the requirements of full-featured, full-stack applications. It achieves this by eliminating the reliance on disparate tools and aligning with the ethos of Rails as a comprehensive platform for web application development. Hotwire provides the tools to construct dynamic and interactive user experiences while maintaining consistency with the familiar Rails paradigms. +With the introduction of Hotwire, Rails aims to tackle the challenges posed by the rapidly evolving JavaScript landscape. This is in contrast to the more steadily-paced Ruby ecosystem, which tends to favor incremental and conservative evolution. Hotwire offers a more streamlined and cohesive approach to fulfilling the requirements of full-featured, full-stack applications. It achieves this by eliminating the reliance on disparate tools and aligning with the ethos of Rails as a comprehensive platform for web application development. Hotwire provides the tools to construct dynamic and interactive user experiences while maintaining consistency with the familiar Rails paradigms. ## Introduction to Hotwire Components @@ -28,17 +34,285 @@ Hotwire encompasses three core components, each serving a specific purpose: Turb 2. **Stimulus** -Stimulus is a lightweight Javascript framework that enhances interactivity and user interactions in server-rendered HTML views. By attaching JavaScript behavior to HTML elements, it improves the user experience without complex frameworks or extensive coding. +Stimulus is a lightweight JavaScript framework that enhances interactivity and user interactions in server-rendered HTML views. By attaching JavaScript behavior to HTML elements, it improves the user experience without complex frameworks or extensive coding. 3. **Strada** Strada is an extension of Hotwire that allows developers to build iOS and Android applications using Rails and Turbo. Currently, Strada is being developed as separate repositories: [turbo-ios](https://github.com/hotwired/turbo-ios) for iOS and [turbo-android](https://github.com/hotwired/turbo-android) for Android, respectively. -## Pagination +## Turbo Frames, getting ready + +Before we start, let us simplify our app a bit. Start by removing the mini profiler by deleting the following line from Gemfile + +``` +gem 'rack-mini-profiler' +``` + +and by running _bundle install_. + +Let us also remove all the code that is implementing the [server-side caching](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/english/week7.md#server-caching-functionality). So from the view templates, we get rid of all the the _cache_ elements that wrap the real page content. Eg. in _views/beers/index.html.erb_ we should get rid of this element that wraps the real content: + +```html +

Beers

+ +<% cache "beerlist-#{@order}", skip_digest: true do %> +
+ + ... +
+
+<% end %> +``` + +and from the corresponding controllers, the guards that prevent full page render should also be removed. Eg. in the _controllers/beers.rb_ the change is the following: + +```ruby + def index + @order = params[:order] || 'name' + + # remove this line: + return if request.format.html? && fragment_exist?("beerlist-#{@order}") + + @beers = Beer.all + + @beers = case @order + when "name" then @beers.sort_by(&:name) + when "brewery" then @beers.sort_by { |b| b.brewery.name } + when "style" then @beers.sort_by { |b| b.style.name } + when "rating" then @beers.sort_by(&:average_rating).reverse + end + end +``` + +Now we are ready to begin! + +### The first steps + +Turbo Frames provide a convenient way to update specific parts of a page upon request, allowing us to focus on updating only the necessary content while keeping the rest of the page intact. + +Let us add a Turbo Frame that contains a link element to the bottom of the styles page, that is, to the view _views/styles/index.html.erb_: + +```html +

Styles

+ +
+ <% @styles.each do |style| %> +

+ <%= link_to style.name, style %> +

+ <% end %> +
+ +<%= link_to "New style", new_style_path %> + +

+ +<%= turbo_frame_tag "about_style" do %> + <%= link_to "about", styles_path %> +<% end %> +``` + +The Turbo Frame is created with a helper function turbo_frame_tag that has an identifier as a parameter. We use now the string _"about_style_"_ as the identifier. + +The generated HTML looks like the following: + +![image](../images/8-1.png) + +So the frame has just a link element that points back to the page itself. We intend to show some information about beer styles within the Turbo Frame when the user clicks the link. -Before jumping into the Hotwire components in detail, let's take a slight detour. After last week's [increased amount of beers](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/english/week7.md#server-caching-functionality) you start to wonder that it would be kinda nice to have a pagination for our beers page. Let's add it first without utilizing Hotwire features. +Let us now create a partial */views/styles/_about.html.erb* that also has the same Turbo Frame ID: -First we start by adding links for previous and next pages to the end of our beers table in `/beers/index.html.erb`: +![image](../images/8-2.png) + +Now when the user clicks the link, that creates a GET request to the same URL and the request is handled by the function _index_ of the _beers_ controller. We can use the helper function *turbo_frame_request?* to detect the Turbo request and handle it accordingly: + +```rb +class StylesController < ApplicationController + + def index + if turbo_frame_request? + # this was a request from the Turbo Frame + render partial: 'about' + else + # this was a normal requesst + @styles = Style.all + end + end + + // ... +end +``` + +So in case of a turbo request (that is link "about" is clicked), instead of a full page reload only the partial about is rendered. + +From the console, we can also see, that the GET request caused by the link clicking within the frame has a special header _Turbo frame_ that tells the Rails controller to treat the request as a turbo request and **not** cause a full page reload: + +![image](../images/8-3.png) + +We are now using the index controller function both for rendering the whole styles page and for the partial that renders the about information. Let us separate the partial rendering to on own controller function. The *routes.rb* extends as follows + +```rb +Rails.application.routes.draw do + resources :styles do + get 'about', on: :collection + end + # ... +end +``` + +The controller cleans up a bit: + +```rb +class StylesController < ApplicationController + before_action :set_style, only: %i[show edit update destroy] + + def index + # now this takes only care of the full page reloads + @styles = Style.all + end + + # own controller function for the partial + def about + render partial: 'about' + end + # ... +end +``` + +The link is changed accordingly: + +```html +<%= turbo_frame_tag "about_style" do %> + <%= link_to "about", about_styles_path %> +<% end %> +``` + +#### Rendering style details on demand + +Instead of having just an individual page for each style, let us show the style details on the styles page when the user clicks a style name on the list. We start by wrapping the style list in a Turbo Frame: + +```html +

Styles

+ +
+ <%= turbo_frame_tag "styles" do %> + <% @styles.each do |style| %> + <%= link_to style.name, style %> + <% end %> + <% end %> +
+``` + +We add the following to partial *_details.html.erb* that shows besides the style name, its description and the beers of that style: + +```html +<%= turbo_frame_tag "styles" do %> +

<%= style.name %>

+

+ Description: + <%= style.description %> +

+ +

beers

+ +
    + <% @style.beers.each do |beer| %> +
  • + <%= link_to beer.name, beer %> +
  • + <% end %> +
+<% end %> +``` + +Clicking a style name now causes a Turbo Frame request for a single style, and the controller is altered to render the above partial in this case: + +```rb +class StylesController < ApplicationController + # ... + + def show + if turbo_frame_request? + render partial: 'details', locals: { style: @style } + end + # the default is a full page reload + end + + # ... +end +``` + +Notice now that the partial is given the _style_ as a variable! + +Now when a style name is clicked, the list of styles is **replaced** with the details of a particular style. + +#### Targetting a different frame + +This is perhaps not quite what we want. Instead, let the style list remain visible all the time, and add a new Turbo Frame (with ID "style_details") where the details of the clicked style are shown: + +```html +
+ <%= turbo_frame_tag "styles" do %> + <% @styles.each do |style| %> + <%= link_to style.name, style, data: { turbo_frame: "style_details" } %> + <% end %> + <% end %> + + <%= turbo_frame_tag "style_details" do %> + <% end %> +
+ +<%= link_to "New style", new_style_path %> + +``` + +Since we now want to target a _different_ Turbo Frame instead of the one where links reside, we must define the targeted frame as an attribute. As seen from the above snippet it is done as follows: + +```html +link_to style.name, style, data: { turbo_frame: "style_details" } +``` + +The Turbo Frame tag in the partial _details.html.erb needs to be changed accordingly: + +```html +<%= turbo_frame_tag "style_details" do %> +

<%= style.name %>

+

+ Description: + <%= style.description %> +

+ + # ... +<% end %> +``` + +The result is finally as we expected it to be: + +![image](../images/8-4.png) + +### A very important thing to remember + +It is **EXTREMELY IMPORTANT** to follow all the possible error messages, in the Rails console and the network tab of the browser especially when working with the Hotwire. For unknown reasons, some beginners do not believe this and end up in deep trouble. Do not even think taking that dark path... + +
+ +## Exercise 1 + +Extend the user page so that when clicking a rating, the basic info of the rated beer is shown. + +Note: it is **EXTREMELY IMPORTANT** to follow all the possible error messages, in the Rails console and the network tab of the browser! + +
+Your solution could look like this: + +![image](../images/8-5.png) + +### Pagination + +Before continuing with the Hotwire further, let's take a slight detour. After last week's [increased amount of beers](https://github.com/mluukkai/WebPalvelinohjelmointi2023/blob/main/english/week7.md#server-caching-functionality), you start to wonder that it would be kind of nice to have a pagination for our beers page. Let's add it first without utilizing Hotwire features. +First, we start by adding links for the previous and next pages to the end of our beer table: + +**app/views/beers/index.html.erb** ```html @@ -61,77 +335,51 @@ First we start by adding links for previous and next pages to the end of our bee
``` -Our links don't do much yet so let's add some logic to the controller as well. Last week we defined ordering of the beers in our controller like so: - -```ruby -def index - @beers = Beer.includes(:brewery, :style, :ratings).all - - order = params[:order] || 'name' - - @beers = case order - when 'name' then @beers.sort_by(&:name) - when 'brewery' then @beers.sort_by{ |b| b.brewery.name } - when 'style' then @beers.sort_by{ |b| b.style.name } - when "rating" then @beers.sort_by(&:average_rating).reverse - end -end -``` +Our links don't do much yet so let's add some logic to the controller as well. Last week we defined the ordering of the beers in our controller as so: -Which contains a bit of a problem. Method `sort_by` will load all the beers to central memory as an array and only then sort the order. But now we would want to fetch only limited amount of records from the database at a time, only what is needed for the current page. There's no sense fetching all the beers. That's why we'll opt out for using ActiveRecord SQL queries instead for ordering: +**app/controllers/beers_controller.rb** ```ruby def index - @beers = Beer.includes(:brewery, :style, :ratings).all + @order = params[:order] || 'name' - order = params[:order] || 'name' + @beers = Beer.all @beers = case @order - when "name" then @beers.order(:name) - when "brewery" then @beers.joins(:brewery).order("breweries.name") - when "style" then @beers.joins(:style).order("styles.name") - when "rating" then @beers.left_joins(:ratings) - .select("beers.*, avg(ratings.score)") - .group("beers.id") - .order("avg(ratings.score) DESC") - end + when "name" then @beers.sort_by(&:name) + when "brewery" then @beers.sort_by { |b| b.brewery.name } + when "style" then @beers.sort_by { |b| b.style.name } + when "rating" then @beers.sort_by(&:average_rating).reverse + end end ``` -This will allow us to use ActiveRecord methods `limit` and `offset` to fetch only the wanted amount of records from the database at a time. We can set the wanted amount per page to top our `beers_controller.rb` via constant: +This approach contains a bit of a problem. The code loads all the beers to the main memory as an array and only then uses `sort_by` to get the appropriate order. But now we would want to fetch only a limited amount of records from the database at a time, only what is needed for the current page. There's no sense fetching all the beers. That's why we'll opt for using ActiveRecord SQL queries for the ordering. + +Let us at first forget about the different orderings and get the pagination to work for beers ordered by name. The controller changes as follows: ```ruby class BeersController < ApplicationController PAGE_SIZE = 20 - # ... -end -``` -After that we can define our `index` method like so: - -```ruby -def index + def index @order = params[:order] || 'name' @page = params[:page]&.to_i || 1 - @last_page = (Beer.count / PAGE_SIZE).ceil - return if request.format.html? && fragment_exist?("beerlist-#{@order}") + @last_page = (Beer.count / PAGE_SIZE.to_f).ceil + offset = (@page - 1) * PAGE_SIZE - @beers = Beer.includes(:brewery, :style, :ratings).all + @beers = Beer.order(:name).limit(PAGE_SIZE).offset(offset) + end - @beers = case @order - when "name" then @beers.order(:name) - when "brewery" then @beers.joins(:brewery).order("breweries.name") - when "style" then @beers.joins(:style).order("styles.name") - when "rating" then @beers.left_joins(:ratings) - .select("beers.*, avg(ratings.score)") - .group("beers.id") - .order("avg(ratings.score) DESC") - end - @beers = @beers.limit(PAGE_SIZE).offset((@page - 1) * PAGE_SIZE) + # ... end ``` -Pay attention to the `@last_page` instance variable as we are going to need it in our view. Now we are going to add the new `@page` instance variable to all of our links in the index and redefine our previous and next page links. +We are using a combination of ActiveRecord [order](https://edgeguides.rubyonrails.org/active_record_querying.html#ordering), [limit and offset](https://edgeguides.rubyonrails.org/active_record_querying.html#limit-and-offset) to control what page of the ordered beers is queried from the database. + +Pay attention to the `@last_page` instance variable as we are going to need it in our view. Now we are going to add the new `@page` instance variable to all of our links in the index and redefine our previous and next page links: + +**app/views/beers/index.html.erb** ```html @@ -166,42 +414,114 @@ Pay attention to the `@last_page` instance variable as we are going to need it i
``` -Remember to also update our cache key to include our new `@page` variable: +Pagination works now nicely with the default ordering! We need a bit more advanced use of ActiveRecord to get the other orders to work. -```html -<% cache "beerlist-#{@page}-#{@order}", skip_digest: true do %> +When ordering based on a brewery name or style name, we can not just use the data in the beer object, we must do an SQL [join](https://edgeguides.rubyonrails.org/active_record_querying.html#joining-tables) to get the associated rows from the database and to do the ordering based on the fields of those. The controller extends as follows: + +```ruby +class BeersController < ApplicationController + PAGE_SIZE = 20 + + def index + @order = params[:order] || 'name' + @page = params[:page]&.to_i || 1 + @last_page = (Beer.count / PAGE_SIZE.to_f).ceil + offset = (@page - 1) * PAGE_SIZE + + @beers = case @order + when "name" then Beer.order(:name) + .limit(PAGE_SIZE).offset(offset) + when "brewery" then Beer.joins(:brewery) + .order("breweries.name").limit(PAGE_SIZE).offset(offset) + when "style" then Beer.joins(:style) + .order("styles.name").limit(PAGE_SIZE).offset(offset) + end + + end + + # ... +end ``` -![image](../images/ratebeer-w8-1.png) +So now depending on the order the user wants, a different kind of query is executed to get a page of beers. -And voilà! We have working pagination for our beers. But one thing that is kinda annoying is that when we navigate between the pages, the whole pages gets reloaded with menus and all even though the contents of the table are the only thing changing. Here is where we come to where Turbo Frames can help us... +The last one, ordering by ratings is the most tricky case. One way to achieve the functionality is shown below. The required SQL mastery is beyond the objectives of this course, so you may just copy-paste the code and believe that it works. -## Turbo Frames +```ruby +class BeersController < ApplicationController + PAGE_SIZE = 20 -Turbo Frames provide a convenient way to update specific parts of a page upon request, allowing us to focus on updating only the necessary content while keeping the rest of the page intact. + def index + @order = params[:order] || 'name' + @page = params[:page]&.to_i || 1 + @last_page = (Beer.count / PAGE_SIZE.to_f).ceil + offset = (@page - 1) * PAGE_SIZE -To begin, let's create a new partial called `_beers_page.html.erb` to the folder `app/views/beers` and extract the table containing the beers from our `beers/index.html.erb` file. This way, our `index.html.erb` will appear as follows: + @beers = case @order + when "name" then Beer.order(:name) + .limit(PAGE_SIZE).offset(offset) + when "brewery" then Beer.joins(:brewery) + .order("breweries.name").limit(PAGE_SIZE).offset(offset) + when "style" then Beer.joins(:style) + .order("styles.name").limit(PAGE_SIZE).offset(offset) + when "rating" then Beer.left_joins(:ratings) + .select("beers.*, avg(ratings.score)") + .group("beers.id") + .order("avg(ratings.score) DESC").limit(PAGE_SIZE).offset(offset) + end + + end + + # ... +end +``` + +And voilà! We have a working pagination for our beers. But one kinda annoying thing is that when we navigate between the pages, the whole page gets reloaded with menus and all even though the contents of the table are the only thing changing. Here is where we come to where Turbo Frames can help us... + +The controller code has now a slightly ugly feature, it contains repetition, eg. the following piece of code is repeated many times: + +```rb +.limit(PAGE_SIZE).offset(offset) +``` + +It would be possible to clean up the repetition, but we will leave that as a volunteer exercise. + +
+ +## Exercise 2 + +Change the ratings page to show *all* ratings in a paginated form. The default order is to show the most recent rating first. Add a button that allows reversing the order. + +
+ +Your solution could look like the following: + +![image](../images/8-6.png) + +### Turbo framing the beer list + +To begin, let's create a new partial called `_beer_list.html.erb` to the folder `app/views/beers` and extract the table containing the beers from our `beers/index.html.erb` file. + +This way, our `index.html.erb` will appear as follows: **app/views/beers/index.html.erb** ```html

Beers

-<% cache "beerlist-#{@page}-#{@order}", skip_digest: true do %> -
- <%= render "beers_page", beers: @beers, page: @page, order: @order, last_page: @last_page %> -
-<% end %> +
+ <%= render "beer_list", beers: @beers, page: @page, order: @order, last_page: @last_page %> +
<%= link_to('New Beer', new_beer_path) if current_user %> ``` -Once the above is functioning correctly, we can enclose our table within the `_beers_page.html.erb` partial using a turbo frame: +Once the above is functioning correctly, we can enclose our table within the `_beer_list.html.erb` partial using a Turbo Frame: -**app/views/beers/\_beers_page.html.erb** +**app/views/beers/\_beer_list.html.erb** ```html -<%= turbo_frame_tag "beers_page" do %> +<%= turbo_frame_tag "beer_list_frame" do %>
@@ -214,7 +534,7 @@ By using a Turbo Frame, all links and buttons within it will be controlled by Tu def index # ... if turbo_frame_request? - render partial: "beers_page", + render partial: "beer_list", locals: { beers: @beers, page: @page, order: @order, last_page: @last_page } else render :index @@ -222,19 +542,25 @@ def index end ``` -The `turbo_frame_request?` condition ensures that when the request is made within a Turbo Frame, only the partial containing our beer table is returned. We can now observe the behavior within the network tab of our browser's developer tools. +The `turbo_frame_request?` condition ensures that when the request is made within a Turbo Frame, only the partial containing our beer table is returned. We can now observe the behavior within the network tab of our browser's developer tools: + +![image](../images/8-7.png) -![image](../images/ratebeer-w8-2.png) +We can see that the headers include the ID of the Turbo Frame we are targeting, allowing Turbo to identify which part of the page should be replaced with the response data: -We can see that the headers include the ID of the Turbo Frame we are targeting, allowing Turbo to identify which part of the page should be replaced. +![image](../images/8-8.png) -![image](../images/ratebeer-w8-3.png) +Indeed, the response contains only the partial and excludes the application layout that accompanies the HTML document. The Turbo magic automatically handles this aspect. -Indeed, the response contains only the partial and excludes the application layout that accompanies the HTML document. Turbo automatically handles this aspect. +The only remaining issue is that the links to beers, breweries, and styles are no longer functional. If we eg. click a beer name, the response looks as follows: -The only remaining issue is that the links to beers, breweries, and styles are no longer functional. Turbo attempts to load the links and replace our table with their content but fails to find a suitable turbo tag for replacement. We can easily resolve this by adding the target attribute to our links: +![image](../images/8-9.png) -**app/views/beers/\_beers_page.html.erb** +Turbo attempts to replace our table with their content but fails to find a suitable turbo tag (*beer_list_frame*) for replacement so it simply renders nothing within the frame. + +We can easily resolve this by adding a suitable target attribute to our links: + +**app/views/beers/\_beer_list.html.erb** ```html <% beers.each do |beer| %> @@ -247,35 +573,123 @@ The only remaining issue is that the links to beers, breweries, and styles are n <% end %> ``` -The `target="_top"` signifies that Turbo should break out of the frame and replace the entire page with the opened link. Alternatively, the `target` could be set to `_self`, targeting the current frame, or the ID of another Turbo Frame, in which case Turbo would attempt to replace that specific frame. +The `turbo_frame="_top"` signifies that Turbo should break out of the frame and replace the entire page with the opened link. As seen from the earlier examples we can also use an ID of another Turbo Frame here, in which case Turbo would attempt to replace that specific frame. We also notice that the URL remains unchanged when navigating between pages, and using the browser's back button may lead to unexpected results. We can easily address this by promoting our Turbo Actions into visits: -**app/views/beers/\_beers_page.html.erb** +**app/views/beers/\_beer_list.html.erb** + +```html +<%= turbo_frame_tag "beer_list_frame", data: { turbo_action: "advance" } do %> +``` + +#### Asynchronous frame + +Let's say we want to suggest a beer to the user based on how they've rated other beers. Calculating the recommendation might take a long time, which is why we decided to load it asynchronously. So initially when the user goes to their own page, it just shows a "loading indicator", and when the recommendation is ready, that gets rendered to the page. + +This can be achieved with Turbo frames with a src attribute: + +```rb + <%= turbo_frame_tag "beer_recommendation_tag", src: recommendation_user_path do %> + calculating the recommendation... + <% end %> +``` + +Now initially only the text calculating the recommendation... is rendered. After the page is rendered Turbo makes an HTTP GET request to the specified path (users/id/recommendation) and fills in the HTML that it gets as a response. The partial for the recommendation looks the following: + +**views/users/_recommendation.html.erb** ```html -<%= turbo_frame_tag "beers_page", data: { turbo_action: "advance" } do %> +<%= turbo_frame_tag "beer_recommendation_tag" do %> +
+

Recommendation based on your ratings

+ +

<%= link_to beer.name, beer %> by <%= link_to beer.brewery.name, beer.brewery %>

+
+<% end %> ``` -Under the hood, Turbo utilizes JavaScript to manipulate the [HTML DOM](https://www.w3schools.com/js/js_htmldom.asp) of the page, eliminating the need for us to write any JavaScript code ourselves! +We will need a route and controller for the recommendation. The route (in *routes.rb*) is defined as follows: + +```rb +resources :users do + post 'toggle_closed', on: :member + get 'recommendation', on: :member +end +``` + +The controller finds out the recommendation (that is in our case just a randomly picked beer) and renders the partial. We have added a sleep of 2 seconds to simulate that calculating the recommendation takes a bit of time: + +```rb +class UsersController < ApplicationController + # ... + + def recommendation + # simulate a delay in calculating the recommendation + sleep(2) + ids = Beer.pluck(:id) + # our recommendation us just a randomly picked beer... + random_beer = Beer.find(ids.sample) + render partial: 'recommendation', locals: { beer: random_beer } + end + + # ... +end +``` + +Now when the user navigates to their own page, there is an indication that the recommendation is still to be calculated: + +![image](../images/8-10.png) + +After a while, the HTTP response is ready, and the returned partial containing the recommendation is rendered: + +![image](../images/8-11.png) + +#### Turbo under the hood + +As we have seen at the beginning of this week's material, Turbo Frame blocks are identified and separated with ```id``` tags and caught by the controller with the ```turbo_frame_request?```method. The controller then queries the model for the data needed and sends the updated part of HTML to the view. With the help of ID tags, only the specific part inside `````` is updated without having to refresh the entire page. + +Turbo Frames is built on the concept of [AJAX](https://www.w3schools.com/xml/ajax_intro.asp). In a traditional Rails application, a typical HTTP request (like ```GET```) would involve the controller processing the page load request and querying the model's database before delivering an entire HTML page back to the browser. With AJAX, and by extension Turbo Frames, instead of returning a full HTML page, only a section of the page is updated. This leads to faster loading as the application doesn't have to reload all data from the database. Turbo utilizes JavaScript to manipulate the [HTML DOM](https://www.w3schools.com/js/js_htmldom.asp) of the page, eliminating the need for us to write any JavaScript code ourselves!
-## Exercise 1 +## Exercise 3 + +In this and the next exercise, we will refactor the breweries page to render the brewery lists asynchronously. + +Start by refactoring the breweries page so that there is a new partial `_brewery_list.html.erb` which is used separately to list breweries under active breweries and retired breweries. -`turbo_frame_tag` has an attribute `src` available that will lazy load the contents of the source address into the turbo frame. +Create the new endpoint GET `breweries/active` that returns the partial for the active breweries and uses that to render the active breweries asynchronously. + +The retired brewery list can still remain as it is. + +Note: it is **EXTREMELY IMPORTANT** to follow all the possible error messages, in the Rails console and the network tab of the browser when working with Action Frame! + +## Exercise 4 + +Create also the new endpoint GET `breweries/retired` that returns the partial for the retired breweries and use that also in rendering the breweries page. + +The same partial should be used both for active and retired breweries. Note that you **can not** anymore use the **same** Turbo Frame tag for both the active and retired breweries. + +Notice that instead of defining a Turbo Frame tag as a hard-coded string, we can define it also as a variable that you set in the controller: + +```rb +<%= turbo_frame_tag tag_as_a_variable do %> + # ... +<% end %> +``` + +This makes it possible to use the same partial to render the contents of many different Turbo frames (that all have their own identifiers). + +Fix also the links to breweries so that they work inside the Turbo Frames. -1. Refactor breweries page so that there is new partial `_breweries_list.html.erb` which is used separately to list breweries under active breweries and retired breweries. -2. Create new endpoints behind `breweries/active` and `breweries/retired` routes that return the partials for the active and retired breweries respectively. -3. Use `turbo_frame_tag` with `src` attribute to lazy load active and retired breweries into their respective turbo frames. -4. Fix the links to breweries so that they work inside the turbo frames.
## Turbo Streams -The purpose of [Turbo Streams](https://turbo.hotwired.dev/handbook/streams) is to enable page updates in fragments. For example, when a page displays a list of beers, instead of performing a complete page reload, a single beer can be appended or removed from the list in response to a change. +The purpose of [Turbo Streams](https://turbo.hotwired.dev/handbook/streams) is to enable page updates in fragments. For example, when a page displays a list of breweries and a new beer is added or deleted, instead of performing a full page reload, a single brewery can be appended or removed from the list in response to a change. -In modern web applications, achieving this behavior often involves having a separate server-side REST API or GraphQL API, commonly referred to as the back-end, to provide the necessary information in JSON format. The front-end queries this back-end, receives the JSON data, and renders the required HTML while updating the DOM accordingly. +In modern web applications, achieving this kind of behavior often involves having a separate server-side REST API or GraphQL API, commonly referred to as the back-end, to provide the necessary information in JSON format. The front-end queries this back-end, receives the JSON data, and renders the required HTML while updating the DOM accordingly by using logic written in JavaScript. Turbo simplifies this process by streaming pre-rendered HTML, compiled on the back-end, directly to the browser and handling the necessary actions internally. @@ -285,7 +699,7 @@ Key concepts in Turbo Streams include **actions**, **targets**, and **templates* In Turbo Streams, **actions** are a fundamental concept used to specify the changes or updates that should be performed on the client-side HTML DOM in response to a server-side event. An action represents a specific operation that can be applied to one or more target elements within a Turbo Stream response. -**Actions** are defined using HTML-like syntax and consist of a combination of elements and attributes. Each action includes a target element, which represents the HTML element on the client-side that needs to be updated, and one or more operations that define how the target element should be modified. +**Actions** are defined using HTML-like syntax and consist of a combination of elements and attributes. Each action includes a target element, which represents the HTML element on the client side that needs to be updated, and one or more operations that define how the target element should be modified. The operations that can be applied to a target element include: @@ -299,7 +713,7 @@ The operations that can be applied to a target element include: ### Turbo Streams Targets -In order for **actions** to function properly, Turbo requires the identification of target elements within the DOM. This can be achieved by assigning unique HTML `id` parameters to individual elements or by utilizing `class` parameters to target multiple elements. +For **actions** to function properly, Turbo requires the identification of target elements within the DOM. This can be achieved by assigning unique HTML `id` parameters to individual elements or by utilizing `class` parameters to target multiple elements. For identifying a single element, one can explicitly create an ID value in the view or leverage the convenient Rails [dom_id](https://api.rubyonrails.org/classes/ActionView/RecordIdentifier.html) helper, which automatically generates the ID tag. For example: @@ -329,15 +743,15 @@ you could use the remove action to remove all retired breweries from the list by To leverage the capabilities of Turbo Streams, view templates should be designed as [partials](https://guides.rubyonrails.org/layouts_and_rendering.html#using-partials) that can be rendered individually. This enables targeted streaming of changes to specific components. For example, when streaming updates for breweries and appending new breweries to a list, the brewery row should be implemented as a partial. -To prepare the Breweries index page for streaming, extract the row rendering logic (created in Exercise 1) from `app/views/breweries/_breweries_list.html.erb`: +To prepare the Breweries index page for streaming, extract the row rendering logic (created in Exercise 1) from `app/views/breweries/_brewery_list.html.erb`: -**app/views/breweries/\_breweries_list.html.erb** +**app/views/breweries/\_brewery_list.html.erb** ```html <% breweries.each do |brewery| %> - "> - <%= link_to brewery.name, brewery, data: { turbo_frame: "_top"} %> + + <%= link_to brewery.name, brewery, data: { turbo_frame: "_top" } %> <%= brewery.year %> <%= brewery.beers.count %> <%= round(brewery.average_rating) %> @@ -351,7 +765,7 @@ To new partial file `app/views/breweries/_brewery_row.html.erb`: **app/views/breweries/\_brewery_row.html.erb** ```html -"> + <%= link_to brewery.name, brewery, data: { turbo_frame: "_top"} %> <%= brewery.year %> <%= brewery.beers.count %> @@ -359,7 +773,7 @@ To new partial file `app/views/breweries/_brewery_row.html.erb`: ``` -And change the original code to use this partial : +And change the original code to use this partial: ```html @@ -369,20 +783,16 @@ And change the original code to use this partial : ``` -Pay attention to new ID that we give to the tbody element. We need `active_brewery_rows` or `retired_brewery_rows` ID to **target** the **action** of appending new breweries as children of the correct table. If in Exercise 1 you did not define local `status` or something similar containing `active`/`retired` information for the different brewery listings, you should do that now as it will help us later. +Pay attention to the new ID that we give to the tbody element. We need `active_brewery_rows` or `retired_brewery_rows` ID to **target** the **action** of appending new breweries as children of the correct table. If in Exercises 3 and 4 you did not define local `status` or something similar containing `active`/`retired` information for the different brewery listings, you should do that now as it will help us later. Next, let's enable the addition of new breweries directly from the index page. Replace the following code in `app/views/breweries/index.html.erb`: -**app/views/breweries/index.html.erb** - ```html <%= link_to "New brewery", new_brewery_path if current_user %> ``` With the following Turbo Frame tag: -**app/views/breweries/index.html.erb** - ```html <%= turbo_frame_tag "new_brewery", src: new_brewery_path if current_user %> ``` @@ -391,8 +801,6 @@ This Turbo Frame will include a part of our existing code from the `new_brewery` In `app/views/breweries/_new.html.erb` specify which part of the view you want to show in the Turbo Frame: -**app/views/breweries/\_new.html.erb** - ```html

New brewery

@@ -402,47 +810,51 @@ In `app/views/breweries/_new.html.erb` specify which part of the view you want t # ... ``` +Now the breweries page looks like this: + ![image](../images/ratebeer-w8-4.png) -In order to append the created beer to the list without doing a full page update, we need to modify the response in the create action of the `app/controllers/breweries_controller.rb` file. +To append the created brewery to the list without doing a full page update, we need to modify the response in the create action of the `app/controllers/breweries_controller.rb` file. By adding the `format.turbo_stream` block, we specify that the response should be rendered as a Turbo Stream template with the action of appending the new brewery row to the target element with the ID `active_brewery_rows` or `retired_brewery_rows`. These steps enable the addition of breweries directly from the index page while only appending the created brewery to the list without refreshing the entire page. -**app/controllers/breweries_controller.rb** - ```ruby -def create - @brewery = Brewery.new(brewery_params) +class BreweriesController < ApplicationController - respond_to do |format| - if @brewery.save - format.turbo_stream { - status = @brewery.active? ? "active" : "retired" - render turbo_stream: turbo_stream.append("#{status}_brewery_rows", partial: "brewery_row", locals: { brewery: @brewery }) - } - format.html { redirect_to brewery_url(@brewery), notice: "Brewery was successfully created." } - format.json { render :show, status: :created, location: @brewery } - else - format.html { render :new, status: :unprocessable_entity } - format.json { render json: @brewery.errors, status: :unprocessable_entity } + def create + @brewery = Brewery.new(brewery_params) + + respond_to do |format| + if @brewery.save + format.turbo_stream { + status = @brewery.active? ? "active" : "retired" + render turbo_stream: turbo_stream.append("#{status}_brewery_rows", partial: "brewery_row", locals: { brewery: @brewery }) + } + format.html { redirect_to brewery_url(@brewery), notice: "Brewery was successfully created." } + format.json { render :show, status: :created, location: @brewery } + else + format.html { render :new, status: :unprocessable_entity } + format.json { render json: @brewery.errors, status: :unprocessable_entity } + end end end + end ``` This change involves one line of code, but under the hood, several components enable this to work: -1. When the browser initiates a new request, the Turbo framework includes the `text/vnd.turbo-stream.html` in the request's `Accept` headers. This informs the server that it expects a Turbo Stream template instead of a full page update. +1. When the browser initiates a new request, the Turbo framework includes the `text/vnd.turbo-stream.html` in the request's `Accept` headers. This informs the server that it expects a Turbo Stream template instead of a full-page update. ![image](../images/ratebeer-w8-turbo-streams-header.png) 2. The controller, based on the `Accept` header, recognizes the request's Turbo Stream format and responds by rendering a Turbo Stream template instead of a complete page. This ensures that only the necessary HTML fragments are sent back to the browser. -3. Using the `turbo_stream.append` method, a response HTML fragment is generated with the action set to `append`. This fragment targets the element with the identifier `brewery_rows` and utilizes the `_brewery_row.html.erb` partial to generate the content. Here is an example of the resulting fragment: +3. Using the `turbo_stream.append` method, a response HTML fragment is generated with the action set to `append`. This fragment targets the element with the identifier `brewery_rows` and utilizes the `_brewery_row.html.erb` partial to generate the content. Here is an example of the resulting fragment (go and see yourself from the developer tools how the response looks): ```html -