diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb new file mode 100644 index 0000000..3f9a352 --- /dev/null +++ b/app/controllers/projects_controller.rb @@ -0,0 +1,45 @@ +class ProjectsController < ApplicationController + allow_unauthenticated_access only: %i[ index ] + + def index + @projects = Project.by_year + end + + def new + @project = Project.new + end + + def create + @project = Project.new(project_params) + if @project.save + redirect_to projects_url, notice: "Project was successfully created." + else + render :new, status: :unprocessable_content + end + end + + def edit + @project = Project.find(params[:id]) + end + + def update + @project = Project.find(params[:id]) + if @project.update(project_params) + redirect_to projects_url, notice: "Project was successfully updated." + else + render :edit, status: :unprocessable_content + end + end + + def destroy + @project = Project.find(params[:id]) + @project.destroy + redirect_to projects_url, notice: "Project was successfully destroyed." + end + + private + + def project_params + params.expect(project: [ :name, :year, :github_url, :license, :language, :description ]) + end +end diff --git a/app/models/project.rb b/app/models/project.rb new file mode 100644 index 0000000..1d5f812 --- /dev/null +++ b/app/models/project.rb @@ -0,0 +1,5 @@ +class Project < ApplicationRecord + validates :name, presence: true + + scope :by_year, -> { order(year: :desc) } +end diff --git a/app/views/projects/_form.html.erb b/app/views/projects/_form.html.erb new file mode 100644 index 0000000..8d37876 --- /dev/null +++ b/app/views/projects/_form.html.erb @@ -0,0 +1,37 @@ +<%= form_with model: project, class: "space-y-6", url: project.persisted? ? project_url(project) : projects_url do |form| %> + <%= render "shared/form_errors", object: project %> + +
+ <%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-200" %> + <%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-xs focus:border-indigo-500 focus:ring-indigo-500" %> +
+ +
+ <%= form.label :year, class: "block text-sm font-medium text-gray-700 dark:text-gray-200" %> + <%= form.number_field :year, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-xs focus:border-indigo-500 focus:ring-indigo-500" %> +
+ +
+ <%= form.label :github_url, class: "block text-sm font-medium text-gray-700 dark:text-gray-200" %> + <%= form.text_field :github_url, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-xs focus:border-indigo-500 focus:ring-indigo-500" %> +
+ +
+ <%= form.label :license, class: "block text-sm font-medium text-gray-700 dark:text-gray-200" %> + <%= form.text_field :license, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-xs focus:border-indigo-500 focus:ring-indigo-500" %> +
+ +
+ <%= form.label :language, class: "block text-sm font-medium text-gray-700 dark:text-gray-200" %> + <%= form.text_field :language, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-xs focus:border-indigo-500 focus:ring-indigo-500" %> +
+ +
+ <%= form.label :description, class: "block text-sm font-medium text-gray-700 dark:text-gray-200" %> + <%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-xs focus:border-indigo-500 focus:ring-indigo-500" %> +
+ +
+ <%= form.submit class: "rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" %> +
+<% end %> diff --git a/app/views/projects/_project.html.erb b/app/views/projects/_project.html.erb new file mode 100644 index 0000000..270adc2 --- /dev/null +++ b/app/views/projects/_project.html.erb @@ -0,0 +1,41 @@ +
+
+

+ <%= link_to project.name, project.github_url, target: "_blank", rel: "noopener noreferrer", + class: "text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300" %> +

+
+ <% if project.year.present? %> + <%= project.year %> + <% end %> + <% if project.language.present? %> + + <%= project.language %> + + <% end %> + <% if project.license.present? %> + + <%= project.license %> + + <% end %> + <% if project.github_url.present? %> + <%= link_to project.github_url, target: "_blank", rel: "noopener noreferrer", + class: "text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" do %> + + <% end %> + <% end %> +
+
+ <% if project.description.present? %> +

+ <%= project.description %> +

+ <% end %> + <% if authenticated? %> +
+ <%= link_to "Edit", edit_project_path(project), class: "text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" %> +
+ <% end %> +
diff --git a/app/views/projects/edit.html.erb b/app/views/projects/edit.html.erb new file mode 100644 index 0000000..aec7e84 --- /dev/null +++ b/app/views/projects/edit.html.erb @@ -0,0 +1,3 @@ +

Editing Project

+<%= button_to "Delete", @project, method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 mb-6" %> +<%= render "form", project: @project %> diff --git a/app/views/projects/index.html.erb b/app/views/projects/index.html.erb new file mode 100644 index 0000000..f95ed3e --- /dev/null +++ b/app/views/projects/index.html.erb @@ -0,0 +1,17 @@ +<% content_for :title do %> +Projects | <%= Rails.application.config.site_name %> +<% end %> + +
+
+

Projects

+
+ + <% if @projects.any? %> +
+ <%= render @projects %> +
+ <% else %> +

No projects yet.

+ <% end %> +
diff --git a/app/views/projects/new.html.erb b/app/views/projects/new.html.erb new file mode 100644 index 0000000..b44c5f1 --- /dev/null +++ b/app/views/projects/new.html.erb @@ -0,0 +1,2 @@ +

New Project

+<%= render "form", project: @project %> diff --git a/app/views/shared/_admin_navigation.html.erb b/app/views/shared/_admin_navigation.html.erb index 4c6d21e..f928161 100644 --- a/app/views/shared/_admin_navigation.html.erb +++ b/app/views/shared/_admin_navigation.html.erb @@ -17,6 +17,10 @@ class: "text-white hover:text-gray-300 transition-colors" do %> New Link <% end %> + <%= link_to new_project_path, + class: "text-white hover:text-gray-300 transition-colors" do %> + New Project + <% end %> <%= link_to feeds_path, class: "text-white hover:text-gray-300 transition-colors" do %> Feeds diff --git a/app/views/shared/_navigation.html.erb b/app/views/shared/_navigation.html.erb index e063ef5..ac42308 100644 --- a/app/views/shared/_navigation.html.erb +++ b/app/views/shared/_navigation.html.erb @@ -17,8 +17,8 @@ <%= link_to "Presentations", "/p/presentations", class: "#{current_page?('/p/presentations') ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'} transition-colors" %> - <%= link_to "Projects", "/p/projects", - class: "#{current_page?('/p/projects') ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'} transition-colors" %> + <%= link_to "Projects", projects_path, + class: "#{current_page?(projects_path) ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'} transition-colors" %> <%= link_to "Links", links_path, class: "#{current_page?(links_path) ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'} transition-colors" %> diff --git a/config/routes.rb b/config/routes.rb index b3e173d..9ddce3a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -34,7 +34,7 @@ # handle old pages from capotej.com get "/about", to: redirect("/p/about") - get "/projects", to: redirect("/p/projects") + resources :projects, only: %i[ index new create edit update destroy ] get "/presentations", to: redirect("/p/presentations") get "/render-image-links-directly-inside-adium", to: "blog#redirect" get "/finagle-with-scala-bootstrapper", to: "blog#redirect" diff --git a/db/migrate/20260430005908_create_projects.rb b/db/migrate/20260430005908_create_projects.rb new file mode 100644 index 0000000..f93523d --- /dev/null +++ b/db/migrate/20260430005908_create_projects.rb @@ -0,0 +1,14 @@ +class CreateProjects < ActiveRecord::Migration[8.1] + def change + create_table :projects do |t| + t.string :name, null: false + t.integer :year + t.string :github_url + t.string :license + t.string :language + t.text :description + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 705f352..7e13aa3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,26 +10,26 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_08_30_213252) do +ActiveRecord::Schema[8.1].define(version: 2026_04_30_005908) do create_table "active_storage_attachments", force: :cascade do |t| - t.string "name", null: false - t.string "record_type", null: false - t.bigint "record_id", null: false t.bigint "blob_id", null: false t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", force: :cascade do |t| - t.string "key", null: false - t.string "filename", null: false - t.string "content_type" - t.text "metadata" - t.string "service_name", null: false t.bigint "byte_size", null: false t.string "checksum" + t.string "content_type" t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -40,95 +40,106 @@ end create_table "feed_posts", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "feed_id" t.string "guid" + t.boolean "promoted" + t.datetime "published_at" t.string "summary" - t.string "url" t.string "title" - t.integer "feed_id" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.datetime "published_at" - t.boolean "promoted" + t.string "url" t.index ["guid"], name: "index_feed_posts_on_guid", unique: true end create_table "feeds", force: :cascade do |t| - t.string "name", null: false - t.string "url", null: false t.datetime "created_at", null: false + t.string "name", null: false t.datetime "updated_at", null: false + t.string "url", null: false t.index ["name", "url"], name: "index_feeds_on_name_and_url", unique: true end create_table "links", force: :cascade do |t| - t.string "url" - t.string "title" - t.string "description" t.datetime "created_at", null: false + t.string "description" + t.string "title" t.datetime "updated_at", null: false + t.string "url" t.index ["url"], name: "index_links_on_url", unique: true end create_table "pages", force: :cascade do |t| - t.string "title" - t.string "slug" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.text "markdown_body" + t.string "slug" + t.string "title" + t.datetime "updated_at", null: false t.index ["slug"], name: "index_pages_on_slug", unique: true end create_table "papers", force: :cascade do |t| - t.string "title" - t.text "description" - t.string "url" t.datetime "created_at", null: false + t.text "description" + t.string "title" t.datetime "updated_at", null: false + t.string "url" t.index ["url"], name: "index_papers_on_url", unique: true end create_table "posts", force: :cascade do |t| - t.boolean "draft" - t.string "title" t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "slug" - t.text "markdown_excerpt" + t.boolean "draft" t.text "markdown_body" + t.text "markdown_excerpt" t.string "redirect_from" + t.string "slug" + t.string "title" + t.datetime "updated_at", null: false t.index ["draft"], name: "index_posts_on_draft" t.index ["redirect_from"], name: "index_posts_on_redirect_from", unique: true t.index ["slug"], name: "index_posts_on_slug", unique: true end + create_table "projects", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description" + t.string "github_url" + t.string "language" + t.string "license" + t.string "name", null: false + t.datetime "updated_at", null: false + t.integer "year" + end + create_table "sessions", force: :cascade do |t| - t.integer "user_id", null: false - t.string "ip_address" - t.string "user_agent" t.datetime "created_at", null: false + t.string "ip_address" t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false t.index ["user_id"], name: "index_sessions_on_user_id" end create_table "taggings", force: :cascade do |t| + t.datetime "created_at", null: false t.integer "post_id" t.integer "tag_id" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["post_id", "tag_id"], name: "index_taggings_on_post_id_and_tag_id", unique: true end create_table "tags", force: :cascade do |t| - t.string "name" t.datetime "created_at", null: false + t.string "name" t.datetime "updated_at", null: false t.index ["name"], name: "index_tags_on_name", unique: true end create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false t.string "email_address", null: false t.string "password_digest", null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["email_address"], name: "index_users_on_email_address", unique: true end diff --git a/test/controllers/projects_controller_test.rb b/test/controllers/projects_controller_test.rb new file mode 100644 index 0000000..c047ba1 --- /dev/null +++ b/test/controllers/projects_controller_test.rb @@ -0,0 +1,78 @@ +require "test_helper" + +class ProjectsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:one) + @project = projects(:abbey) + end + + test "should get index without authentication" do + get projects_url + assert_response :success + end + + test "should not get new without authentication" do + get new_project_url + assert_response :redirect + end + + test "should get new when authenticated" do + sign_in(@user) + get new_project_url + assert_response :success + end + + test "should create project when authenticated" do + sign_in(@user) + assert_difference("Project.count", 1) do + post projects_url, params: { + project: { + name: "New Project", + year: 2025, + github_url: "https://github.com/capotej/new-project", + license: "MIT", + language: "Go", + description: "A new project." + } + } + end + assert_redirected_to projects_url + end + + test "should not create project with invalid data" do + sign_in(@user) + assert_no_difference("Project.count") do + post projects_url, params: { project: { name: "" } } + end + assert_response :unprocessable_content + end + + test "should get edit when authenticated" do + sign_in(@user) + get edit_project_url(@project) + assert_response :success + end + + test "should update project when authenticated" do + sign_in(@user) + patch project_url(@project), params: { + project: { name: "Updated Abbey" } + } + assert_redirected_to projects_url + assert_equal "Updated Abbey", @project.reload.name + end + + test "should destroy project when authenticated" do + sign_in(@user) + assert_difference("Project.count", -1) do + delete project_url(@project) + end + assert_redirected_to projects_url + end + + private + + def sign_in(user) + post session_url, params: { email_address: user.email_address, password: "password" } + end +end diff --git a/test/fixtures/projects.yml b/test/fixtures/projects.yml new file mode 100644 index 0000000..61335b2 --- /dev/null +++ b/test/fixtures/projects.yml @@ -0,0 +1,15 @@ +abbey: + name: Abbey + year: 2024 + github_url: https://github.com/capotej/abbey + license: MIT + language: Ruby + description: A personal blog built with Rails. + +example: + name: Example Project + year: 2022 + github_url: https://github.com/capotej/example + license: Apache-2.0 + language: Go + description: An example Go project. diff --git a/test/models/project_test.rb b/test/models/project_test.rb new file mode 100644 index 0000000..30b15b7 --- /dev/null +++ b/test/models/project_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class ProjectTest < ActiveSupport::TestCase + test "valid project with all fields" do + project = Project.new( + name: "Abbey", + year: 2024, + github_url: "https://github.com/capotej/abbey", + license: "MIT", + language: "Ruby", + description: "A personal blog." + ) + assert project.valid? + end + + test "invalid without name" do + project = Project.new(name: nil) + assert_not project.valid? + assert_includes project.errors[:name], "can't be blank" + end + + test "ordered by year descending by default" do + old = Project.create!(name: "Old", year: 2020) + recent = Project.create!(name: "Recent", year: 2024) + assert_equal [ recent, old ], Project.where(id: [ old.id, recent.id ]).order(year: :desc) + end +end diff --git a/test/system/projects_test.rb b/test/system/projects_test.rb new file mode 100644 index 0000000..7750966 --- /dev/null +++ b/test/system/projects_test.rb @@ -0,0 +1,96 @@ +require "application_system_test_case" + +class ProjectsTest < ApplicationSystemTestCase + test "can view projects page publicly" do + visit projects_path + assert_selector "h1", text: "Projects" + assert_text "Abbey" + assert_text "A personal blog built with Rails." + end + + test "projects page shows GitHub link" do + visit projects_path + assert_link "Abbey", href: "https://github.com/capotej/abbey" + end + + test "projects are ordered by year descending" do + visit projects_path + # Abbey (2024) should appear before Example Project (2022) + first_position = page.text.index("Abbey") + second_position = page.text.index("Example Project") + assert first_position < second_position, "Abbey should appear before Example Project" + end + + test "projects page shows language and license badges" do + visit projects_path + assert_text "Ruby" + assert_text "MIT" + end + + test "can create a new project when logged in" do + sign_in_as users(:one) + visit new_project_path + + fill_in "Name", with: "New Lib" + fill_in "Year", with: "2025" + fill_in "Github url", with: "https://github.com/capotej/new-lib" + fill_in "License", with: "ISC" + fill_in "Language", with: "Rust" + fill_in "Description", with: "A new Rust library." + click_button "Create Project" + + assert_current_path projects_path + assert_text "New Lib" + assert_text "Rust" + assert_text "ISC" + end + + test "cannot create project with missing name" do + sign_in_as users(:one) + visit new_project_path + + click_button "Create Project" + assert_text "can't be blank" + end + + test "edit link visible on projects when logged in" do + sign_in_as users(:one) + visit projects_path + assert_link "Edit" + end + + test "edit link hidden on projects when logged out" do + visit projects_path + assert_no_link "Edit" + end + + test "can edit an existing project" do + sign_in_as users(:one) + visit projects_path + + click_link "Edit", match: :first + fill_in "Name", with: "Updated Abbey" + click_button "Update Project" + + assert_current_path projects_path + assert_text "Updated Abbey" + end + + test "can delete a project" do + sign_in_as users(:one) + visit projects_path + + click_link "Edit", match: :first + accept_confirm do + click_button "Delete" + end + + assert_current_path projects_path + assert_no_text "Abbey" + end + + test "unauthenticated user redirected to login when accessing new project form" do + visit new_project_path + assert_current_path new_session_path + end +end