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
470 changes: 279 additions & 191 deletions engine/app/assets/stylesheets/coplan/application.css

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions engine/app/controllers/coplan/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ def allowed_to?(record, action)
end
helper_method :allowed_to?

# A bottom-corner toast as a Turbo Stream — append it to any
# turbo_stream response so in-place actions get feedback without a
# top-of-page flash (which would mean scrolling the reader away).
def toast_stream(message, kind)
turbo_stream.append("coplan-toasts",
helpers.content_tag(:div, message,
class: "flash flash--#{kind} toasts__toast",
role: "status",
data: { controller: "coplan--toast" }))
end

# Fires once per successful, signed-in HTML GET. Skips Turbo Frame
# requests (those are partial reloads within an already-counted page),
# non-2xx responses, agent/API traffic, and anything that isn't HTML.
Expand Down
8 changes: 0 additions & 8 deletions engine/app/controllers/coplan/attachments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,5 @@ def count_stream(id, count)
turbo_stream.replace(id,
html: helpers.content_tag(:span, count, class: "section-count", id: id))
end

def toast_stream(message, kind)
turbo_stream.append("coplan-toasts",
helpers.content_tag(:div, message,
class: "flash flash--#{kind} toasts__toast",
role: "status",
data: { controller: "coplan--toast" }))
end
end
end
55 changes: 47 additions & 8 deletions engine/app/controllers/coplan/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,8 @@ def show
# Old ?tab=history links: history is its own page now (the other
# former tabs are same-page sections).
return redirect_to history_plan_path(@plan) if params[:tab] == "history"
# Folder-jump discovery: every shelf this plan sits on. The plan
# itself is already authorized above, and placements inherit the
# plan's visibility — a shelf never reveals more than the plan does.
# The viewer's own placement (if any) drives the toolbar's
# Save/Saved state and the folder navigator's current-folder mark.
@shelf_placements = @plan.placements
.includes(:library, folder: { parent: :parent })
.order(:created_at)
Expand Down Expand Up @@ -257,9 +256,9 @@ def preview
render html: html, layout: false
end

# Publishing is the one-way door out of draft: explicit, confirmed in
# the UI, and irreversible by design (archive is the tool for "done
# with this", not unpublish).
# One direction of the header visibility toggle: share a private plan
# with the whole org. The other direction is #hide — visibility is a
# two-way switch (archive is the tool for "done with this").
def publish
authorize!(@plan, :publish?)
@plan.update!(visibility: "published")
Expand All @@ -279,6 +278,10 @@ def publish
via: "web"
)
respond_to do |format|
# The toggle fetches this: the header re-render carries the new
# state flag, so the page repaints even when the ActionCable
# broadcast can't reach this browser.
format.turbo_stream { render turbo_stream: visibility_streams("Shared with everyone in the org.") }
format.json { render json: { visibility: @plan.visibility } }
format.html { redirect_to plan_path(@plan), notice: "Plan published — everyone can see it now." }
end
Expand Down Expand Up @@ -306,25 +309,36 @@ def hide
via: "web"
)
respond_to do |format|
format.turbo_stream { render turbo_stream: visibility_streams("Private again — hidden from lists and search.") }
format.json { render json: { visibility: @plan.visibility } }
format.html { redirect_to plan_path(@plan), notice: "Plan is private again — hidden from lists and search." }
end
end

# Archiving happens in place: the banner appears (with Restore — the
# undo), the toolbar's menu loses its Archive entry, and a toast
# confirms. No navigation, so the consequence is visible right where
# the action happened.
def archive
authorize!(@plan, :archive?)
@plan.update!(archived_at: Time.current)
broadcast_plan_update(@plan)
Plans::LogEvent.call(plan: @plan, actor: current_user, event_type: "archived")
redirect_to plan_path(@plan), notice: "Plan archived. It's hidden from lists unless someone filters for archived plans."
respond_to do |format|
format.turbo_stream { render turbo_stream: archive_streams("Archived — hidden from lists, still readable at this URL.") }
format.html { redirect_to plan_path(@plan), notice: "Plan archived. It's hidden from lists unless someone filters for archived plans." }
end
end

