Merge branch 'main' into plain-text-mentions

* main: (48 commits)
  Improve layout and truncation
  This is annoying
  Fix summary
  Remove index columns
  Same treatment for the events index
  New menu filters and navigates
  Don't test for _Added by_ events
  Indention
  @collection is always available when card scoped
  Style
  Remove check for _Added by..._ line
  The placeholder attribute is on the `house-md` element
  Duh, no before destroy commit, but this is still not right!
  No long transactions!
  Don't display published event in threads
  But feels more essentially part of the User, so list first
  Not related to the other two
  Missing controller
  Style
  Errant `bubbles` => `cards` replacement
  ...

# Conflicts:
#	app/models/card.rb
#	app/models/user.rb
#	db/schema.rb
#	db/schema_cache.yml
This commit is contained in:
Jorge Manrubia
2025-04-23 17:24:01 +02:00
58 changed files with 402 additions and 283 deletions
+9 -9
View File
@@ -223,24 +223,24 @@ GEM
net-protocol
net-ssh (7.3.0)
nio4r (2.7.4)
nokogiri (1.18.7)
nokogiri (1.18.8)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.7-aarch64-linux-gnu)
nokogiri (1.18.8-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-aarch64-linux-musl)
nokogiri (1.18.8-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.7-arm-linux-gnu)
nokogiri (1.18.8-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-arm-linux-musl)
nokogiri (1.18.8-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.7-arm64-darwin)
nokogiri (1.18.8-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-darwin)
nokogiri (1.18.8-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-gnu)
nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-musl)
nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.6.1)
parallel (1.26.3)
+9 -5
View File
@@ -116,6 +116,10 @@
}
}
.card__content {
flex: 2 1 auto;
}
.card__title {
--hover-size: 0;
--input-border-radius: 0;
@@ -123,7 +127,6 @@
--lines: 3;
color: inherit;
flex: 2 1 75%;
font-size: var(--text-xx-large);
font-weight: 900;
line-height: 1.2;
@@ -148,10 +151,11 @@
.card__stages {
display: flex;
flex: 1 1 25%;
flex: 0 1 auto;
flex-direction: column;
max-width: 100%;
min-width: 0;
justify-self: end;
max-inline-size: 20ch;
min-inline-size: 15ch;
padding-block: var(--block-space-half);
}
@@ -214,10 +218,10 @@
flex-direction: column;
inset: auto var(--block-space) var(--block-space-double) auto;
justify-content: space-between;
mix-blend-mode: multiply;
padding: var(--block-space-half) calc(var(--inline-space) * 1.5);
position: absolute;
transform: rotate(-10deg);
z-index: -1;
}
.card__closed-by {
+2 -9
View File
@@ -11,8 +11,6 @@
--column-gap: clamp(var(--inline-space), 3cqmin, calc(var(--inline-space) * 3));
--grid-lines: 0.1rem;
display: grid;
grid-template-columns: 1fr 5fr 1fr;
margin: auto;
}
@@ -61,15 +59,10 @@
}
.events__popup {
.panel:is(&) {
max-block-size: calc(100dvh - (2 * var(--block-space-double)));
max-inline-size: calc(100dvw - (2 * var(--block-space-double)));
}
margin-block-start: var(--block-space-double);
#header:has(&) {
max-inline-size: 100%;
.events__index-header:has(&) {
position: relative;
z-index: 5;
}
}
+3 -1
View File
@@ -1,6 +1,8 @@
@layer components {
/* Editor */
house-md {
--placeholder: attr(placeholder);
display: flex;
flex-direction: column;
flex-grow: 1;
@@ -65,7 +67,7 @@
padding: 1rem;
&.house-md-content-empty::before {
content: attr(placeholder);
content: var(--placeholder);
color: currentColor;
opacity: 0.5;
pointer-events: none;
+5
View File
@@ -94,6 +94,11 @@
@media (prefers-color-scheme: dark) {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
}
option {
background-color: var(--color-bg);
color: var(--color-ink);
}
}
.input--textarea {
@@ -1,5 +1,5 @@
@layer components {
.comments {
.markdown-content {
:where(h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6) {
font-weight: 800;
hyphens: auto;
+7 -4
View File
@@ -31,6 +31,7 @@
--text-xx-large: 2em;
border: 1px solid var(--color-bg);
view-transition-name: unset !important;
}
.card__collection {
@@ -42,16 +43,18 @@
padding-block-end: 0;
}
.card__link {
z-index: 1;
}
.card__title {
--lines: 1;
font-size: var(--text-small);
font-weight: bold;
min-block-size: 0;
}
.card__title-link {
--lines: 1;
}
.card__stages {
padding: 0;
}
+85 -14
View File
@@ -1,19 +1,42 @@
@layer components {
.popup {
--panel-border-radius: 0.5em;
--panel-padding: 0.4em 0.5em 0.5em;
--panel-padding: var(--block-space);
--panel-size: auto;
inline-size: auto;
inset: 0 auto auto 0;
min-inline-size: 20ch;
max-inline-size: 40ch;
inset: 0 auto auto 50%;
min-inline-size: min(20ch, 90vw);
max-inline-size: min(50ch, 90vw);
position: absolute;
transform: translateX(-50%);
z-index: 2;
&[open] {
display: flex;
}
#header:has(&) {
max-inline-size: 100%;
position: relative;
z-index: 5;
}
}
.popup__group {
background: var(--color-subtle-light);
max-inline-size: 100%;
padding-inline: var(--inline-space);
.btn {
--btn-background: transparent;
}
@media (any-hover: hover) {
&:hover {
background: var(--color-selected);
}
}
}
.popup__item {
@@ -25,17 +48,10 @@
--hover-size: 0;
--outline-color: transparent;
flex: 1 1 auto;
justify-content: start;
&::after {
border-radius: var(--btn-border-radius);
border: var(--btn-border-size) solid var(--btn-border-color, var(--color-subtle));
content: "";
display: block;
inline-size: calc(100% - var(--btn-padding) * 2);
block-size: calc(100% - var(--btn-padding) * 2);
}
max-inline-size: 100%;
text-align: start;
@media (any-hover: hover) {
&:where(:not(:active):hover) {
@@ -78,7 +94,62 @@
row-gap: 2px;
}
.popup__new {
--btn-icon-size: 1em;
.icon {
margin-inline-end: 0.5em;
}
padding-inline-start: var(--inline-space-half);
}
.popup__title {
font-weight: 800;
}
}
.popup--animated {
--backdrop-speed: 150ms;
--panel-size: max-content;
--speed: 150ms;
opacity: 0;
transform: scale(0.2) translateX(-50%);
transform-origin: top left;
transition:
display var(--speed) allow-discrete,
opacity var(--speed),
overlay var(--speed) allow-discrete,
transform var(--speed);
&::backdrop {
background-color: var(--color-always-black);
opacity: 0;
transform: scale(1);
transition:
display var(--backdrop-speed) allow-discrete,
opacity var(--backdrop-speed),
overlay var(--backdrop-speed) allow-discrete;
}
&[open] {
opacity: 1;
transform: scale(1) translateX(-50%);
&::backdrop {
opacity: 0.5;
}
}
@starting-style {
&[open] {
opacity: 0;
transform: scale(0.2) translateX(-50%);
}
&[open]::backdrop {
opacity: 0;
}
}
}
-8
View File
@@ -27,13 +27,5 @@
background-color: color(from var(--card-color) srgb r g b / 0.65);
}
}
.ticket & {
--btn-background: var(--color-ink);
background: var(--card-color);
border-radius: 0.2em;
color: var(--color-ink-reversed);
}
}
}
+1 -1
View File
@@ -28,6 +28,6 @@ class Cards::PinsController < ApplicationController
end
def render_pin_button_replacement
turbo_stream.replace [ @card, :pin_button ], partial: "cards/pins/pin_button", locals: { card: @card }
render turbo_stream: turbo_stream.replace([ @card, :pin_button ], partial: "cards/pins/pin_button", locals: { card: @card })
end
end
+2 -14
View File
@@ -1,20 +1,8 @@
class Cards::StagingsController < ApplicationController
include CardScoped
before_action :set_stage
def create
if @stage
@card.toggle_stage @stage
else
@card.update!(stage: nil)
end
def update
@card.change_stage_to @collection.workflow.stages.find(params[:stage_id])
render_card_replacement
end
private
def set_stage
@stage = Workflow::Stage.find_by(id: params[:stage_id])
end
end
+1 -1
View File
@@ -15,7 +15,7 @@ module CardsHelper
**options
end
def card_article_tag(card, id: dom_id(card, :ticket), **options, &block)
def card_article_tag(card, id: dom_id(card, :article), **options, &block)
classes = [
options.delete(:class),
("card--golden" if card.golden?),
+3 -3
View File
@@ -2,14 +2,14 @@ module WorkflowsHelper
def button_to_set_stage(card, stage)
button_to \
tag.span(stage.name, class: "overflow-ellipsis"),
card_stagings_path(card, stage_id: stage),
method: :post,
card_staging_path(card, stage_id: stage),
method: :put,
class: [ "btn justify-start workflow-stage txt-uppercase workflow-stage", { "workflow-stage--current": stage == card.stage } ],
form_class: "flex align-center gap-half",
data: { turbo_frame: "_top" }
end
def stage_color(stage)
stage.color.presence || Colorable::DEFAULT_COLOR
stage.color.presence || Card::DEFAULT_COLOR
end
end
@@ -47,7 +47,7 @@ export default class extends Controller {
#triggerChangeEvent(newValue) {
if (this.inputTarget.tagName === "HOUSE-MD") {
this.inputTarget.dispatchEvent(new CustomEvent('house-md:change', {
cards: true,
bubbles: true,
detail: {
previousContent: '',
newContent: newValue
@@ -12,7 +12,7 @@ export default class extends Controller {
for (const file of files) {
const uploadEvent = new CustomEvent("house-md:before-upload", {
cards: true,
bubbles: true,
detail: { file },
cancelable: true
})
+2 -2
View File
@@ -7,7 +7,7 @@ module Card::Closeable
has_one :closure, dependent: :destroy
scope :closed, -> { joins(:closure) }
scope :active, -> { where.missing(:closure) }
scope :open, -> { where.missing(:closure) }
scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) }
scope :due_to_be_closed, -> { considering.where(last_active_at: ..AUTO_CLOSE_AFTER.ago) }
@@ -29,7 +29,7 @@ module Card::Closeable
closure.present?
end
def active?
def open?
!closed?
end
+4 -1
View File
@@ -1,8 +1,11 @@
module Card::Colored
extend ActiveSupport::Concern
COLORS = %w[ #3b3633 #67695e #eb7a32 #bf7c2b #c09c6f #746b1e #2c6da8 #5d618f #663251 #ff63a8 ]
DEFAULT_COLOR = "#2c6da8"
def color
color_from_stage || Colorable::DEFAULT_COLOR
color_from_stage || DEFAULT_COLOR
end
private
+4 -4
View File
@@ -6,8 +6,8 @@ module Card::Engageable
included do
has_one :engagement, dependent: :destroy, class_name: "Card::Engagement"
scope :considering, -> { published_or_drafted_by(Current.user).active.where.missing(:engagement) }
scope :doing, -> { published.active.joins(:engagement) }
scope :considering, -> { published_or_drafted_by(Current.user).open.where.missing(:engagement) }
scope :doing, -> { published.open.joins(:engagement) }
scope :stagnated, -> { doing.where(last_active_at: ..STAGNATED_AFTER.ago) }
scope :by_engagement_status, ->(status) do
@@ -29,11 +29,11 @@ module Card::Engageable
end
def doing?
active? && published? && engagement.present?
open? && published? && engagement.present?
end
def considering?
active? && published? && engagement.blank?
open? && published? && engagement.blank?
end
def engage
+1 -2
View File
@@ -2,10 +2,9 @@ module Card::Golden
extend ActiveSupport::Concern
included do
scope :golden, -> { joins(:goldness) }
has_one :goldness, dependent: :destroy, class_name: "Card::Goldness"
scope :golden, -> { joins(:goldness) }
scope :with_golden_first, -> { left_outer_joins(:goldness).prepend_order("card_goldnesses.id IS NULL") }
end
+4 -10
View File
@@ -4,7 +4,7 @@ module Card::Staged
included do
belongs_to :stage, class_name: "Workflow::Stage", optional: true
after_create :assign_initial_stage
before_create :assign_initial_stage
scope :in_stage, ->(stage) { where stage: stage }
end
@@ -13,21 +13,15 @@ module Card::Staged
stage&.workflow
end
def toggle_stage(stage)
new_stage, event = self.stage_id == stage.id ? [ nil, :unstaged ] : [ stage, :staged ]
def change_stage_to(new_stage)
transaction do
update! stage: new_stage
track_event event, stage_id: stage.id, stage_name: stage.name
track_event :staged, stage_id: new_stage.id, stage_name: new_stage.name
end
end
private
def assign_initial_stage
if workflow_stage = collection.workflow&.stages&.first
self.stage = workflow_stage
save! touch: false
track_event :staged, stage_id: workflow_stage.id, stage_name: workflow_stage.name
end
self.stage = collection.initial_workflow_stage
end
end
+1 -9
View File
@@ -1,18 +1,10 @@
class Collection < ApplicationRecord
include Accessible, Broadcastable, Filterable
include Accessible, Broadcastable, Filterable, Workflowing
belongs_to :creator, class_name: "User", default: -> { Current.user }
belongs_to :workflow, optional: true
has_many :cards, dependent: :destroy
has_many :tags, -> { distinct }, through: :cards
after_save :update_cards_workflow, if: :saved_change_to_workflow_id?
scope :alphabetically, -> { order("lower(name)") }
private
def update_cards_workflow
cards.update_all(stage_id: workflow&.stages&.first&.id)
end
end
+18
View File
@@ -0,0 +1,18 @@
module Collection::Workflowing
extend ActiveSupport::Concern
included do
belongs_to :workflow, optional: true
after_update_commit :set_all_cards_to_initial_workflow_stage, if: :saved_change_to_workflow_id?
end
def initial_workflow_stage
workflow&.stages&.first
end
private
def set_all_cards_to_initial_workflow_stage
cards.update_all(stage_id: initial_workflow_stage&.id)
end
end
-10
View File
@@ -1,10 +0,0 @@
module Colorable
extend ActiveSupport::Concern
COLORS = %w[ #3b3633 #67695e #eb7a32 #bf7c2b #c09c6f #746b1e #2c6da8 #5d618f #663251 #ff63a8 ]
DEFAULT_COLOR = "#2c6da8"
included do
attribute :color, default: DEFAULT_COLOR
end
end
+3 -1
View File
@@ -9,7 +9,9 @@ module Filterable
end
private
# FIXME: This is too inefficient to have part of a destroy transaction.
# Need to find a way to use a job or a single query.
def remove_from_filters
filters.each { |filter| filter.resource_removed self }
filters.each { it.resource_removed self }
end
end
-2
View File
@@ -13,8 +13,6 @@ class EventSummary < ApplicationRecord
def summarize(event)
case event.action
when "published"
"Added by #{event.creator.name}."
when "assigned"
"Assigned to #{event.assignees.pluck(:name).to_sentence}."
when "unassigned"
+1 -1
View File
@@ -19,7 +19,7 @@ class Filter < ApplicationRecord
def cards
@cards ||= begin
result = creator.accessible_cards.indexed_by(indexed_by)
result = result.active unless indexed_by.closed?
result = result.open unless indexed_by.closed?
result = result.by_engagement_status(engagement_status) if engagement_status.present?
result = result.unassigned if assignment_status.unassigned?
result = result.assigned_to(assignees.ids) if assignees.present?
+6 -6
View File
@@ -1,5 +1,7 @@
class User < ApplicationRecord
include Accessor, Assignee, Avatar, Mentionable, Named, Role, Transferable
include Accessor, Assignee, Named, Role, Transferable
has_one_attached :avatar
has_many :sessions, dependent: :destroy
has_secure_password validations: false
@@ -16,11 +18,9 @@ class User < ApplicationRecord
normalizes :email_address, with: ->(value) { value.strip.downcase }
def deactivate
transaction do
sessions.delete_all
accesses.destroy_all
update! active: false, email_address: deactived_email_address
end
sessions.delete_all
accesses.destroy_all
update! active: false, email_address: deactived_email_address
end
private
-17
View File
@@ -1,17 +0,0 @@
module User::Avatar
extend ActiveSupport::Concern
included do
has_one_attached :avatar
end
class_methods do
def from_avatar_token(sid)
find_signed!(sid, purpose: :avatar)
end
end
def avatar_token
signed_id(purpose: :avatar)
end
end
+1 -2
View File
@@ -7,7 +7,6 @@ class Workflow < ApplicationRecord
private
def create_default_stages
Workflow::Stage.insert_all \
DEFAULT_STAGES.collect { |default_stage_name| { workflow_id: id, name: default_stage_name } }
Workflow::Stage.insert_all(DEFAULT_STAGES.map { { name: it, workflow_id: id } })
end
end
+1 -1
View File
@@ -19,7 +19,7 @@
<%= render "cards/display/perma/tags", card: card %>
</header>
<div class="card__body flex gap full-width flex-item-grow">
<div class="card__body flex gap full-width flex-item-grow justify-space-between">
<%= render "cards/container/title", card: card %>
<%= render "cards/stagings/stages", card: card if card.doing? %>
</div>
+2 -2
View File
@@ -1,8 +1,8 @@
<%= turbo_frame_tag @card, :assignment do %>
<strong class="popup__title margin-block-start-half pad-inline-half txt-nowrap">Assign this to…</strong>
<strong class="popup__title pad-inline-half txt-nowrap">Assign this to…</strong>
<div class="flex flex-column full-width popup__list">
<% @card.collection.users.active.alphabetically.each do |user| %>
<% @collection.users.active.alphabetically.each do |user| %>
<%= button_to card_assignments_path(@card, params: { assignee_id: user.id }), method: :post, class: "btn popup__item full-width" do %>
<span class="overflow-ellipsis"><%= user.name %></span>
<%= icon_tag "check", size: 18, class: "checked flex-item-justify-end" if @card.assignees.include?(user) %>
+2 -2
View File
@@ -16,12 +16,12 @@
<% end %>
<%= link_to edit_card_comment_path(comment.card, comment),
class: "comment__edit btn btn--plain txt-xx-small fill-transparent translucent flex-item-justify-end" do %>
class: "comment__edit btn btn--plain txt-xx-small fill-transparent translucent flex-item-justify-end" do %>
<%= icon_tag "menu-dots-horizontal" %> <span class="for-screen-reader">Edit this comment</span>
<% end %>
</div>
<div class="comment__body txt-align-start">
<div class="comment__body markdown-content txt-align-start">
<%= sanitize comment.body_html %>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@
</figure>
<div class="comment__content flex-inline flex-column full-width">
<div class="comment__body txt-align-start">
<div class="comment__body markdown-content txt-align-start">
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width",
data: { controller: "form paste local-save",
local_save_key_value: "comment-#{card.id}",
+1 -1
View File
@@ -1,6 +1,6 @@
<%= turbo_frame_tag dom_id(@comment) do %>
<div class="comment__content comment--edit flex-inline flex-column full-width">
<div class="comment__body txt-align-start">
<div class="comment__body markdown-content txt-align-start">
<%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width",
data: { controller: "form paste", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop paste->paste#pasteFiles" } do |form| %>
<%= form.markdown_area :body, class: "input comment__input", required: true, autofocus: true, placeholder: new_comment_placeholder(@card) %>
+5 -5
View File
@@ -1,23 +1,23 @@
<% if card.published? %>
<%= turbo_frame_tag card, :edit do %>
<div class="full-width">
<div class="card__content">
<h1 class="card__title flex align-start gap-half">
<%= link_to card.title, edit_collection_card_path(card.collection, card), class: "card__title-link" %>
</h1>
<p class="card__description margin-block-half">
<div class="card__description markdown-content margin-block-half">
<%= sanitize card.description_html %>
</p>
</div>
</div>
<% end %>
<% else %>
<%= form_with model: card, url: collection_card_path(card.collection, card), id: "card_form", class: "full-width", data: { controller: "auto-save" } do |form| %>
<%= form_with model: card, url: collection_card_path(card.collection, card), id: "card_form", class: "card__content", data: { controller: "paste auto-save" } do |form| %>
<h1 class="card__title">
<%= form.text_area :title, placeholder: "Name it…",
class: "input input--textarea full-width borderless txt-align-start",
autofocus: card.title.blank?,
data: { action: "auto-save#change blur->auto-save#submit keydown.enter->auto-save#submit:prevent keydown.ctrl+enter->auto-save#submit:prevent" } %>
</h1>
<div class="card__description margin-block-half">
<div class="card__description markdown-content margin-block-half">
<%= form.markdown_area :description, class: "input input--textarea full-width borderless txt-align-start card-field__description",
placeholder: "Add some notes, context, pictures, or video about this…",
data: { action: "house-md:change->auto-save#change focusout->auto-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop paste->paste#pasteFiles" } %>
+9 -7
View File
@@ -5,14 +5,16 @@
<%= render "cards/display/preview/tags", card: card %>
</header>
<div class="card__body flex gap full-width flex-item-grow">
<h1 class="card__title overflow-line-clamp">
<%= card.title %>
</h1>
<div class="card__body flex gap full-width flex-item-grow justify-space-between">
<div class="card__content">
<h1 class="card__title overflow-line-clamp">
<%= card.title %>
</h1>
<%= link_to collection_card_path(card.collection, card), class: "card__link" do %>
<span class="for-screen-reader"><%= card.title %></span>
<% end %>
<%= link_to collection_card_path(card.collection, card), class: "card__link", data: { turbo_frame: "_top" } do %>
<span class="for-screen-reader"><%= card.title %></span>
<% end %>
</div>
<%= render "cards/stagings/stages", card: card if card.doing? %>
</div>
@@ -20,7 +20,8 @@
Reported by <strong><%= familiar_name_for(card.creator) %></strong>
</span>
<span class="card__meta-item card__meta-item--assigned overflow-ellipsis">
<%= card.assignees.any? ? card.assignees.map { |assignee| "Assigned to <strong>#{familiar_name_for(assignee)}</strong>" }.to_sentence.html_safe : "Not assigned" %>
<%= card.assignees.any? ? "Assigned to" : "Not assigned" %>
<%= card.assignees.map { |assignee| "<strong>#{familiar_name_for(assignee)}</strong>" }.to_sentence.html_safe %>
</span>
</div>
+3 -3
View File
@@ -1,5 +1,5 @@
<%= turbo_frame_tag @card, :edit do %>
<%= form_with model: @card, url: collection_card_path(@card.collection, @card), class: "full-width", data: { controller: "form paste" } do |form| %>
<%= form_with model: @card, url: collection_card_path(@card.collection, @card), class: "card__content", data: { controller: "form paste" } do |form| %>
<h1 class="card__title">
<%= form.label :title, class: "flex flex-column align-center" do %>
<%= form.text_area :title, class: "input input--textarea full-width borderless txt-align-start card-field__title",
@@ -8,12 +8,12 @@
data: { action: "keydown.enter->form#submit:prevent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel focus->form#select" } %>
<% end %>
</h1>
<div class="card__description margin-block-half">
<div class="card__description markdown-content margin-block-half">
<%= form.markdown_area :description, class: "input input--textarea full-width borderless txt-align-start card-field__description",
placeholder: "Add some notes, context, pictures, or video about this…",
data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop paste->paste#pasteFiles" } %>
</div>
<%= form.button "Save changes", type: :submit, class: "btn"%>
<%= form.button "Save changes", type: :submit, class: "btn" %>
<%= link_to "Close editor and discard changes", collection_card_path(@card.collection, @card), data: { form_target: "cancel" }, hidden: true %>
<% end %>
<% end %>
@@ -1,23 +1,29 @@
<%= form_with url: cards_path, method: :get, class: "flex flex-column full-width popup__list",
<%= form_with url: cards_path, method: :get, class: "flex flex-column max-width popup__list",
data: { controller: "form" } do |form| %>
<% filter.as_params.except(:collection_ids).each do |key, value| %>
<%= filter_hidden_field_tag key, value %>
<% end %>
<%= link_to cards_path(filter.as_params.except(:collection_ids)), class: "btn popup__item" do %>
<span class="overflow-ellipsis">All collections</span>
<% end %>
<% Current.user.collections.order(:name).each do |collection| %>
<div class="btn popup__item">
<%= form.check_box "collection_ids[]", {
checked: filter.collections.include?(collection),
data: { action: "change->form#submit" },
include_hidden: false,
}, collection.id %>
<div class="popup__group flex align-center">
<div class="btn txt-xx-small flex-item-no-shrink">
<%= form.check_box "collection_ids[]", {
checked: filter.collections.include?(collection),
data: { action: "change->form#submit" },
include_hidden: false,
}, collection.id %>
<%= form.label "collection_ids[]", collection.name, for: dom_id(collection, :filter), class: "overflow-ellipsis" %>
<%= icon_tag "check", size: 18, class: "checked flex-item-justify-end" %>
<span class="for-screen-reader">
Toggle filter for <%= collection.name %>
</span>
<%= icon_tag "check", size: 18, class: "checked" %>
</div>
<%= link_to cards_path(collection_ids: [ collection ]), class: "btn popup__item min-width" do %>
<span class="overflow-ellipsis"><%= collection.name %></span>
<% end %>
<span class="translucent flex-item-no-shrink"> </span>
</div>
<% end %>
<% end %>
+12 -3
View File
@@ -5,15 +5,24 @@
<div class="btn btn--placeholder flex-item-justify-start"></div>
<header class="flex flex-inline flex-column center flex-shrink">
<div class="card-filter--collection flex-inline center position-relative" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<div class="card-filter--collection flex-inline center position-relative" data-controller="dialog" data-action="click@document->dialog#closeOnClickOutside">
<button class="txt-align-center input input--select borderless center txt-medium" data-action="click->dialog#open:stop">
<h1 class="txt-large overflow-ellipsis margin-none">
<%= filter_title filter %>
</h1>
</button>
<dialog class="events__popup popup panel flex-column align-start gap-half fill-white shadow" data-dialog-target="dialog" data-action="turbo:before-cache@document->dialog#close">
<strong class="popup__title margin-block-start-half pad-inline-half">In Collection</strong>
<dialog class="popup popup--animated panel flex-column align-start gap-half fill-white shadow" data-dialog-target="dialog" data-action="keydown.esc->dialog#close:stop turbo:before-cache@document->dialog#close">
<div class="flex align-center full-width gap-half">
<span class="btn borderless" style="pointer-events: none; --icon-size: 1.3em; --btn-padding: 0 0.6em 0 0.3em;">
<%= icon_tag "filter" %>
</span>
<strong class="popup__title txt-medium">Collections</strong>
<button class="btn borderless txt-x-small flex-item-justify-end" data-action="click->dialog#close" >
<%= icon_tag "remove-med" %>
<span class="for-screen-reader">Close</span>
</button>
</div>
<%= render "cards/index/collections_filter", filter: filter %>
</dialog>
+2 -2
View File
@@ -1,9 +1,9 @@
<%= turbo_frame_tag @card, :tagging do %>
<dialog class="popup panel flex-column align-start justify-start fill-white shadow" data-dialog-target="dialog">
<strong class="popup__title margin-block-half pad-inline-half">Tag this…</strong>
<strong class="popup__title margin-block-end-half pad-inline-half">Tag this…</strong>
<%= form_with url: card_taggings_path(@card), class: "flex gap-half align-center full-width pad-inline-half margin-block-end" do |form| %>
<%= form.text_field :tag_title, placeholder: "New tag name...", class: "input txt-small full-width" %>
<%= form.text_field :tag_title, placeholder: "New tag name...", class: "input txt-small full-width", autocomplete: "off" %>
<%= form.button "Add a tag…", type: "submit", class: "btn txt-small" do %>
<%= icon_tag "add" %>
<span class="for-screen-reader">Add a tag</span>
+37 -14
View File
@@ -1,5 +1,5 @@
<% presenter = CollectionFilterPresenter.new(@collections, params, cookies) %>
<div class="position-relative max-width margin-block-end-half"
<div class="max-width margin-block-end-half"
data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<button class="events__filter-btn input input--select flex-inline center min-width max-width txt-small" data-action="click->dialog#open:stop">
<span class="overflow-ellipsis center">
@@ -7,22 +7,45 @@
</span>
</button>
<dialog class="events__popup popup panel flex-column full-width align-start gap-half fill-white shadow"
style="z-index: 5; inset: 0 0 auto auto;" data-dialog-target="dialog" data-action="turbo:before-cache@document->dialog#close">
<strong class="popup__title margin-block-start-half pad-inline-half txt-nowrap">Filter by Collection…</strong>
<dialog class="events__popup popup popup--animated panel flex-column full-width align-start gap-half fill-white shadow"
style="z-index: 5;" data-dialog-target="dialog" data-action="turbo:before-cache@document->dialog#close">
<div class="flex align-center full-width gap-half">
<span class="btn borderless" style="pointer-events: none; --icon-size: 1.3em; --btn-padding: 0 0.6em 0 0.3em;">
<%= icon_tag "filter" %>
</span>
<strong class="popup__title txt-medium">Collections</strong>
</div>
<%= form_with url: events_path, method: :get, class: "flex flex-column full-width popup__list",
data: { controller: "form" } do |form| %>
<%= link_to "Show everything", events_path(clear_filter: true), class: "btn popup__item txt-nowrap" %>
<% @collections.each do |collection| %>
<div class="btn popup__item">
<%= form.check_box "collection_ids[]", {
checked: (params[:collection_ids] || cookies[:collection_filter]&.split(","))&.include?(collection.id.to_s),
data: { action: "change->form#submit" },
include_hidden: false,
}, collection.id %>
<%#= link_to "Show everything", events_path(clear_filter: true), class: "btn popup__item txt-nowrap" %>
<div class="popup__group flex align-center full-width gap-half overflow-ellipsis">
<%= link_to new_collection_path, class: "btn popup__new popup__item", style: "view-transition-name: new-collection" do %>
<%= icon_tag "add" %>
<span>Add a new collection</span>
<% end %>
</div>
<hr class="full-width separator--horizontal" style="--border-color: transparent;">
<%= form.label "collection_ids[]", collection.name, for: dom_id(collection, :filter), class: "overflow-ellipsis" %>
<%= icon_tag "check", class: "checked flex-item-justify-end" %>
<% @collections.each do |collection| %>
<div class="popup__group flex align-center full-width gap-half overflow-ellipsis">
<div class="btn txt-xx-small">
<%= form.check_box "collection_ids[]", {
checked: (params[:collection_ids] || cookies[:collection_filter]&.split(","))&.include?(collection.id.to_s),
data: { action: "change->form#submit" },
include_hidden: false,
}, collection.id %>
<span class="for-screen-reader">
Toggle filter for <%= collection.name %>
</span>
<%= icon_tag "check", size: 18, class: "checked flex-item-justify-end" %>
</div>
<%= link_to cards_path(collection_ids: [ collection ]), class: "btn popup__item flex-item-grow justify-space-between" do %>
<span class="overflow-ellipsis flex-item-grow"><%= collection.name %></span>
<span class="translucent"> </span>
<% end %>
</div>
<% end %>
<% end %>
-26
View File
@@ -17,36 +17,10 @@
<% end %>
<div class="events__container align-start gap">
<div class="txt-align-start">
<h2 class="txt-normal txt-uppercase txt-nowrap">Custom views</h2>
<div style="border-block-start: 1px solid var(--color-subtle); padding-block-start: var(--block-space-half);">
<% @filters.each do |filter| %>
<h3 class="txt-small margin-block-end-half txt-tight-lines">
<%= link_to filter.summary, cards_path(**filter.as_params) %>
</h3>
<% end %>
</div>
</div>
<div class="flex flex-column">
<div id="activity" class="events__index flex flex-column">
<%= render "events/day", activity_day: @activity_day, next_day: @next_day, events: @events %>
</div>
<%= event_next_page_link(@next_day) %>
</div>
<div class="txt-align-start">
<div class="flex align-start gap min-width" >
<h2 class="txt-normal txt-uppercase overflow-ellipsis">Collections</h2>
<%= link_to new_collection_path, class: "btn txt-xx-small flex-item-justify-end flex-item-no-shrink", style: "view-transition-name: new-collection" do %>
<%= icon_tag "add" %>
<span class="for-screen-reader">New</span>
<% end %>
</div>
<div style="border-block-start: 1px solid var(--color-subtle); padding-block-start: var(--block-space-half);">
<% @collections.each do |collection| %>
<h3 class="txt-small margin-block-end-half txt-tight-lines"><%= link_to collection.name, cards_path(collection_ids: [ collection ]) %></h3>
<% end %>
</div>
</div>
</div>
+1 -1
View File
@@ -6,7 +6,7 @@
<dialog class="color-picker panel fill-white shadow gap-half" data-dialog-target="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<%= form_with model: stage, url: workflow_stage_path(stage.workflow, stage), class: "flex gap-half", data: { controller: "form", action: "change->form#submit" } do |form| %>
<% Colorable::COLORS.each do |color| %>
<% Card::COLORS.each do |color| %>
<span class="color-picker__color">
<label class="btn btn--circle" style="--btn-background: <%= color %>" title="<%= color %>">
<%= form.radio_button :color, color %>
+9 -26
View File
@@ -1,31 +1,14 @@
#!/usr/bin/env ruby
require "fileutils"
APP_ROOT = File.expand_path("..", __dir__)
def system!(*args) system(*args, exception: true) end
def system!(*args)
system(*args, exception: true)
end
puts "== Installing dependencies =="
system("gem install bundler --conservative")
system("bundle check") || system!("bundle install")
FileUtils.chdir APP_ROOT do
# This script is a way to set up or update your development environment automatically.
# This script is idempotent, so that you can run it at any time and get an expectable outcome.
# Add necessary setup steps to this file.
puts "\n== Preparing database =="
system! "bin/rails db:prepare"
system! "bin/rails db:reset" if ARGV.include?("--reset")
puts "== Installing dependencies =="
system("gem install bundler --conservative")
system("bundle check") || system!("bundle install")
puts "\n== Preparing database =="
system! "bin/rails db:prepare"
system! "bin/rails db:reset" if ARGV.include?("--reset")
puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear"
unless ARGV.include?("--skip-server")
puts "\n== Starting development server =="
STDOUT.flush # flush the output before exec(2) so that it displays
exec "bin/dev"
end
end
puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear"
+2 -2
View File
@@ -26,17 +26,17 @@ Rails.application.routes.draw do
resources :cards do
scope module: :cards do
resource :engagement
resource :goldness
resource :image
resource :pin
resource :closure
resource :publish
resource :reading
resource :recover
resource :staging
resource :watch
resource :goldness
resources :assignments
resources :stagings
resources :taggings
resources :comments do
@@ -0,0 +1,5 @@
class RemoveDueDateFromEvents < ActiveRecord::Migration[8.1]
def change
remove_column :events, :due_date
end
end
+64 -10
View File
@@ -3,19 +3,73 @@ create_tenant "honcho"
david = create_first_run "David Heinemeier Hansson", "david@37signals.com"
login_as david
jz = create_user "Jason Zimdars", "jz@37signals.com"
jason = create_user "Jason Zimdars", "jz@37signals.com"
kevin = create_user "Kevin Mcconnell", "kevin@37signals.com"
sarah = create_user "Sarah Johnson", "sarah@37signals.com"
mike = create_user "Mike Peterson", "mike@37signals.com"
create_collection("Mucho", access_to: [ jz, kevin ]).tap do |fizzy|
create_card("Prepare MUCHO sign-up page", description: "We need to do this before the launch.", collection: fizzy)
# Array of authors for random assignment
authors = [ david, jason, kevin, sarah, mike ]
create_card("Prepare MUCHO sign-up page", description: "We need to do this before the launch.", collection: fizzy).tap do |card|
card.toggle_assignment(kevin)
card.engage
end
# Card titles for reuse across collections
card_titles = [
"Implement authentication",
"Design landing page",
"Set up database",
"Create API endpoints",
"Write unit tests",
"Optimize performance",
"Add user profiles",
"Implement search",
"Create admin panel",
"Set up CI/CD",
"Design logo",
"Create documentation",
"Add payment system",
"Implement notifications",
"Set up analytics",
"Create mobile layout",
"Add social sharing",
"Implement caching",
"Set up monitoring",
"Create error handling"
]
create_card("Plain MUCHO text mentions", description: "We'll support plain text mentions first.", collection: fizzy).tap do |card|
card.toggle_assignment(david)
card.close
# Create 10 collections
collections = [
"Project Launch",
"Frontend Dev",
"Backend Dev",
"Design System",
"Testing Suite",
"Performance",
"User Experience",
"API Development",
"DevOps",
"Documentation"
]
collections.each_with_index do |collection_name, index|
create_collection(collection_name, access_to: authors.sample(3)).tap do |collection|
# Create 20 unique cards for each collection
card_titles.each do |title|
create_card(title,
description: "#{title} for #{collection_name} phase #{index + 1}.",
collection: collection
).tap do |card|
# Randomly assign to 1-2 authors
card.toggle_assignment(authors.sample)
card.toggle_assignment(authors.sample) if rand > 0.5
# Randomly set card state
case rand(3)
when 0
card.engage
when 1
card.close
# 2 remains open
end
end
end
end
end
@@ -0,0 +1,12 @@
require "test_helper"
class Cards::StagingsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "update" do
put card_staging_path(cards(:logo)), params: { stage_id: workflow_stages(:qa_in_progress).id }, as: :turbo_stream
assert_card_container_rerendered(cards(:logo))
end
end
+4
View File
@@ -1,6 +1,7 @@
logo:
collection: writebook
creator: david
stage: qa_triage
title: The logo isn't big enough
due_on: <%= 3.days.from_now %>
created_at: <%= 1.week.ago %>
@@ -10,6 +11,7 @@ logo:
layout:
collection: writebook
creator: david
stage: qa_triage
title: Layout is broken
created_at: <%= 1.week.ago %>
status: published
@@ -18,6 +20,7 @@ layout:
text:
collection: writebook
creator: kevin
stage: qa_triage
title: The text is too small
created_at: <%= 1.week.ago %>
status: published
@@ -27,6 +30,7 @@ text:
shipping:
collection: writebook
creator: kevin
stage: qa_triage
title: We need to ship the app
created_at: <%= 1.week.ago %>
status: published
-14
View File
@@ -25,19 +25,5 @@ class CardMessagesTest < ActionDispatch::IntegrationTest
assert_predicate card.messages.last, :event_summary?
assert_equal 1, card.messages.last.event_summary.events.count
assert_equal "assigned", card.messages.last.messageable.events.last.action
# Stage it
post card_stagings_path(card), params: { stage_id: workflow_stages(:qa_triage).id }
assert_equal 3, card.messages.count
assert_predicate card.messages.last, :event_summary?
assert_equal 2, card.messages.last.event_summary.events.count
assert_equal "staged", card.messages.last.messageable.events.last.action
# Unstage it
post card_stagings_path(card), params: { stage_id: workflow_stages(:qa_triage).id }
assert_equal 3, card.messages.count
assert_predicate card.messages.last, :event_summary?
assert_equal 3, card.messages.last.event_summary.events.count
assert_equal "unstaged", card.messages.last.messageable.events.last.action
end
end
+1 -1
View File
@@ -3,7 +3,7 @@ require "test_helper"
class Card::CloseableTest < ActiveSupport::TestCase
test "closed scope" do
assert_equal [ cards(:shipping) ], Card.closed
assert_not_includes Card.active, cards(:shipping)
assert_not_includes Card.open, cards(:shipping)
end
test "popping" do
+2 -2
View File
@@ -2,11 +2,11 @@ require "test_helper"
class Card::ColoredTest < ActiveSupport::TestCase
test "use default color no stage" do
assert_equal Colorable::DEFAULT_COLOR, cards(:logo).color
assert_equal Card::DEFAULT_COLOR, cards(:logo).color
end
test "use default color not in doing stage" do
assert_equal Colorable::DEFAULT_COLOR, cards(:text).color
assert_equal Card::DEFAULT_COLOR, cards(:text).color
end
test "infer color from stage when in doing stage" do
+12
View File
@@ -0,0 +1,12 @@
require "test_helper"
class Card::StagedTest < ActiveSupport::TestCase
setup { Current.session = sessions(:david) }
test "change stage" do
assert_difference -> { Event.where(action: :staged).count }, +1 do
cards(:logo).change_stage_to(workflow_stages(:qa_in_progress))
assert_equal workflow_stages(:qa_in_progress), cards(:logo).reload.stage
end
end
end
+2 -2
View File
@@ -70,8 +70,8 @@ class CardTest < ActiveSupport::TestCase
assert_equal [ cards(:shipping) ], Card.closed
end
test "active" do
assert_equal cards(:logo, :layout, :text), Card.active
test "open" do
assert_equal cards(:logo, :layout, :text), Card.open
end
test "unassigned" do
@@ -0,0 +1,13 @@
require "test_helper"
class Collection::WorkflowingTest < ActiveSupport::TestCase
test "change all card stages when changing workflow" do
collections(:writebook).update! workflow: workflows(:on_call)
assert_equal [ collections(:writebook).initial_workflow_stage ], collections(:writebook).cards.reload.collect(&:stage).uniq
end
test "remove stage from cards if workflow is removed" do
collections(:writebook).update! workflow: nil
assert_equal [], collections(:writebook).cards.reload.collect(&:stage).compact
end
end
+1 -2
View File
@@ -2,8 +2,7 @@ require "test_helper"
class EventSummaryTest < ActiveSupport::TestCase
test "body" do
assert_equal "Added by David. Assigned to JZ.", event_summaries(:logo_initial_activity).body
assert_equal " Assigned to JZ.", event_summaries(:logo_initial_activity).body
assert_equal "Assigned to Kevin.", event_summaries(:logo_second_activity).body
assert_equal "Added by Kevin.", event_summaries(:text_initial_activity).body
end
end
+3 -3
View File
@@ -5,11 +5,11 @@ class FilterTest < ActiveSupport::TestCase
Current.set session: sessions(:david) do
@new_collection = Collection.create! name: "Inaccessible Collection"
@new_card = @new_collection.cards.create!
@new_card.update!(stage: workflow_stages(:qa_triage))
@new_card.update!(stage: workflow_stages(:qa_on_hold))
cards(:layout).capture Comment.new(body: "I hate haggis")
cards(:logo).capture Comment.new(body: "I love haggis")
cards(:logo).update(stage: workflow_stages(:qa_triage))
cards(:logo).update(stage: workflow_stages(:qa_on_hold))
end
assert_not_includes users(:kevin).filters.new.cards, @new_card
@@ -17,7 +17,7 @@ class FilterTest < ActiveSupport::TestCase
filter = users(:david).filters.new creator_ids: [ users(:david).id ], tag_ids: [ tags(:mobile).id ]
assert_equal [ cards(:layout) ], filter.cards
filter = users(:david).filters.new stage_ids: [ workflow_stages(:qa_triage).id ]
filter = users(:david).filters.new stage_ids: [ workflow_stages(:qa_on_hold).id ]
assert_equal [ cards(:logo), @new_card ], filter.cards
filter = users(:david).filters.new assignment_status: "unassigned", collection_ids: [ @new_collection.id ]