Merge branch 'main' into mobile/prepare-webviews-2
* main: (26 commits) Add a new Pins section to docs/API.md covering pin/unpin and the pinned cards list response. Align board name start so it doesn't get too big Clean up card/events header layout Sync email to Stripe when user changes email address (#2432) Fix IDOR in webhook activation endpoint (#2431) Add card reactions to API docs and reactions_url to card JSON (#2427) Remove unnecessary claude plan Allow boosts on cards (#2411) Revert "Fix notification click URL by using correct data property" Add migration to remove draft cards from search index Guard search indexing with searchable? check Forbid comments on draft cards prefactor: update search to use published cards Fix notification click URL by using correct data property Wait for service worker to be active before subscribing Fix stuck state when permission granted but no subscription Extract Card::Commentable Include arm64 build in Docker workflow Remove unnecessary `await` in push handler Correctly initialise WebPush connection (#2417) ...
This commit is contained in:
@@ -34,6 +34,9 @@ jobs:
|
||||
- runner: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
arch: amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
arch: arm64
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
@@ -170,11 +173,13 @@ jobs:
|
||||
docker buildx imagetools create \
|
||||
--tag "$tag" \
|
||||
--annotation "index:org.opencontainers.image.description=${IMAGE_DESCRIPTION}" \
|
||||
"${src_tag}-amd64"
|
||||
"${src_tag}-amd64" \
|
||||
"${src_tag}-arm64"
|
||||
else
|
||||
docker buildx imagetools create \
|
||||
--tag "$tag" \
|
||||
"${src_tag}-amd64"
|
||||
"${src_tag}-amd64" \
|
||||
"${src_tag}-arm64"
|
||||
fi
|
||||
done <<< "$tags"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17 17"><path d="m11.5 10.3-.6-.2c-.2-.5-.3-1.1-.1-1.6 1.1-1.2 1.6-2.7 1.5-4.3 0-2.4-1.6-4.2-3.8-4.2S4.7 1.8 4.7 4.2c-.1 1.5.4 3.1 1.5 4.3.2.5.2 1.1-.1 1.6l-.6.2c-2.4.9-4.2 1.5-4.8 2.6-.4 1.2-.7 2.4-.7 3.6 0 .3.2.5.5.5h16c.3 0 .5-.2.5-.5 0-1.2-.3-2.4-.7-3.5-.6-1.1-2.4-1.8-4.8-2.7"/></svg>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m15.5294 14.1176-.7059-.2352c-.2353-.5883-.3529-1.2942-.1176-1.8824 1.2941-1.4118 1.8823-3.17647 1.7647-5.05882 0-2.82353-1.8824-4.94118-4.4706-4.94118-2.58824 0-4.47059 2.11765-4.47059 4.94118-.11765 1.7647.47059 3.64702 1.76471 5.05882.23529.5882.23529 1.2941-.11765 1.8824l-.70588.2352c-2.82353 1.0589-4.94118 1.7648-5.64706 3.0589-.47059 1.4117-.82353 2.8235-.82353 4.2353 0 .3529.23529.5882.58824.5882h18.82356c.3529 0 .5882-.2353.5882-.5882 0-1.4118-.3529-2.8236-.8235-4.1177-.7059-1.2941-2.8236-2.1176-5.6471-3.1765z" fill="#000"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 341 B After Width: | Height: | Size: 614 B |
@@ -536,16 +536,33 @@
|
||||
/* Set lower limit for font size */
|
||||
font-size: clamp(0.6rem, 0.85cqi, 100px);
|
||||
|
||||
.card__comments {
|
||||
--column-gap: 0.8ch;
|
||||
.card__counts {
|
||||
--gap: 0.5ch;
|
||||
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
margin-block-end: calc(var(--block-space) * -1.7);
|
||||
margin-inline-end: calc(var(--card-padding-inline) * -0.5);
|
||||
flex-shrink: 0;
|
||||
gap: calc(2 * var(--gap));
|
||||
margin-inline: auto calc(var(--card-padding-inline) * -0.5);
|
||||
padding-inline-start: var(--gap);
|
||||
}
|
||||
|
||||
.icon {
|
||||
--icon-size: 1.6em;
|
||||
.card__boosts,
|
||||
.card__comments {
|
||||
--icon-size: 1.6em;
|
||||
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
gap: var(--gap);
|
||||
|
||||
img {
|
||||
block-size: var(--icon-size);
|
||||
inline-size: var(--icon-size);
|
||||
}
|
||||
|
||||
.icon--comment {
|
||||
color: var(--card-color);
|
||||
}
|
||||
}
|
||||
@@ -584,11 +601,16 @@
|
||||
.local-time-value {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
inline-size: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(.card__background img:not([src=""])) {
|
||||
.card__content,
|
||||
.card__meta,
|
||||
.card__boosts,
|
||||
.card__comments,
|
||||
.card__column-name:not(.card__column-name--current) {
|
||||
opacity: 0;
|
||||
@@ -599,6 +621,7 @@
|
||||
&:hover {
|
||||
.card__content,
|
||||
.card__footer,
|
||||
.card__boosts,
|
||||
.card__comments,
|
||||
.card__column-name:not(.card__column-name--current) {
|
||||
opacity: 1;
|
||||
|
||||
@@ -34,6 +34,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 799px) {
|
||||
--half-btn-height: 1.25rem;
|
||||
--padding-inline: 1.5ch;
|
||||
|
||||
column-gap: 0;
|
||||
grid-template-areas:
|
||||
"notch-top notch-top notch-top"
|
||||
"card card card"
|
||||
"actions-left notch-bottom actions-right"
|
||||
"closure-message closure-message closure-message";
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
inline-size: calc(100% + 2 * var(--padding-inline));
|
||||
margin-inline: calc(-1 * var(--padding-inline));
|
||||
max-inline-size: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card {
|
||||
--card-aspect-ratio: 2 / 0.95;
|
||||
--lexxy-bg-color: var(--card-bg-color);
|
||||
@@ -47,6 +64,8 @@
|
||||
}
|
||||
|
||||
.card__body {
|
||||
position: relative;
|
||||
|
||||
@media (max-width: 639px) {
|
||||
flex-direction: column;
|
||||
padding-block-end: calc(var(--card-padding-block) * 1.5);
|
||||
@@ -86,6 +105,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.card__meta {
|
||||
grid-area: meta;
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.card__stages {
|
||||
max-inline-size: 32ch;
|
||||
|
||||
@@ -119,6 +143,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
.card__footer {
|
||||
--btn-size: 2.5rem;
|
||||
|
||||
display: flex;
|
||||
gap: 0.5ch;
|
||||
inline-size: 100%;
|
||||
text-align: start;
|
||||
|
||||
/* Switch to grid layout so that the bg zoom button can stay next to the
|
||||
* meta element, and the reactions can sit below */
|
||||
&:has(.reaction) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
"meta bg-zoom"
|
||||
"reactions reactions";
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
font-size: var(--text-x-small);
|
||||
}
|
||||
}
|
||||
|
||||
.reactions {
|
||||
--reaction-size: var(--btn-size);
|
||||
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
gap: 0.5ch;
|
||||
grid-area: reactions;
|
||||
margin-inline-start: auto;
|
||||
|
||||
&:has(.reaction) {
|
||||
--padding: calc(var(--card-padding-block) / 2);
|
||||
--reaction-size: 1.6875rem;
|
||||
|
||||
margin-block: var(--padding) calc(-1 * var(--padding));
|
||||
padding-block-start: var(--padding);
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
border-block-start: 1px dashed color-mix(in srgb, transparent, var(--card-color) 33%);
|
||||
content: "";
|
||||
inset: 0 calc(-1 * var(--card-padding-inline)) auto;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:has(.reaction)) {
|
||||
position: static;
|
||||
|
||||
.reactions__trigger {
|
||||
--btn-border-color: var(--color-ink-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reaction__popup.popup {
|
||||
inline-size: max-content;
|
||||
}
|
||||
|
||||
.card__zoom-bg-btn {
|
||||
grid-area: bg-zoom;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
--bubble-number-max: 42px;
|
||||
--bubble-size: 6rem;
|
||||
@@ -132,23 +221,6 @@
|
||||
inset: calc(var(--bubble-size) / 1.5) 0 auto auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 799px) {
|
||||
--half-btn-height: 1.5rem;
|
||||
--padding-inline: 1.5ch;
|
||||
|
||||
column-gap: 0;
|
||||
grid-template-areas:
|
||||
"notch-top notch-top notch-top"
|
||||
"card card card"
|
||||
"actions-left notch-bottom actions-right"
|
||||
"closure-message closure-message closure-message";
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
inline-size: calc(100% + 2 * var(--padding-inline));
|
||||
margin-inline: calc(-1 * var(--padding-inline));
|
||||
max-inline-size: none;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
/* Child items
|
||||
|
||||
@@ -62,6 +62,8 @@
|
||||
}
|
||||
|
||||
.card__board {
|
||||
align-items: center;
|
||||
align-self: start;
|
||||
background-color: var(--card-color);
|
||||
border-radius: var(--border-radius) 0 var(--border-radius) 0;
|
||||
color: var(--color-ink-inverted);
|
||||
@@ -252,9 +254,10 @@
|
||||
padding-block: var(--block-space-half);
|
||||
}
|
||||
|
||||
/* Meta
|
||||
/* Footer
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
/* Card metadata */
|
||||
.card__meta {
|
||||
--meta-spacer-block: 0.5ch;
|
||||
--meta-spacer-inline: 0.75ch;
|
||||
@@ -376,11 +379,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-weight: bold;
|
||||
inset: auto 1ch 1ch auto;
|
||||
inset: auto 0 -1lh auto;
|
||||
justify-content: center;
|
||||
max-inline-size: 25ch;
|
||||
min-inline-size: 16ch;
|
||||
padding: 1ch;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
rotate: 5deg;
|
||||
transform-origin: top right;
|
||||
|
||||
@@ -80,6 +80,11 @@
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.reactions {
|
||||
margin-block-start: var(--block-space-half);
|
||||
margin-inline: calc(var(--column-gap) / -1);
|
||||
}
|
||||
}
|
||||
|
||||
.comment__author {
|
||||
|
||||
@@ -291,6 +291,7 @@
|
||||
|
||||
.event {
|
||||
--column-gap: 0.7ch;
|
||||
--event-padding: 0.6em;
|
||||
|
||||
background-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));
|
||||
border-radius: 0.2em;
|
||||
@@ -300,7 +301,7 @@
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
overflow: clip;
|
||||
padding: 0.6em;
|
||||
padding: var(--event-padding);
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
|
||||
@@ -325,12 +326,15 @@
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.card__header {
|
||||
inline-size: 100%;
|
||||
margin-block-start: calc(-1 * var(--event-padding));
|
||||
}
|
||||
|
||||
.card__board {
|
||||
background-color: transparent;
|
||||
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
|
||||
font-size: 0.7em;
|
||||
padding: 0;
|
||||
translate: 0 -0.3em;
|
||||
}
|
||||
|
||||
.card__board-name {
|
||||
@@ -377,16 +381,11 @@
|
||||
}
|
||||
|
||||
.event__icon {
|
||||
color: var(--card-color);
|
||||
display: grid;
|
||||
margin: -0.3em -0.3em auto auto;
|
||||
margin-inline-start: auto;
|
||||
place-content: center;
|
||||
|
||||
.icon {
|
||||
background-color: var(--card-color);
|
||||
block-size: 1em;
|
||||
inline-size: 1em;
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
translate: calc(var(--event-padding) / 2);
|
||||
}
|
||||
|
||||
.event__timestamp {
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
flex-wrap: wrap;
|
||||
gap: var(--inline-space-half);
|
||||
inline-size: 100%;
|
||||
margin-block-start: var(--block-space-half);
|
||||
margin-inline: calc(var(--column-gap) / -1);
|
||||
|
||||
&:has([open]) {
|
||||
z-index: var(--z-popup);
|
||||
@@ -34,11 +32,8 @@
|
||||
|
||||
.reactions__trigger {
|
||||
--btn-border-color: var(--color-ink-lightest);
|
||||
background-color: var(--color-ink-lightest);
|
||||
|
||||
&:not(:hover) {
|
||||
opacity: 0.66;
|
||||
}
|
||||
background-color: var(--color-ink-lightest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +440,7 @@
|
||||
.card__meta-text:not(.card__meta-text--updated),
|
||||
.card__stages,
|
||||
.card__steps,
|
||||
.card__boosts,
|
||||
.card__comments,
|
||||
.card__closed {
|
||||
display: none;
|
||||
|
||||
@@ -2,20 +2,26 @@ class Cards::Comments::ReactionsController < ApplicationController
|
||||
include CardScoped
|
||||
|
||||
before_action :set_comment
|
||||
before_action :set_reaction, only: %i[ destroy ]
|
||||
before_action :ensure_permision_to_administer_reaction, only: %i[ destroy ]
|
||||
before_action :set_reactable
|
||||
|
||||
with_options only: :destroy do
|
||||
before_action :set_reaction
|
||||
before_action :ensure_permission_to_administer_reaction
|
||||
end
|
||||
|
||||
def index
|
||||
render "reactions/index"
|
||||
end
|
||||
|
||||
def new
|
||||
render "reactions/new"
|
||||
end
|
||||
|
||||
def create
|
||||
@reaction = @comment.reactions.create!(params.expect(reaction: :content))
|
||||
@reaction = @reactable.reactions.create!(params.expect(reaction: :content))
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.turbo_stream { render "reactions/create" }
|
||||
format.json { head :created }
|
||||
end
|
||||
end
|
||||
@@ -24,7 +30,7 @@ class Cards::Comments::ReactionsController < ApplicationController
|
||||
@reaction.destroy
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.turbo_stream { render "reactions/destroy" }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
@@ -34,11 +40,15 @@ class Cards::Comments::ReactionsController < ApplicationController
|
||||
@comment = @card.comments.find(params[:comment_id])
|
||||
end
|
||||
|
||||
def set_reaction
|
||||
@reaction = @comment.reactions.find(params[:id])
|
||||
def set_reactable
|
||||
@reactable = @comment
|
||||
end
|
||||
|
||||
def ensure_permision_to_administer_reaction
|
||||
def set_reaction
|
||||
@reaction = @reactable.reactions.find(params[:id])
|
||||
end
|
||||
|
||||
def ensure_permission_to_administer_reaction
|
||||
head :forbidden if Current.user != @reaction.reacter
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ class Cards::CommentsController < ApplicationController
|
||||
|
||||
before_action :set_comment, only: %i[ show edit update destroy ]
|
||||
before_action :ensure_creatorship, only: %i[ edit update destroy ]
|
||||
before_action :ensure_card_is_commentable, only: :create
|
||||
|
||||
def index
|
||||
set_page_and_extract_portion_from @card.comments.chronologically
|
||||
@@ -50,6 +51,10 @@ class Cards::CommentsController < ApplicationController
|
||||
head :forbidden if Current.user != @comment.creator
|
||||
end
|
||||
|
||||
def ensure_card_is_commentable
|
||||
head :forbidden unless @card.commentable?
|
||||
end
|
||||
|
||||
def comment_params
|
||||
params.expect(comment: [ :body, :created_at ])
|
||||
end
|
||||
|
||||
@@ -9,14 +9,22 @@ class Cards::PinsController < ApplicationController
|
||||
@pin = @card.pin_by Current.user
|
||||
|
||||
broadcast_add_pin_to_tray
|
||||
render_pin_button_replacement
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream { render_pin_button_replacement }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@pin = @card.unpin_by Current.user
|
||||
|
||||
broadcast_remove_pin_from_tray
|
||||
render_pin_button_replacement
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream { render_pin_button_replacement }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
class Cards::ReactionsController < ApplicationController
|
||||
include CardScoped
|
||||
|
||||
before_action :set_reactable
|
||||
|
||||
with_options only: :destroy do
|
||||
before_action :set_reaction
|
||||
before_action :ensure_permission_to_administer_reaction
|
||||
end
|
||||
|
||||
def index
|
||||
render "reactions/index"
|
||||
end
|
||||
|
||||
def new
|
||||
render "reactions/new"
|
||||
end
|
||||
|
||||
def create
|
||||
@reaction = @reactable.reactions.create!(params.expect(reaction: :content))
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream { render "reactions/create" }
|
||||
format.json { head :created }
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@reaction.destroy
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream { render "reactions/destroy" }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_reactable
|
||||
@reactable = @card
|
||||
end
|
||||
|
||||
def set_reaction
|
||||
@reaction = @reactable.reactions.find(params[:id])
|
||||
end
|
||||
|
||||
def ensure_permission_to_administer_reaction
|
||||
head :forbidden if Current.user != @reaction.reacter
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,15 @@
|
||||
class My::PinsController < ApplicationController
|
||||
def index
|
||||
@pins = Current.user.pins.includes(:card).ordered.limit(20)
|
||||
@pins = user_pins
|
||||
fresh_when etag: [ @pins, @pins.collect(&:card) ]
|
||||
end
|
||||
|
||||
private
|
||||
def user_pins
|
||||
Current.user.pins.includes(:card).ordered.limit(pins_limit)
|
||||
end
|
||||
|
||||
def pins_limit
|
||||
request.format.json? ? 100 : 20
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
class Webhooks::ActivationsController < ApplicationController
|
||||
include BoardScoped
|
||||
|
||||
before_action :ensure_admin
|
||||
|
||||
def create
|
||||
webhook = Current.account.webhooks.find(params[:webhook_id])
|
||||
webhook = @board.webhooks.find(params[:webhook_id])
|
||||
webhook.activate
|
||||
|
||||
redirect_to webhook
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
module ReactionsHelper
|
||||
def reaction_path_prefix_for(reactable)
|
||||
case reactable
|
||||
when Card then [ reactable ]
|
||||
when Comment then [ reactable.card, reactable ]
|
||||
else
|
||||
raise ArgumentError, "Unknown reactable type: #{reactable.class}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -11,8 +11,7 @@ export default class extends Controller {
|
||||
|
||||
switch(Notification.permission) {
|
||||
case "default":
|
||||
this.subscribeButtonTarget.hidden = false
|
||||
this.explainerTarget.hidden = true
|
||||
this.#showButtonToSubscribe()
|
||||
break
|
||||
case "granted":
|
||||
const registration = await this.#getServiceWorkerRegistration()
|
||||
@@ -20,6 +19,8 @@ export default class extends Controller {
|
||||
|
||||
if (registration && subscription) {
|
||||
this.element.classList.add(this.enabledClass)
|
||||
} else {
|
||||
this.#showButtonToSubscribe()
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -54,8 +55,9 @@ export default class extends Controller {
|
||||
return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: "/" })
|
||||
}
|
||||
|
||||
#registerServiceWorker() {
|
||||
return navigator.serviceWorker.register("/service-worker.js", { scope: "/" })
|
||||
async #registerServiceWorker() {
|
||||
await navigator.serviceWorker.register("/service-worker.js", { scope: "/" })
|
||||
return navigator.serviceWorker.ready
|
||||
}
|
||||
|
||||
async #subscribe(registration) {
|
||||
@@ -76,6 +78,11 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
#showButtonToSubscribe() {
|
||||
this.subscribeButtonTarget.hidden = false
|
||||
this.explainerTarget.hidden = true
|
||||
}
|
||||
|
||||
async #requestPermissionAndSubscribe(registration) {
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission === "granted") this.#subscribe(registration)
|
||||
|
||||
+4
-53
@@ -1,13 +1,13 @@
|
||||
class Card < ApplicationRecord
|
||||
include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
|
||||
Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
|
||||
include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Commentable,
|
||||
Entropic, Eventable, Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
|
||||
Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable
|
||||
|
||||
belongs_to :account, default: -> { board.account }
|
||||
belongs_to :board
|
||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||
|
||||
has_many :comments, dependent: :destroy
|
||||
has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all
|
||||
has_one_attached :image, dependent: :purge_later
|
||||
|
||||
has_rich_text :description
|
||||
@@ -23,7 +23,7 @@ class Card < ApplicationRecord
|
||||
scope :chronologically, -> { order created_at: :asc, id: :asc }
|
||||
scope :latest, -> { order last_active_at: :desc, id: :desc }
|
||||
scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) }
|
||||
scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds }
|
||||
scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, reactions: :reacter, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds }
|
||||
|
||||
scope :indexed_by, ->(index) do
|
||||
case index
|
||||
@@ -66,55 +66,6 @@ class Card < ApplicationRecord
|
||||
end
|
||||
|
||||
private
|
||||
STORAGE_BATCH_SIZE = 1000
|
||||
|
||||
# Override to include comments, but only load comments that have attachments.
|
||||
# Cards can have thousands of comments; most won't have attachments.
|
||||
# Streams in batches to avoid loading all IDs into memory at once.
|
||||
def storage_transfer_records
|
||||
comment_ids_with_attachments = storage_comment_ids_with_attachments
|
||||
|
||||
if comment_ids_with_attachments.any?
|
||||
[ self, *comments.where(id: comment_ids_with_attachments).to_a ]
|
||||
else
|
||||
[ self ]
|
||||
end
|
||||
end
|
||||
|
||||
def storage_comment_ids_with_attachments
|
||||
direct = []
|
||||
rich_text_map = {}
|
||||
|
||||
# Stream comment IDs in batches to avoid loading all into memory
|
||||
comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
|
||||
batch_ids = batch.pluck(:id)
|
||||
|
||||
direct.concat \
|
||||
ActiveStorage::Attachment
|
||||
.where(record_type: "Comment", record_id: batch_ids)
|
||||
.distinct
|
||||
.pluck(:record_id)
|
||||
|
||||
ActionText::RichText
|
||||
.where(record_type: "Comment", record_id: batch_ids)
|
||||
.pluck(:id, :record_id)
|
||||
.each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
|
||||
end
|
||||
|
||||
embed_comment_ids = if rich_text_map.any?
|
||||
rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
|
||||
ActiveStorage::Attachment
|
||||
.where(record_type: "ActionText::RichText", record_id: batch_ids)
|
||||
.distinct
|
||||
.pluck(:record_id)
|
||||
end.filter_map { |rt_id| rich_text_map[rt_id] }
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
(direct + embed_comment_ids).uniq
|
||||
end
|
||||
|
||||
def set_default_title
|
||||
self.title = "Untitled" if title.blank?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
module Card::Commentable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :comments, dependent: :destroy
|
||||
end
|
||||
|
||||
def commentable?
|
||||
published?
|
||||
end
|
||||
|
||||
private
|
||||
STORAGE_BATCH_SIZE = 1000
|
||||
|
||||
# Override to include comments, but only load comments that have attachments.
|
||||
# Cards can have thousands of comments; most won't have attachments.
|
||||
# Streams in batches to avoid loading all IDs into memory at once.
|
||||
def storage_transfer_records
|
||||
comment_ids_with_attachments = storage_comment_ids_with_attachments
|
||||
|
||||
if comment_ids_with_attachments.any?
|
||||
[ self, *comments.where(id: comment_ids_with_attachments).to_a ]
|
||||
else
|
||||
[ self ]
|
||||
end
|
||||
end
|
||||
|
||||
def storage_comment_ids_with_attachments
|
||||
direct = []
|
||||
rich_text_map = {}
|
||||
|
||||
# Stream comment IDs in batches to avoid loading all into memory
|
||||
comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
|
||||
batch_ids = batch.pluck(:id)
|
||||
|
||||
direct.concat \
|
||||
ActiveStorage::Attachment
|
||||
.where(record_type: "Comment", record_id: batch_ids)
|
||||
.distinct
|
||||
.pluck(:record_id)
|
||||
|
||||
ActionText::RichText
|
||||
.where(record_type: "Comment", record_id: batch_ids)
|
||||
.pluck(:id, :record_id)
|
||||
.each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
|
||||
end
|
||||
|
||||
embed_comment_ids = if rich_text_map.any?
|
||||
rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
|
||||
ActiveStorage::Attachment
|
||||
.where(record_type: "ActionText::RichText", record_id: batch_ids)
|
||||
.distinct
|
||||
.pluck(:record_id)
|
||||
end.filter_map { |rt_id| rich_text_map[rt_id] }
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
(direct + embed_comment_ids).uniq
|
||||
end
|
||||
end
|
||||
@@ -25,4 +25,8 @@ module Card::Searchable
|
||||
def search_board_id
|
||||
board_id
|
||||
end
|
||||
|
||||
def searchable?
|
||||
published?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,10 +4,12 @@ class Comment < ApplicationRecord
|
||||
belongs_to :account, default: -> { card.account }
|
||||
belongs_to :card, touch: true
|
||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||
has_many :reactions, -> { order(:created_at) }, dependent: :delete_all
|
||||
has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all
|
||||
|
||||
has_rich_text :body
|
||||
|
||||
validate :card_is_commentable
|
||||
|
||||
scope :chronologically, -> { order created_at: :asc, id: :desc }
|
||||
scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) }
|
||||
scope :by_system, -> { joins(:creator).where(creator: { role: :system }) }
|
||||
@@ -22,6 +24,10 @@ class Comment < ApplicationRecord
|
||||
end
|
||||
|
||||
private
|
||||
def card_is_commentable
|
||||
errors.add(:card, "does not allow comments") unless card.commentable?
|
||||
end
|
||||
|
||||
def watch_card_by_creator
|
||||
card.watch_by creator
|
||||
end
|
||||
|
||||
@@ -20,4 +20,8 @@ module Comment::Searchable
|
||||
def search_board_id
|
||||
card.board_id
|
||||
end
|
||||
|
||||
def searchable?
|
||||
card.published?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,11 +15,17 @@ module Searchable
|
||||
|
||||
private
|
||||
def create_in_search_index
|
||||
search_record_class.create!(search_record_attributes)
|
||||
if searchable?
|
||||
search_record_class.create!(search_record_attributes)
|
||||
end
|
||||
end
|
||||
|
||||
def update_in_search_index
|
||||
search_record_class.upsert!(search_record_attributes)
|
||||
if searchable?
|
||||
search_record_class.upsert!(search_record_attributes)
|
||||
else
|
||||
remove_from_search_index
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_search_index
|
||||
@@ -53,4 +59,5 @@ module Searchable
|
||||
# - search_content: returns content string
|
||||
# - search_card_id: returns the card id (self.id for cards, card_id for comments)
|
||||
# - search_board_id: returns the board id
|
||||
# - searchable?: returns whether this record should be indexed
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Reaction < ApplicationRecord
|
||||
belongs_to :account, default: -> { comment.account }
|
||||
belongs_to :comment, touch: true
|
||||
belongs_to :account, default: -> { reactable.account }
|
||||
belongs_to :reactable, polymorphic: true, touch: true
|
||||
belongs_to :reacter, class_name: "User", default: -> { Current.user }
|
||||
|
||||
scope :ordered, -> { order(:created_at) }
|
||||
@@ -11,6 +11,6 @@ class Reaction < ApplicationRecord
|
||||
|
||||
private
|
||||
def register_card_activity
|
||||
comment.card.touch_last_active_at
|
||||
reactable.card.touch_last_active_at
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,4 +20,5 @@ json.cache! card do
|
||||
json.has_more_assignees card.assignees.size > 5
|
||||
|
||||
json.comments_url card_comments_url(card)
|
||||
json.reactions_url card_reactions_url(card)
|
||||
end
|
||||
|
||||
@@ -21,11 +21,14 @@
|
||||
<% if card.published? %>
|
||||
<%= render "cards/triage/columns", card: card %>
|
||||
<% end %>
|
||||
|
||||
<%= render "cards/display/common/stamp", card: card %>
|
||||
</div>
|
||||
|
||||
<footer class="card__footer full-width flex align-start gap">
|
||||
<footer class="card__footer">
|
||||
<%= render "cards/display/perma/meta", card: card %>
|
||||
<%= render "cards/display/perma/background", card: card %>
|
||||
<%= render "reactions/reactions", reactable: card %>
|
||||
</footer>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<%= comment.body %>
|
||||
</div>
|
||||
|
||||
<%= render "cards/comments/reactions/reactions", comment: comment %>
|
||||
<%= render "reactions/reactions", reactable: comment %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<%= turbo_frame_tag comment, :reacting do %>
|
||||
<div class="reactions">
|
||||
<div id="<%= dom_id(comment, :reactions) %>" class="reactions__list">
|
||||
<%= render partial: "cards/comments/reactions/reaction", collection: comment.reactions %>
|
||||
</div>
|
||||
|
||||
<%= turbo_frame_tag comment, :new_reaction do %>
|
||||
<%= link_to new_card_comment_reaction_path(comment.card, comment), role: "button",
|
||||
class: "reactions__trigger btn btn--circle", action: "soft-keyboard#open",
|
||||
data: { turbo_frame: dom_id(comment, :new_reaction), action: "dialog#close" } do %>
|
||||
<%= image_tag "boost-color.svg", aria: { hidden: true } %>
|
||||
<span class="for-screen-reader">Add your own reaction</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,3 +0,0 @@
|
||||
<%= turbo_stream.replace([ @comment, :reacting ]) do %>
|
||||
<%= render "cards/comments/reactions/reactions", comment: @comment.reload %>
|
||||
<% end %>
|
||||
@@ -1 +0,0 @@
|
||||
<%= render "cards/comments/reactions/reactions", comment: @comment %>
|
||||
@@ -1 +0,0 @@
|
||||
json.array! @comment.reactions.ordered, partial: "cards/comments/reactions/reaction", as: :reaction
|
||||
@@ -41,12 +41,16 @@
|
||||
</div>
|
||||
|
||||
<%= render "cards/display/preview/columns", card: card %>
|
||||
<%= render "cards/display/common/stamp", card: card %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="card__footer flex gap-half">
|
||||
<%= render "cards/display/preview/meta", card: card, preview: true %>
|
||||
<%= render "cards/display/preview/comments", card: card %>
|
||||
<div class="card__counts">
|
||||
<%= render "cards/display/preview/boosts", card: card %>
|
||||
<%= render "cards/display/preview/comments", card: card %>
|
||||
</div>
|
||||
<%= render "cards/display/common/background", card: card %>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
</div>
|
||||
|
||||
<%= render "cards/display/public_preview/columns", card: card if card.triaged? %>
|
||||
<%= render "cards/display/common/stamp", card: card %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,19 +5,3 @@
|
||||
|
||||
<%= yield %>
|
||||
<% end %>
|
||||
|
||||
<% if card.postponed? %>
|
||||
<div class="card__closed <%= "card__closed--system" if card.postponed_by&.system? %>">
|
||||
<span class="card__closed-title" data-text="Not Now">Not Now</span>
|
||||
<strong class="card__closed-date"><%= card.postponed_at.strftime("%b %d, %Y") %></strong>
|
||||
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.postponed_by&.familiar_name %></span></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if card.closed? %>
|
||||
<div class="card__closed">
|
||||
<span class="card__closed-title" data-text="Done">Done</span>
|
||||
<strong class="card__closed-date"><%= card.closed_at.strftime("%b %d, %Y") %></strong>
|
||||
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.closed_by.familiar_name %></span></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<% if card.postponed? %>
|
||||
<div class="card__closed <%= "card__closed--system" if card.postponed_by&.system? %>">
|
||||
<span class="card__closed-title" data-text="Not Now">Not Now</span>
|
||||
<strong class="card__closed-date"><%= card.postponed_at.strftime("%b %d, %Y") %></strong>
|
||||
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.postponed_by&.familiar_name %></span></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if card.closed? %>
|
||||
<div class="card__closed">
|
||||
<span class="card__closed-title" data-text="Done">Done</span>
|
||||
<strong class="card__closed-date"><%= card.closed_at.strftime("%b %d, %Y") %></strong>
|
||||
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.closed_by.familiar_name %></span></span>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,5 +1,5 @@
|
||||
<%= render "cards/display/common/background", card: card do %>
|
||||
<%= link_to card.image.presence, class: "btn fill-transparent flex-item-justify-end", data: { controller: "tooltip", lightbox_target: "image" } do %>
|
||||
<%= link_to card.image.presence, class: "card__zoom-bg-btn btn", data: { controller: "tooltip", lightbox_target: "image" } do %>
|
||||
<%= icon_tag "picture-zoom" %>
|
||||
<span class="for-screen-reader">Zoom background image</span>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<% boosts = card.reactions %>
|
||||
<% if boosts.any? %>
|
||||
<div class="card__boosts">
|
||||
<%= image_tag "boost-color.svg", aria: { hidden: true } %>
|
||||
<span><%= boosts.size %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,7 +1,7 @@
|
||||
<% comments = card.comments.by_user %>
|
||||
<% if comments.any? %>
|
||||
<div class="card__comments align-center gap-half flex-item-justify-end flex-item-no-shrink">
|
||||
<div class="card__comments">
|
||||
<%= icon_tag "comment" %>
|
||||
<strong><%= comments.count %></strong>
|
||||
<span><%= comments.count %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="card__footer full-width flex align-start gap">
|
||||
<footer class="card__footer">
|
||||
<%= render "cards/display/perma/meta", card: card %>
|
||||
<%= render "cards/display/perma/background", card: card %>
|
||||
</footer>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% cache event do %>
|
||||
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
|
||||
<%# Template Dependency Updated: _layout.html.erb 2026-01-26 %>
|
||||
<% if lookup_context.exists?("events/event/eventable/_#{event.action}") %>
|
||||
<%= render "events/event/eventable/#{event.action}", event: event %>
|
||||
<% else %>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<% cache [ day_timeline.events ] do %>
|
||||
<%# Template Dependency Updated: _layout.html.erb 2026-01-26 %>
|
||||
<% if day_timeline.has_activity? %>
|
||||
<div class="events__columns">
|
||||
<%= render "events/day_timeline/column", column: day_timeline.added_column %>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
related_element_target: "related",
|
||||
related_element_group_value: card.id,
|
||||
action: "mouseover->related-element#highlight mouseout->related-element#unhighlight" } do %>
|
||||
<div class="card__header flex align-start gap full-width">
|
||||
<h4 class="card__board flex-inline align-start">
|
||||
<div class="card__header">
|
||||
<h4 class="card__board">
|
||||
<span class="card__id">
|
||||
<span class="for-screen-reader">Card number</span>
|
||||
<%= card.number %>
|
||||
@@ -19,9 +19,7 @@
|
||||
</h4>
|
||||
|
||||
<% unless event.action.in?(%w[ card_closed card_published card_reopened ]) %>
|
||||
<span class="event__icon flex-item-justify-end txt-reversed">
|
||||
<%= icon_tag event_action_icon(event) %>
|
||||
</span>
|
||||
<%= icon_tag event_action_icon(event), class: "event__icon" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="event__content">
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
json.array! @pins do |pin|
|
||||
json.partial! "cards/card", card: pin.card
|
||||
end
|
||||
@@ -30,11 +30,12 @@
|
||||
<div class="card__body justify-space-between">
|
||||
<%= render "public/cards/show/content", card: @card %>
|
||||
<%= render "cards/display/public_preview/columns", card: @card if @card.open? %>
|
||||
<%= render "cards/display/common/stamp", card: @card %>
|
||||
</div>
|
||||
|
||||
<%= render "public/cards/show/steps", card: @card %>
|
||||
|
||||
<footer class="card__footer full-width flex align-start gap">
|
||||
<footer class="card__footer">
|
||||
<%= render "cards/display/public_preview/meta", card: @card %>
|
||||
<%= render "cards/display/perma/background", card: @card %>
|
||||
</footer>
|
||||
|
||||
@@ -9,8 +9,8 @@ self.addEventListener('fetch', (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener("push", async (event) => {
|
||||
const data = await event.data.json()
|
||||
self.addEventListener("push", (event) => {
|
||||
const data = event.data.json()
|
||||
event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
class: [ "txt-small", { "txt-medium": reaction.all_emoji? } ],
|
||||
data: { action: "click->reaction-delete#reveal keydown.enter->reaction-delete#reveal:prevent", reaction_delete_target: "content" } %>
|
||||
|
||||
<%= button_to card_comment_reaction_path(reaction.comment.card, reaction.comment, reaction),
|
||||
<%= button_to polymorphic_path([ *reaction_path_prefix_for(reaction.reactable), reaction ]),
|
||||
method: :delete,
|
||||
class: "reaction__delete btn btn--negative flex-item-justify-end",
|
||||
data: { action: "reaction-delete#perform", reaction_delete_target: "button" } do %>
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
json.cache! reaction do
|
||||
json.(reaction, :id, :content)
|
||||
json.reacter reaction.reacter, partial: "users/user", as: :user
|
||||
json.url card_comment_reaction_url(reaction.comment.card, reaction.comment, reaction)
|
||||
json.url polymorphic_url([ *reaction_path_prefix_for(reaction.reactable), reaction ])
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
<%= turbo_frame_tag reactable, :reacting do %>
|
||||
<div class="reactions">
|
||||
<div id="<%= dom_id(reactable, :reactions) %>" class="reactions__list">
|
||||
<%= render partial: "reactions/reaction", collection: reactable.reactions %>
|
||||
</div>
|
||||
|
||||
<%= turbo_frame_tag reactable, :new_reaction do %>
|
||||
<%= link_to new_polymorphic_path([ *reaction_path_prefix_for(reactable), :reaction ]), role: "button",
|
||||
class: "reactions__trigger btn btn--circle", action: "soft-keyboard#open",
|
||||
data: { turbo_frame: dom_id(reactable, :new_reaction), action: "dialog#close" } do %>
|
||||
<%= image_tag "boost-color.svg", aria: { hidden: true } %>
|
||||
<span class="for-screen-reader">Add your own reaction</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,3 @@
|
||||
<%= turbo_stream.replace([ @reactable, :reacting ]) do %>
|
||||
<%= render "reactions/reactions", reactable: @reactable.reload %>
|
||||
<% end %>
|
||||
@@ -0,0 +1 @@
|
||||
<%= render "reactions/reactions", reactable: @reactable %>
|
||||
@@ -0,0 +1 @@
|
||||
json.array! @reactable.reactions.ordered, partial: "reactions/reaction", as: :reaction
|
||||
@@ -1,8 +1,8 @@
|
||||
<%= turbo_frame_tag @comment, :new_reaction do %>
|
||||
<%= form_with model: [ @comment.card, @comment, Reaction.new ],
|
||||
<%= turbo_frame_tag @reactable, :new_reaction do %>
|
||||
<%= form_with model: [ *reaction_path_prefix_for(@reactable), Reaction.new ],
|
||||
class: "reaction reaction__form expanded",
|
||||
html: { aria: { label: "New reaction" } },
|
||||
data: { controller: "form reaction-emoji", turbo_frame: dom_id(@comment, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit submit->form#preventComposingSubmit" } do |form| %>
|
||||
data: { controller: "form reaction-emoji", turbo_frame: dom_id(@reactable, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit submit->form#preventComposingSubmit" } do |form| %>
|
||||
<label class="reaction__form-label flex gap" style="--column-gap: 0.4ch;">
|
||||
<figure class="reaction__avatar margin-none flex-item-no-shrink">
|
||||
<%= avatar_tag Current.user %>
|
||||
@@ -12,14 +12,14 @@
|
||||
pattern: /\S+.*/, class: "input reaction__input txt-small", data: { form_target: "input", reaction_emoji_target: "input", action: "compositionstart->form#compositionStart compositionend->form#compositionEnd" }, aria: { label: "Add a reaction" } %>
|
||||
</label>
|
||||
|
||||
<%= render "cards/comments/reactions/menu", comment: @comment %>
|
||||
<%= render "reactions/menu" %>
|
||||
|
||||
<%= form.button class: "reaction__submit-btn btn btn--circle borderless", type: "submit", data: { form_target: "submit" } do %>
|
||||
<%= icon_tag "check-circle" %> <span class="for-screen-reader">Submit</span>
|
||||
<% end %>
|
||||
|
||||
<%= link_to card_comment_reactions_path(@comment.card, @comment), role: "button",
|
||||
data: { turbo_frame: dom_id(@comment, :reacting), form_target: "cancel" }, class: "reaction__cancel-btn btn btn--circle borderless" do %>
|
||||
<%= link_to polymorphic_path([ *reaction_path_prefix_for(@reactable), :reactions ]), role: "button",
|
||||
data: { turbo_frame: dom_id(@reactable, :reacting), form_target: "cancel" }, class: "reaction__cancel-btn btn btn--circle borderless" do %>
|
||||
<%= icon_tag "close-circle" %> <span class="for-screen-reader">Cancel</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -20,7 +20,8 @@ module WebPush::PersistentRequest
|
||||
endpoint_ip = @options[:endpoint_ip]
|
||||
|
||||
if endpoint_ip
|
||||
http = Net::HTTP.new(uri.host, uri.port, ipaddr: endpoint_ip)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.ipaddr = endpoint_ip
|
||||
http.use_ssl = true
|
||||
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
|
||||
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
|
||||
|
||||
@@ -86,6 +86,8 @@ Rails.application.routes.draw do
|
||||
resource :watch
|
||||
resource :reading
|
||||
|
||||
resources :reactions
|
||||
|
||||
resources :assignments
|
||||
resource :self_assignment, only: :create
|
||||
resources :steps
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
class MakeReactionsPolymorphic < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :reactions, :reactable_type, :string
|
||||
add_column :reactions, :reactable_id, :uuid
|
||||
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
execute <<~SQL
|
||||
UPDATE reactions SET reactable_type = 'Comment', reactable_id = comment_id
|
||||
SQL
|
||||
end
|
||||
|
||||
dir.down do
|
||||
execute <<~SQL
|
||||
UPDATE reactions SET comment_id = reactable_id WHERE reactable_type = 'Comment'
|
||||
SQL
|
||||
end
|
||||
end
|
||||
|
||||
change_column_null :reactions, :reactable_type, false
|
||||
change_column_null :reactions, :reactable_id, false
|
||||
|
||||
remove_column :reactions, :comment_id, :uuid
|
||||
|
||||
add_index :reactions, [:reactable_type, :reactable_id]
|
||||
end
|
||||
end
|
||||
Generated
+4
-3
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do
|
||||
ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do
|
||||
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "accessed_at"
|
||||
t.uuid "account_id", null: false
|
||||
@@ -412,13 +412,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do
|
||||
|
||||
create_table "reactions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.uuid "account_id", null: false
|
||||
t.uuid "comment_id", null: false
|
||||
t.string "content", limit: 16, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.uuid "reactable_id", null: false
|
||||
t.string "reactable_type", null: false
|
||||
t.uuid "reacter_id", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_reactions_on_account_id"
|
||||
t.index ["comment_id"], name: "index_reactions_on_comment_id"
|
||||
t.index ["reactable_type", "reactable_id"], name: "index_reactions_on_reactable_type_and_reactable_id"
|
||||
t.index ["reacter_id"], name: "index_reactions_on_reacter_id"
|
||||
end
|
||||
|
||||
|
||||
+4
-3
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do
|
||||
ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do
|
||||
create_table "accesses", id: :uuid, force: :cascade do |t|
|
||||
t.datetime "accessed_at"
|
||||
t.uuid "account_id", null: false
|
||||
@@ -412,13 +412,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do
|
||||
|
||||
create_table "reactions", id: :uuid, force: :cascade do |t|
|
||||
t.uuid "account_id", null: false
|
||||
t.uuid "comment_id", null: false
|
||||
t.string "content", limit: 16, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.uuid "reactable_id", null: false
|
||||
t.string "reactable_type", limit: 255, null: false
|
||||
t.uuid "reacter_id", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_reactions_on_account_id"
|
||||
t.index ["comment_id"], name: "index_reactions_on_comment_id"
|
||||
t.index ["reactable_type", "reactable_id"], name: "index_reactions_on_reactable_type_and_reactable_id"
|
||||
t.index ["reacter_id"], name: "index_reactions_on_reacter_id"
|
||||
end
|
||||
|
||||
|
||||
+136
-2
@@ -553,7 +553,8 @@ __Response:__
|
||||
"created_at": "2025-12-05T19:36:35.401Z",
|
||||
"url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
|
||||
},
|
||||
"comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments"
|
||||
"comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments",
|
||||
"reactions_url": "http://fizzy.localhost:3006/897362094/cards/4/reactions"
|
||||
},
|
||||
]
|
||||
```
|
||||
@@ -614,6 +615,7 @@ __Response:__
|
||||
"url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
|
||||
},
|
||||
"comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments",
|
||||
"reactions_url": "http://fizzy.localhost:3006/897362094/cards/4/reactions",
|
||||
"steps": [
|
||||
{
|
||||
"id": "03f8huu0sog76g3s975963b5e",
|
||||
@@ -803,6 +805,79 @@ __Response:__
|
||||
|
||||
Returns `204 No Content` on success.
|
||||
|
||||
## Pins
|
||||
|
||||
Pins let users keep quick access to important cards.
|
||||
|
||||
### `POST /:account_slug/cards/:card_number/pin`
|
||||
|
||||
Pins a card for the current user.
|
||||
|
||||
__Response:__
|
||||
|
||||
Returns `204 No Content` on success.
|
||||
|
||||
### `DELETE /:account_slug/cards/:card_number/pin`
|
||||
|
||||
Unpins a card for the current user.
|
||||
|
||||
__Response:__
|
||||
|
||||
Returns `204 No Content` on success.
|
||||
|
||||
### `GET /my/pins`
|
||||
|
||||
Returns the current user's pinned cards. This endpoint is not paginated and returns up to 100 cards.
|
||||
|
||||
__Response:__
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "03f5vaeq985jlvwv3arl4srq2",
|
||||
"number": 1,
|
||||
"title": "First!",
|
||||
"status": "published",
|
||||
"description": "Hello, World!",
|
||||
"description_html": "<div class=\"action-text-content\"><p>Hello, World!</p></div>",
|
||||
"image_url": null,
|
||||
"tags": ["programming"],
|
||||
"golden": false,
|
||||
"last_active_at": "2025-12-05T19:38:48.553Z",
|
||||
"created_at": "2025-12-05T19:38:48.540Z",
|
||||
"url": "http://fizzy.localhost:3006/897362094/cards/4",
|
||||
"board": {
|
||||
"id": "03f5v9zkft4hj9qq0lsn9ohcm",
|
||||
"name": "Fizzy",
|
||||
"all_access": true,
|
||||
"created_at": "2025-12-05T19:36:35.534Z",
|
||||
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
|
||||
"creator": {
|
||||
"id": "03f5v9zjw7pz8717a4no1h8a7",
|
||||
"name": "David Heinemeier Hansson",
|
||||
"role": "owner",
|
||||
"active": true,
|
||||
"email_address": "david@example.com",
|
||||
"created_at": "2025-12-05T19:36:35.401Z",
|
||||
"url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7",
|
||||
"avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7/avatar"
|
||||
}
|
||||
},
|
||||
"creator": {
|
||||
"id": "03f5v9zjw7pz8717a4no1h8a7",
|
||||
"name": "David Heinemeier Hansson",
|
||||
"role": "owner",
|
||||
"active": true,
|
||||
"email_address": "david@example.com",
|
||||
"created_at": "2025-12-05T19:36:35.401Z",
|
||||
"url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7",
|
||||
"avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7/avatar"
|
||||
},
|
||||
"comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Comments
|
||||
|
||||
Comments are attached to cards and support rich text.
|
||||
@@ -928,7 +1003,66 @@ __Response:__
|
||||
|
||||
Returns `204 No Content` on success.
|
||||
|
||||
## Reactions
|
||||
## Card Reactions (Boosts)
|
||||
|
||||
Card reactions (also called "boosts") let users add short responses directly to cards. These are limited to 16 characters.
|
||||
|
||||
### `GET /:account_slug/cards/:card_number/reactions`
|
||||
|
||||
Returns a list of reactions on a card.
|
||||
|
||||
__Response:__
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "03f5v9zo9qlcwwpyc0ascnikz",
|
||||
"content": "👍",
|
||||
"reacter": {
|
||||
"id": "03f5v9zjw7pz8717a4no1h8a7",
|
||||
"name": "David Heinemeier Hansson",
|
||||
"role": "owner",
|
||||
"active": true,
|
||||
"email_address": "david@example.com",
|
||||
"created_at": "2025-12-05T19:36:35.401Z",
|
||||
"url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
|
||||
},
|
||||
"url": "http://fizzy.localhost:3006/897362094/cards/3/reactions/03f5v9zo9qlcwwpyc0ascnikz"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `POST /:account_slug/cards/:card_number/reactions`
|
||||
|
||||
Adds a reaction (boost) to a card.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `content` | string | Yes | The reaction text (max 16 characters) |
|
||||
|
||||
__Request:__
|
||||
|
||||
```json
|
||||
{
|
||||
"reaction": {
|
||||
"content": "Great 👍"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
__Response:__
|
||||
|
||||
Returns `201 Created` on success.
|
||||
|
||||
### `DELETE /:account_slug/cards/:card_number/reactions/:reaction_id`
|
||||
|
||||
Removes your reaction from a card. Only the reaction creator can remove their own reactions.
|
||||
|
||||
__Response:__
|
||||
|
||||
Returns `204 No Content` on success.
|
||||
|
||||
## Comment Reactions
|
||||
|
||||
Reactions are short (16-character max) responses to comments.
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
class Account::SyncStripeCustomerEmailJob < ApplicationJob
|
||||
queue_as :default
|
||||
retry_on Stripe::StripeError, wait: :polynomially_longer
|
||||
|
||||
def perform(subscription)
|
||||
subscription.sync_customer_email_to_stripe
|
||||
end
|
||||
end
|
||||
@@ -31,6 +31,10 @@ module Account::Billing
|
||||
reload_billing_waiver
|
||||
end
|
||||
|
||||
def owner_email_changed
|
||||
Account::SyncStripeCustomerEmailJob.perform_later(subscription) if subscription
|
||||
end
|
||||
|
||||
private
|
||||
def active_subscription
|
||||
if comped?
|
||||
|
||||
@@ -47,4 +47,21 @@ class Account::Subscription < SaasRecord
|
||||
# Subscription already deleted/canceled in Stripe - treat as success
|
||||
Rails.logger.warn "Stripe subscription #{stripe_subscription_id} not found during cancel: #{e.message}"
|
||||
end
|
||||
|
||||
def sync_customer_email_to_stripe
|
||||
if stripe_customer_id && (email = owner_email)
|
||||
Stripe::Customer.update(stripe_customer_id, email: email)
|
||||
end
|
||||
rescue Stripe::InvalidRequestError => e
|
||||
# Customer already deleted in Stripe - treat as success
|
||||
Rails.logger.warn "Stripe customer #{stripe_customer_id} not found during email sync: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
# Account owner email for Stripe customer record. Returns nil when:
|
||||
# - No owner exists (ownership being transferred, account in limbo)
|
||||
# - Owner has no identity (deactivated user)
|
||||
def owner_email
|
||||
account.users.owner.first&.identity&.email_address
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
module User::NotifiesAccountOfEmailChange
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_update :notify_account_of_owner_change, if: :account_owner_changed?
|
||||
end
|
||||
|
||||
private
|
||||
# Account owner changed when:
|
||||
# - The current owner changed their email
|
||||
# - A user just became the owner (ownership transfer)
|
||||
def account_owner_changed?
|
||||
owner? && identity && (saved_change_to_identity_id? || became_owner?)
|
||||
end
|
||||
|
||||
def became_owner?
|
||||
saved_change_to_role? && role_before_last_save != "owner"
|
||||
end
|
||||
|
||||
def notify_account_of_owner_change
|
||||
account.owner_email_changed
|
||||
end
|
||||
end
|
||||
@@ -133,6 +133,7 @@ module Fizzy
|
||||
|
||||
config.to_prepare do
|
||||
::Account.include Account::Billing, Account::Limited
|
||||
::User.include User::NotifiesAccountOfEmailChange
|
||||
::Signup.prepend Fizzy::Saas::Signup
|
||||
CardsController.include(Card::LimitedCreation)
|
||||
Cards::PublishesController.include(Card::LimitedPublishing)
|
||||
|
||||
@@ -68,4 +68,26 @@ class Account::BillingTest < ActiveSupport::TestCase
|
||||
|
||||
account.incinerate
|
||||
end
|
||||
|
||||
test "owner_email_changed enqueues sync job when subscription exists" do
|
||||
account = accounts(:"37s")
|
||||
account.create_subscription!(
|
||||
stripe_customer_id: "cus_test",
|
||||
plan_key: "monthly_v1",
|
||||
status: "active"
|
||||
)
|
||||
|
||||
assert_enqueued_with(job: Account::SyncStripeCustomerEmailJob, args: [ account.subscription ]) do
|
||||
account.owner_email_changed
|
||||
end
|
||||
end
|
||||
|
||||
test "owner_email_changed does nothing without subscription" do
|
||||
account = accounts(:initech)
|
||||
account.subscription&.destroy
|
||||
|
||||
assert_no_enqueued_jobs do
|
||||
account.owner_email_changed
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -117,4 +117,78 @@ class Account::SubscriptionTest < ActiveSupport::TestCase
|
||||
subscription.cancel
|
||||
end
|
||||
end
|
||||
|
||||
test "sync_customer_email_to_stripe updates Stripe customer with owner email" do
|
||||
account = accounts(:"37s")
|
||||
owner = account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
|
||||
subscription = account.create_subscription!(
|
||||
stripe_customer_id: "cus_test",
|
||||
plan_key: "monthly_v1",
|
||||
status: "active"
|
||||
)
|
||||
|
||||
Stripe::Customer.expects(:update).with("cus_test", email: owner.identity.email_address).once
|
||||
|
||||
subscription.sync_customer_email_to_stripe
|
||||
end
|
||||
|
||||
test "sync_customer_email_to_stripe does nothing without stripe_customer_id" do
|
||||
account = accounts(:"37s")
|
||||
subscription = account.build_subscription(
|
||||
stripe_customer_id: nil,
|
||||
plan_key: "free_v1",
|
||||
status: "active"
|
||||
)
|
||||
|
||||
Stripe::Customer.expects(:update).never
|
||||
|
||||
subscription.sync_customer_email_to_stripe
|
||||
end
|
||||
|
||||
test "sync_customer_email_to_stripe does nothing without owner" do
|
||||
account = accounts(:"37s")
|
||||
account.users.update_all(role: :member)
|
||||
subscription = account.create_subscription!(
|
||||
stripe_customer_id: "cus_test",
|
||||
plan_key: "monthly_v1",
|
||||
status: "active"
|
||||
)
|
||||
|
||||
Stripe::Customer.expects(:update).never
|
||||
|
||||
subscription.sync_customer_email_to_stripe
|
||||
end
|
||||
|
||||
test "sync_customer_email_to_stripe does nothing when owner has no identity" do
|
||||
account = accounts(:"37s")
|
||||
owner = account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
|
||||
owner.update_column(:identity_id, nil)
|
||||
subscription = account.create_subscription!(
|
||||
stripe_customer_id: "cus_test",
|
||||
plan_key: "monthly_v1",
|
||||
status: "active"
|
||||
)
|
||||
|
||||
Stripe::Customer.expects(:update).never
|
||||
|
||||
subscription.sync_customer_email_to_stripe
|
||||
end
|
||||
|
||||
test "sync_customer_email_to_stripe treats deleted customer as success" do
|
||||
account = accounts(:"37s")
|
||||
account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
|
||||
subscription = account.create_subscription!(
|
||||
stripe_customer_id: "cus_deleted",
|
||||
plan_key: "monthly_v1",
|
||||
status: "active"
|
||||
)
|
||||
|
||||
Stripe::Customer.stubs(:update).raises(
|
||||
Stripe::InvalidRequestError.new("No such customer", {})
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
subscription.sync_customer_email_to_stripe
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
require "test_helper"
|
||||
|
||||
class User::NotifiesAccountOfEmailChangeTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@account = accounts(:"37s")
|
||||
@owner = @account.users.find_by(role: :owner) || @account.users.first.tap { |u| u.update!(role: :owner) }
|
||||
@member = @account.users.where.not(id: @owner.id).first || @account.users.create!(
|
||||
name: "Member",
|
||||
identity: Identity.create!(email_address: "member@example.com"),
|
||||
role: :member
|
||||
)
|
||||
end
|
||||
|
||||
test "notifies account when owner changes email" do
|
||||
@account.expects(:owner_email_changed).once
|
||||
|
||||
new_identity = Identity.create!(email_address: "new-owner@example.com")
|
||||
@owner.update!(identity: new_identity)
|
||||
end
|
||||
|
||||
test "does not notify account when non-owner changes email" do
|
||||
@account.expects(:owner_email_changed).never
|
||||
|
||||
new_identity = Identity.create!(email_address: "new-member@example.com")
|
||||
@member.update!(identity: new_identity)
|
||||
end
|
||||
|
||||
test "does not notify account when owner is deactivated" do
|
||||
@account.expects(:owner_email_changed).never
|
||||
|
||||
@owner.update!(identity: nil)
|
||||
end
|
||||
|
||||
test "does not notify account when identity unchanged" do
|
||||
@account.expects(:owner_email_changed).never
|
||||
|
||||
@owner.update!(name: "New Name")
|
||||
end
|
||||
|
||||
test "notifies account when user becomes owner" do
|
||||
@account.expects(:owner_email_changed).once
|
||||
|
||||
@member.update!(role: :owner)
|
||||
end
|
||||
|
||||
test "does not notify account when owner becomes member" do
|
||||
@account.expects(:owner_email_changed).never
|
||||
|
||||
@owner.update!(role: :member)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
require_relative "../../config/environment"
|
||||
|
||||
total_deleted = 0
|
||||
|
||||
Account.find_each do |account|
|
||||
search_record_class = Search::Record.for(account.id)
|
||||
|
||||
# Find search records for draft cards (both Card and Comment searchables)
|
||||
draft_card_ids = Card.where(account_id: account.id, status: "drafted").pluck(:id)
|
||||
|
||||
if draft_card_ids.any?
|
||||
count = search_record_class.where(card_id: draft_card_ids).delete_all
|
||||
if count > 0
|
||||
puts "#{account.name}: deleted #{count} search records for draft cards"
|
||||
total_deleted += count
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
puts "Migration completed! Total deleted: #{total_deleted}"
|
||||
@@ -13,6 +13,16 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "create on draft card is forbidden" do
|
||||
draft_card = boards(:writebook).cards.create!(status: :drafted, creator: users(:kevin))
|
||||
|
||||
assert_no_difference -> { draft_card.comments.count } do
|
||||
post card_comments_path(draft_card), params: { comment: { body: "This should be forbidden" } }, as: :json
|
||||
end
|
||||
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "update" do
|
||||
put card_comment_path(cards(:logo), comments(:logo_agreement_kevin)), params: { comment: { body: "I've changed my mind" } }, as: :turbo_stream
|
||||
|
||||
@@ -69,6 +79,8 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_equal comment.id, @response.parsed_body["id"]
|
||||
assert_equal comment.card.id, @response.parsed_body.dig("card", "id")
|
||||
assert_equal card_url(comment.card.id), @response.parsed_body.dig("card", "url")
|
||||
assert_equal card_comment_reactions_url(comment.card_id, comment.id), @response.parsed_body["reactions_url"]
|
||||
assert_equal card_comment_url(comment.card_id, comment.id), @response.parsed_body["url"]
|
||||
end
|
||||
|
||||
test "update as JSON" do
|
||||
|
||||
@@ -17,6 +17,17 @@ class Cards::PinsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "create as JSON" do
|
||||
card = cards(:layout)
|
||||
|
||||
assert_not card.pinned_by?(users(:kevin))
|
||||
|
||||
post card_pin_path(card), as: :json
|
||||
|
||||
assert_response :no_content
|
||||
assert card.reload.pinned_by?(users(:kevin))
|
||||
end
|
||||
|
||||
test "destroy" do
|
||||
assert_changes -> { cards(:shipping).pinned_by?(users(:kevin)) }, from: true, to: false do
|
||||
perform_enqueued_jobs do
|
||||
@@ -28,4 +39,15 @@ class Cards::PinsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "destroy as JSON" do
|
||||
card = cards(:shipping)
|
||||
|
||||
assert card.pinned_by?(users(:kevin))
|
||||
|
||||
delete card_pin_path(card), as: :json
|
||||
|
||||
assert_response :no_content
|
||||
assert_not card.reload.pinned_by?(users(:kevin))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
require "test_helper"
|
||||
|
||||
class Cards::ReactionsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in_as :david
|
||||
@card = cards(:logo)
|
||||
end
|
||||
|
||||
test "index" do
|
||||
get card_reactions_path(@card)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "new" do
|
||||
get new_card_reaction_path(@card)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "create" do
|
||||
assert_difference -> { @card.reactions.count }, 1 do
|
||||
post card_reactions_path(@card, format: :turbo_stream), params: { reaction: { content: "Great work!" } }
|
||||
assert_turbo_stream action: :replace, target: dom_id(@card, :reacting)
|
||||
end
|
||||
end
|
||||
|
||||
test "destroy" do
|
||||
reaction = reactions(:logo_card_david)
|
||||
assert_difference -> { @card.reactions.count }, -1 do
|
||||
delete card_reaction_path(@card, reaction, format: :turbo_stream)
|
||||
assert_turbo_stream action: :remove, target: dom_id(reaction)
|
||||
end
|
||||
end
|
||||
|
||||
test "non-owner cannot destroy reaction" do
|
||||
reaction = reactions(:logo_card_kevin)
|
||||
|
||||
assert_no_difference -> { @card.reactions.count } do
|
||||
delete card_reaction_path(@card, reaction, format: :turbo_stream)
|
||||
assert_response :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
test "index as JSON" do
|
||||
get card_reactions_path(@card), as: :json
|
||||
|
||||
assert_response :success
|
||||
assert_equal @card.reactions.count, @response.parsed_body.count
|
||||
end
|
||||
|
||||
test "create as JSON" do
|
||||
assert_difference -> { @card.reactions.count }, 1 do
|
||||
post card_reactions_path(@card), params: { reaction: { content: "👍" } }, as: :json
|
||||
end
|
||||
|
||||
assert_response :created
|
||||
end
|
||||
|
||||
test "destroy as JSON" do
|
||||
reaction = reactions(:logo_card_david)
|
||||
|
||||
assert_difference -> { @card.reactions.count }, -1 do
|
||||
delete card_reaction_path(@card, reaction), as: :json
|
||||
end
|
||||
|
||||
assert_response :no_content
|
||||
end
|
||||
end
|
||||
@@ -161,6 +161,8 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_equal card.title, @response.parsed_body["title"]
|
||||
assert_equal card.closed?, @response.parsed_body["closed"]
|
||||
assert_equal 2, @response.parsed_body["steps"].size
|
||||
assert_equal card_comments_url(card), @response.parsed_body["comments_url"]
|
||||
assert_equal card_reactions_url(card), @response.parsed_body["reactions_url"]
|
||||
end
|
||||
|
||||
test "create as JSON" do
|
||||
|
||||
@@ -11,4 +11,14 @@ class My::PinsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :success
|
||||
assert_select "div", text: /#{users(:kevin).pins.first.card.title}/
|
||||
end
|
||||
|
||||
test "index as JSON" do
|
||||
expected_ids = users(:kevin).pins.ordered.pluck(:card_id)
|
||||
|
||||
get my_pins_path(format: :json)
|
||||
|
||||
assert_response :success
|
||||
assert_equal expected_ids.count, @response.parsed_body.count
|
||||
assert_equal expected_ids, @response.parsed_body.map { |card| card["id"] }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,10 +5,10 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
setup do
|
||||
@board.update!(all_access: true)
|
||||
@card = @board.cards.create!(title: "Layout is broken", description: "Look at this mess.", creator: @user)
|
||||
@comment_card = @board.cards.create!(title: "Some card", creator: @user)
|
||||
@card = @board.cards.create!(title: "Layout is broken", description: "Look at this mess.", status: "published", creator: @user)
|
||||
@comment_card = @board.cards.create!(title: "Some card", status: "published", creator: @user)
|
||||
@comment_card.comments.create!(body: "overflowing text issue", creator: @user)
|
||||
@comment2_card = @board.cards.create!(title: "Just haggis", description: "More haggis", creator: @user)
|
||||
@comment2_card = @board.cards.create!(title: "Just haggis", description: "More haggis", status: "published", creator: @user)
|
||||
@comment2_card.comments.create!(body: "I love haggis", creator: @user)
|
||||
|
||||
untenanted { sign_in_as @user }
|
||||
@@ -43,7 +43,7 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "search highlights matched terms with proper HTML marks" do
|
||||
@board.cards.create!(title: "Testing search highlighting", creator: @user)
|
||||
@board.cards.create!(title: "Testing search highlighting", status: "published", creator: @user)
|
||||
|
||||
get search_path(q: "highlighting", script_name: "/#{@account.external_account_id}")
|
||||
assert_response :success
|
||||
@@ -52,6 +52,7 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
|
||||
test "search preserves highlight marks but escapes surrounding HTML" do
|
||||
@board.cards.create!(
|
||||
title: "<b>Bold</b> testing content",
|
||||
status: "published",
|
||||
creator: @user
|
||||
)
|
||||
|
||||
|
||||
@@ -16,4 +16,20 @@ class Webhooks::ActivationsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
assert_redirected_to board_webhook_path(webhook.board, webhook)
|
||||
end
|
||||
|
||||
test "cannot activate webhook on board without access" do
|
||||
logout_and_sign_in_as :jason
|
||||
webhook = webhooks(:inactive) # on private board, jason has no access
|
||||
|
||||
post board_webhook_activation_path(webhook.board, webhook)
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "non-admin cannot activate webhook" do
|
||||
logout_and_sign_in_as :jz # member with writebook access, but not admin
|
||||
webhook = webhooks(:active) # on writebook board
|
||||
|
||||
post board_webhook_activation_path(webhook.board, webhook)
|
||||
assert_response :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
@@ -121,4 +121,13 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
assert_redirected_to board_webhooks_path(webhook.board)
|
||||
end
|
||||
|
||||
test "cannot access webhooks on board without access" do
|
||||
logout_and_sign_in_as :jason
|
||||
|
||||
webhook = webhooks(:inactive) # on private board, jason has no access
|
||||
|
||||
get board_webhooks_path(webhook.board)
|
||||
assert_response :not_found
|
||||
end
|
||||
end
|
||||
|
||||
Vendored
+16
-2
@@ -2,12 +2,26 @@ kevin:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("kevin_reaction", :uuid) %>
|
||||
account: 37s_uuid
|
||||
content: "👍"
|
||||
comment: logo_agreement_jz_uuid
|
||||
reactable: logo_agreement_jz_uuid (Comment)
|
||||
reacter: kevin_uuid
|
||||
|
||||
david:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("david_reaction", :uuid) %>
|
||||
account: 37s_uuid
|
||||
content: "👍"
|
||||
comment: logo_agreement_jz_uuid
|
||||
reactable: logo_agreement_jz_uuid (Comment)
|
||||
reacter: david_uuid
|
||||
|
||||
logo_card_kevin:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("logo_card_kevin_reaction", :uuid) %>
|
||||
account: 37s_uuid
|
||||
content: "🎯"
|
||||
reactable: logo_uuid (Card)
|
||||
reacter: kevin_uuid
|
||||
|
||||
logo_card_david:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("logo_card_david_reaction", :uuid) %>
|
||||
account: 37s_uuid
|
||||
content: "👍"
|
||||
reactable: logo_uuid (Card)
|
||||
reacter: david_uuid
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
require "test_helper"
|
||||
|
||||
class CardPreviewBoostCountTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in_as :kevin
|
||||
@card_with_reactions = cards(:logo)
|
||||
@card_without_reactions = cards(:layout)
|
||||
@column = @card_with_reactions.column
|
||||
end
|
||||
|
||||
test "card preview displays boost count when card has reactions" do
|
||||
get board_column_path(@column.board, @column)
|
||||
assert_response :success
|
||||
|
||||
# Check that boost count is displayed for cards with reactions
|
||||
assert_select ".card__boosts", text: /2/
|
||||
end
|
||||
|
||||
test "card preview does not display boost count when card has no reactions" do
|
||||
# Ensure layout card is in the same column for this test
|
||||
@card_without_reactions.update!(column: @column)
|
||||
|
||||
get board_column_path(@column.board, @column)
|
||||
assert_response :success
|
||||
|
||||
# Find the card without reactions and verify no boost count is shown
|
||||
# We check the overall page doesn't have a boost count for zero reactions
|
||||
# (This is an imperfect test but reasonable given the structure)
|
||||
assert @card_without_reactions.reactions.none?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
require "test_helper"
|
||||
|
||||
class WebPush::PersistentRequestTest < ActiveSupport::TestCase
|
||||
PUBLIC_TEST_IP = "142.250.185.206"
|
||||
ENDPOINT = "https://fcm.googleapis.com/fcm/send/test123"
|
||||
|
||||
test "pins connection to endpoint_ip" do
|
||||
request = stub_request(:post, ENDPOINT)
|
||||
.with(ipaddr: PUBLIC_TEST_IP)
|
||||
.to_return(status: 201)
|
||||
|
||||
notification = WebPush::Notification.new(
|
||||
title: "Test",
|
||||
body: "Test notification",
|
||||
path: "/test",
|
||||
badge: 0,
|
||||
endpoint: ENDPOINT,
|
||||
endpoint_ip: PUBLIC_TEST_IP,
|
||||
p256dh_key: "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
|
||||
auth_key: "tBHItJI5svbpez7KI4CCXg"
|
||||
)
|
||||
notification.deliver
|
||||
|
||||
assert_requested request
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,18 @@
|
||||
require "test_helper"
|
||||
|
||||
class Card::CommentableTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
Current.session = sessions(:david)
|
||||
end
|
||||
|
||||
test "capturing comments" do
|
||||
assert_difference -> { cards(:logo).comments.count }, +1 do
|
||||
cards(:logo).comments.create!(body: "Agreed.")
|
||||
end
|
||||
|
||||
assert_equal "Agreed.", cards(:logo).comments.last.body.to_plain_text.chomp
|
||||
end
|
||||
|
||||
test "creating a comment on a card makes the creator watch the card" do
|
||||
boards(:writebook).access_for(users(:kevin)).access_only!
|
||||
assert_not cards(:text).watched_by?(users(:kevin))
|
||||
@@ -11,4 +23,9 @@ class Card::CommentableTest < ActiveSupport::TestCase
|
||||
|
||||
assert cards(:text).watched_by?(users(:kevin))
|
||||
end
|
||||
|
||||
test "commentable is true for published cards, false for drafts" do
|
||||
assert cards(:logo).commentable?
|
||||
assert_not cards(:unfinished_thoughts).commentable?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,20 +3,30 @@ require "test_helper"
|
||||
class Card::SearchableTest < ActiveSupport::TestCase
|
||||
include SearchTestHelper
|
||||
|
||||
test "searchable? returns true for published cards" do
|
||||
card = @board.cards.create!(title: "Published Card", status: "published", creator: @user)
|
||||
assert card.searchable?
|
||||
end
|
||||
|
||||
test "searchable? returns false for draft cards" do
|
||||
card = @board.cards.create!(title: "Draft Card", status: "drafted", creator: @user)
|
||||
assert_not card.searchable?
|
||||
end
|
||||
|
||||
test "card search" do
|
||||
# Searching by title
|
||||
card = @board.cards.create!(title: "layout is broken", creator: @user)
|
||||
card = @board.cards.create!(title: "layout is broken", status: "published", creator: @user)
|
||||
results = Card.mentioning("layout", user: @user)
|
||||
assert_includes results, card
|
||||
|
||||
# Searching by comment
|
||||
card_with_comment = @board.cards.create!(title: "Some card", creator: @user)
|
||||
card_with_comment = @board.cards.create!(title: "Some card", status: "published", creator: @user)
|
||||
card_with_comment.comments.create!(body: "overflowing text", creator: @user)
|
||||
results = Card.mentioning("overflowing", user: @user)
|
||||
assert_includes results, card_with_comment
|
||||
|
||||
# Sanitizing search query
|
||||
card_broken = @board.cards.create!(title: "broken layout", creator: @user)
|
||||
card_broken = @board.cards.create!(title: "broken layout", status: "published", creator: @user)
|
||||
results = Card.mentioning("broken \"", user: @user)
|
||||
assert_includes results, card_broken
|
||||
|
||||
@@ -25,8 +35,8 @@ class Card::SearchableTest < ActiveSupport::TestCase
|
||||
|
||||
# Filtering by board_ids
|
||||
other_board = Board.create!(name: "Other Board", account: @account, creator: @user)
|
||||
card_in_board = @board.cards.create!(title: "searchable content", creator: @user)
|
||||
card_in_other_board = other_board.cards.create!(title: "searchable content", creator: @user)
|
||||
card_in_board = @board.cards.create!(title: "searchable content", status: "published", creator: @user)
|
||||
card_in_other_board = other_board.cards.create!(title: "searchable content", status: "published", creator: @user)
|
||||
results = Card.mentioning("searchable", user: @user)
|
||||
assert_includes results, card_in_board
|
||||
assert_not_includes results, card_in_other_board
|
||||
@@ -37,7 +47,7 @@ class Card::SearchableTest < ActiveSupport::TestCase
|
||||
|
||||
# Create a card with unreasonably long content
|
||||
long_content = "asdf " * Searchable::SEARCH_CONTENT_LIMIT
|
||||
card = @board.cards.create!(title: "Card with long description", creator: @user)
|
||||
card = @board.cards.create!(title: "Card with long description", status: "published", creator: @user)
|
||||
card.description = ActionText::Content.new(long_content)
|
||||
card.save!
|
||||
|
||||
@@ -52,7 +62,7 @@ class Card::SearchableTest < ActiveSupport::TestCase
|
||||
|
||||
test "deleting card removes search record and FTS entry" do
|
||||
search_record_class = Search::Record.for(@user.account_id)
|
||||
card = @board.cards.create!(title: "Card to delete", creator: @user)
|
||||
card = @board.cards.create!(title: "Card to delete", status: "published", creator: @user)
|
||||
|
||||
# Verify search record exists
|
||||
search_record = search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
|
||||
@@ -78,4 +88,46 @@ class Card::SearchableTest < ActiveSupport::TestCase
|
||||
assert_equal 0, fts_count, "FTS entry should be deleted"
|
||||
end
|
||||
end
|
||||
|
||||
test "updating a draft card does not index it" do
|
||||
search_record_class = Search::Record.for(@user.account_id)
|
||||
|
||||
card = @board.cards.create!(title: "Draft card", creator: @user, status: "drafted")
|
||||
assert_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
|
||||
|
||||
card.update!(title: "Updated draft card")
|
||||
assert_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id),
|
||||
"Draft card should not be indexed after update"
|
||||
|
||||
results = Card.mentioning("Updated", user: @user)
|
||||
assert_not_includes results, card
|
||||
end
|
||||
|
||||
test "publishing a draft card indexes it" do
|
||||
search_record_class = Search::Record.for(@user.account_id)
|
||||
|
||||
card = @board.cards.create!(title: "Draft to publish", creator: @user, status: "drafted")
|
||||
assert_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
|
||||
|
||||
card.publish
|
||||
search_record = search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
|
||||
assert_not_nil search_record, "Published card should be indexed"
|
||||
assert_equal card.id, search_record.card_id
|
||||
|
||||
results = Card.mentioning("publish", user: @user)
|
||||
assert_includes results, card
|
||||
end
|
||||
|
||||
test "unpublishing a draft card removes it from the search index" do
|
||||
search_record_class = Search::Record.for(@user.account_id)
|
||||
|
||||
card = @board.cards.create!(title: "Draft to publish", creator: @user, status: "published")
|
||||
assert_not_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
|
||||
|
||||
card.update!(status: "drafted")
|
||||
|
||||
assert_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
|
||||
results = Card.mentioning("publish", user: @user)
|
||||
assert_not_includes results, card
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,14 +18,6 @@ class CardTest < ActiveSupport::TestCase
|
||||
assert_equal account.reload.cards_count, card.number
|
||||
end
|
||||
|
||||
test "capturing messages" do
|
||||
assert_difference -> { cards(:logo).comments.count }, +1 do
|
||||
cards(:logo).comments.create!(body: "Agreed.")
|
||||
end
|
||||
|
||||
assert_equal "Agreed.", cards(:logo).comments.last.body.to_plain_text.chomp
|
||||
end
|
||||
|
||||
test "assignment states" do
|
||||
assert cards(:logo).assigned_to?(users(:kevin))
|
||||
assert_not cards(:logo).assigned_to?(users(:david))
|
||||
@@ -207,4 +199,18 @@ class CardTest < ActiveSupport::TestCase
|
||||
assert card.watched_by?(kevin), "Kevin's watch should remain (has board access)"
|
||||
assert_not card.watched_by?(david), "David's watch should be deleted (no board access)"
|
||||
end
|
||||
|
||||
test "card has reactions association" do
|
||||
card = cards(:logo)
|
||||
user = users(:david)
|
||||
|
||||
assert_difference "card.reactions.count", +1 do
|
||||
card.reactions.create!(content: "👍", reacter: user)
|
||||
end
|
||||
|
||||
reaction = card.reactions.last
|
||||
assert_equal "👍", reaction.content
|
||||
assert_equal user, reaction.reacter
|
||||
assert_equal card, reaction.reactable
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,18 @@ class Comment::SearchableTest < ActiveSupport::TestCase
|
||||
include SearchTestHelper
|
||||
|
||||
setup do
|
||||
@card = @board.cards.create!(title: "Test Card", creator: @user)
|
||||
@card = @board.cards.create!(title: "Test Card", status: "published", creator: @user)
|
||||
end
|
||||
|
||||
test "searchable? returns true for comments on published cards" do
|
||||
comment = @card.comments.create!(body: "test comment", creator: @user)
|
||||
assert comment.searchable?
|
||||
end
|
||||
|
||||
test "searchable? returns false for comments on draft cards" do
|
||||
draft_card = @board.cards.create!(title: "Draft Card", status: "drafted", creator: @user)
|
||||
comment = draft_card.comments.build(body: "test comment", creator: @user)
|
||||
assert_not comment.searchable?
|
||||
end
|
||||
|
||||
test "comment search" do
|
||||
@@ -40,9 +51,9 @@ class Comment::SearchableTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
# Finding cards via comment search
|
||||
card_with_comment = @board.cards.create!(title: "Card One", creator: @user)
|
||||
card_with_comment = @board.cards.create!(title: "Card One", status: "published", creator: @user)
|
||||
card_with_comment.comments.create!(body: "unique searchable phrase", creator: @user)
|
||||
card_without_comment = @board.cards.create!(title: "Card Two", creator: @user)
|
||||
card_without_comment = @board.cards.create!(title: "Card Two", status: "published", creator: @user)
|
||||
results = Card.mentioning("searchable", user: @user)
|
||||
assert_includes results, card_with_comment
|
||||
assert_not_includes results, card_without_comment
|
||||
|
||||
@@ -5,6 +5,19 @@ class CommentTest < ActiveSupport::TestCase
|
||||
Current.session = sessions(:david)
|
||||
end
|
||||
|
||||
test "cannot create comment on a draft card" do
|
||||
draft_card = cards(:unfinished_thoughts)
|
||||
|
||||
comment = draft_card.comments.build(body: "This should fail")
|
||||
|
||||
assert_not comment.valid?
|
||||
assert_includes comment.errors[:card], "does not allow comments"
|
||||
|
||||
assert_raises(ActiveRecord::RecordInvalid) do
|
||||
draft_card.comments.create!(body: "This should raise")
|
||||
end
|
||||
end
|
||||
|
||||
test "rich text embed variants are processed immediately on attachment" do
|
||||
comment = cards(:logo).comments.create!(body: "Check this out")
|
||||
comment.body.body.attachables # force load
|
||||
|
||||
@@ -70,16 +70,6 @@ class MentionsTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "don't create mentions from comments when belonging to unpublished cards" do
|
||||
perform_enqueued_jobs only: Mention::CreateJob do
|
||||
card = boards(:writebook).cards.create title: "Cleanup", description: "Some initial content"
|
||||
|
||||
assert_no_difference -> { Mention.count } do
|
||||
card.comments.create!(body: "Great work on this #{mention_html_for(users(:david))}!")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "can't mention users that don't have access to the board" do
|
||||
boards(:writebook).update! all_access: false
|
||||
boards(:writebook).accesses.revoke_from(users(:david))
|
||||
|
||||
@@ -4,8 +4,7 @@ class Filter::SearchTest < ActiveSupport::TestCase
|
||||
include SearchTestHelper
|
||||
|
||||
test "deduplicate multiple results" do
|
||||
card = @board.cards.create!(title: "Duplicate results test", description: "Have you had any haggis today?", creator: @user)
|
||||
card.published!
|
||||
card = @board.cards.create!(title: "Duplicate results test", description: "Have you had any haggis today?", creator: @user, status: "published")
|
||||
card.comments.create(body: "I hate haggis.", creator: @user)
|
||||
card.comments.create(body: "I love haggis.", creator: @user)
|
||||
|
||||
|
||||
@@ -5,9 +5,40 @@ class ReactionTest < ActiveSupport::TestCase
|
||||
Current.session = sessions(:david)
|
||||
end
|
||||
|
||||
test "creating a reaction touches the card activity" do
|
||||
test "creating a comment reaction touches the card activity" do
|
||||
assert_changes -> { cards(:logo).reload.last_active_at } do
|
||||
comments(:logo_1).reactions.create!(content: "Nice!")
|
||||
end
|
||||
end
|
||||
|
||||
test "reactions are deleted when comment is destroyed" do
|
||||
comment = comments(:logo_1)
|
||||
comment.reactions.create!(content: "👍")
|
||||
reaction_ids = comment.reactions.pluck(:id)
|
||||
|
||||
assert reaction_ids.any?, "Expected comment to have reactions"
|
||||
|
||||
comment.destroy
|
||||
|
||||
assert_empty Reaction.where(id: reaction_ids)
|
||||
end
|
||||
|
||||
test "creating a card reaction touches the card activity" do
|
||||
card = cards(:logo)
|
||||
|
||||
assert_changes -> { card.reload.last_active_at } do
|
||||
card.reactions.create!(content: "🎉")
|
||||
end
|
||||
end
|
||||
|
||||
test "reactions are deleted when card is destroyed" do
|
||||
card = cards(:logo)
|
||||
reaction_ids = card.reactions.pluck(:id)
|
||||
|
||||
assert reaction_ids.any?, "Expected card to have reactions"
|
||||
|
||||
card.destroy
|
||||
|
||||
assert_empty Reaction.where(id: reaction_ids)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,8 +5,8 @@ class SearchTest < ActiveSupport::TestCase
|
||||
|
||||
test "search" do
|
||||
# Search cards and comments
|
||||
card = @board.cards.create!(title: "layout design", creator: @user)
|
||||
comment_card = @board.cards.create!(title: "Some card", creator: @user)
|
||||
card = @board.cards.create!(title: "layout design", creator: @user, status: "published")
|
||||
comment_card = @board.cards.create!(title: "Some card", creator: @user, status: "published")
|
||||
comment_card.comments.create!(body: "overflowing text", creator: @user)
|
||||
|
||||
results = Search::Record.for(@user.account_id).search("layout", user: @user)
|
||||
@@ -15,11 +15,16 @@ class SearchTest < ActiveSupport::TestCase
|
||||
results = Search::Record.for(@user.account_id).search("overflowing", user: @user)
|
||||
assert results.find { |it| it.card_id == comment_card.id && it.searchable_type == "Comment" }
|
||||
|
||||
# Drafted cards are excluded from search results
|
||||
drafted_card = @board.cards.create!(title: "drafted searchable content", creator: @user, status: "drafted")
|
||||
results = Search::Record.for(@user.account_id).search("drafted", user: @user)
|
||||
assert_not results.find { |it| it.card_id == drafted_card.id }
|
||||
|
||||
# Don't include inaccessible boards
|
||||
other_user = User.create!(name: "Other User", account: @account)
|
||||
inaccessible_board = Board.create!(name: "Inaccessible Board", account: @account, creator: other_user)
|
||||
accessible_card = @board.cards.create!(title: "searchable content", creator: @user)
|
||||
inaccessible_card = inaccessible_board.cards.create!(title: "searchable content", creator: other_user)
|
||||
accessible_card = @board.cards.create!(title: "searchable content", creator: @user, status: "published")
|
||||
inaccessible_card = inaccessible_board.cards.create!(title: "searchable content", creator: other_user, status: "published")
|
||||
|
||||
results = Search::Record.for(@user.account_id).search("searchable", user: @user)
|
||||
assert results.find { |it| it.card_id == accessible_card.id }
|
||||
|
||||
@@ -3,6 +3,7 @@ require_relative "../config/environment"
|
||||
|
||||
require "rails/test_help"
|
||||
require "webmock/minitest"
|
||||
require_relative "webmock_ipaddr_extension"
|
||||
require "vcr"
|
||||
require "mocha/minitest"
|
||||
require "turbo/broadcastable/test_helper"
|
||||
|
||||
@@ -17,6 +17,7 @@ module SearchTestHelper
|
||||
Current.account = @account
|
||||
@identity = Identity.create!(email_address: "test@example.com")
|
||||
@user = User.create!(name: "Test User", account: @account, identity: @identity)
|
||||
Current.user = @user
|
||||
@board = Board.create!(name: "Test Board", account: @account, creator: @user)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Extends WebMock to support ipaddr matching for testing IP pinning.
|
||||
#
|
||||
# Usage:
|
||||
# stub_request(:post, "https://example.com/push")
|
||||
# .with(ipaddr: "93.184.216.34")
|
||||
# .to_return(status: 201)
|
||||
#
|
||||
# If the HTTP connection's ipaddr doesn't match, the stub won't match and
|
||||
# WebMock will raise an error about an unregistered request.
|
||||
|
||||
module WebMock
|
||||
class RequestSignature
|
||||
attr_accessor :ipaddr
|
||||
end
|
||||
|
||||
module RequestPatternIpaddrExtension
|
||||
attr_accessor :ipaddr_pattern
|
||||
|
||||
def assign_options(options)
|
||||
options = options.dup
|
||||
@ipaddr_pattern = options.delete(:ipaddr) || options.delete("ipaddr")
|
||||
super(options)
|
||||
end
|
||||
|
||||
def matches?(request_signature)
|
||||
super && ipaddr_matches?(request_signature)
|
||||
end
|
||||
|
||||
private
|
||||
def ipaddr_matches?(request_signature)
|
||||
@ipaddr_pattern.nil? || @ipaddr_pattern == request_signature.ipaddr
|
||||
end
|
||||
end
|
||||
|
||||
RequestPattern.prepend RequestPatternIpaddrExtension
|
||||
|
||||
module NetHTTPUtilityIpaddrExtension
|
||||
def request_signature_from_request(net_http, request, body = nil)
|
||||
super.tap { |signature| signature.ipaddr = net_http.ipaddr }
|
||||
end
|
||||
end
|
||||
|
||||
NetHTTPUtility.singleton_class.prepend NetHTTPUtilityIpaddrExtension
|
||||
end
|
||||
Reference in New Issue
Block a user