def unarchive
authorize!(@plan, :unarchive?)
@plan.update!(archived_at: nil)
broadcast_plan_update(@plan)
Plans::LogEvent.call(plan: @plan, actor: current_user, event_type: "unarchived")
redirect_to plan_path(@plan), notice: "Plan restored."
respond_to do |format|
format.turbo_stream { render turbo_stream: archive_streams("Plan restored.") }
format.html { redirect_to plan_path(@plan), notice: "Plan restored." }
end
end

def toggle_checkbox
Expand Down Expand Up @@ -663,5 +677,30 @@ def set_plan
def broadcast_plan_update(plan)
Broadcaster.replace_to(plan, target: "plan-header", partial: "coplan/plans/header", locals: { plan: plan })
end

# Turbo Streams for a visibility change: re-render the header (the
# byline's Private flag) and the toolbar (whose menu offers the
# opposite direction now) in the acting browser, and confirm with a
# toast.
def visibility_streams(message)
[
turbo_stream.replace("plan-header", partial: "coplan/plans/header", locals: { plan: @plan }),
turbo_stream.replace("plan-toolbar", partial: "coplan/plans/toolbar", locals: { plan: @plan }),
toast_stream(message, "notice")
]
end

# Turbo Streams for archive/restore: the banner slot is the loud,
# visible consequence (it appears with a Restore button — the undo),
# the header's byline picks up/drops the Archived flag, and the
# toolbar re-renders so its menu tracks the new state.
def archive_streams(message)
[
turbo_stream.replace("plan-header", partial: "coplan/plans/header", locals: { plan: @plan }),
turbo_stream.replace("plan-banner-slot", partial: "coplan/plans/banner", locals: { plan: @plan }),
turbo_stream.replace("plan-toolbar", partial: "coplan/plans/toolbar", locals: { plan: @plan }),
toast_stream(message, "notice")
]
end
end
end
16 changes: 12 additions & 4 deletions engine/app/helpers/coplan/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,20 @@ def user_avatar(user, size: "sm")
end
end

def coplan_environment_badge
# Non-production environments mark the nav logo instead of adding a
# badge next to the brand — a badge changes the nav's layout, so dev
# and staging would never reproduce production's spacing. The logo
# gets a colored ring (same palette as the favicon) and a tooltip.
def coplan_environment_logo_modifier
return "" if Rails.env.production?

" site-nav__logo--#{Rails.env}"
end

def coplan_environment_logo_title
return if Rails.env.production?

label = Rails.env.capitalize
colors = FAVICON_COLORS.fetch(Rails.env, FAVICON_COLORS["development"])
tag.span(label, class: "env-badge", style: "background: #{colors[:start]};")
"#{Rails.env.capitalize} environment"
end
end
end
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
import { Controller } from "@hotwired/stimulus"

