diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..ba24bdab3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(git merge:*)", + "Bash(git add:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/Gemfile b/Gemfile index c5ac64538..2778cbdb0 100644 --- a/Gemfile +++ b/Gemfile @@ -40,10 +40,13 @@ gem "sentry-ruby" gem "sentry-rails" gem "rails_structured_logging", bc: "rails-structured-logging" gem "yabeda" -gem "yabeda-rails" -gem "yabeda-puma-plugin" -gem "webrick" # required for yabeda-prometheus metrics server +gem "yabeda-activejob", github: "rosa/yabeda-activejob", branch: "support-end-time-in-seconds" +gem "yabeda-gc" +gem "yabeda-http_requests" gem "yabeda-prometheus-mmap" +gem "yabeda-puma-plugin" +gem "yabeda-rails" +gem "webrick" # required for yabeda-prometheus metrics server gem "prometheus-client-mmap", "~> 1.1" # AI diff --git a/Gemfile.lock b/Gemfile.lock index 4d38c63d0..243cc1489 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -197,6 +197,15 @@ GIT tsort (>= 0.2) zeitwerk (~> 2.6) +GIT + remote: https://github.com/rosa/yabeda-activejob.git + revision: 08207e7dddf6f4dd1d039c1ddf792b35655a3feb + branch: support-end-time-in-seconds + specs: + yabeda-activejob (0.5.0) + rails (>= 5.2) + yabeda (~> 0.6) + PATH remote: gems/fizzy-saas specs: @@ -215,9 +224,9 @@ GEM activemodel (>= 5.0.0.a) activesupport (>= 5.0.0.a) builder (~> 3.1) - activerecord-tenanted (0.3.0) - activerecord (>= 8.1.alpha) - railties (>= 8.1.alpha) + activerecord-tenanted (0.4.1) + activerecord (>= 8.1.beta) + railties (>= 8.1.beta) zeitwerk activeresource (6.2.0) activemodel (>= 7.0) @@ -548,6 +557,9 @@ GEM sentry-ruby (5.27.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + sniffer (0.5.0) + anyway_config (>= 1.0) + dry-initializer (~> 3) solid_cable (3.0.12) actioncable (>= 7.2) activejob (>= 7.2) @@ -569,11 +581,11 @@ GEM sqlite-vec (0.1.7.alpha.2-x86_64-darwin) sqlite-vec (0.1.7.alpha.2-x86_64-linux) sqlite-vec (0.1.7.alpha.2-x86_64-mingw32) - sqlite3 (2.7.3) + sqlite3 (2.7.4) mini_portile2 (~> 2.8.0) - sqlite3 (2.7.3-arm64-darwin) - sqlite3 (2.7.3-x86_64-darwin) - sqlite3 (2.7.3-x86_64-linux-gnu) + sqlite3 (2.7.4-arm64-darwin) + sqlite3 (2.7.4-x86_64-darwin) + sqlite3 (2.7.4-x86_64-linux-gnu) sshkit (1.24.0) base64 logger @@ -627,6 +639,11 @@ GEM anyway_config (>= 1.0, < 3) concurrent-ruby dry-initializer + yabeda-gc (0.4.0) + yabeda (~> 0.6) + yabeda-http_requests (0.2.1) + sniffer + yabeda yabeda-prometheus-mmap (0.4.0) prometheus-client-mmap yabeda (~> 0.10) @@ -704,6 +721,9 @@ DEPENDENCIES webmock webrick yabeda + yabeda-activejob! + yabeda-gc + yabeda-http_requests yabeda-prometheus-mmap yabeda-puma-plugin yabeda-rails diff --git a/app/assets/images/close.svg b/app/assets/images/close.svg index 878343d21..c3066ce69 100644 --- a/app/assets/images/close.svg +++ b/app/assets/images/close.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 7735be1f7..0f6c4c2c1 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -326,7 +326,7 @@ --color-terminal-bg: var(--color-canvas); --color-terminal-text-light: oklch(var(--lch-green-dark)); - --color-golden: oklch(var(--lch-blue-light)); + --color-golden: oklch(var(--lch-blue-medium)); --shadow: 0 0 0 1px oklch(var(--lch-black) / 0.42), 0 .2em 1.6em -0.8em oklch(var(--lch-black) / 0.6), diff --git a/app/assets/stylesheets/bar.css b/app/assets/stylesheets/bar.css index 8370ef900..be5eb6a2e 100644 --- a/app/assets/stylesheets/bar.css +++ b/app/assets/stylesheets/bar.css @@ -3,6 +3,7 @@ --row-gap: 0.2lh; background-color: var(--color-terminal-bg); + block-size: var(--footer-height); color: var(--color-terminal-text); display: flex; flex-direction: column; @@ -17,14 +18,18 @@ @media (prefers-color-scheme: dark) { border-block: 1px solid var(--color-ink-lighter); } + + &:has(.bar__placeholder[hidden]) { + padding-inline: 1ch; + } } .bar__input { - transform: translateX(50%); + transform: translateY(50%); transition: transform 350ms cubic-bezier(0.25, 1.25, 0.5, 1); .bar:has(.bar__placeholder[hidden]) & { - transform: translateX(0); + transform: translateY(0); } } diff --git a/app/assets/stylesheets/bubble.css b/app/assets/stylesheets/bubble.css index 132e10b84..e334b8582 100644 --- a/app/assets/stylesheets/bubble.css +++ b/app/assets/stylesheets/bubble.css @@ -54,7 +54,7 @@ } .bubble--doing, - .bubble--considering { + .bubble--on_deck { inset-inline-start: 100%; translate: -60% -60%; diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index b8a30c7f6..11e52668c 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -64,6 +64,7 @@ .btn[aria-label]:where(:has(.icon)), .btn:where(:has(.for-screen-reader):has(.icon)) { --btn-padding: 0; + --icon-size: 75%; aspect-ratio: 1; block-size: var(--btn-size); diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 26d3c3ede..5d36ad6c0 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -282,11 +282,13 @@ /* ------------------------------------------------------------------------ */ .cards--considering { + --gradient-opacity: 25%; + background-color: var(--color-canvas); background-image: linear-gradient(90deg, transparent 0%, - color-mix(in srgb, var(--color-considering) 25%, transparent) 33%, - color-mix(in srgb, var(--color-considering) 25%, transparent) 66%, + color-mix(in srgb, var(--color-considering) var(--gradient-opacity), transparent) 33%, + color-mix(in srgb, var(--color-considering) var(--gradient-opacity), transparent) 66%, transparent 100%); @supports(mask: radial-gradient(black)) { @@ -305,6 +307,51 @@ } } + @media (min-width: 640px) { + padding-inline: var(--cards-gap); + } + + @media (prefers-color-scheme: dark) { + --gradient-opacity: 10%; + } + + .cards__decoration { + --gradient-opacity: 30%; + --size: 4.25em; + + background-color: var(--color-canvas); + background-image: radial-gradient(ellipse at bottom, + color-mix(in srgb, var(--color-considering) var(--gradient-opacity), transparent) 0%, + transparent 70%); + block-size: var(--size); + inline-size: 100%; + inset: calc(var(--size) * -1) 0 auto 0; + position: absolute; + z-index: -1; + + @media (prefers-color-scheme: dark) { + --gradient-opacity: 15%; + } + + @supports(mask: radial-gradient(black)) { + &:before { + background: radial-gradient(ellipse at bottom, + color-mix(in srgb, var(--color-considering) var(--gradient-opacity), transparent) 0%, + transparent 70%); + content: ""; + inset: 0; + mask: radial-gradient(6px at 50% calc(100% + 3px), transparent calc(99% - 2px), black calc(101% - 2px) 99%, transparent 101%) calc(50% - 8px) calc(50% - 3px + .5px)/16px 6px, + radial-gradient(6px at 50% -3px, transparent calc(99% - 2px), black calc(101% - 2px) 99%, transparent 101%) 50% calc(50% + 3px)/16px 6px; + position: absolute; + z-index: 0; + } + } + + &.cards__decoration--end { + inset: auto 0 calc(var(--size) * -1); + rotate: 180deg; + } + } .card { --avatar-size: 2.75em; --text-small: 1.1em; @@ -364,38 +411,6 @@ } } - .cards__decoration { - --size: 4.25em; - - background-color: var(--color-canvas); - background-image: radial-gradient(ellipse at bottom, - color-mix(in srgb, var(--color-considering) 30%, transparent) 0%, - transparent 70%); - block-size: var(--size); - inline-size: 100%; - inset: calc(var(--size) * -1) 0 auto 0; - position: absolute; - z-index: -1; - - @supports(mask: radial-gradient(black)) { - &:before { - background: radial-gradient(ellipse at bottom, - color-mix(in srgb, var(--color-considering) 30%, transparent) 0%, - transparent 70%); - content: ""; - inset: 0; - mask: radial-gradient(6px at 50% calc(100% + 3px), transparent calc(99% - 2px), black calc(101% - 2px) 99%, transparent 101%) calc(50% - 8px) calc(50% - 3px + .5px)/16px 6px, - radial-gradient(6px at 50% -3px, transparent calc(99% - 2px), black calc(101% - 2px) 99%, transparent 101%) 50% calc(50% + 3px)/16px 6px; - position: absolute; - z-index: 0; - } - } - - &.cards__decoration--end { - inset: auto 0 calc(var(--size) * -1); - rotate: 180deg; - } - } .card.card--new { --border-color: var(--color-selected-dark); diff --git a/app/assets/stylesheets/filters.css b/app/assets/stylesheets/filters.css index f5ad4dc34..db259ee7c 100644 --- a/app/assets/stylesheets/filters.css +++ b/app/assets/stylesheets/filters.css @@ -13,7 +13,6 @@ } .filters { - .btn { --btn-border-color: var(--color-ink-medium); --input-background: var(--color-canvas); @@ -111,7 +110,15 @@ } .quick-filter { - &:not(.default-value):has(.checked) { + .checked { + display: none; + } + + [aria-checked="true"] .checked { + display: block; + } + + &:has([aria-checked="true"]):not(.quick-filter--with-default) { .input--select { --input-background: var(--color-selected); } @@ -133,5 +140,13 @@ display: block; } } + + .filters__manage { + display: none; + } + + .filters--has-filters-set .filters__manage { + display: flex; + } } diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index eb19c788a..f2dd41d2e 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -88,6 +88,8 @@ } lexxy-toolbar { + --lexxy-toolbar-icon-size: 1em; + background-color: inherit; border-block-end: 1px solid var(--color-ink-light); color: currentColor; @@ -112,48 +114,60 @@ min-inline-size: 30ch; } } + } - > button { - aspect-ratio: 4/3.5; - appearance: none; - background-color: transparent; - block-size: 2em; - border: none; - border-radius: 0.2em; - color: currentColor; - cursor: pointer; - display: grid; - font-size: inherit; - inline-size: auto; - place-items: center; - transition: background-color 300ms ease; + .lexxy-editor__toolbar-button { + --toolbar-button-size: 44px; - svg { - -webkit-touch-callout: none; - block-size: 0.85em; - fill: currentColor; - grid-area: 1/1; - inline-size: auto; - user-select: none; - } + appearance: none; + aspect-ratio: 1; + background-color: transparent; + block-size: var(--toolbar-button-size); + border: none; + border-radius: 0.2em; + color: currentColor; + cursor: pointer; + display: grid; + font-size: inherit; + place-items: center; - &:is(:focus, :hover) { - background-color: var(--color-ink-lighter); - box-shadow: none; - } + &:is(:focus, :hover) { + background-color: var(--color-ink-lighter); + box-shadow: none; + } - &:is(:active) { - background-color: var(--color-selected); - } + &:is(:active), + &[aria-pressed="true"] { + background-color: var(--color-selected); + } - &[aria-pressed="true"] { - background-color: var(--color-selected); - } + svg { + -webkit-touch-callout: none; + block-size: var(--lexxy-toolbar-icon-size); + fill: currentColor; + grid-area: 1/1; + inline-size: var(--lexxy-toolbar-icon-size); + user-select: none; + } + + @media (min-width: 640px) { + --toolbar-button-size: 2em; } } + .lexxy-editor__toolbar-overflow-menu { + background-color: var(--color-canvas); + border-radius: 0.5ch; + box-shadow: var(--shadow); + display: flex; + inset-inline-end: 0; + padding: 4px; + position: absolute; + z-index: 1; + } + + /* Based on .input, .input--select */ .lexxy-code-language-picker { - /* Based on .input, .input--select */ -webkit-appearance: none; appearance: none; background-color: var(--color-canvas); diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index c6035dedd..c864655a4 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -46,7 +46,8 @@ /* Hide lists when all the items within are hidden */ .popup__section { - &:has(.popup__section-title:only-child), + &:not(:has(.popup__list)), + &:not(:has(.popup__list > *)), &:has(.popup__item[hidden]):not(:has(.popup__item:not([hidden]))) { display: none; } diff --git a/app/assets/stylesheets/reset.css b/app/assets/stylesheets/reset.css index e224c8cf9..959a6e60b 100644 --- a/app/assets/stylesheets/reset.css +++ b/app/assets/stylesheets/reset.css @@ -71,9 +71,18 @@ cursor: pointer; } + summary { + &::-webkit-details-marker { + display: none; + } + + &::marker { + content: ""; + } + } + /* Remove all animations and transitions for people that prefer not to see them */ @media (prefers-reduced-motion: reduce) { - *, *::before, *::after { diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css index 49e466b39..371ed21fd 100644 --- a/app/assets/stylesheets/search.css +++ b/app/assets/stylesheets/search.css @@ -1,4 +1,20 @@ +summary { + + &::-webkit-details-marker { + display: none !important; + } + + &::marker { + display: none !important; + } + + &::marker { + content: ""; + } +} + @layer components { + .search { --gap: 4vmin; --width: 80ch; @@ -16,7 +32,7 @@ /* Form /* ------------------------------------------------------------------------ */ - .search__form-input { + .search__input { --clear-icon-size: 1em; --focus-ring-size: 0; --input-border-color: var(--color-ink-light); @@ -28,26 +44,22 @@ position: relative; &::-webkit-search-cancel-button { - --clear-icon-size: 0.66rem; + display: none; + } + } - -webkit-appearance: none; - appearance: none; - background-image: url('data:image/svg+xml;utf8,'); - background-repeat: no-repeat; - background-size: var(--clear-icon-size); - block-size: var(--clear-icon-size); - cursor: pointer; - inline-size: var(--clear-icon-size); - inset: 50% 0 auto auto; - position: absolute; - translate: 0 -50%; + .search__reset { + --btn-background: var(--color-terminal-bg); + --btn-size: 1.25lh; - @media (prefers-color-scheme: dark) { - background-image: url('data:image/svg+xml;utf8,'); + @media (min-width: 800px) { + .search__input:placeholder-shown ~ & { + opacity: 0; } } } + /* Content /* ------------------------------------------------------------------------ */ diff --git a/app/assets/stylesheets/settings.css b/app/assets/stylesheets/settings.css index 9baa51b14..15b7b0d06 100644 --- a/app/assets/stylesheets/settings.css +++ b/app/assets/stylesheets/settings.css @@ -21,6 +21,7 @@ flex-direction: column; gap: var(--settings-spacer); min-block-size: 100%; + min-inline-size: 0; @media (min-width: 960px) { --panel-padding: calc(var(--settings-spacer) * 2); diff --git a/app/controllers/cards/drops_controller.rb b/app/controllers/cards/drops_controller.rb index 6bb10c562..007580a3f 100644 --- a/app/controllers/cards/drops_controller.rb +++ b/app/controllers/cards/drops_controller.rb @@ -1,5 +1,6 @@ class Cards::DropsController < ApplicationController - include Collections::ColumnsScoped + include FilterScoped + before_action :set_card, :set_drop_target def create @@ -40,7 +41,8 @@ class Cards::DropsController < ApplicationController end def render_column_replacement - page_and_filter = page_and_filter_for @filter.with(engagement_status: @drop_target.to_s), per_page: CardsController::PAGE_SIZE + columns = Cards::Columns.new(user_filtering: @user_filtering, page_size: CardsController::PAGE_SIZE) + column = columns.public_send(@drop_target) if @drop_target == :doing && params[:stage_id].present? # For stage-specific doing columns, we need to render the specific stage @@ -49,14 +51,14 @@ class Cards::DropsController < ApplicationController turbo_stream: turbo_stream.replace("doing-cards-#{stage.id}", method: :morph, partial: "cards/index/engagement/doing", - locals: { user_filtering: @user_filtering, **page_and_filter.to_h, stage: stage }) + locals: { column: column, stage: stage }) else # For other columns, use the standard approach render \ turbo_stream: turbo_stream.replace("#{@drop_target.to_s.gsub('_', '-')}-cards", method: :morph, partial: "cards/index/engagement/#{@drop_target}", - locals: { user_filtering: @user_filtering, **page_and_filter.to_h }) + locals: { column: column }) end end end diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index 1bdd201c0..e30867991 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -1,7 +1,7 @@ require "ostruct" class CardsController < ApplicationController - include Collections::ColumnsScoped + include FilterScoped before_action :set_collection, only: %i[ create ] before_action :set_card, only: %i[ show edit update destroy ] @@ -11,13 +11,9 @@ class CardsController < ApplicationController PAGE_SIZE = 25 def index - @considering = page_and_filter_for @filter.with(engagement_status: "considering"), per_page: PAGE_SIZE - @on_deck = page_and_filter_for @filter.with(engagement_status: "on_deck"), per_page: PAGE_SIZE - @doing = page_and_filter_for @filter.with(engagement_status: "doing"), per_page: PAGE_SIZE - @closed = page_and_filter_for_closed_cards + @columns = Cards::Columns.new(user_filtering: @user_filtering, page_size: PAGE_SIZE) - @cache_key = [ @considering, @on_deck, @doing, @closed ].collect { it.page.records }.including([ Workflow.all, @user_filtering ]) - fresh_when etag: @cache_key + fresh_when etag: @columns end def create @@ -26,7 +22,7 @@ class CardsController < ApplicationController end def show - fresh_when @card + fresh_when etag: @card.cache_invalidation_parts.for_perma end def edit diff --git a/app/controllers/concerns/collections/columns_scoped.rb b/app/controllers/concerns/collections/columns_scoped.rb deleted file mode 100644 index a588a15b6..000000000 --- a/app/controllers/concerns/collections/columns_scoped.rb +++ /dev/null @@ -1,23 +0,0 @@ -module Collections::ColumnsScoped - extend ActiveSupport::Concern - - included do - include FilterScoped - end - - def page_and_filter_for(filter, per_page: nil) - cards = block_given? ? yield(filter.cards) : filter.cards - - OpenStruct.new \ - page: GearedPagination::Recordset.new(cards, per_page:).page(1), - filter: filter - end - - def page_and_filter_for_closed_cards - if @filter.indexed_by.stalled? - page_and_filter_for(@filter, per_page: CardsController::PAGE_SIZE) { |cards| cards.recently_closed_first } - else - page_and_filter_for(@filter.with(indexed_by: "closed"), per_page: CardsController::PAGE_SIZE) { |cards| cards.recently_closed_first } - end - end -end diff --git a/app/controllers/concerns/filter_scoped.rb b/app/controllers/concerns/filter_scoped.rb index b4e9aa526..2c422be2a 100644 --- a/app/controllers/concerns/filter_scoped.rb +++ b/app/controllers/concerns/filter_scoped.rb @@ -13,16 +13,18 @@ module FilterScoped end private - DEFAULT_PARAMS = { indexed_by: "all", sorted_by: "latest" } - def set_filter if params[:filter_id].present? @filter = Current.user.filters.find(params[:filter_id]) else - @filter = Current.user.filters.from_params params.reverse_merge(**DEFAULT_PARAMS).permit(*Filter::PERMITTED_PARAMS) + @filter = Current.user.filters.from_params filter_params end end + def filter_params + params.reverse_merge(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS) + end + def set_user_filtering @user_filtering = User::Filtering.new(Current.user, @filter, expanded: expanded_param) end diff --git a/app/controllers/filters/settings_refreshes_controller.rb b/app/controllers/filters/settings_refreshes_controller.rb new file mode 100644 index 000000000..b7dfac0ec --- /dev/null +++ b/app/controllers/filters/settings_refreshes_controller.rb @@ -0,0 +1,6 @@ +class Filters::SettingsRefreshesController < ApplicationController + include FilterScoped + + def create + end +end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index cbb44963b..efb06aaef 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -1,28 +1,21 @@ class FiltersController < ApplicationController - before_action :set_filter, only: :destroy + before_action :set_filters def create @filter = Current.user.filters.remember filter_params - redirect_to cards_path(filter_id: @filter.id) end def destroy - filter_params = @filter.as_params + @filter = Current.user.filters.find(params[:id]) @filter.destroy! - - if request.referer == root_url - redirect_to root_path - else - redirect_to cards_path(filter_params) - end end private - def set_filter - @filter = Current.user.filters.find params[:id] + def set_filters + @filters = Current.user.filters end def filter_params - params.permit(*Filter::PERMITTED_PARAMS).compact_blank + Filter.normalize_params(params.permit(*Filter::PERMITTED_PARAMS)) end end diff --git a/app/helpers/accesses_helper.rb b/app/helpers/accesses_helper.rb index 0fb8e8f36..833a1a2b0 100644 --- a/app/helpers/accesses_helper.rb +++ b/app/helpers/accesses_helper.rb @@ -17,12 +17,12 @@ module AccessesHelper cached: ->(user) { [ user, selected ] } end - def access_involvement_advance_button(collection, user, show_watchers: true) + def access_involvement_advance_button(collection, user, show_watchers: true, icon_only: false) access = collection.access_for(user) turbo_frame_tag dom_id(collection, :involvement_button) do concat collection_watchers_list(collection) if show_watchers - concat involvement_button(collection, access, show_watchers) + concat involvement_button(collection, access, show_watchers, icon_only) end end @@ -43,7 +43,7 @@ module AccessesHelper end end - def involvement_button(collection, access, show_watchers) + def involvement_button(collection, access, show_watchers, icon_only) involvement_label_id = dom_id(collection, :involvement_label) button_to( @@ -57,7 +57,7 @@ module AccessesHelper icon_tag("notification-bell-#{access.involvement.dasherize}"), tag.span( involvement_access_label(collection, access.involvement), - class: "txt-nowrap txt-uppercase", + class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: involvement_label_id ) ]) diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb index 797682c51..7c75954ae 100644 --- a/app/helpers/filters_helper.rb +++ b/app/helpers/filters_helper.rb @@ -35,8 +35,8 @@ module FiltersHelper }, &block end - def filter_collapsible_section(title, &block) - tag.details class: "popup__section", open: true do + def collapsible_nav_section(title, **properties, &block) + tag.details class: "popup__section", data: { action: "toggle->nav-section-expander#toggle", nav_section_expander_target: "section", nav_section_expander_key_value: title.parameterize }, open: true, **properties do concat(tag.summary(class: "popup__section-title") do concat title concat icon_tag "caret-down" diff --git a/app/helpers/webhooks_helper.rb b/app/helpers/webhooks_helper.rb new file mode 100644 index 000000000..7fe91d8c0 --- /dev/null +++ b/app/helpers/webhooks_helper.rb @@ -0,0 +1,27 @@ +module WebhooksHelper + ACTION_LABELS = { + card_assigned: "Card assigned", + card_closed: "Card closed", + card_collection_changed: "Card collection changed", + card_due_date_added: "Card due date added", + card_due_date_changed: "Card due date changed", + card_due_date_removed: "Card due date removed", + card_published: "Card published", + card_reopened: "Card reopened", + card_staged: "Card staged", + card_title_changed: "Card title changed", + card_unassigned: "Card unassigned", + card_unstaged: "Card unstaged", + comment_created: "Comment created" + }.with_indifferent_access.freeze + + def webhook_action_options(actions = Webhook::PERMITTED_ACTIONS) + actions.each_with_object({}) do |action, hash| + hash[action.to_s] = webhook_action_label(action) + end + end + + def webhook_action_label(action) + ACTION_LABELS[action] || action.to_s.humanize + end +end diff --git a/app/javascript/controllers/bar_controller.js b/app/javascript/controllers/bar_controller.js index 5ceed089c..31fc11e3f 100644 --- a/app/javascript/controllers/bar_controller.js +++ b/app/javascript/controllers/bar_controller.js @@ -18,6 +18,7 @@ export default class extends Controller { this.dialogOutlet.close() this.#clearTurboFrame() + this.searchInputTarget.querySelector("input").value = "" this.#showItem(this.buttonsContainerTarget) this.#hideItem(this.searchInputTarget) if (this.hasAskInputTarget) { diff --git a/app/javascript/controllers/combobox_controller.js b/app/javascript/controllers/combobox_controller.js new file mode 100644 index 000000000..02579e5a1 --- /dev/null +++ b/app/javascript/controllers/combobox_controller.js @@ -0,0 +1,85 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + #hiddenField + + static targets = [ "label", "item", "hiddenFieldTemplate" ] + static values = { + selectPropertyName: { type: String, default: "aria-checked" }, + defaultValue: String, + defaultLabel: String + } + static classes = ["withDefault"] + + connect() { + this.#selectedItem = this.#selectedItem + } + + change(event) { + const item = event.target.closest("[role='checkbox']") + if (item) { + this.#selectedItem = item + } + } + + get #selectedLabel() { + const selectedValue = this.#selectedItemValue() + + if (this.hasDefaultLabelValue && (selectedValue === this.defaultValueValue || !selectedValue)) { + return this.defaultLabelValue + } + + return this.#selectedItem?.dataset?.comboboxLabel || "" + } + + get #selectedItem() { + return this.itemTargets.find(item => item.getAttribute(this.selectPropertyNameValue) === "true") + } + + #selectedItemValue() { + return this.#selectedItem?.dataset?.comboboxValue || "" + } + + set #selectedItem(item) { + if (!item) return + + this.#clearSelection() + item.setAttribute(this.selectPropertyNameValue, "true") + this.labelTarget.textContent = this.#selectedLabel + this.hiddenField.value = item.dataset.comboboxValue + this.hiddenField.disabled = !item.dataset.comboboxValue + this.#updateWithDefaultClass() + } + + #clearSelection() { + this.itemTargets.forEach(target => { + target.setAttribute(this.selectPropertyNameValue, "false") + }) + } + + get hiddenField() { + if (!this.#hiddenField) { + this.#hiddenField = this.#buildHiddenField() + } + return this.#hiddenField + } + + #buildHiddenField() { + const [field] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children + this.element.appendChild(field) + return field + } + + #updateWithDefaultClass() { + if (this.hasWithDefaultClass && this.hasDefaultValueValue) { + const selectedValue = this.#selectedItemValue() + const shouldHaveClass = selectedValue === this.defaultValueValue + + if (shouldHaveClass) { + this.element.classList.add(this.withDefaultClass) + } else { + this.element.classList.remove(this.withDefaultClass) + } + } + } +} diff --git a/app/javascript/controllers/filter_controller.js b/app/javascript/controllers/filter_controller.js index 688c46155..7f3770a8e 100644 --- a/app/javascript/controllers/filter_controller.js +++ b/app/javascript/controllers/filter_controller.js @@ -11,7 +11,7 @@ export default class extends Controller { filter() { this.itemTargets.forEach(item => { - if (filterMatches(item.innerText, this.inputTarget.value)) { + if (filterMatches(item.textContent, this.inputTarget.value)) { item.removeAttribute("hidden") } else { item.toggleAttribute("hidden", true) diff --git a/app/javascript/controllers/filter_settings_controller.js b/app/javascript/controllers/filter_settings_controller.js new file mode 100644 index 000000000..85784583a --- /dev/null +++ b/app/javascript/controllers/filter_settings_controller.js @@ -0,0 +1,68 @@ +import { Controller } from "@hotwired/stimulus" +import { debounce } from "helpers/timing_helpers"; +import { post } from "@rails/request.js" + +export default class extends Controller { + static classes = ["filtersSet"] + static targets = ["field", "form"] + static values = { refreshUrl: String } + + initialize() { + this.debouncedToggle = debounce(this.#toggle.bind(this), 50) + } + + connect() { + this.#toggle() + } + + change() { + this.#toggle() + this.#refreshSaveToggleButton() + } + + async fieldTargetConnected(field) { + this.debouncedToggle() + } + + #toggle() { + this.element.classList.toggle(this.filtersSetClass, this.#hasFiltersSet) + } + + get #hasFiltersSet() { + return this.fieldTargets.some(field => this.#isFieldSet(field)) + } + + #isFieldSet(field) { + const value = field.value?.trim() + + if (!value) return false + + const defaultValue = this.#defaultValueForField(field) + return defaultValue ? value !== defaultValue : true + } + + #defaultValueForField(field) { + const comboboxContainer = field.closest("[data-combobox-default-value-value]") + return comboboxContainer?.dataset?.comboboxDefaultValueValue + } + + #refreshSaveToggleButton() { + post(this.refreshUrlValue, { + body: this.#collectFilterFormData(), + responseKind: "turbo-stream" + }) + } + + #collectFilterFormData() { + const formData = new FormData() + + this.formTargets.forEach(form => { + const hiddenFields = form.querySelectorAll('input[type="hidden"]:not([disabled])[name]') + hiddenFields.forEach(field => { + formData.append(field.name, field.value) + }) + }) + + return formData + } +} diff --git a/app/javascript/controllers/multi_selection_combobox_controller.js b/app/javascript/controllers/multi_selection_combobox_controller.js new file mode 100644 index 000000000..70a853b2b --- /dev/null +++ b/app/javascript/controllers/multi_selection_combobox_controller.js @@ -0,0 +1,98 @@ +import { Controller } from "@hotwired/stimulus" +import { toSentence } from "helpers/text_helpers" + +export default class extends Controller { + #hiddenField + + static targets = [ "label", "item", "hiddenFieldTemplate" ] + static values = { + selectPropertyName: { type: String, default: "aria-checked" }, + defaultValue: String, + noSelectionLabel: { type: String, default: "No selection" }, + labelPrefix: String + } + + connect() { + this.labelTarget.textContent = this.#selectedLabel + this.#updateHiddenFields() + } + + change(event) { + const item = event.target.closest("[role='checkbox']") + if (item) { + this.#toggleSelection(item) + } + } + + + clear(event) { + this.#deselectAll() + this.#updateHiddenFields() + this.labelTarget.textContent = this.#selectedLabel + } + + get #selectedLabel() { + const selectedValues = this.#selectedValues() + if (selectedValues.length === 0) { + return this.noSelectionLabelValue + } + + const labels = this.#selectedItems().map(item => item.dataset.multiSelectionComboboxLabel) + const sentence = toSentence(labels, { + two_words_connector: " or ", + last_word_connector: ", or " + }) + + return this.hasLabelPrefixValue ? `${this.labelPrefixValue} ${sentence}` : sentence + } + + #toggleSelection(item) { + const isSelected = item.getAttribute(this.selectPropertyNameValue) === "true" + + if (isSelected) { + item.setAttribute(this.selectPropertyNameValue, "false") + } else { + item.setAttribute(this.selectPropertyNameValue, "true") + } + + this.#updateHiddenFields() + this.labelTarget.textContent = this.#selectedLabel + } + + #updateHiddenFields() { + this.#clearHiddenFields() + this.#addHiddenFields() + } + + #deselectAll() { + this.itemTargets.forEach(item => { + item.setAttribute(this.selectPropertyNameValue, "false") + }) + } + + #selectedItems() { + return this.itemTargets.filter(item => + item.getAttribute(this.selectPropertyNameValue) === "true" + ) + } + + #selectedValues() { + return this.#selectedItems().map(item => item.dataset.multiSelectionComboboxValue) + } + + #clearHiddenFields() { + this.element.querySelectorAll('input[type="hidden"]').forEach(field => { + if (field !== this.hiddenField) { + field.remove() + } + }) + } + + #addHiddenFields() { + this.#selectedValues().forEach(value => { + const [field] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children + field.value = value + this.element.appendChild(field) + }) + } +} diff --git a/app/javascript/controllers/nav_section_expander_controller.js b/app/javascript/controllers/nav_section_expander_controller.js new file mode 100644 index 000000000..d0db6e014 --- /dev/null +++ b/app/javascript/controllers/nav_section_expander_controller.js @@ -0,0 +1,49 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { key: String } + static targets = [ "input", "section" ] + + sectionTargetConnected() { + this.#restoreToggles() + } + + toggle(event) { + const section = event.target + if (section.hasAttribute("data-is-filtering")) return + + const key = this.#localStorageKeyFor(section) + if (section.open) { + localStorage.removeItem(key) + } else { + localStorage.setItem(key, true) + } + } + + showWhileFiltering() { + if (this.inputTarget.value) { + this.#expandAll(); + } else { + this.#restoreToggles() + } + } + + #expandAll() { + this.sectionTargets.forEach(section => { + section.setAttribute("data-is-filtering", true) + section.open = true + }) + } + + #restoreToggles() { + this.sectionTargets.forEach(section => { + const key = this.#localStorageKeyFor(section) + section.open = !localStorage.getItem(key) + section.removeAttribute("data-is-filtering") + }) + } + + #localStorageKeyFor(section) { + return section.getAttribute("data-nav-section-expander-key-value") + } +} diff --git a/app/javascript/helpers/text_helpers.js b/app/javascript/helpers/text_helpers.js index 22593fe6f..d886e94b9 100644 --- a/app/javascript/helpers/text_helpers.js +++ b/app/javascript/helpers/text_helpers.js @@ -11,3 +11,27 @@ export function normalizeFilteredText(string) { export function filterMatches(text, potentialMatch) { return normalizeFilteredText(text).includes(normalizeFilteredText(potentialMatch)) } + +export function toSentence(array, options = {}) { + const defaultConnectors = { + words_connector: ", ", + two_words_connector: " and ", + last_word_connector: ", and " + } + + const connectors = { ...defaultConnectors, ...options } + + if (array.length === 0) { + return "" + } + + if (array.length === 1) { + return array[0] + } + + if (array.length === 2) { + return array.join(connectors.two_words_connector) + } + + return array.slice(0, -1).join(connectors.words_connector) + connectors.last_word_connector + array[array.length - 1] +} diff --git a/app/models/card.rb b/app/models/card.rb index 00214fa5d..02394131c 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,5 +1,5 @@ class Card < ApplicationRecord - include Assignable, Attachments, Closeable, Colored, Engageable, Entropic, Eventable, + include Assignable, Attachments, Cacheable, Closeable, Colored, Engageable, Entropic, Eventable, Golden, Mentions, Multistep, Pinnable, Promptable, Readable, Searchable, Staged, Stallable, Statuses, Taggable, Watchable @@ -41,10 +41,6 @@ class Card < ApplicationRecord delegate :accessible_to?, to: :collection - def cache_key - [ super, collection.name ].compact.join("/") - end - def card self end diff --git a/app/models/card/cacheable.rb b/app/models/card/cacheable.rb new file mode 100644 index 000000000..94c6b6d11 --- /dev/null +++ b/app/models/card/cacheable.rb @@ -0,0 +1,27 @@ +module Card::Cacheable + extend ActiveSupport::Concern + + def cache_key + [ super, collection.name ].compact.join("/") + end + + def cache_invalidation_parts + @cache_invalidation_parts ||= InvalidationParts.new(self) + end + + class InvalidationParts + attr_reader :card + + def initialize(card) + @card = card + end + + def for_perma(*other) + [ card, Workflow.all, User.all, Tag.all, *other ] + end + + def for_preview(*other) + [ card, card.workflow, Workflow.all, card.collection.entropy_configuration, card.collection.publication, *other ] + end + end +end diff --git a/app/models/card/staged.rb b/app/models/card/staged.rb index fd9b1d09c..4cd497abd 100644 --- a/app/models/card/staged.rb +++ b/app/models/card/staged.rb @@ -7,10 +7,7 @@ module Card::Staged before_create :assign_initial_stage scope :in_stage, ->(stage) { where stage: stage } - end - - def workflow - stage&.workflow + delegate :workflow, to: :collection, allow_nil: true end def staged? diff --git a/app/models/cards/columns.rb b/app/models/cards/columns.rb new file mode 100644 index 000000000..2ef9b9957 --- /dev/null +++ b/app/models/cards/columns.rb @@ -0,0 +1,45 @@ +class Cards::Columns + attr_reader :user_filtering, :page_size + + delegate :filter, to: :user_filtering + + def initialize(user_filtering:, page_size:) + @user_filtering = user_filtering + @page_size = page_size + end + + def considering + @considering ||= build_column_for "considering" + end + + def on_deck + @on_deck ||= build_column_for "on_deck" + end + + def doing + @doing ||= build_column_for "doing" + end + + def closed + @closed ||= if filter.indexed_by.stalled? + build_column(filter) { |cards| cards.recently_closed_first } + else + build_column(filter.with(indexed_by: "closed")) { |cards| cards.recently_closed_first } + end + end + + def cache_key + ActiveSupport::Cache.expand_cache_key([ considering, on_deck, doing, closed, Workflow.all, user_filtering ]) + end + + private + def build_column_for(engagement_status) + build_column(filter.with(engagement_status: engagement_status)) + end + + def build_column(filter, &block) + cards = block ? yield(filter.cards) : filter.cards + + Column.new(page: GearedPagination::Recordset.new(cards, per_page: page_size).page(1), filter: filter, user_filtering: user_filtering) + end +end diff --git a/app/models/cards/columns/column.rb b/app/models/cards/columns/column.rb new file mode 100644 index 000000000..c48c29fb8 --- /dev/null +++ b/app/models/cards/columns/column.rb @@ -0,0 +1,17 @@ +class Cards::Columns::Column + attr_reader :page, :filter, :user_filtering + + def initialize(page:, filter:, user_filtering:) + @page = page + @filter = filter + @user_filtering = user_filtering + end + + def cards + page.records + end + + def cache_key + ActiveSupport::Cache.expand_cache_key([ cards ]) + end +end diff --git a/app/models/collection/accessible.rb b/app/models/collection/accessible.rb index 022185069..4e8cd40b2 100644 --- a/app/models/collection/accessible.rb +++ b/app/models/collection/accessible.rb @@ -43,8 +43,8 @@ module Collection::Accessible def clean_inaccessible_data_for(user) return if accessible_to?(user) - clean_inaccessible_records user.notifications - clean_inaccessible_records user.mentions + mentions_for_user(user).destroy_all + notifications_for_user(user).destroy_all end private @@ -56,11 +56,33 @@ module Collection::Accessible accesses.grant_to(User.all) if all_access_previously_changed?(to: true) end - def clean_inaccessible_records(records) - records.find_each do |record| - if record.card&.collection == self - record.destroy - end - end + def mentions_for_user(user) + # Query handles 2 paths: + # + # 1. Mention->Card + # 2. Mention->Comment->Card + user.mentions + .joins("LEFT JOIN cards ON mentions.source_id = cards.id AND mentions.source_type = 'Card'") + .joins("LEFT JOIN comments ON mentions.source_id = comments.id AND mentions.source_type = 'Comment'") + .joins("LEFT JOIN cards AS comment_cards ON comments.card_id = comment_cards.id") + .where("(mentions.source_type = 'Card' AND cards.collection_id = ?) OR (mentions.source_type = 'Comment' AND comment_cards.collection_id = ?)", id, id) + end + + def notifications_for_user(user) + # Query handles 2 paths: + # + # 1. Notification->Event->Card + # 2. Notification->Event->Comment->Card + # + # Notification->Event->Mention->Card and Notification->Event->Mention->Comment->Card are + # handled by destroying mentions_for_user. + user.notifications + .joins("LEFT JOIN events ON notifications.source_id = events.id AND notifications.source_type = 'Event'") + .joins("LEFT JOIN cards AS event_cards ON events.eventable_id = event_cards.id AND events.eventable_type = 'Card'") + .joins("LEFT JOIN comments AS event_comments ON events.eventable_id = event_comments.id AND events.eventable_type = 'Comment'") + .joins("LEFT JOIN cards AS event_comment_cards ON event_comments.card_id = event_comment_cards.id") + .where("(notifications.source_type = 'Event' AND events.eventable_type = 'Card' AND event_cards.collection_id = ?) OR + (notifications.source_type = 'Event' AND events.eventable_type = 'Comment' AND event_comment_cards.collection_id = ?)", + id, id) end end diff --git a/app/models/filter/params.rb b/app/models/filter/params.rb index 7685c87fc..81ba67c99 100644 --- a/app/models/filter/params.rb +++ b/app/models/filter/params.rb @@ -28,7 +28,13 @@ module Filter::Params end def normalize_params(params) - params.to_h.compact_blank.reject(&method(:default_value?)).sort_by { |name, _| name.to_s } + params + .to_h + .compact_blank + .reject(&method(:default_value?)) + .collect { |name, value| [ name, value.is_a?(Array) ? value.collect(&:to_s) : value.to_s ] } + .sort_by { |name, _| name.to_s } + .to_h end end diff --git a/app/models/filter/resources.rb b/app/models/filter/resources.rb index e4d84ba99..da71ce982 100644 --- a/app/models/filter/resources.rb +++ b/app/models/filter/resources.rb @@ -21,4 +21,16 @@ module Filter::Resources def collections creator.collections.where id: super.ids end + + def collection_titles + if collections.none? + [ Collection.one? ? collections.first.name : "All collections" ] + else + collections.map(&:name) + end + end + + def collections_label + collection_titles.to_sentence + end end diff --git a/app/models/user/filtering.rb b/app/models/user/filtering.rb index d0fddc962..9f321bbed 100644 --- a/app/models/user/filtering.rb +++ b/app/models/user/filtering.rb @@ -3,7 +3,7 @@ class User::Filtering attr_reader :user, :filter, :expanded - delegate :as_params, :any?, :single_collection, to: :filter + delegate :as_params, :single_collection, to: :filter delegate :only_closed?, to: :filter def initialize(user, filter, expanded: false) @@ -15,15 +15,11 @@ class User::Filtering end def selected_collection_titles - if filter.collections.none? - [ collections.one? ? collections.first.name : "All collections" ] - else - filter.collections.map(&:name) - end + filter.collection_titles end def selected_collections_label - selected_collection_titles.to_sentence + filter.collections_label end def tags diff --git a/app/models/user/highlights.rb b/app/models/user/highlights.rb index 8383e6a44..43a6745f8 100644 --- a/app/models/user/highlights.rb +++ b/app/models/user/highlights.rb @@ -15,6 +15,7 @@ module User::Highlights def generate_weekly_highlights(date = Time.current) in_time_zone do + date = date - 1.day if date.sunday? PeriodHighlights.create_or_find_for collections, starts_at: highlights_starts_at(date), duration: 1.week end end @@ -27,7 +28,7 @@ module User::Highlights private def highlights_starts_at(date = Time.current) - date = date - 1.day if date.sunday? + date = date.in_time_zone(timezone) date.beginning_of_week(:sunday) end end diff --git a/app/views/cards/_container.html.erb b/app/views/cards/_container.html.erb index 3c38bc5a8..abd4c3c9a 100644 --- a/app/views/cards/_container.html.erb +++ b/app/views/cards/_container.html.erb @@ -1,4 +1,4 @@ -<% cache card do %> +<% cache card.cache_invalidation_parts.for_perma do %>
<%= render "cards/container/status", card: card %> diff --git a/app/views/cards/columns/_column.html.erb b/app/views/cards/columns/_column.html.erb new file mode 100644 index 000000000..e2da152a4 --- /dev/null +++ b/app/views/cards/columns/_column.html.erb @@ -0,0 +1 @@ +<%= render partial: "cards/display/preview", collection: column.cards, as: :card, locals: { draggable: draggable }, cached: ->(card) { card.cache_invalidation_parts.for_preview } %> \ No newline at end of file diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb index 41b430536..2f580a519 100644 --- a/app/views/cards/display/_preview.html.erb +++ b/app/views/cards/display/_preview.html.erb @@ -1,43 +1,41 @@ <% draggable = local_assigns.fetch(:draggable, false) && card.published? %> -<% cache cacheable_preview_parts_for(card, draggable) do %> - <%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.id, drag_and_drop_target: "item" } do %> -
-
- <%= render "cards/display/preview/collection", card: card %> - <%= render "cards/display/preview/tags", card: card %> - <%= render "cards/display/preview/steps", card: card %> - <%= icon_tag "attachment", class: "card__attachments-indicator translucent txt-x-small" if card.has_attachments? %> +<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.id, drag_and_drop_target: "item" } do %> +
+
+ <%= render "cards/display/preview/collection", card: card %> + <%= render "cards/display/preview/tags", card: card %> + <%= render "cards/display/preview/steps", card: card %> + <%= icon_tag "attachment", class: "card__attachments-indicator translucent txt-x-small" if card.has_attachments? %> - <% if card.staged? && card.open? %> + <% if card.staged? && card.doing? %> <%= card.stage.name %> - <% end %> -
+ <% end %> +
-
-
-

