diff --git a/Gemfile b/Gemfile index d8d3b5c..95da340 100644 --- a/Gemfile +++ b/Gemfile @@ -43,6 +43,7 @@ gem "thruster", require: false gem "slim" gem "dartsass-rails" gem "bootstrap", "~> 5.3.3" +gem "friendly_id", "~> 5.5.0" group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem diff --git a/Gemfile.lock b/Gemfile.lock index 9a05126..e55c180 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -118,6 +118,8 @@ GEM et-orbi (1.2.11) tzinfo execjs (2.10.0) + friendly_id (5.5.1) + activerecord (>= 4.0.0) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -403,6 +405,7 @@ DEPENDENCIES capybara dartsass-rails debug + friendly_id (~> 5.5.0) importmap-rails jbuilder kamal diff --git a/app/controllers/contests_controller.rb b/app/controllers/contests_controller.rb index ecca06e..af758cc 100644 --- a/app/controllers/contests_controller.rb +++ b/app/controllers/contests_controller.rb @@ -84,6 +84,6 @@ class ContestsController < ApplicationController end def contest_params - params.expect(contest: [ :name, :team, :allow_registration, :slug ]) + params.expect(contest: [ :name, :team, :allow_registration ]) end end diff --git a/app/models/contest.rb b/app/models/contest.rb index 5b6fa87..64e411c 100644 --- a/app/models/contest.rb +++ b/app/models/contest.rb @@ -13,6 +13,7 @@ # # Indexes # +# index_contests_on_slug (slug) UNIQUE # index_contests_on_user_id (user_id) # # Foreign Keys @@ -20,12 +21,14 @@ # user_id (user_id => users.id) # class Contest < ApplicationRecord - belongs_to :user + extend FriendlyId + belongs_to :user has_many :completions, dependent: :destroy has_many :contestants, dependent: :destroy has_many :puzzles, dependent: :destroy + friendly_id :name, use: :slugged + validates :name, presence: true - validates :slug, presence: true, uniqueness: true, format: { with: /\A(\w|-)*\z/, message: 'Only alphanumeric characters, "-" and "_" allowed.' } end diff --git a/app/views/contests/_form.html.slim b/app/views/contests/_form.html.slim index 6e008b1..fddfd6b 100644 --- a/app/views/contests/_form.html.slim +++ b/app/views/contests/_form.html.slim @@ -4,12 +4,6 @@ .form-floating = form.text_field :name, autocomplete: "off", class: "form-control" = form.label :name, class: "required" - .row.mb-3 - .col - .form-floating - = form.text_field :slug, autocomplete: "off", class: "form-control" - = form.label :slug, class: "required" - .form-text This will be used for building the public scoreboard URL: https://puzzle-scoreboard.org/public/<slug>. .row.mb-3 .col .form-check.form-switch diff --git a/config/initializers/friendly_id.rb b/config/initializers/friendly_id.rb new file mode 100644 index 0000000..4354581 --- /dev/null +++ b/config/initializers/friendly_id.rb @@ -0,0 +1,107 @@ +# FriendlyId Global Configuration +# +# Use this to set up shared configuration options for your entire application. +# Any of the configuration options shown here can also be applied to single +# models by passing arguments to the `friendly_id` class method or defining +# methods in your model. +# +# To learn more, check out the guide: +# +# http://norman.github.io/friendly_id/file.Guide.html + +FriendlyId.defaults do |config| + # ## Reserved Words + # + # Some words could conflict with Rails's routes when used as slugs, or are + # undesirable to allow as slugs. Edit this list as needed for your app. + config.use :reserved + + config.reserved_words = %w[new edit index session login logout users admin + stylesheets assets javascripts images] + + # This adds an option to treat reserved words as conflicts rather than exceptions. + # When there is no good candidate, a UUID will be appended, matching the existing + # conflict behavior. + + config.treat_reserved_as_conflict = true + + # ## Friendly Finders + # + # Uncomment this to use friendly finders in all models. By default, if + # you wish to find a record by its friendly id, you must do: + # + # MyModel.friendly.find('foo') + # + # If you uncomment this, you can do: + # + # MyModel.find('foo') + # + # This is significantly more convenient but may not be appropriate for + # all applications, so you must explicitly opt-in to this behavior. You can + # always also configure it on a per-model basis if you prefer. + # + # Something else to consider is that using the :finders addon boosts + # performance because it will avoid Rails-internal code that makes runtime + # calls to `Module.extend`. + + config.use :finders + + # ## Slugs + # + # Most applications will use the :slugged module everywhere. If you wish + # to do so, uncomment the following line. + + config.use :slugged + + # By default, FriendlyId's :slugged addon expects the slug column to be named + # 'slug', but you can change it if you wish. + # + # config.slug_column = 'slug' + # + # By default, slug has no size limit, but you can change it if you wish. + # + # config.slug_limit = 255 + # + # When FriendlyId can not generate a unique ID from your base method, it appends + # a UUID, separated by a single dash. You can configure the character used as the + # separator. If you're upgrading from FriendlyId 4, you may wish to replace this + # with two dashes. + # + # config.sequence_separator = '-' + # + # Note that you must use the :slugged addon **prior** to the line which + # configures the sequence separator, or else FriendlyId will raise an undefined + # method error. + # + # ## Tips and Tricks + # + # ### Controlling when slugs are generated + # + # As of FriendlyId 5.0, new slugs are generated only when the slug field is + # nil, but if you're using a column as your base method can change this + # behavior by overriding the `should_generate_new_friendly_id?` method that + # FriendlyId adds to your model. The change below makes FriendlyId 5.0 behave + # more like 4.0. + # Note: Use(include) Slugged module in the config if using the anonymous module. + # If you have `friendly_id :name, use: slugged` in the model, Slugged module + # is included after the anonymous module defined in the initializer, so it + # overrides the `should_generate_new_friendly_id?` method from the anonymous module. + # + # config.use :slugged + # config.use Module.new { + # def should_generate_new_friendly_id? + # slug.blank? || _changed? + # end + # } + # + # FriendlyId uses Rails's `parameterize` method to generate slugs, but for + # languages that don't use the Roman alphabet, that's not usually sufficient. + # Here we use the Babosa library to transliterate Russian Cyrillic slugs to + # ASCII. If you use this, don't forget to add "babosa" to your Gemfile. + # + # config.use Module.new { + # def normalize_friendly_id(text) + # text.to_slug.normalize! :transliterations => [:russian, :latin] + # end + # } +end diff --git a/db/migrate/20250326162646_remove_slug_from_contests.rb b/db/migrate/20250326162646_remove_slug_from_contests.rb new file mode 100644 index 0000000..2d93442 --- /dev/null +++ b/db/migrate/20250326162646_remove_slug_from_contests.rb @@ -0,0 +1,5 @@ +class RemoveSlugFromContests < ActiveRecord::Migration[8.0] + def change + remove_column :contests, :slug, :string + end +end diff --git a/db/migrate/20250326162828_add_slug_to_contests.rb b/db/migrate/20250326162828_add_slug_to_contests.rb new file mode 100644 index 0000000..8baf8eb --- /dev/null +++ b/db/migrate/20250326162828_add_slug_to_contests.rb @@ -0,0 +1,6 @@ +class AddSlugToContests < ActiveRecord::Migration[8.0] + def change + add_column :contests, :slug, :string + add_index :contests, :slug, unique: true + end +end diff --git a/db/migrate/20250326162920_create_friendly_id_slugs.rb b/db/migrate/20250326162920_create_friendly_id_slugs.rb new file mode 100644 index 0000000..8275d9e --- /dev/null +++ b/db/migrate/20250326162920_create_friendly_id_slugs.rb @@ -0,0 +1,21 @@ +MIGRATION_CLASS = + if ActiveRecord::VERSION::MAJOR >= 5 + ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"] + else + ActiveRecord::Migration + end + +class CreateFriendlyIdSlugs < MIGRATION_CLASS + def change + create_table :friendly_id_slugs do |t| + t.string :slug, null: false + t.integer :sluggable_id, null: false + t.string :sluggable_type, limit: 50 + t.string :scope + t.datetime :created_at + end + add_index :friendly_id_slugs, [ :sluggable_type, :sluggable_id ] + add_index :friendly_id_slugs, [ :slug, :sluggable_type ], length: { slug: 140, sluggable_type: 50 } + add_index :friendly_id_slugs, [ :slug, :sluggable_type, :scope ], length: { slug: 70, sluggable_type: 50, scope: 70 }, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 5e235cd..d823306 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_03_26_153736) do +ActiveRecord::Schema[8.0].define(version: 2025_03_26_162920) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -71,9 +71,21 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_26_153736) do t.boolean "team", default: false t.boolean "allow_registration", default: false t.string "slug" + t.index ["slug"], name: "index_contests_on_slug", unique: true t.index ["user_id"], name: "index_contests_on_user_id" end + create_table "friendly_id_slugs", force: :cascade do |t| + t.string "slug", null: false + t.integer "sluggable_id", null: false + t.string "sluggable_type", limit: 50 + t.string "scope" + t.datetime "created_at" + t.index ["slug", "sluggable_type", "scope"], name: "index_friendly_id_slugs_on_slug_and_sluggable_type_and_scope", unique: true + t.index ["slug", "sluggable_type"], name: "index_friendly_id_slugs_on_slug_and_sluggable_type" + t.index ["sluggable_type", "sluggable_id"], name: "index_friendly_id_slugs_on_sluggable_type_and_sluggable_id" + end + create_table "puzzles", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false diff --git a/test/fixtures/contests.yml b/test/fixtures/contests.yml index 4f48d0c..23349b3 100644 --- a/test/fixtures/contests.yml +++ b/test/fixtures/contests.yml @@ -15,6 +15,7 @@ # # Indexes # +# index_contests_on_slug (slug) UNIQUE # index_contests_on_user_id (user_id) # # Foreign Keys diff --git a/test/models/contest_test.rb b/test/models/contest_test.rb index b92497f..d387cea 100644 --- a/test/models/contest_test.rb +++ b/test/models/contest_test.rb @@ -13,6 +13,7 @@ # # Indexes # +# index_contests_on_slug (slug) UNIQUE # index_contests_on_user_id (user_id) # # Foreign Keys