// The "save to library" bookmark. Behaves like a browser bookmark star:
// unsaved, a click opens the folder navigator popover (the viewer's folder
// tree, a little filesystem) to pick where it goes; saved, a click just
// removes it — no dialog, no toast — and you can re-add if you want. One
// shared popover per page — triggers carry the plan's move URL, current
// folder, and saved state so rows and the plan header can all reuse it.
// The folder navigator popover: the viewer's folder tree, a little
// filesystem. Every trigger opens it — Save on someone's plan, "Move to
// folder…" in the plan menu, workspace row fallbacks. Triggers carry the
// plan's move URL, current folder, and saved state; already-saved triggers
// get a "Remove from library" row inside the navigator, so removal is a
// deliberate labeled choice instead of a surprise toggle on the trigger.
export default class extends Controller {
static targets = ["modal", "title"]
static targets = ["modal", "title", "heading", "remove", "removeLabel"]

open(event) {
event.preventDefault()
const trigger = event.currentTarget
this._moveUrl = trigger.dataset.moveUrl
this._currentFolderId = trigger.dataset.currentFolderId || ""

// Already saved: the second click unbookmarks, quietly.
if (trigger.dataset.saved === "true") {
this._patch("", { quiet: true })
return
if (this.hasHeadingTarget) {
this.headingTarget.textContent = trigger.dataset.pickerHeading || "Save to library"
}
if (this.hasRemoveTarget) {
this.removeTarget.hidden = trigger.dataset.saved !== "true"
if (this.hasRemoveLabelTarget) {
// "Remove" means different things per trigger: a reader letting a
// saved plan go entirely vs. an owner unfiling their own plan.
this.removeLabelTarget.textContent = trigger.dataset.removeLabel || "Remove from library"
}
}

if (this.hasTitleTarget && trigger.dataset.planTitle) {
this.titleTarget.textContent = trigger.dataset.planTitle
}
Expand All @@ -45,6 +50,13 @@ export default class extends Controller {
this._patch(folderId)
}

// The "Remove from library" row inside the navigator (only shown when
// the trigger was already saved).
remove() {
if (!this._moveUrl) return
this._patch("")
}

_patch(folderId, { quiet = false } = {}) {
const token = document.querySelector('meta[name="csrf-token"]')?.content
fetch(this._moveUrl, {
Expand Down
41 changes: 41 additions & 0 deletions engine/app/javascript/controllers/coplan/menu_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Controller } from "@hotwired/stimulus"

// A small dropdown menu on the native Popover API (light-dismiss and Esc
// for free). Popovers open centered by default; this anchors the menu
// under its trigger's right edge on every open, and closes it on scroll
// so it never floats away from the button.
export default class extends Controller {
static targets = ["trigger", "menu"]

reposition(event) {
if (event.newState !== "open") {
this.#stopWatchingScroll()
return
}
const rect = this.triggerTarget.getBoundingClientRect()
const menu = this.menuTarget
menu.style.position = "fixed"
menu.style.margin = "0"
menu.style.top = `${rect.bottom + 6}px`
menu.style.left = "auto"
menu.style.right = `${Math.max(window.innerWidth - rect.right, 8)}px`
this.#watchScroll()
}

close() {
try { this.menuTarget.hidePopover() } catch {}
}

disconnect() {
this.#stopWatchingScroll()
}

#watchScroll() {
this.scrollHandler ||= () => this.close()
window.addEventListener("scroll", this.scrollHandler, { passive: true, once: true })
}

#stopWatchingScroll() {
if (this.scrollHandler) window.removeEventListener("scroll", this.scrollHandler)
}
}

This file was deleted.

This file was deleted.

2 changes: 1 addition & 1 deletion engine/app/models/coplan/plan_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def broadcast_history_update
Broadcaster.replace_to(
plan,
target: "history-count",
html: ApplicationController.helpers.content_tag(:span, count, class: "section-count", id: "history-count")
html: ApplicationController.helpers.content_tag(:span, ApplicationController.helpers.pluralize(count, "entry"), class: "section-count", id: "history-count")
)
end
end
Expand Down
2 changes: 1 addition & 1 deletion engine/app/models/coplan/plan_version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def broadcast_history_update
Broadcaster.replace_to(
plan,
target: "history-count",
html: ApplicationController.helpers.content_tag(:span, count, class: "section-count", id: "history-count")
html: ApplicationController.helpers.content_tag(:span, ApplicationController.helpers.pluralize(count, "entry"), class: "section-count", id: "history-count")
)
end

Expand Down
13 changes: 13 additions & 0 deletions engine/app/views/coplan/plans/_banner.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<%# The plan's full-width state banner (archived, for now). Always renders
the slot div so Turbo Streams can replace it in both directions —
archiving inserts the banner, restoring empties it. %>
<div id="plan-banner-slot">
<% if plan.archived? %>
<div class="plan-banner plan-banner--archived">
<span class="plan-banner__text">This plan is archived — it stays readable at this URL but is hidden from lists and search results.</span>
<% if allowed_to?(plan, :unarchive?) %>
<%= button_to "Restore", unarchive_plan_path(plan), method: :patch, class: "btn btn--secondary btn--sm" %>
<% end %>
</div>
<% end %>
</div>
Loading
Loading