diff --git a/db/seeds.rb b/db/seeds.rb index 0368308..44e0d20 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -83,6 +83,41 @@ # Backfill any plans without a plan type CoPlan::Plan.where(plan_type_id: nil).update_all(plan_type_id: general.id) +puts "Seeding tags on plans..." +CoPlan::Plan.includes(:tags, :plan_type).find_each do |p| + if p.tags.empty? && p.plan_type&.default_tags&.any? + p.tag_names = p.plan_type.default_tags + end +end + +# Add some demo plans with tags for local development +if CoPlan::Plan.count < 3 + rfc_type = CoPlan::PlanType.find_by(name: "RFC") + design_type = CoPlan::PlanType.find_by(name: "Design Doc") + + api_plan = CoPlan::Plans::Create.call( + title: "API Rate Limiting Strategy", + content: "# API Rate Limiting Strategy\n\n## Problem\n\nOur API endpoints have no rate limiting, leading to occasional abuse.\n\n## Proposal\n\nImplement token-bucket rate limiting at the gateway level.\n\n## Alternatives Considered\n\n- Per-IP limiting\n- API key quotas\n", + user: hampton, + plan_type_id: rfc_type&.id + ) + api_plan.update!(status: "considering") + api_plan.tag_names = ["api", "infrastructure", "security"] + + auth_plan = CoPlan::Plans::Create.call( + title: "Authentication System Redesign", + content: "# Authentication System Redesign\n\n## Goals\n\n- Migrate from session-based to token-based auth\n- Support SSO providers\n- Improve security posture\n\n## Architecture\n\nOIDC-based flow with JWT access tokens.\n", + user: hampton, + plan_type_id: design_type&.id + ) + auth_plan.update!(status: "developing") + auth_plan.tag_names = ["security", "infrastructure", "design"] +end + +# Tag the original Q3 roadmap if it has no tags +q3 = CoPlan::Plan.find_by(title: "Q3 Product Roadmap") +q3.tag_names = ["roadmap", "product"] if q3 && q3.tags.empty? + puts "Seeding automated plan reviewers..." CoPlan::AutomatedPlanReviewer.create_defaults diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index f6a7c2f..7430e20 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -374,6 +374,10 @@ img, svg { .badge--resolved { background: #d1fae5; color: var(--color-status-live); } .badge--agent { background: var(--color-agent-bg); color: var(--color-agent); font-size: 0.65rem; padding: 1px 6px; vertical-align: middle; } .badge--type { background: #f0fdf4; color: #166534; } +.badge--tag { background: #f3f4f6; color: #374151; text-decoration: none; font-size: 0.7rem; text-transform: none; letter-spacing: normal; } +.badge--tag:hover { background: #e5e7eb; color: #111827; } +.badge--tag-active { background: #374151; color: #fff; } +.badge--tag-active:hover { background: #1f2937; color: #fff; } /* Forms */ .form-group { @@ -525,10 +529,39 @@ img, svg { color: var(--color-primary); } +.plans-list__tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + margin-top: var(--space-xs); +} + .plans-list__meta { margin-top: var(--space-xs); } +.active-filter { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + background: #f9fafb; + border: 1px solid var(--color-border); + border-radius: var(--radius); + font-size: var(--text-sm); + margin-bottom: var(--space-md); +} + +.active-filter__clear { + color: var(--color-text-muted); + text-decoration: none; + font-size: var(--text-sm); +} + +.active-filter__clear:hover { + color: var(--color-text); +} + /* Plan form */ .plan-form__actions { display: flex; diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index 77a6349..d600399 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -3,10 +3,11 @@ class PlansController < ApplicationController before_action :set_plan, only: [:show, :edit, :update, :update_status] def index - @plans = Plan.includes(:plan_type).order(updated_at: :desc) + @plans = Plan.includes(:plan_type, :tags).order(updated_at: :desc) @plans = @plans.where(status: params[:status]) if params[:status].present? @plans = @plans.where(created_by_user: current_user) if params[:scope] == "mine" @plans = @plans.where(plan_type_id: params[:plan_type]) if params[:plan_type].present? + @plans = @plans.with_tag(params[:tag]) if params[:tag].present? @plan_types = PlanType.order(:name) diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index eac9e91..e45b5cf 100644 --- a/engine/app/models/coplan/plan.rb +++ b/engine/app/models/coplan/plan.rb @@ -21,6 +21,8 @@ class Plan < ApplicationRecord validates :title, presence: true validates :status, presence: true, inclusion: { in: STATUSES } + scope :with_tag, ->(name) { joins(:tags).where(coplan_tags: { name: name }) } + def self.ransackable_attributes(auth_object = nil) %w[id title status plan_type_id created_by_user_id current_plan_version_id current_revision created_at updated_at] end diff --git a/engine/app/views/coplan/plans/_header.html.erb b/engine/app/views/coplan/plans/_header.html.erb index 4edb56d..ec8a065 100644 --- a/engine/app/views/coplan/plans/_header.html.erb +++ b/engine/app/views/coplan/plans/_header.html.erb @@ -6,6 +6,9 @@ <% if plan.plan_type %> · <%= plan.plan_type.name %> <% end %> + <% plan.tags.each do |tag| %> + <%= link_to tag.name, plans_path(tag: tag.name), class: "badge badge--tag" %> + <% end %>
diff --git a/engine/app/views/coplan/plans/index.html.erb b/engine/app/views/coplan/plans/index.html.erb index 51311a3..a7f2e69 100644 --- a/engine/app/views/coplan/plans/index.html.erb +++ b/engine/app/views/coplan/plans/index.html.erb @@ -3,26 +3,33 @@
- <%= link_to "All Plans", plans_path(params.permit(:status)), class: "status-filter #{'status-filter--active' if params[:scope].blank?}" %> - <%= link_to "My Plans", plans_path(params.permit(:status).merge(scope: "mine")), class: "status-filter #{'status-filter--active' if params[:scope] == 'mine'}" %> + <%= link_to "All Plans", plans_path(params.permit(:status, :tag)), class: "status-filter #{'status-filter--active' if params[:scope].blank?}" %> + <%= link_to "My Plans", plans_path(params.permit(:status, :tag).merge(scope: "mine")), class: "status-filter #{'status-filter--active' if params[:scope] == 'mine'}" %>
- <%= link_to "All", plans_path(params.permit(:scope, :plan_type)), class: "status-filter #{'status-filter--active' if params[:status].blank?}" %> + <%= link_to "All", plans_path(params.permit(:scope, :plan_type, :tag)), class: "status-filter #{'status-filter--active' if params[:status].blank?}" %> <% CoPlan::Plan::STATUSES.each do |status| %> - <%= link_to status.titleize, plans_path(params.permit(:scope, :plan_type).merge(status: status)), class: "status-filter status-filter--#{status} #{'status-filter--active' if params[:status] == status}" %> + <%= link_to status.titleize, plans_path(params.permit(:scope, :plan_type, :tag).merge(status: status)), class: "status-filter status-filter--#{status} #{'status-filter--active' if params[:status] == status}" %> <% end %>
<% if @plan_types.any? %>
- <%= link_to "All Types", plans_path(params.permit(:scope, :status)), class: "status-filter #{'status-filter--active' if params[:plan_type].blank?}" %> + <%= link_to "All Types", plans_path(params.permit(:scope, :status, :tag)), class: "status-filter #{'status-filter--active' if params[:plan_type].blank?}" %> <% @plan_types.each do |pt| %> - <%= link_to pt.name, plans_path(params.permit(:scope, :status).merge(plan_type: pt.id)), class: "status-filter #{'status-filter--active' if params[:plan_type] == pt.id}" %> + <%= link_to pt.name, plans_path(params.permit(:scope, :status, :tag).merge(plan_type: pt.id)), class: "status-filter #{'status-filter--active' if params[:plan_type] == pt.id}" %> <% end %>
<% end %> +<% if params[:tag].present? %> +
+ Filtered by tag: <%= params[:tag] %> + <%= link_to "✕ Clear", plans_path(params.permit(:scope, :status, :plan_type)), class: "active-filter__clear" %> +
+<% end %> + <% if @plans.any? %>
<% @plans.each do |plan| %> @@ -38,6 +45,13 @@ <%= plan_unread %> <% end %>
+ <% if plan.tags.any? %> +
+ <% plan.tags.each do |tag| %> + <%= link_to tag.name, plans_path(params.permit(:scope, :status, :plan_type).merge(tag: tag.name)), class: "badge badge--tag #{'badge--tag-active' if params[:tag] == tag.name}" %> + <% end %> +
+ <% end %>
by <%= plan.created_by_user.name %> · v<%= plan.current_revision %> · updated <%= time_ago_in_words(plan.updated_at) %> ago
diff --git a/spec/requests/plans_spec.rb b/spec/requests/plans_spec.rb index ae778bc..2e49308 100644 --- a/spec/requests/plans_spec.rb +++ b/spec/requests/plans_spec.rb @@ -31,6 +31,41 @@ expect(response.body).not_to include(bobs_plan.title) end + it "index filters by tag" do + plan.tag_names = ["infra"] + other = create(:plan, :considering, created_by_user: alice, title: "Other Plan") + other.tag_names = ["frontend"] + get plans_path(tag: "infra") + expect(response).to have_http_status(:success) + expect(response.body).to include(plan.title) + expect(response.body).not_to include("Other Plan") + end + + it "index shows tag badges on plan cards" do + plan.tag_names = ["infra", "api"] + get plans_path + expect(response.body).to include("badge--tag") + expect(response.body).to include("infra") + expect(response.body).to include("api") + end + + it "index shows active tag filter bar" do + plan.tag_names = ["infra"] + get plans_path(tag: "infra") + expect(response.body).to include("active-filter") + expect(response.body).to include("infra") + expect(response.body).to include("Clear") + end + + it "show plan renders tag badges in header" do + plan.tag_names = ["infra", "security"] + get plan_path(plan) + expect(response).to have_http_status(:success) + expect(response.body).to include("badge--tag") + expect(response.body).to include("infra") + expect(response.body).to include("security") + end + it "show plan renders successfully" do get plan_path(plan) expect(response).to have_http_status(:success)