- <%= card.title %> -

-
- - <%# render "cards/display/preview/stages", card: card if card.doing? %> +
+
+

+ <%= card.title %> +

+ + <%= render "cards/display/preview/stages", card: card if card.doing? %>
+
-
- <%= render "cards/display/preview/meta", card: card %> - <%= render "cards/display/common/background", card: card %> -
+
+ <%= render "cards/display/preview/meta", card: card %> + <%= render "cards/display/common/background", card: card %> +
- <%= link_to card_path(card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %> - <%= card.title %> - <% end %> + <%= link_to card_path(card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %> + <%= card.title %> + <% end %> - <% if card.entropic? %> - <%= render "cards/display/preview/bubble", card: card %> - <% end %> + <% if card.entropic? %> + <%= render "cards/display/preview/bubble", card: card %> <% end %> <% end %> diff --git a/app/views/cards/display/_public_preview.html.erb b/app/views/cards/display/_public_preview.html.erb index e0f7e945a..c35af99a8 100644 --- a/app/views/cards/display/_public_preview.html.erb +++ b/app/views/cards/display/_public_preview.html.erb @@ -1,33 +1,27 @@ -<% cache cacheable_preview_parts_for(card) do %> +<% cache card.cache_invalidation_parts.for_preview do %> <%= card_article_tag card, class: "card" do %> -
-
-
+
+
-
- <%= card.id %> - - <%= card.collection.name %> - -
- - <%= render "cards/display/preview/tags", card: card %> -
- -
-
-

- <%= card.title %> -

- - <%= link_to published_card_path(card), class: "card__link", title: card_title_tag(card) do %> - <%= card.title %> - <% end %> -
+
+ <%= card.id %> + + <%= card.collection.name %> +
-
- <%= render "cards/display/public_preview/stages", card: card if card.doing? %> + <%= render "cards/display/preview/tags", card: card %> +
+ +
+
+

+ <%= card.title %> +

+
+ + <%= render "cards/display/public_preview/stages", card: card if card.doing? %> +