Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 33 additions & 0 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion engine/app/controllers/coplan/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions engine/app/models/coplan/plan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions engine/app/views/coplan/plans/_header.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
<% if plan.plan_type %>
· <span class="badge badge--type"><%= plan.plan_type.name %></span>
<% end %>
<% plan.tags.each do |tag| %>
<%= link_to tag.name, plans_path(tag: tag.name), class: "badge badge--tag" %>
<% end %>
</div>
</div>
<div class="page-header__actions">
Expand Down
26 changes: 20 additions & 6 deletions engine/app/views/coplan/plans/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,33 @@
</div>

<div class="status-filters">
<%= 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'}" %>
</div>

<div class="status-filters">
<%= 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 %>
</div>

<% if @plan_types.any? %>
<div class="status-filters">
<%= 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 %>
</div>
<% end %>

<% if params[:tag].present? %>
<div class="active-filter">
Filtered by tag: <span class="badge badge--tag badge--tag-active"><%= params[:tag] %></span>
<%= link_to "✕ Clear", plans_path(params.permit(:scope, :status, :plan_type)), class: "active-filter__clear" %>
</div>
<% end %>

<% if @plans.any? %>
<div class="plans-list">
<% @plans.each do |plan| %>
Expand All @@ -38,6 +45,13 @@
<span class="inbox-badge"><%= plan_unread %></span>
<% end %>
</div>
<% if plan.tags.any? %>
<div class="plans-list__tags">
<% 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 %>
</div>
<% end %>
<div class="plans-list__meta text-sm text-muted">
by <%= plan.created_by_user.name %> · v<%= plan.current_revision %> · updated <%= time_ago_in_words(plan.updated_at) %> ago
</div>
Expand Down
35 changes: 35 additions & 0 deletions spec/requests/plans_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading