Merge branch 'main' into mobile/fix-card-perma-footer

* main: (33 commits)
  Revert "Add temporary performance degradation message"
  Fix double-escaped HTML entities in webhook payloads
  Fix crash when a notification is deleted while a bundle is being sent
  Fix race condition on notification creation
  Fix double _seconds suffix in GVL request wait metric name (#2539)
  Add GVL contention metrics via gvltools (#2538)
  Backfill tests for Event::Description strings' html-safety
  Update copy
  Add temporary performance degradation message
  Render inline code in card titles (#2518)
  Add stackprof for profiling
  Table style tweaks
  Fix dark mode colors
  Index notifications on updated_at
  Stack notifications everywhere
  HR fixes
  Removing duplicate code language picker
  Regression fixes
  Touch up link button hover styles
  Remove link dialog size limit
  ...
This commit is contained in:
Adrien Maston
2026-02-13 15:44:01 +01:00
75 changed files with 1070 additions and 3899 deletions
+1 -1
View File
@@ -25,7 +25,6 @@ gem "trilogy", "~> 2.9"
gem "bcrypt", "~> 3.1.7"
gem "geared_pagination", "~> 1.2"
gem "rqrcode"
gem "redcarpet"
gem "rouge"
gem "jbuilder"
gem "lexxy", bc: "lexxy"
@@ -41,6 +40,7 @@ gem "useragent", bc: "useragent"
# Operations
gem "autotuner"
gem "mission_control-jobs"
gem "stackprof"
gem "benchmark" # indirect dependency, being removed from Ruby 3.5 stdlib so here to quash warnings
group :development, :test do
+4 -4
View File
@@ -1,8 +1,8 @@
GIT
remote: https://github.com/basecamp/lexxy
revision: 4f0fc4d5773bc6892de70f175440c259974c12a7
revision: 4bcf8d0213971560e90734493f8bd835addc3642
specs:
lexxy (0.7.0.beta)
lexxy (0.7.4.beta)
rails (>= 8.0.2)
GIT
@@ -355,7 +355,6 @@ GEM
erb
psych (>= 4.0.0)
tsort
redcarpet (3.6.1)
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
@@ -437,6 +436,7 @@ GEM
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
stackprof (0.2.27)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
@@ -520,7 +520,6 @@ DEPENDENCIES
puma (>= 5.0)
rack-mini-profiler
rails!
redcarpet
rouge
rqrcode
rubocop-rails-omakase
@@ -529,6 +528,7 @@ DEPENDENCIES
solid_cache (~> 1.0)
solid_queue (~> 1.2)
sqlite3 (>= 2.0)
stackprof
stimulus-rails
thruster
trilogy (~> 2.9)
+1
View File
@@ -24,3 +24,4 @@ gem "yabeda-puma-plugin"
gem "yabeda-rails"
gem "webrick" # required for yabeda-prometheus metrics server
gem "prometheus-client-mmap", "~> 1.3"
gem "gvltools"
+6 -4
View File
@@ -23,9 +23,9 @@ GIT
GIT
remote: https://github.com/basecamp/lexxy
revision: 4f0fc4d5773bc6892de70f175440c259974c12a7
revision: 4bcf8d0213971560e90734493f8bd835addc3642
specs:
lexxy (0.7.0.beta)
lexxy (0.7.4.beta)
rails (>= 8.0.2)
GIT
@@ -282,6 +282,7 @@ GEM
addressable (>= 2.5.0)
globalid (1.3.0)
activesupport (>= 6.1)
gvltools (0.4.0)
hashdiff (1.2.1)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
@@ -466,7 +467,6 @@ GEM
erb
psych (>= 4.0.0)
tsort
redcarpet (3.6.1)
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
@@ -558,6 +558,7 @@ GEM
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
stackprof (0.2.27)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
@@ -657,6 +658,7 @@ DEPENDENCIES
faker
fizzy-saas!
geared_pagination (~> 1.2)
gvltools
image_processing (~> 1.14)
importmap-rails
jbuilder
@@ -675,7 +677,6 @@ DEPENDENCIES
rack-mini-profiler
rails!
rails_structured_logging!
redcarpet
rouge
rqrcode
rubocop-rails-omakase
@@ -686,6 +687,7 @@ DEPENDENCIES
solid_cache (~> 1.0)
solid_queue (~> 1.2)
sqlite3 (>= 2.0)
stackprof
stimulus-rails
stripe (~> 18.0)
thruster
+207
View File
@@ -0,0 +1,207 @@
@layer components {
.attachment {
block-size: auto;
display: block;
inline-size: fit-content;
position: relative;
max-inline-size: 100%;
progress {
inline-size: 100%;
margin: auto;
}
}
.attachment__caption {
color: color-mix(in oklch, var(--color-ink) 66%, transparent);
font-size: var(--text-small);
textarea {
--input-border-radius: 0.3em;
--input-border-size: 0;
--input-padding: 0;
background-color: var(--input-background, transparent);
border: none;
color: inherit;
inline-size: 100%;
max-inline-size: 100%;
resize: none;
text-align: center;
&:focus {
--focus-ring-size: 0;
}
@supports (field-sizing: content) {
field-sizing: content;
inline-size: auto;
min-inline-size: 20ch;
}
}
}
.attachment__icon {
aspect-ratio: 4/5;
background-color: color-mix(var(--attachment-icon-color), transparent 90%);
block-size: 2.5lh;
border: 2px solid var(--attachment-icon-color);
border-block-start-width: 1ch;
border-radius: 0.5ch;
box-sizing: border-box;
color: var(--attachment-icon-color);
display: grid;
font-size: var(--text-small);
font-weight: bold;
inline-size: auto;
padding-inline: 0.5ch;
place-content: center;
text-transform: uppercase;
white-space: nowrap;
}
.attachment--preview {
margin-inline: auto;
text-align: center;
img, video {
block-size: auto;
display: block;
margin-inline: auto;
max-inline-size: 100%;
user-select: none;
}
> a {
display: block;
}
.attachment__caption {
column-gap: 0.5ch;
display: flex;
flex-wrap: wrap;
justify-content: center;
margin-block-start: 0.5ch;
}
}
.attachment--file {
--attachment-icon-color: var(--color-ink-medium);
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 1ch;
inline-size: 100%;
margin-inline: 0;
.attachment__caption {
display: grid;
flex: 1;
text-align: start;
}
.attachment__name {
color: var(--color-ink);
font-weight: bold;
}
/* Video attachments don't have an identifiable class, but we need to
* make sure the caption is always below the video */
&:has(video) {
.attachment__caption {
flex: none;
inline-size: 100%;
}
}
}
.attachment--psd,
.attachment--key,
.attachment--sketch,
.attachment--ai,
.attachment--eps,
.attachment--indd,
.attachment--svg,
.attachment--ppt,
.attachment--pptx {
--attachment-icon-color: oklch(var(--lch-red-medium));
}
.attachment--css,
.attachment--crash,
.attachment--php,
.attachment--json,
.attachment--htm,
.attachment--html,
.attachment--rb,
.attachment--erb,
.attachment--ts,
.attachment--js {
--attachment-icon-color: oklch(var(--lch-purple-medium));
}
.attachment--txt,
.attachment--pages,
.attachment--rtf,
.attachment--md,
.attachment--doc,
.attachment--docx {
--attachment-icon-color: oklch(var(--lch-blue-medium));
}
.attachment--csv &,
.attachment--numbers &,
.attachment--xls &,
.attachment--xlsx & {
--attachment-icon-color: oklch(var(--lch-green-medium));
}
.attachment__link {
color: var(--color-link);
text-decoration: underline;
}
/* Custom attachments such as mentions, etc. */
action-text-attachment[content-type^='application/vnd.actiontext'] {
--attachment-bg-color: transparent;
--attachment-image-size: 1em;
--attachment-text-color: currentColor;
align-items: center;
background: var(--attachment-bg-color);
border-radius: 99rem;
box-shadow:
-0.25ch 0 0 var(--attachment-bg-color),
0.5ch 0 0 var(--attachment-bg-color);
color: var(--attachment-text-color);
display: inline-flex;
gap: 0.25ch;
margin: 0;
padding: 0;
position: relative;
vertical-align: bottom;
white-space: normal;
lexxy-editor & {
cursor: pointer;
}
img {
block-size: var(--attachment-image-size);
border-radius: 50%;
inline-size: var(--attachment-image-size);
}
&.node--selected {
--attachment-bg-color: oklch(var(--lch-blue-dark));
--attachment-text-color: var(--color-ink-inverted);
}
}
action-text-attachment[content-type^='application/vnd.actiontext.mention'] {
img {
object-fit: cover;
}
}
}
+17 -6
View File
@@ -88,7 +88,7 @@
@media (max-width: 639px) {
flex-direction: column;
padding-block-end: calc(var(--card-padding-block) * 1.5);
padding-block: var(--card-padding-block) calc(var(--card-padding-block) * 1.5);
}
}
@@ -110,12 +110,24 @@
}
&:has(textarea) {
margin-block-end: 0;
@media (min-width: 640px) {
margin-block-end: 0;
}
@supports not (field-sizing: content) {
text-wrap: unset; /* Safari is annoying if you have text-wrap: balance in textareas */
}
}
@media (max-width: 639px) {
margin-block-end: 0.75ch;
}
}
.card__description {
@media (max-width: 639px) {
margin-block-end: 1ch;
}
}
.card__meta,
@@ -172,12 +184,12 @@
@media (max-width: 639px) {
border: 1px solid var(--card-color);
border-radius: 0.25em;
border-radius: calc(0.2em + 3px);
flex-direction: row;
gap: 0;
overflow: auto;
max-inline-size: 100%;
padding: 0;
padding: 3px;
position: relative;
white-space: nowrap;
@@ -187,7 +199,7 @@
min-block-size: 2.5em;
}
& > form + form:not(:has(.card__column-name--current)) {
& > form:not(:has(.card__column-name--current)) + form:not(:has(.card__column-name--current)) {
box-shadow: -1px 0 0 0 var(--color-container);
}
}
@@ -195,7 +207,6 @@
.card__column-name {
@media (max-width: 639px) {
--btn-border-radius: 0;
justify-content: center;
}
}
+10 -1
View File
@@ -220,6 +220,15 @@
.card__title-link {
color: inherit;
}
code {
background-color: var(--color-canvas);
border: 1px solid var(--color-ink-lighter);
border-radius: 0.25ch;
font-family: var(--font-mono);
font-size: smaller;
padding: 0.1ch 0.25ch;
}
}
.card__description {
@@ -229,7 +238,7 @@
}
lexxy-toolbar {
border-block-start: 1px solid var(--color-ink-light);
border-block-start: 1px solid var(--lexxy-border-color);
}
& ~ .btn.btn--reversed {
+12 -1
View File
@@ -91,6 +91,17 @@
.btn {
font-weight: inherit;
}
@media (max-width: 639px) {
margin-block-end: calc(var(--block-space-half) / 2);
h3 {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 0.4em;
}
}
}
.comment__avatar {
@@ -155,7 +166,7 @@
text-transform: capitalize;
@media (max-width: 639px) {
line-height: 1.5lh;
font-size: var(--text-small);
}
}
+141 -469
View File
@@ -1,515 +1,187 @@
@import url("lexxy-variables.css") layer(base);
@import url("lexxy-content.css") layer(base);
@import url("lexxy-editor.css") layer(base);
:root {
--lexxy-color-ink: var(--color-ink);
--lexxy-color-ink-medium: var(--color-ink-dark);
--lexxy-color-ink-light: var(--color-ink-medium);
--lexxy-color-ink-lighter: var(--color-ink-light);
--lexxy-color-ink-lightest: var(--color-ink-lighter);
--lexxy-color-ink-inverted: var(--color-ink-inverted);
--lexxy-color-canvas: var(--color-canvas);
--lexxy-color-accent-dark: var(--color-accent-dark);
--lexxy-color-accent-medium: var(--color-accent-medium);
--lexxy-color-accent-light: var(--color-accent-light);
--lexxy-color-accent-lightest: var(--color-accent-lightest);
--lexxy-color-red: oklch(var(--lch-red-medium));
--lexxy-color-green: oklch(var(--lch-green-medium));
--lexxy-color-blue: oklch(var(--lch-blue-medium));
--lexxy-color-purple: oklch(var(--lch-purple-medium));
--lexxy-color-code-token-att: var(--color-code-token__att);
--lexxy-color-code-token-comment: var(--color-code-token__comment);
--lexxy-color-code-token-function: var(--color-code-token__function);
--lexxy-color-code-token-operator: var(--color-code-token__operator);
--lexxy-color-code-token-property: var(--color-code-token__property);
--lexxy-color-code-token-punctuation: var(--color-code-token__punctuation);
--lexxy-color-code-token-selector: var(--color-code-token__selector);
--lexxy-color-code-token-variable: var(--color-code-token__variable);
--lexxy-color-selected: oklch(var(--lch-blue-light));
--lexxy-color-selected-dark: oklch(var(--lch-blue-medium));
--lexxy-color-table-cell-border: var(--color-ink-ligher);
--lexxy-color-table-cell-selected-bg: var(--lexxy-color-selected);
--lexxy-color-table-cell-toggle: var(--lexxy-color-selected);
--lexxy-color-table-cell-remove: oklch(var(--lch-red-medium) / 20%);
}
@layer components {
/* Editor
/* ------------------------------------------------------------------------ */
lexxy-editor {
display: block;
position: relative;
overflow: visible;
--lexxy-border-color: oklch(var(--lch-ink-darkest) / 20%);
--lexxy-editor-padding: 0;
--lexxy-toolbar-button-size: 2rem;
figure.node--selected,
div.node--selected {
&:has(img) {
img {
outline: var(--focus-ring-size) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
}
&:not(:has(img)) {
outline: var(--focus-ring-size) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
}
}
.lexxy-content__table-wrapper {
margin: 0;
margin-block: 1ch;
overflow-x: auto;
}
table {
th, td {
&.table-cell--selected {
background-color: var(--color-selected-light);
}
&.lexxy-content__table-cell--selected {
background-color: var(--color-selected);
border-color: var(--color-selected-dark);
}
}
&.lexxy-content__table--selection {
::selection {
background: transparent;
}
}
}
/* Lexical uses the `lexxy-editor--empty` class even if you have a list
* started but haven't added other characters. Here, we hide the placeholder
* when you click the List button in the toolbar. */
.lexxy-editor--empty {
.lexxy-editor__content:not(:has(ul, ol))::before {
content: attr(placeholder);
color: currentColor;
cursor: text;
opacity: 0.66;
pointer-events: none;
position: absolute;
white-space: pre-line;
}
}
.lexxy-editor__content {
margin-block-start: var(--block-space-half);
min-block-size: calc(7lh + var(--block-space));
outline: 0;
/* Allow color highlights to show through a bit */
::selection {
background: oklch(var(--lch-blue-light) / 0.5);
}
> :last-child {
margin-block-end: 0;
}
}
.lexxy-editor--drag-over {
background-color: var(--color-selected);
border-radius: 4px;
outline: 2px dashed var(--color-selected-dark);
}
.lexxy-code-language-picker {
--caret-icon: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
--caret-icon-dark: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
-webkit-appearance: none;
appearance: none;
background-color: var(--color-canvas);
background-image: var(--caret-icon);
background-position: center right 0.9em;
background-repeat: no-repeat;
background-size: 0.5em;
border: 1px solid var(--color-ink-light);
border-radius: 99rem;
color: var(--color-ink);
cursor: pointer;
font-family: var(--font-base);
font-size: var(--text-x-small);
font-weight: 500;
inset-inline-end: 0;
line-height: 1.15lh;
margin: 0.75ch 0.75ch 0 0;
padding-inline: 1.5ch 1.8em;
text-align: start;
html[data-theme="dark"] & {
--caret-icon: var(--caret-icon-dark);
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
--caret-icon: var(--caret-icon-dark);
}
}
option {
background-color: var(--color-canvas);
color: var(--color-ink);
}
}
/* Toolbar
/* ------------------------------------------------------------------------ */
/* TODO: Temporary - hide table button while we work on the styles */
button[name="table"] {
display: none;
background-color: transparent;
border: none;
border-radius: 0;
}
lexxy-toolbar {
--lexxy-toolbar-icon-size: 1em;
background-color: var(--lexxy-bg-color, var(--color-canvas));
border-block-end: 1px solid var(--color-ink-light);
color: currentColor;
display: flex;
font-size: inherit;
margin: 0;
max-inline-size: 100%;
padding: 0.2em 0;
position: relative;
position: sticky;
inset-block-start: 0;
z-index: 2;
border-color: var(--lexxy-border-color);
gap: 0;
}
.lexxy-editor__toolbar-button {
--toolbar-button-size: 44px;
background: transparent;
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;
&[aria-pressed="true"],
[open] > & {
background-color: oklch(var(--lch-blue-medium) / 20%);
}
&:is(:active),
&[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;
flex-wrap: wrap;
inset-inline-end: 0;
padding: 4px;
position: absolute;
z-index: 2;
}
.lexxy-editor__toolbar-spacer {
flex: 1;
}
/* Dropdowns
/* ------------------------------------------------------------------------ */
.lexxy-editor__toolbar-dropdown-content {
--lexxy-dropdown-padding: 0.75rem;
--lexxy-dropdown-btn-size: 2rem;
background-color: var(--color-canvas);
border: 1px solid var(--color-ink-lighter);
border-radius: 0.5em;
box-shadow: var(--shadow);
color: var(--color-ink);
font-size: var(--text-small);
padding: var(--lexxy-dropdown-padding);
position: absolute;
z-index: 2;
button {
block-size: var(--lexxy-dropdown-btn-size);
}
}
.lexxy-editor__toolbar-dropdown-actions {
display: flex;
font-size: var(--text-x-small);
flex: 1 1 0%;
gap: var(--lexxy-dropdown-padding);
margin-block-start: var(--lexxy-dropdown-padding);
.btn {
--radius: 99rem;
inline-size: 100%;
}
.btn[type="submit"] {
--btn-background: var(--card-color, var(--color-link));
--btn-color: var(--color-ink-inverted);
--focus-ring-color: var(--card-color, var(--color-link));
}
span {
inline-size: 100%;
@media(any-hover: hover) {
&:hover:not([aria-pressed="true"]) {
background-color: oklch(var(--lch-ink-dark) / 20%);
}
}
}
lexxy-link-dropdown {
.input {
min-inline-size: 30ch;
}
}
--lexxy-toolbar-spacing: 1.5ch;
lexxy-highlight-dropdown {
--gap: 0.5ch;
.lexxy-editor__toolbar-button {
border-radius: 99rem;
[data-button-group] {
display: flex;
flex-direction: row;
gap: var(--gap);
+ & {
margin-block-start: var(--gap);
}
@media (max-width: 639px) {
flex-wrap: wrap;
}
}
}
.lexxy-highlight-button {
--outline-color: oklch(var(--lch-ink-darkest) / 0.2);
appearance: none;
background: var(--color-canvas);
border: none;
border-radius: 0.5ch;
color: inherit;
display: grid;
font-weight: 500;
inline-size: var(--lexxy-dropdown-btn-size);
place-content: center;
position: relative;
outline: none;
&:after {
content: "Aa";
}
&:hover,
&[aria-pressed="true"] {
box-shadow: 0 0 0 1px var(--color-canvas), 0 0 0 3px var(--outline-color);
}
&[aria-pressed="true"] {
--outline-color: var(--color-link);
&:after {
content: "✓";
}
}
}
.lexxy-editor__toolbar-dropdown-reset {
background: var(--color-canvas);
border: 1px solid var(--color-ink-light);
border-radius: 99rem;
inline-size: 100%;
margin-block-start: var(--lexxy-dropdown-padding);
&[disabled] {
display: none;
}
}
.lexxy-table-handle-buttons {
--button-size: 2.5em;
color: var(--color-ink-inverted);
display: none;
flex-direction: row;
font-size: var(--text-x-small);
gap: 0.25ch;
line-height: 1;
position: absolute;
transform: translate(-50%, -120%);
z-index: 1;
&:has([open]) {
z-index: 4;
}
.lexxy-table-control {
align-items: center;
background-color: var(--color-ink);
border-radius: 0.75ch;
display: flex;
flex-direction: row;
gap: 1ch;
padding: 2px;
white-space: nowrap;
button,
summary {
aspect-ratio: 1 / 1;
align-items: center;
background-color: transparent;
border-radius: 0.5ch;
border: 0;
color: var(--color-ink-inverted);
cursor: pointer;
display: flex;
font-size: inherit;
font-weight: 700;
justify-content: center;
line-height: 1;
list-style: none;
min-block-size: var(--button-size);
min-inline-size: var(--button-size);
padding: 0;
user-select: none;
-webkit-user-select: none;
@media(any-hover: hover) {
&:hover {
background-color: var(--color-ink-darker);
filter: brightness(0.9);
opacity: 1;
}
}
&:focus,
&:focus-visible {
background-color: var(--color-ink-darker);
outline: var(--focus-ring-size) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
svg {
block-size: 1em;
inline-size: 1em;
fill: currentColor;
}
span {
display: none;
}
&[type="submit"],
&[type="submit"]:hover {
background-color: var(--color-link);
}
}
.lexxy-table-control__more-menu {
gap: 0;
padding: 2px;
position: relative;
summary {
&::-webkit-details-marker {
display: none;
}
}
.lexxy-table-control__more-menu-details {
display: flex;
flex-direction: column;
gap: 0.25ch;
inset-block-start: 105%;
inset-inline-end: 0;
padding: 0;
position: absolute;
.lexxy-table-control__more-menu-section {
background: var(--color-ink);
border-radius: 0.75ch;
display: flex;
flex-direction: column;
padding: 2px;
}
button {
aspect-ratio: unset;
align-items: center;
flex-direction: row;
font-weight: normal;
gap: 1ch;
justify-content: flex-start;
padding: 0.5ch 2ch;
padding-inline-start: 1ch;
white-space: nowrap;
svg {
block-size: 1.3em;
inline-size: 1.3em;
}
span {
display: inline-block;
}
}
&[type="button"] {
border: 1px solid var(--color-ink-light);
}
}
}
/* Prompt menu (@mentions, etc.)
.lexxy-editor__content {
margin-block-start: 0.5lh;
}
.lexxy-code-language-picker {
border-radius: 99rem;
}
lexxy-table-tools {
font-size: var(--text-x-small);
}
[data-lexical-cursor] {
animation: blink 1s step-end infinite;
block-size: 1lh;
border-inline-start: 1px solid currentColor;
line-height: inherit;
margin-block: 1em;
}
/* Content
/* ------------------------------------------------------------------------ */
.lexxy-prompt-menu {
--lexxy-prompt-avatar-size: 24px;
--lexxy-prompt-min-width: 20ch;
--lexxy-prompt-padding: 0.5ch;
.lexxy-content {
--lexxy-content-margin: 0.5lh;
background-color: var(--color-canvas);
border-radius: calc(var(--lexxy-prompt-padding) * 2);
box-shadow: var(--shadow);
color: var(--color-ink);
font-family: var(--font-sans);
font-size: var(--text-small);
list-style: none;
margin: 0;
max-height: 200px;
min-inline-size: var(--lexxy-prompt-min-width);
overflow: auto;
padding: var(--lexxy-prompt-padding);
visibility: hidden;
z-index: var(--z-popup);
}
color: currentColor;
.lexxy-prompt-menu--visible {
visibility: initial;
}
.lexxy-prompt-menu__item {
align-items: center;
border-radius: 0.5ch;
cursor: pointer;
display: flex;
gap: var(--lexxy-prompt-padding);
padding: var(--lexxy-prompt-padding);
white-space: nowrap;
&:hover {
background-color: var(--color-ink-lightest);
h1, h2, h3, h4, h5, h6 {
font-weight: 800;
letter-spacing: -0.02ch;
line-height: 1.1;
overflow-wrap: break-word;
text-wrap: balance;
}
&[aria-selected] {
background-color: var(--color-selected);
}
img {
block-size: var(--lexxy-prompt-avatar-size);
border-radius: 50%;
flex-shrink: 0;
inline-size: var(--lexxy-prompt-avatar-size);
p:has(+ p) {
margin: 0;
}
+ & {
margin-top: 2px;
ol, ul {
padding-inline-start: 1ch;
}
blockquote {
border-inline-start: 0.25em solid var(--color-ink-lighter);
padding-block: 0;
}
code {
background-color: var(--color-terminal-text-light);
border-radius: 0.25ch;
font-size: 0.95em;
padding-inline: 0.5ch;
background: var(--color-canvas);
border: 1px solid var(--color-ink-lighter);
}
.horizontal-divider {
padding-block: var(--lexxy-content-margin);
hr { margin: 0; }
}
hr {
border: 0;
border-block-end: 2px solid currentColor;
color: currentColor;
inline-size: 20%;
margin: calc(var(--lexxy-content-margin) * 2) 0;
}
table {
th, td {
font-size: var(--text-small);
padding-block: 0.75ch;
}
tr:not([data-action="delete"]) {
th:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-ink-lightest); }
td:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-canvas); }
}
}
.attachment {
margin-inline: auto;
}
}
/* Empty state */
.lexxy-prompt-menu__item--empty {
color: var(--color-ink-medium);
padding: var(--lexxy-prompt-padding);
.attachment {
margin-inline: 0;
}
}
+6
View File
@@ -66,6 +66,12 @@
translate: unset;
}
.card-perma--draft {
.card-perma__bg {
box-shadow: 0 calc(100vh + 100px) 0 100vh var(--color-container);
}
}
/* Search
/* ------------------------------------------------------------------------ */
@@ -0,0 +1,11 @@
.performance-notice {
background: oklch(var(--lch-yellow-lightest));
border-radius: 1ch;
border: 1px solid oklch(var(--lch-yellow-light));
font-size: var(--text-small);
margin-block-end: 2ch;
margin-inline: auto;
max-inline-size: 60ch;
padding-inline: 2ch;
padding: 1ch;
}
@@ -1,469 +0,0 @@
@layer components {
:where(.rich-text-content) {
--block-margin: 0.5lh;
h1, h2, h3, h4, h5, h6 {
display: block;
font-weight: 800;
letter-spacing: -0.02ch;
line-height: 1.1;
margin-block: 0 var(--block-margin);
overflow-wrap: break-word;
text-wrap: balance;
}
h1 { font-size: 2em; }
h2 { font-size: 1.5em; }
h3 { font-size: 1.17em; }
h4 { font-size: 1em; }
h5 { font-size: 0.83em; }
h6 { font-size: 0.67em; }
p, ul, ol, dl, blockquote, figure, .attachment {
margin-block: 0 var(--block-margin);
&:not(lexxy-editor &) {
overflow-wrap: break-word;
text-wrap: pretty;
}
}
p:has(+ p) {
margin: 0;
}
ol, ul {
padding-inline-start: 3ch;
}
ol {
list-style: decimal;
}
ul {
list-style: disc;
}
li:has(li) {
list-style: none;
ol, ul {
margin: 0;
padding-inline-start: 2ch;
}
}
b, strong, .lexxy-content__bold {
font-weight: 700;
}
i, em, .lexxy-content__italic {
font-style: italic;
}
s, .lexxy-content__strikethrough {
text-decoration: line-through;
}
mark, .lexxy-content__highlight {
background-color: transparent;
color: inherit;
}
p, blockquote {
letter-spacing: -0.005ch;
}
/* Avoid extra space due to empty paragraphs */
p:empty {
display: none;
}
blockquote {
border-inline-start: 0.25em solid var(--color-ink-lighter);
font-style: italic;
margin: var(--block-margin) 0;
padding-inline-start: 2ch;
}
img, video, embed, object {
inline-size: auto;
margin-inline: auto;
max-block-size: 32rem;
object-fit: contain;
}
/* Links should hug media contained within */
a:has(img),
a:has(video) {
inline-size: fit-content;
margin-inline: auto;
}
hr {
color: currentColor;
block-size: 0;
border: none;
border-block-end: 2px solid currentColor;
inline-size: 20%;
margin: calc(var(--block-margin) * 2) 0;
}
.horizontal-divider {
margin: var(--block-margin) 0;
padding-block: var(--block-margin);
}
/* Code */
code, pre {
background-color: var(--color-canvas);
border: 1px solid var(--color-ink-lighter);
border-radius: 0.3em;
color: var(--color-ink);
font-family: var(--font-mono);
font-size: 0.85em;
font-weight: 500;
margin-block: var(--block-margin);
padding: 0.1em 0.3em;
&:is(pre),
&[data-language] {
border-radius: 0.5em;
display: block;
overflow-x: auto;
padding: 0.5lh 2ch;
tab-size: 2;
text-wrap: nowrap;
white-space: pre;
word-break: break-word;
/* Keywords and attributes */
.code-token__attr,
.token.atrule,
.token.attr,
.token.keyword {
color: var(--color-code-token__att);
}
/* Constants, booleans, numbers, properties, tags */
.code-token__property,
.token.boolean,
.token.constant,
.token.deleted,
.token.number,
.token.property,
.token.symbol,
.token.tag {
color: var(--color-code-token__property);
}
/* Strings, selectors, and built-in constructs */
.code-token__selector,
.token.builtin,
.token.char,
.token.inserted,
.token.selector,
.token.string {
color: var(--color-code-token__selector);
}
/* Comments and meta information */
.code-token__comment,
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog {
color: var(--color-code-token__comment);
font-style: italic;
}
/* Operators and symbolic entities */
.code-token__operator,
.token.entity,
.token.operator,
.token.url {
color: var(--color-code-token__operator);
}
/* Functions and class names */
.code-token__function,
.token.class,
.token.class-name,
.token.function {
color: var(--color-code-token__function);
}
/* Variables, regex, namespaces, important */
.code-token__variable,
.token.important,
.token.namespace,
.token.regex,
.token.variable {
color: var(--color-code-token__variable);
}
/* Punctuation */
.code-token__punctuation,
.token.punctuation {
color: var(--color-code-token__punctuation);
}
}
}
/* Tables */
.lexxy-content__table-wrapper {
margin: 0;
margin-block: 1ch;
overflow-x: auto;
}
table {
border-collapse: collapse;
border-spacing: 0;
inline-size: calc(100% - 0.5ch);
margin: 0.25ch;
th,
td {
border: 1px solid var(--color-ink-light);
padding: 0.5ch 1ch;
text-align: start;
word-break: normal;
*:last-child {
margin-block-end: 0;
}
*:not(pre, code) {
word-break: normal;
}
}
th,
.lexxy-content__table-cell--header {
background: var(--color-ink-lightest);
}
td {
background: var(--color-canvas);
}
}
}
/* Attachments
/* ------------------------------------------------------------------------ */
.attachment {
block-size: auto;
display: block;
inline-size: fit-content;
position: relative;
max-inline-size: 100%;
progress {
inline-size: 100%;
margin: auto;
}
}
.attachment__caption {
color: color-mix(in oklch, var(--color-ink) 66%, transparent);
font-size: var(--text-small);
textarea {
--input-border-radius: 0.3em;
--input-border-size: 0;
--input-padding: 0;
background-color: var(--input-background, transparent);
border: none;
color: inherit;
inline-size: 100%;
max-inline-size: 100%;
resize: none;
text-align: center;
&:focus {
--focus-ring-size: 0;
}
@supports (field-sizing: content) {
field-sizing: content;
inline-size: auto;
min-inline-size: 20ch;
}
}
}
.attachment__icon {
aspect-ratio: 4/5;
background-color: color-mix(var(--attachment-icon-color), transparent 90%);
block-size: 2.5lh;
border: 2px solid var(--attachment-icon-color);
border-block-start-width: 1ch;
border-radius: 0.5ch;
box-sizing: border-box;
color: var(--attachment-icon-color);
display: grid;
font-size: var(--text-small);
font-weight: bold;
inline-size: auto;
padding-inline: 0.5ch;
place-content: center;
text-transform: uppercase;
white-space: nowrap;
}
.attachment--preview {
margin-inline: auto;
text-align: center;
img, video {
block-size: auto;
display: block;
margin-inline: auto;
max-inline-size: 100%;
user-select: none;
}
> a {
display: block;
}
.attachment__caption {
column-gap: 0.5ch;
display: flex;
flex-wrap: wrap;
justify-content: center;
margin-block-start: 0.5ch;
}
}
.attachment--file {
--attachment-icon-color: var(--color-ink-medium);
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 1ch;
inline-size: 100%;
margin-inline: 0;
.attachment__caption {
display: grid;
flex: 1;
text-align: start;
}
.attachment__name {
color: var(--color-ink);
font-weight: bold;
}
/* Video attachments don't have an identifiable class, but we need to
* make sure the caption is always below the video */
&:has(video) {
.attachment__caption {
flex: none;
inline-size: 100%;
}
}
}
.attachment--psd,
.attachment--key,
.attachment--sketch,
.attachment--ai,
.attachment--eps,
.attachment--indd,
.attachment--svg,
.attachment--ppt,
.attachment--pptx {
--attachment-icon-color: oklch(var(--lch-red-medium));
}
.attachment--css,
.attachment--crash,
.attachment--php,
.attachment--json,
.attachment--htm,
.attachment--html,
.attachment--rb,
.attachment--erb,
.attachment--ts,
.attachment--js {
--attachment-icon-color: oklch(var(--lch-purple-medium));
}
.attachment--txt,
.attachment--pages,
.attachment--rtf,
.attachment--md,
.attachment--doc,
.attachment--docx {
--attachment-icon-color: oklch(var(--lch-blue-medium));
}
.attachment--csv &,
.attachment--numbers &,
.attachment--xls &,
.attachment--xlsx & {
--attachment-icon-color: oklch(var(--lch-green-medium));
}
.attachment__link {
color: var(--color-link);
text-decoration: underline;
}
/* Custom attachments such as mentions, etc. */
action-text-attachment[content-type^='application/vnd.actiontext'] {
--attachment-bg-color: transparent;
--attachment-image-size: 1em;
--attachment-text-color: currentColor;
align-items: center;
background: var(--attachment-bg-color);
border-radius: 99rem;
box-shadow:
-0.25ch 0 0 var(--attachment-bg-color),
0.5ch 0 0 var(--attachment-bg-color);
color: var(--attachment-text-color);
display: inline-flex;
gap: 0.25ch;
margin: 0;
padding: 0;
position: relative;
vertical-align: bottom;
white-space: normal;
lexxy-editor & {
cursor: pointer;
}
img {
block-size: var(--attachment-image-size);
border-radius: 50%;
inline-size: var(--attachment-image-size);
}
&.node--selected {
--attachment-bg-color: oklch(var(--lch-blue-dark));
--attachment-text-color: var(--color-ink-inverted);
}
}
action-text-attachment[content-type^='application/vnd.actiontext.mention'] {
img {
object-fit: cover;
}
}
[data-lexical-cursor] {
animation: blink 1s step-end infinite;
block-size: 1lh;
border-inline-start: 1px solid currentColor;
line-height: inherit;
margin-block: 1em;
}
}
+2 -2
View File
@@ -2,12 +2,12 @@ class Cards::ReadingsController < ApplicationController
include CardScoped
def create
@notifications = @card.read_by(Current.user)
@card.read_by(Current.user)
record_board_access
end
def destroy
@notifications = @card.unread_by(Current.user)
@card.unread_by(Current.user)
record_board_access
end
@@ -1,6 +1,6 @@
class Notifications::TraysController < ApplicationController
def show
@notifications = Current.user.notifications.preloaded.unread.ordered.limit(100)
@notifications = Current.user.notifications.unread.preloaded.ordered.limit(100)
# Invalidate on the whole set instead of the unread set since the max updated at in the unread set
# can stay the same when reading old notifications.
+6
View File
@@ -2,4 +2,10 @@ module HtmlHelper
def format_html(html)
Loofah::HTML5::DocumentFragment.parse(html).scrub!(AutoLinkScrubber.new).to_html.html_safe
end
def card_html_title(card)
return card.title if card.title.blank?
ERB::Util.html_escape(card.title).gsub(/`([^`]+)`/, '<code>\1</code>').html_safe
end
end
+2 -4
View File
@@ -29,9 +29,7 @@ module NotificationsHelper
def notification_tag(notification, &)
tag.div id: dom_id(notification), class: "tray__item tray__item--notification", data: {
navigable_list_target: "item",
notifications_tray_target: "notification",
card_id: notification.card.id,
timestamp: notification.created_at.to_i
card_id: notification.card.id
} do
link_to(notification,
class: [ "card card--notification", { "card--closed": notification.card.closed? }, { "unread": !notification.read? } ],
@@ -58,7 +56,7 @@ module NotificationsHelper
data: { action: "form#submit:stop badge#update:stop", form_target: "submit" },
form: { data: { controller: "form" } } do
concat(icon_tag("remove"))
concat(tag.span("1", class: "badge-count", data: { group_count: "" }))
concat(tag.span(notification.unread_count, class: "badge-count")) if notification.unread_count > 1
end
end
end
+1 -5
View File
@@ -15,11 +15,7 @@ module RichTextHelper
content_tag "lexxy-prompt", "", trigger: "#", src: prompts_cards_path, name: "card", "insert-editable-text": true, "remote-filtering": true, "supports-space-in-searches": true
end
def code_language_picker
content_tag "lexxy-code-language-picker"
end
def general_prompts(board)
safe_join([ mentions_prompt(board), cards_prompt, code_language_picker ])
safe_join([ mentions_prompt(board), cards_prompt ])
end
end
@@ -1,89 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "notification", "hiddenNotifications" ]
static classes = [ "grouped" ]
connect() {
this.group()
}
group() {
const notificationsByCardId = this.#groupNotificationsByCardId()
for (const cardId in notificationsByCardId) {
const notifications = notificationsByCardId[cardId]
if (notifications.length > 1) {
this.#renderGroup(notifications)
}
}
this.grouped = true
}
notificationTargetConnected(notification) {
if (this.grouped && notification.parentElement !== this.hiddenNotificationsTarget) {
this.#groupNotification(notification)
}
}
#groupNotificationsByCardId() {
const notificationsByCardId = {}
this.notificationTargets.forEach(notification => {
const cardId = notification.dataset.cardId
notificationsByCardId[cardId] ||= []
notificationsByCardId[cardId].push(notification)
})
return notificationsByCardId
}
#groupNotification(notification) {
const groupedNotifications = this.#groupedNotificationsFor(notification)
if (groupedNotifications.length > 1) {
this.#renderGroup(groupedNotifications)
}
}
#groupedNotificationsFor(notification) {
const cardId = notification.dataset.cardId
return this.notificationTargets
.filter(notification => notification.dataset.cardId === cardId)
}
#renderGroup(groupedNotifications) {
groupedNotifications.sort((notification_1, notification_2) => parseInt(notification_1.dataset.timestamp) - parseInt(notification_2.dataset.timestamp))
this.#showAsGrouped(groupedNotifications[0], groupedNotifications.length)
groupedNotifications.slice(1).forEach(notification => this.#hideInGroup(notification))
}
#showAsGrouped(notification, size) {
notification.classList.add(this.groupedClass)
this.#showGroupedNotification(notification)
this.#setGroupCount(notification, size)
}
#hideInGroup(notification) {
this.#hideGroupedNotification(notification)
this.#setGroupCount(notification, "")
}
// We use a hidden container instead of hiding the notifications directly so that the CSS that sort the
// tray element indexes doesn't get messed up with the child positions changing.
#showGroupedNotification(notification) {
if (notification.parentElement === this.hiddenNotificationsTarget) {
this.hiddenNotificationsTarget.parentElement.insertBefore(notification, this.hiddenNotificationsTarget)
}
}
#hideGroupedNotification(notification) {
this.hiddenNotificationsTarget.appendChild(notification)
}
#setGroupCount(notification, count) {
notification.querySelector("[data-group-count]").textContent = count
}
}
+7 -3
View File
@@ -7,10 +7,14 @@ class Notification::BundleMailer < ApplicationMailer
@user = bundle.user
@bundle = bundle
@notifications = bundle.notifications
.preload(:card, :creator, source: [ :board, :creator ])
.reject { |n| n.source.nil? || n.card.nil? }
@unsubscribe_token = @user.generate_token_for(:unsubscribe)
mail \
to: bundle.user.identity.email_address,
subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: New notifications"
if @notifications.any?
mail \
to: bundle.user.identity.email_address,
subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: New notifications"
end
end
end
+2 -18
View File
@@ -2,15 +2,11 @@ module Card::Readable
extend ActiveSupport::Concern
def read_by(user)
notifications_for(user).tap do |notifications|
notifications.each(&:read)
end
user.notifications.find_by(card: self)&.read
end
def unread_by(user)
all_notifications_for(user).tap do |notifications|
notifications.each(&:unread)
end
user.notifications.find_by(card: self)&.unread
end
def remove_inaccessible_notifications
@@ -25,18 +21,6 @@ module Card::Readable
Card::RemoveInaccessibleNotificationsJob.perform_later(self)
end
def notifications_for(user)
scope = user.notifications.unread
scope.where(source: event_notification_sources)
.or(scope.where(source: mention_notification_sources))
end
def all_notifications_for(user)
scope = user.notifications
scope.where(source: event_notification_sources)
.or(scope.where(source: mention_notification_sources))
end
def event_notification_sources
events.or(comment_creation_events)
end
+1
View File
@@ -3,6 +3,7 @@ module PushNotifiable
included do
after_create_commit :push_notification_later
after_update_commit :push_notification_later, if: :source_id_previously_changed?
end
private
+28 -12
View File
@@ -14,7 +14,7 @@ class Event::Description
end
def to_plain_text
to_sentence(creator_name, quoted(card.title))
to_sentence(creator_name, quoted(card.title)).html_safe
end
private
@@ -38,11 +38,11 @@ class Event::Description
end
def creator_name
h(event.creator.name)
h event.creator.name
end
def quoted(text)
%("#{h text}")
h %("#{text}")
end
def card
@@ -86,27 +86,43 @@ class Event::Description
if event.assignees.include?(user)
"#{creator} will handle #{card_title}"
else
"#{creator} assigned #{h event.assignees.pluck(:name).to_sentence} to #{card_title}"
"#{creator} assigned #{assignee_names} to #{card_title}"
end
end
def unassigned_sentence(creator, card_title)
assignees_text = event.assignees.include?(user) ? "yourself" : event.assignees.pluck(:name).to_sentence
"#{creator} unassigned #{h(assignees_text)} from #{card_title}"
"#{creator} unassigned #{unassigned_names} from #{card_title}"
end
def renamed_sentence(creator, card_title)
old_title = event.particulars.dig("particulars", "old_title")
%(#{creator} renamed #{card_title} (was: "#{h old_title}"))
%(#{creator} renamed #{card_title} (was: "#{old_title}"))
end
def moved_sentence(creator, card_title)
new_location = event.particulars.dig("particulars", "new_board") || event.particulars.dig("particulars", "new_collection")
%(#{creator} moved #{card_title} to "#{h new_location}")
%(#{creator} moved #{card_title} to "#{new_location}")
end
def triaged_sentence(creator, card_title)
column = event.particulars.dig("particulars", "column")
%(#{creator} moved #{card_title} to "#{h column}")
%(#{creator} moved #{card_title} to "#{column}")
end
def assignee_names
h event.assignees.pluck(:name).to_sentence
end
def unassigned_names
h(event.assignees.include?(user) ? "yourself" : assignee_names)
end
def old_title
h event.particulars.dig("particulars", "old_title")
end
def new_location
h(event.particulars.dig("particulars", "new_board") || event.particulars.dig("particulars", "new_collection"))
end
def column
h event.particulars.dig("particulars", "column")
end
end
+28 -17
View File
@@ -5,32 +5,39 @@ class Notification < ApplicationRecord
belongs_to :user
belongs_to :creator, class_name: "User"
belongs_to :source, polymorphic: true
belongs_to :card
scope :unread, -> { where(read_at: nil) }
scope :read, -> { where.not(read_at: nil) }
scope :ordered, -> { order(read_at: :desc, created_at: :desc) }
scope :ordered, -> { order(read_at: :desc, updated_at: :desc) }
scope :preloaded, -> { preload(:card, :creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) }
after_create_commit :broadcast_unread
after_destroy_commit :broadcast_read
before_validation :set_card
after_create :bundle
after_update :bundle, if: :source_id_previously_changed?
scope :preloaded, -> { preload(:creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) }
after_create_commit -> { broadcast_prepend_later_to user, :notifications, target: "notifications" }
after_update_commit -> { broadcast_update }
after_destroy_commit -> { broadcast_remove_to user, :notifications }
delegate :notifiable_target, to: :source
delegate :card, to: :source
def self.read_all
all.each { |notification| notification.read }
class << self
def read_all
all.each(&:read)
end
def unread_all
all.each(&:unread)
end
end
def read
update!(read_at: Time.current)
broadcast_read
update!(read_at: Time.current, unread_count: 0)
end
def unread
update!(read_at: nil)
broadcast_unread
update!(read_at: nil, unread_count: 1)
end
def read?
@@ -38,15 +45,19 @@ class Notification < ApplicationRecord
end
private
def broadcast_unread
broadcast_prepend_later_to user, :notifications, target: "notifications"
end
def broadcast_read
broadcast_remove_to user, :notifications
def set_card
self.card = source.card
end
def bundle
user.bundle(self) if user.settings.bundling_emails?
end
def broadcast_update
if read?
broadcast_remove_to(user, :notifications)
else
broadcast_prepend_later_to(user, :notifications, target: "notifications")
end
end
end
+2 -2
View File
@@ -5,7 +5,7 @@ class Notification::Bundle < ApplicationRecord
enum :status, %i[ pending processing delivered ]
scope :due, -> { pending.where("ends_at <= ?", Time.current) }
scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.created_at, notification.created_at) }
scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.updated_at, notification.updated_at) }
scope :overlapping_with, ->(other_bundle) do
where(
"(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)",
@@ -33,7 +33,7 @@ class Notification::Bundle < ApplicationRecord
end
def notifications
user.notifications.where(created_at: window).unread
user.notifications.where(updated_at: window).unread
end
def deliver
+11 -1
View File
@@ -16,7 +16,17 @@ class Notifier
if should_notify?
# Processing recipients in order avoids deadlocks if notifications overlap.
recipients.sort_by(&:id).map do |recipient|
Notification.create! user: recipient, source: source, creator: creator
notification = Notification.create_or_find_by(user: recipient, card: source.card) do |n|
n.source = source
n.creator = creator
n.unread_count = 1
end
unless notification.previously_new_record?
notification.update!(source: source, creator: creator, read_at: nil, unread_count: notification.unread_count + 1)
end
notification
end
end
end
+3 -3
View File
@@ -25,12 +25,12 @@ module User::Notifiable
def expand_pending_bundle_for(notification)
pending = notification_bundles.pending.last
if pending.present? && notification.created_at < pending.starts_at
pending.update!(starts_at: notification.created_at) # expand the window to include this notification
if pending.present? && notification.updated_at < pending.starts_at
pending.update!(starts_at: notification.updated_at) # expand the window to include this notification
end
end
def create_bundle_for(notification)
notification_bundles.create!(starts_at: notification.created_at)
notification_bundles.create!(starts_at: notification.updated_at)
end
end
+1 -1
View File
@@ -30,7 +30,7 @@
<strong class="margin-block-end-half flex justify-center txt-small">Add an optional description to the public page</strong>
<div class="border-radius input fill-white">
<%= form_with model: board, class: "txt-align-start", data: { controller: "form", turbo_frame: "_top" } do |form| %>
<%= form.rich_textarea :public_description, class: "rich-text-content txt-small",
<%= form.rich_textarea :public_description, class: "lexxy-content txt-small",
placeholder: "Add a public note about this board…",
data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" },
readonly: !Current.user.can_administer_board?(@board) %>
+1 -1
View File
@@ -32,7 +32,7 @@
<% end %>
</div>
<div class="comment__body rich-text-content" data-controller="syntax-highlight retarget-links" data-turbo-permanent>
<div class="comment__body lexxy-content" data-controller="syntax-highlight retarget-links" data-turbo-permanent>
<%= comment.body %>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@
</figure>
<div class="comment__content flex-inline flex-column full-width">
<div class="comment__body rich-text-content" data-turbo-permanent>
<div class="comment__body lexxy-content" data-turbo-permanent>
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width",
data: { controller: "form local-save",
local_save_key_value: "comment-#{card.id}",
+1 -1
View File
@@ -5,7 +5,7 @@
</figure>
<div class="comment__content flex flex-column flex-item-grow full-width">
<div class="comment__body rich-text-content" data-turbo-permanent>
<div class="comment__body lexxy-content" data-turbo-permanent>
<%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width",
data: { controller: "form", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %>
<%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %>
+1 -1
View File
@@ -20,7 +20,7 @@
<% end %>
</h1>
<%= form.rich_textarea :description, class: "card__description rich-text-content",
<%= form.rich_textarea :description, class: "card__description lexxy-content",
placeholder: "Add some notes, context, pictures, or video about this…",
data: { action: "lexxy:change->auto-save#change focusout->auto-save#submit" } do %>
<%= general_prompts(card.board) %>
@@ -1,9 +1,9 @@
<h1 class="card__title flex align-start gap-half">
<%= link_to card.title, edit_card_path(card), class: "card__title-link" %>
<%= link_to card_html_title(card), edit_card_path(card), class: "card__title-link" %>
</h1>
<% unless card.description.blank? %>
<div class="card__description rich-text-content" data-controller="syntax-highlight retarget-links">
<div class="card__description lexxy-content" data-controller="syntax-highlight retarget-links">
<%= card.description %>
</div>
<% end %>
+1 -1
View File
@@ -36,7 +36,7 @@
<div class="card__body justify-space-between">
<div class="card__content">
<h3 class="card__title overflow-line-clamp">
<%= card.title %>
<%= card_html_title(card) %>
</h3>
</div>
@@ -17,7 +17,7 @@
<div class="card__body justify-space-between">
<div class="card__content">
<h1 class="card__title overflow-line-clamp">
<%= card.title %>
<%= card_html_title(card) %>
</h1>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
<section id="<%= dom_id(card, :card_container) %>" class="card-perma" style="--card-color: <%= card.color %>;">
<section id="<%= dom_id(card, :card_container) %>" class="card-perma card-perma--draft" style="--card-color: <%= card.color %>;">
<% cache card do %>
<div class="card-perma__actions card-perma__actions--left">
<%= render "cards/container/image", card: card %>
+1 -1
View File
@@ -16,7 +16,7 @@
<% end %>
</h1>
<%= form.rich_textarea :description, class: "card__description rich-text-content",
<%= form.rich_textarea :description, class: "card__description lexxy-content",
placeholder: "Add some notes, context, pictures, or video about this…",
data: { local_save_target: "input", action: "lexxy:change->local-save#save turbo:morph-element->local-save#restoreContent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do %>
<%= general_prompts(@card.board) %>
@@ -1,3 +0,0 @@
<% @notifications.each do |notification| %>
<%= turbo_stream.remove notification %>
<% end %>
@@ -9,7 +9,7 @@
<hr class="notification__board-separator">
<h2 class="notification__board"><%= link_to board.name, board %></h2>
<% board_notifications.group_by(&:card).each do |card, notifications| %>
<%= link_to "##{ card.number } #{ card.title }", card, class: "card__title" %>
<%= link_to card, class: "card__title" do %>#<%= card.number %> <%= card_html_title(card) %><% end %>
<%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %>
<% end %>
<% end %>
@@ -2,7 +2,7 @@
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
<%= notification_tag notification do %>
<%= render "notifications/notification/header", notification: notification do %>
<%= notification_toggle_read_button(notification, url: card_reading_path(notification.card)) %>
<%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>
<% end %>
<%= render "notifications/notification/body", notification: notification %>
<% end %>
@@ -1,5 +1,5 @@
json.cache! notification do
json.(notification, :id)
json.(notification, :id, :unread_count)
json.read notification.read?
json.read_at notification.read_at&.utc
json.created_at notification.created_at.utc
+2 -3
View File
@@ -8,9 +8,8 @@
data-navigable-list-reverse-navigation-value="true"
data-action="keydown->navigable-list#navigate dialog:show@document->navigable-list#reset turbo-visit->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside">
<%= turbo_frame_tag "notifications", src: tray_notifications_path, refresh: "morph", data: {
controller: "notifications-tray frame-reloader",
notifications_tray_grouped_class: "notification--grouped",
action: "turbo:frame-render->notifications-tray#group focus@window->frame-reloader#reload" } %>
controller: "frame-reloader",
action: "focus@window->frame-reloader#reload" } %>
<div class="tray__item tray__item--hat txt-x-small gap-half">
<div data-navigable-list-target="item" class="full-width">
+1 -1
View File
@@ -1 +1 @@
json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification
json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification, cached: true
@@ -1,6 +0,0 @@
<%= notification_tag notification do %>
<%= render "notifications/notification/header", notification: notification do %>
<%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>
<% end %>
<%= render "notifications/notification/body", notification: notification %>
<% end %>
@@ -3,7 +3,7 @@
<h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2>
<div id="notifications_list_read" contents>
<%= render partial: "notifications/index/notification", collection: page.records, cached: true %>
<%= render partial: "notifications/notification", collection: page.records, cached: true %>
</div>
</section>
<% end %>
@@ -11,12 +11,12 @@
<% end %>
<div id="notifications_list" contents>
<%= render partial: "notifications/index/notification", collection: unread, cached: true %>
<%= render partial: "notifications/notification", collection: unread, cached: true %>
</div>
<% if unread.any? %>
<% total_unread_count = Current.user.notifications.unread.count %>
<% if Current.user.notifications.unread.count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
<% if total_unread_count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
<div class="fill-highlight txt-x-small border-radius pad-block-half pad-inline">
Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden)
</div>
@@ -1,4 +1,3 @@
<%= turbo_frame_tag "notifications" do %>
<%= render partial: "notifications/notification", collection: @notifications, cached: true %>
<div hidden data-notifications-tray-target="hiddenNotifications"></div>
<% end %>
+1 -1
View File
@@ -20,7 +20,7 @@
<% end %>
<% if @board.public_description.present? %>
<div class="card__board-public-description rich-text-content txt-align-center center margin-block-end" data-controller="syntax-highlight">
<div class="card__board-public-description lexxy-content txt-align-center center margin-block-end" data-controller="syntax-highlight">
<%= @board.public_description %>
</div>
<% end %>
@@ -1,8 +1,8 @@
<div class="card__content">
<h1 class="card__title flex align-start gap-half">
<%= tag.span card.title, class: "card__title-link" %>
<%= tag.span card_html_title(card), class: "card__title-link" %>
</h1>
<div class="card__description rich-text-content" data-controller="syntax-highlight">
<div class="card__description lexxy-content" data-controller="syntax-highlight">
<%= card.description %>
</div>
</div>
-1
View File
@@ -13,7 +13,6 @@ pin_all_from "app/javascript/initializers", under: "initializers"
pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers"
pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers"
pin_all_from "app/javascript/bridge/controllers/bridge", under: "controllers/bridge", to: "bridge/controllers/bridge"
pin "marked" # @15.0.11
pin "lexxy"
pin "@rails/activestorage", to: "activestorage.esm.js"
pin "@rails/actiontext", to: "actiontext.esm.js"
@@ -0,0 +1,6 @@
class AddCardIdToNotifications < ActiveRecord::Migration[8.2]
def change
add_column :notifications, :card_id, :uuid
add_column :notifications, :unread_count, :integer, null: false, default: 0
end
end
@@ -0,0 +1,84 @@
class NotificationsDataMigration < ActiveRecord::Migration[8.2]
BATCH_SIZE = 10_000
class Notification < ActiveRecord::Base
self.table_name = "notifications"
end
def change
reversible do |dir|
dir.up do
populate_card_id
collapse_duplicates
end
end
change_column_null :notifications, :card_id, false
add_index :notifications, [ :user_id, :card_id ], unique: true
end
private
def populate_card_id
execute(<<~SQL)
UPDATE notifications
SET card_id = (
SELECT CASE events.eventable_type
WHEN 'Card' THEN events.eventable_id
WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = events.eventable_id)
END
FROM events
WHERE events.id = notifications.source_id
)
WHERE notifications.card_id IS NULL
AND notifications.source_type = 'Event'
SQL
execute(<<~SQL)
UPDATE notifications
SET card_id = (
SELECT CASE mentions.source_type
WHEN 'Card' THEN mentions.source_id
WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = mentions.source_id)
END
FROM mentions
WHERE mentions.id = notifications.source_id
)
WHERE notifications.card_id IS NULL
AND notifications.source_type = 'Mention'
SQL
end
def collapse_duplicates
loop do
duplicates = Notification.find_by_sql(<<~SQL)
SELECT user_id, card_id,
MAX(id) AS keep_id,
COUNT(*) AS total,
SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread_total
FROM notifications
WHERE card_id IS NOT NULL
GROUP BY user_id, card_id
HAVING COUNT(*) > 1
LIMIT #{BATCH_SIZE}
SQL
break if duplicates.empty?
duplicates.each do |row|
Notification.where(user_id: row.user_id, card_id: row.card_id)
.where.not(id: row.keep_id)
.delete_all
Notification.where(id: row.keep_id)
.update_all(unread_count: row.unread_total.to_i)
end
end
# Set unread_count for remaining non-collapsed notifications
execute(<<~SQL)
UPDATE notifications
SET unread_count = CASE WHEN read_at IS NULL THEN 1 ELSE 0 END
WHERE unread_count = 0 AND card_id IS NOT NULL
SQL
end
end
@@ -0,0 +1,8 @@
class FixNotificationsOrderedIndex < ActiveRecord::Migration[8.2]
def change
add_index :notifications, [ :user_id, :read_at, :updated_at ],
order: { read_at: :desc, updated_at: :desc },
name: "index_notifications_on_user_id_and_read_at_and_updated_at"
remove_index :notifications, name: "index_notifications_on_user_id_and_read_at_and_created_at"
end
end
Generated
+5 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
ActiveRecord::Schema[8.2].define(version: 2026_02_12_102026) 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
@@ -385,17 +385,20 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
create_table "notifications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "card_id", null: false
t.datetime "created_at", null: false
t.uuid "creator_id"
t.datetime "read_at"
t.uuid "source_id", null: false
t.string "source_type", null: false
t.integer "unread_count", default: 0, null: false
t.datetime "updated_at", null: false
t.uuid "user_id", null: false
t.index ["account_id"], name: "index_notifications_on_account_id"
t.index ["creator_id"], name: "index_notifications_on_creator_id"
t.index ["source_type", "source_id"], name: "index_notifications_on_source"
t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc }
t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true
t.index ["user_id", "read_at", "updated_at"], name: "index_notifications_on_user_id_and_read_at_and_updated_at", order: { read_at: :desc, updated_at: :desc }
t.index ["user_id"], name: "index_notifications_on_user_id"
end
+5 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
ActiveRecord::Schema[8.2].define(version: 2026_02_12_102026) do
create_table "accesses", id: :uuid, force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -385,17 +385,20 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
create_table "notifications", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "card_id", null: false
t.datetime "created_at", null: false
t.uuid "creator_id"
t.datetime "read_at"
t.uuid "source_id", null: false
t.string "source_type", limit: 255, null: false
t.integer "unread_count", default: 0, null: false
t.datetime "updated_at", null: false
t.uuid "user_id", null: false
t.index ["account_id"], name: "index_notifications_on_account_id"
t.index ["creator_id"], name: "index_notifications_on_creator_id"
t.index ["source_type", "source_id"], name: "index_notifications_on_source"
t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc }
t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true
t.index ["user_id", "read_at", "updated_at"], name: "index_notifications_on_user_id_and_read_at_and_updated_at", order: { read_at: :desc, updated_at: :desc }
t.index ["user_id"], name: "index_notifications_on_user_id"
end
+8
View File
@@ -1,6 +1,7 @@
require_relative "transaction_pinning"
require_relative "signup"
require_relative "authorization"
require_relative "gvl_instrumentation"
require_relative "../../rails_ext/active_record_tasks_database_tasks.rb"
module Fizzy
@@ -51,6 +52,10 @@ module Fizzy
app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware)
end
initializer "fizzy_saas.gvl_instrumentation" do |app|
app.config.middleware.insert_before(Rack::Runtime, GvlInstrumentation)
end
initializer "fizzy_saas.solid_queue" do
SolidQueue.on_start do
Process.warmup
@@ -117,6 +122,9 @@ module Fizzy
config.channel_class_name = "ActionCable::Channel::Base"
end
require "yabeda/gvl"
Yabeda::GVL.install!
require_relative "metrics"
end
@@ -0,0 +1,20 @@
module Fizzy
module Saas
class GvlInstrumentation
def initialize(app)
@app = app
end
def call(env)
GVLTools::LocalTimer.enable
before = GVLTools::LocalTimer.monotonic_time
result = @app.call(env)
gvl_wait_ns = GVLTools::LocalTimer.monotonic_time - before
Yabeda.gvl.request_wait.measure({}, gvl_wait_ns / 1_000_000_000.0)
result
ensure
GVLTools::LocalTimer.disable
end
end
end
end
+30
View File
@@ -0,0 +1,30 @@
module Yabeda
module GVL
WAIT_HISTOGRAM_BUCKETS = [ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10 ]
def self.install!
GVLTools::GlobalTimer.enable
GVLTools::WaitingThreads.enable
Yabeda.configure do
group :gvl do
gauge :waiting_threads,
comment: "Number of threads currently waiting to acquire the GVL"
gauge :global_timer_total_seconds,
comment: "Total time all threads spent waiting on the GVL (seconds)"
histogram :request_wait,
unit: :seconds,
comment: "GVL wait time experienced during a single request (seconds)",
buckets: WAIT_HISTOGRAM_BUCKETS
end
collect do
gvl.waiting_threads.set({}, GVLTools::WaitingThreads.count)
gvl.global_timer_total_seconds.set({}, GVLTools::GlobalTimer.monotonic_time / 1_000_000_000.0)
end
end
end
end
end
@@ -8,7 +8,7 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
test "create" do
freeze_time
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, from: nil, to: Time.current do
post card_reading_url(cards(:logo)), as: :turbo_stream
end
@@ -17,31 +17,20 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "read one notification on card visit" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
test "read notification on card visit" do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
post card_reading_path(cards(:logo)), as: :turbo_stream
end
assert_response :success
end
test "read multiple notifications on card visit" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
post card_reading_path(cards(:logo)), as: :turbo_stream
end
end
assert_response :success
end
test "destroy" do
freeze_time
notifications(:logo_published_kevin).read
notifications(:logo_assignment_kevin).read
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, to: Time.current do
delete card_reading_url(cards(:logo)), as: :turbo_stream
end
@@ -50,26 +39,13 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "unread one notification on destroy" do
notifications(:logo_published_kevin).read
test "unread notification on destroy" do
notifications(:logo_assignment_kevin).read
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
delete card_reading_path(cards(:logo)), as: :turbo_stream
end
assert_response :success
end
test "unread multiple notifications on destroy" do
notifications(:logo_published_kevin).read
notifications(:logo_assignment_kevin).read
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
delete card_reading_path(cards(:logo)), as: :turbo_stream
end
end
assert_response :success
end
end
+8 -3
View File
@@ -42,9 +42,14 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to card_draft_path(card)
end
test "show" do
get card_path(cards(:logo))
assert_response :success
test "show renders inline code in title" do
card = cards(:logo)
card.update_column :title, "Fix the `bug` in production"
get card_path(card)
assert_select ".card__title-link" do |element|
assert_equal "Fix the <code>bug</code> in production", element.inner_html
end
end
test "edit" do
@@ -6,7 +6,7 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes
end
test "create marks all notifications as read" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
post bulk_reading_path
end
@@ -24,7 +24,7 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes
end
test "create as JSON" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
post bulk_reading_path, as: :json
end
@@ -3,38 +3,37 @@ require "test_helper"
class Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
@notification = notifications(:logo_assignment_kevin)
end
test "create" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
post notification_reading_path(notifications(:logo_published_kevin), format: :turbo_stream)
assert_changes -> { @notification.reload.read? }, from: false, to: true do
post notification_reading_path(@notification, format: :turbo_stream)
assert_response :success
end
end
test "destroy" do
notification = notifications(:logo_published_kevin)
notification.read # Mark as read first
@notification.read
assert_changes -> { notification.reload.read? }, from: true, to: false do
delete notification_reading_path(notification, format: :turbo_stream)
assert_changes -> { @notification.reload.read? }, from: true, to: false do
delete notification_reading_path(@notification, format: :turbo_stream)
assert_response :success
end
end
test "create as JSON" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
post notification_reading_path(notifications(:logo_published_kevin)), as: :json
assert_changes -> { @notification.reload.read? }, from: false, to: true do
post notification_reading_path(@notification), as: :json
assert_response :no_content
end
end
test "destroy as JSON" do
notification = notifications(:logo_published_kevin)
notification.read
@notification.read
assert_changes -> { notification.reload.read? }, from: true, to: false do
delete notification_reading_path(notification), as: :json
assert_changes -> { @notification.reload.read? }, from: true, to: false do
delete notification_reading_path(@notification), as: :json
assert_response :no_content
end
end
@@ -10,18 +10,17 @@ class NotificationsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
assert_kind_of Array, @response.parsed_body
assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_published_kevin).id }
assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_assignment_kevin).id }
end
test "index as JSON includes notification attributes" do
get notifications_path, as: :json
notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_published_kevin).id }
notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_assignment_kevin).id }
assert_not_nil notification["title"]
assert_not_nil notification["body"]
assert_not_nil notification["created_at"]
assert_not_nil notification["card"]
assert_not_nil notification["creator"]
assert_not_nil notification["unread_count"]
end
end
+8 -18
View File
@@ -1,15 +1,9 @@
logo_published_kevin:
id: <%= ActiveRecord::FixtureSet.identify("logo_published_kevin", :uuid) %>
user: kevin_uuid
source: logo_published_uuid (Event)
created_at: <%= 1.week.ago %>
creator: david_uuid
account: 37s_uuid
logo_assignment_kevin:
id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_kevin", :uuid) %>
user: kevin_uuid
source: logo_assignment_km_uuid (Event)
card: logo_uuid
unread_count: 2
created_at: <%= 1.week.ago %>
creator: david_uuid
account: 37s_uuid
@@ -18,22 +12,18 @@ layout_commented_kevin:
id: <%= ActiveRecord::FixtureSet.identify("layout_commented_kevin", :uuid) %>
user: kevin_uuid
source: layout_commented_uuid (Event)
card: layout_uuid
unread_count: 1
created_at: <%= 1.week.ago %>
creator: david_uuid
account: 37s_uuid
logo_card_david_mention_by_jz:
id: <%= ActiveRecord::FixtureSet.identify("logo_card_david_mention_by_jz_notif", :uuid) %>
user: david_uuid
source: logo_card_david_mention_by_jz_uuid (Mention)
created_at: <%= 1.week.ago %>
creator: david_uuid
account: 37s_uuid
logo_comment_david_mention_by_jz:
id: <%= ActiveRecord::FixtureSet.identify("logo_comment_david_mention_by_jz_notif", :uuid) %>
logo_mentioned_david:
id: <%= ActiveRecord::FixtureSet.identify("logo_mentioned_david", :uuid) %>
user: david_uuid
source: logo_comment_david_mention_by_jz_uuid (Mention)
card: logo_uuid
unread_count: 2
created_at: <%= 1.week.ago %>
creator: david_uuid
account: 37s_uuid
+25
View File
@@ -119,4 +119,29 @@ class HtmlHelperTest < ActionView::TestCase
assert_no_match(/<img/, output, "should not create an img element")
assert_includes output, "&lt;img"
end
test "card_html_title renders backticks as code elements" do
assert_equal "Fix the <code>bug</code> in production", card_html_title(cards(:logo).tap { _1.title = "Fix the `bug` in production" })
end
test "card_html_title renders multiple code spans" do
assert_equal "<code>foo</code> and <code>bar</code>", card_html_title(cards(:logo).tap { _1.title = "`foo` and `bar`" })
end
test "card_html_title renders code spans without surrounding spaces" do
assert_equal "what<code>about</code>this", card_html_title(cards(:logo).tap { _1.title = "what`about`this" })
end
test "card_html_title escapes HTML tags" do
assert_equal "&lt;script&gt;alert(1)&lt;/script&gt;", card_html_title(cards(:logo).tap { _1.title = "<script>alert(1)</script>" })
end
test "card_html_title escapes HTML inside backticks" do
assert_equal "<code>&lt;script&gt;</code>", card_html_title(cards(:logo).tap { _1.title = "`<script>`" })
end
test "card_html_title returns blank title as-is" do
assert_nil card_html_title(cards(:logo).tap { _1.title = nil })
assert_equal "", card_html_title(cards(:logo).tap { _1.title = "" })
end
end
@@ -3,6 +3,7 @@ require "test_helper"
class Notification::BundleMailerTest < ActionMailer::TestCase
setup do
@user = users(:david)
@user.notifications.destroy_all
@bundle = Notification::Bundle.create!(
user: @user,
@@ -86,6 +87,25 @@ class Notification::BundleMailerTest < ActionMailer::TestCase
assert_includes card_titles, "#2 Layout is broken"
end
test "renders inline code in card title" do
cards(:logo).update_column :title, "Fix the `bug` in production"
create_notification(@user, source: events(:logo_published))
html = Nokogiri::HTML5(Notification::BundleMailer.notification(@bundle).html_part.body.to_s)
title_link = html.at_css(".card__title")
assert_equal "#1 Fix the <code>bug</code> in production", title_link.inner_html
end
test "skips notifications whose source event was deleted" do
notification = create_notification(@user)
notification.source.destroy
email = Notification::BundleMailer.notification(@bundle)
assert_not email.respond_to?(:deliver) && email.message.is_a?(Mail::Message),
"Should not generate a real email when all notifications are stale"
end
private
def create_notification(user, source: events(:logo_published))
Notification.create!(user: user, creator: user, source: source, created_at: 30.minutes.ago)
+19 -42
View File
@@ -1,60 +1,41 @@
require "test_helper"
class Card::ReadableTest < ActiveSupport::TestCase
test "read clears events notifications" do
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
cards(:logo).read_by(users(:kevin))
end
test "read marks notification as read" do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
cards(:logo).read_by(users(:kevin))
end
end
test "read clear mentions in the description" do
assert_changes -> { notifications(:logo_card_david_mention_by_jz).reload.read? }, from: false, to: true do
test "read marks mention notification as read" do
assert_changes -> { notifications(:logo_mentioned_david).reload.read? }, from: false, to: true do
cards(:logo).read_by(users(:david))
end
end
test "read clear mentions in comments" do
assert_changes -> { notifications(:logo_comment_david_mention_by_jz).reload.read? }, from: false, to: true do
cards(:logo).read_by(users(:david))
end
end
test "read clears notifications from the comments" do
test "read marks comment notification as read" do
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
cards(:layout).read_by(users(:kevin))
end
end
test "unread marks events notifications as unread" do
notifications(:logo_published_kevin).read
test "unread marks notification as unread" do
notifications(:logo_assignment_kevin).read
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
cards(:logo).unread_by(users(:kevin))
end
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
cards(:logo).unread_by(users(:kevin))
end
end
test "unread marks mentions in the description as unread" do
notifications(:logo_card_david_mention_by_jz).read
test "unread marks mention notification as unread" do
notifications(:logo_mentioned_david).read
assert_changes -> { notifications(:logo_card_david_mention_by_jz).reload.read? }, from: true, to: false do
assert_changes -> { notifications(:logo_mentioned_david).reload.read? }, from: true, to: false do
cards(:logo).unread_by(users(:david))
end
end
test "unread marks mentions in comments as unread" do
notifications(:logo_comment_david_mention_by_jz).read
assert_changes -> { notifications(:logo_comment_david_mention_by_jz).reload.read? }, from: true, to: false do
cards(:logo).unread_by(users(:david))
end
end
test "unread marks notifications from the comments as unread" do
test "unread marks comment notification as unread" do
notifications(:layout_commented_kevin).read
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: true, to: false do
@@ -68,8 +49,8 @@ class Card::ReadableTest < ActiveSupport::TestCase
david = users(:david)
assert card.accessible_to?(kevin)
kevin_notifications = [ notifications(:logo_published_kevin), notifications(:logo_assignment_kevin) ]
david_notifications = [ notifications(:logo_card_david_mention_by_jz), notifications(:logo_comment_david_mention_by_jz) ]
kevin_notification = notifications(:logo_assignment_kevin)
david_notification = notifications(:logo_mentioned_david)
# Kevin loses access
card.board.accesses.find_by(user: kevin).destroy
@@ -78,14 +59,10 @@ class Card::ReadableTest < ActiveSupport::TestCase
card.remove_inaccessible_notifications
# Kevin's notifications removed
kevin_notifications.each do |notification|
assert_not Notification.exists?(notification.id)
end
# Kevin's notification removed
assert_not Notification.exists?(kevin_notification.id)
# David's notifications preserved
david_notifications.each do |notification|
assert Notification.exists?(notification.id)
end
# David's notification preserved
assert Notification.exists?(david_notification.id)
end
end
+102
View File
@@ -1,6 +1,12 @@
require "test_helper"
class Event::DescriptionTest < ActiveSupport::TestCase
test "html description is html safe" do
description = events(:logo_published).description_for(users(:david))
assert_predicate description.to_html, :html_safe?
end
test "generates html description for card published event" do
description = events(:logo_published).description_for(users(:david))
@@ -8,6 +14,12 @@ class Event::DescriptionTest < ActiveSupport::TestCase
assert_includes description.to_html, "logo"
end
test "plain text description is html safe" do
description = events(:logo_published).description_for(users(:david))
assert_predicate description.to_plain_text, :html_safe?
end
test "generates plain text description for card published event" do
description = events(:logo_published).description_for(users(:david))
@@ -33,6 +45,96 @@ class Event::DescriptionTest < ActiveSupport::TestCase
assert_includes description.to_plain_text, "David added"
end
test "to_html escapes assignee names" do
users(:jz).update_column(:name, "Tom & Jerry")
description = events(:logo_assignment_jz).description_for(users(:david))
assert_includes description.to_html, "Tom &amp; Jerry"
assert_not_includes description.to_html, "Tom & Jerry"
end
test "to_plain_text escapes assignee names" do
users(:jz).update_column(:name, "Tom & Jerry")
description = events(:logo_assignment_jz).description_for(users(:david))
assert_includes description.to_plain_text, "Tom &amp; Jerry"
assert_not_includes description.to_plain_text, "Tom &amp;amp; Jerry"
end
test "to_html escapes unassigned names" do
users(:jz).update_column(:name, "Tom & Jerry")
event = events(:logo_assignment_jz)
event.update_column(:action, "card_unassigned")
description = event.description_for(users(:david))
assert_includes description.to_html, "Tom &amp; Jerry"
assert_not_includes description.to_html, "Tom & Jerry"
end
test "to_plain_text escapes unassigned names" do
users(:jz).update_column(:name, "Tom & Jerry")
event = events(:logo_assignment_jz)
event.update_column(:action, "card_unassigned")
description = event.description_for(users(:david))
assert_includes description.to_plain_text, "Tom &amp; Jerry"
assert_not_includes description.to_plain_text, "Tom &amp;amp; Jerry"
end
test "to_html escapes old title in renamed description" do
event = events(:logo_published)
event.update_columns(action: "card_title_changed", particulars: { "particulars" => { "old_title" => "Tom & Jerry" } })
description = event.description_for(users(:david))
assert_includes description.to_html, "Tom &amp; Jerry"
assert_not_includes description.to_html, "Tom & Jerry"
end
test "to_plain_text escapes old title in renamed description" do
event = events(:logo_published)
event.update_columns(action: "card_title_changed", particulars: { "particulars" => { "old_title" => "Tom & Jerry" } })
description = event.description_for(users(:david))
assert_includes description.to_plain_text, "Tom &amp; Jerry"
assert_not_includes description.to_plain_text, "Tom &amp;amp; Jerry"
end
test "to_html escapes board name in moved description" do
event = events(:logo_published)
event.update_columns(action: "card_board_changed", particulars: { "particulars" => { "new_board" => "Tom & Jerry" } })
description = event.description_for(users(:david))
assert_includes description.to_html, "Tom &amp; Jerry"
assert_not_includes description.to_html, "Tom & Jerry"
end
test "to_plain_text escapes board name in moved description" do
event = events(:logo_published)
event.update_columns(action: "card_board_changed", particulars: { "particulars" => { "new_board" => "Tom & Jerry" } })
description = event.description_for(users(:david))
assert_includes description.to_plain_text, "Tom &amp; Jerry"
assert_not_includes description.to_plain_text, "Tom &amp;amp; Jerry"
end
test "to_html escapes column name in triaged description" do
event = events(:logo_published)
event.update_columns(action: "card_triaged", particulars: { "particulars" => { "column" => "Tom & Jerry" } })
description = event.description_for(users(:david))
assert_includes description.to_html, "Tom &amp; Jerry"
assert_not_includes description.to_html, "Tom & Jerry"
end
test "to_plain_text escapes column name in triaged description" do
event = events(:logo_published)
event.update_columns(action: "card_triaged", particulars: { "particulars" => { "column" => "Tom & Jerry" } })
description = event.description_for(users(:david))
assert_includes description.to_plain_text, "Tom &amp; Jerry"
assert_not_includes description.to_plain_text, "Tom &amp;amp; Jerry"
end
test "escapes html in card titles in plain text description" do
card = cards(:logo)
card.update_column(:title, "<script>alert('xss')</script>")
+6 -5
View File
@@ -5,6 +5,7 @@ class Notification::BundleTest < ActiveSupport::TestCase
setup do
@user = users(:david)
@user.notifications.destroy_all
@user.settings.bundle_email_every_few_hours!
end
@@ -25,7 +26,7 @@ class Notification::BundleTest < ActiveSupport::TestCase
end
end
test "notifications are bundled withing the aggregation period" do
test "notifications are bundled within the aggregation period" do
@user.notification_bundles.destroy_all
notification_1 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
@@ -34,12 +35,12 @@ class Notification::BundleTest < ActiveSupport::TestCase
travel_to 3.hours.from_now
notification_2 = assert_no_difference -> { @user.notification_bundles.count } do
@user.notifications.create!(source: events(:logo_published), creator: @user)
@user.notifications.create!(source: events(:layout_published), creator: @user)
end
travel_to 3.days.from_now
notification_3 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
@user.notifications.create!(source: events(:logo_published), creator: @user)
@user.notifications.create!(source: events(:text_published), creator: @user)
end
assert_equal 2, @user.notification_bundles.count
@@ -150,10 +151,10 @@ class Notification::BundleTest < ActiveSupport::TestCase
test "out-of-order notification bundling should still work" do
first_notification = @user.notifications.create!(source: events(:logo_published), creator: @user)
second_notification = @user.notifications.create!(source: events(:logo_published), creator: @user)
second_notification = @user.notifications.create!(source: events(:layout_commented), creator: @user)
@user.notification_bundles.destroy_all
assert first_notification.created_at < second_notification.created_at
assert first_notification.updated_at <= second_notification.updated_at
@user.bundle(second_notification)
@user.bundle(first_notification)
+1 -4
View File
@@ -3,10 +3,7 @@ require "test_helper"
class NotificationPusherTest < ActiveSupport::TestCase
setup do
@user = users(:david)
@notification = @user.notifications.create!(
source: events(:logo_published),
creator: users(:jason)
)
@notification = notifications(:logo_mentioned_david)
@pusher = NotificationPusher.new(@notification)
@user.push_subscriptions.create!(
+49 -31
View File
@@ -1,49 +1,67 @@
require "test_helper"
class NotificationTest < ActiveSupport::TestCase
test "unread marks notification as unread" do
notification = notifications(:logo_published_kevin)
notification.read # Mark as read first
assert_changes -> { notification.reload.read? }, from: true, to: false do
notification.unread
end
end
test "unread broadcasts to notifications" do
notification = notifications(:logo_published_kevin)
notification.read # Mark as read first
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
perform_enqueued_jobs do
notification.unread
end
end
end
test "read marks notification as read" do
notification = notifications(:logo_published_kevin)
# Ensure it starts as unread
notification.update!(read_at: nil)
notification = notifications(:logo_assignment_kevin)
notification.update!(read_at: nil, unread_count: 2)
assert_changes -> { notification.reload.read? }, from: false, to: true do
notification.read
end
assert_equal 0, notification.unread_count
end
test "read broadcasts to notifications" do
notification = notifications(:logo_published_kevin)
# Ensure it starts as unread
notification.update!(read_at: nil)
test "unread marks notification as unread" do
notification = notifications(:logo_assignment_kevin)
notification.read
assert_changes -> { notification.reload.read? }, from: true, to: false do
notification.unread
end
assert_equal 1, notification.unread_count
end
test "read_all marks all notifications and resets unread counts" do
kevin = users(:kevin)
kevin.notifications.unread.read_all
assert kevin.notifications.reload.all?(&:read?)
assert kevin.notifications.reload.all? { |n| n.unread_count == 0 }
end
test "unread_count tracks notification count per card" do
notification = notifications(:logo_assignment_kevin)
assert_equal 2, notification.unread_count
end
test "broadcasting on create prepends" do
kevin = users(:kevin)
layout = cards(:layout)
notifications(:layout_commented_kevin).destroy
perform_enqueued_jobs do
Notification.create!(user: kevin, source: events(:layout_commented), creator: users(:david))
end
assert_turbo_stream_broadcasts [ kevin, :notifications ]
end
test "broadcasting on update when read removes" do
notification = notifications(:layout_commented_kevin)
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
notification.read
perform_enqueued_jobs do
notification.read
end
end
end
test "deleting notification broadcasts its removal" do
notification = notifications(:logo_published_kevin)
notification.update!(read_at: nil)
test "broadcasting on destroy removes" do
notification = notifications(:logo_assignment_kevin)
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
notification.destroy
+7 -7
View File
@@ -71,18 +71,18 @@ class Notifier::EventNotifierTest < ActiveSupport::TestCase
test "create notifications on publish for mentionees" do
users(:kevin).mentioned_by(users(:david), at: cards(:logo))
assert_difference -> { users(:kevin).notifications.count }, +1 do
Notifier.for(events(:logo_published)).notify
end
notifications = Notifier.for(events(:logo_published)).notify
assert_includes notifications.map(&:user), users(:kevin)
end
test "don'create notifications on publish for mentionees that are not watching" do
test "create notifications on publish for mentionees that are not watching" do
users(:kevin).mentioned_by(users(:david), at: cards(:logo))
cards(:logo).unwatch_by(users(:kevin))
assert_difference -> { users(:kevin).notifications.count }, +1 do
Notifier.for(events(:logo_published)).notify
end
notifications = Notifier.for(events(:logo_published)).notify
assert_includes notifications.map(&:user), users(:kevin)
end
test "don't create notifications on comment for mentionees" do
+3 -2
View File
@@ -3,6 +3,7 @@ require "test_helper"
class User::NotifiableTest < ActiveSupport::TestCase
setup do
@user = users(:david)
@user.notifications.destroy_all
@user.settings.bundle_email_every_few_hours!
end
@@ -12,7 +13,7 @@ class User::NotifiableTest < ActiveSupport::TestCase
end
bundle = @user.notification_bundles.last
assert_equal notification.created_at, bundle.starts_at
assert_equal notification.updated_at, bundle.starts_at
assert bundle.pending?
end
@@ -20,7 +21,7 @@ class User::NotifiableTest < ActiveSupport::TestCase
@user.notifications.create!(source: events(:logo_published), creator: @user)
assert_no_difference -> { @user.notification_bundles.count } do
@user.notifications.create!(source: events(:logo_published), creator: @user)
@user.notifications.create!(source: events(:layout_published), creator: @user)
end
end
end
+96
View File
@@ -245,6 +245,102 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase
assert_requested request_stub
end
test "basecamp webhook payload html-escapes special characters" do
cards(:logo).update_column(:title, %(Tom & Jerry's <Great> "Adventure"))
webhook = Webhook.create!(
board: boards(:writebook),
name: "Basecamp",
url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines"
)
delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published))
captured_body = nil
stub_request(:post, webhook.url)
.with { |request| captured_body = request.body; true }
.to_return(status: 200)
delivery.deliver
content = CGI.parse(captured_body)["content"].first
expected = <<~HTML.strip
David added &quot;Tom &amp; Jerry&#39;s &lt;Great&gt; &quot;Adventure&quot;&quot;
<a href="http://example.org/897362094/cards/1"></a>
HTML
assert_equal expected, content
end
test "slack webhook payload html-escapes special characters" do
cards(:logo).update_column(:title, %(Tom & Jerry's <Great> "Adventure"))
webhook = Webhook.create!(
board: boards(:writebook),
name: "Slack",
url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx" # gitleaks:allow
)
delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published))
captured_body = nil
stub_request(:post, webhook.url)
.with { |request| captured_body = request.body; true }
.to_return(status: 200)
delivery.deliver
text = JSON.parse(captured_body)["text"]
expected = <<~TEXT.strip
David added &quot;Tom &amp; Jerry&#39;s &lt;Great&gt; &quot;Adventure&quot;&quot; <http://example.com/897362094/cards/1|Open in Fizzy>
TEXT
assert_equal expected, text
end
test "campfire webhook payload html-escapes special characters" do
cards(:logo).update_column(:title, %(Tom & Jerry's <Great> "Adventure"))
webhook = Webhook.create!(
board: boards(:writebook),
name: "Campfire",
url: "https://example.com/rooms/123/456-room-name/messages"
)
delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published))
captured_body = nil
stub_request(:post, webhook.url)
.with { |request| captured_body = request.body; true }
.to_return(status: 200)
delivery.deliver
expected = <<~HTML.strip
David added &quot;Tom &amp; Jerry&#39;s &lt;Great&gt; &quot;Adventure&quot;&quot;
<a href="http://example.org/897362094/cards/1"></a>
HTML
assert_equal expected, captured_body
end
test "generic webhook payload json-encodes special characters" do
cards(:logo).update_column(:title, %(Tom & Jerry's <Great> "Adventure"))
webhook = Webhook.create!(
board: boards(:writebook),
name: "Generic",
url: "https://example.com/webhook"
)
delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published))
captured_body = nil
stub_request(:post, webhook.url)
.with { |request| captured_body = request.body; true }
.to_return(status: 200)
delivery.deliver
json = JSON.parse(captured_body)
assert_equal %(Tom & Jerry's <Great> "Adventure"), json["eventable"]["title"]
end
test "renders creator name when event creator is not current user" do
webhook = Webhook.create!(
board: boards(:writebook),
+4 -4
View File
@@ -67,13 +67,13 @@ class SmokeTest < ApplicationSystemTestCase
test "dismissing notifications" do
sign_in_as(users(:david))
notif = notifications(:logo_card_david_mention_by_jz)
notification = notifications(:logo_mentioned_david)
assert_selector "div##{dom_id(notif)}"
assert_selector "div##{dom_id(notification)}"
within_window(open_new_window) { visit card_url(notif.card) }
within_window(open_new_window) { visit card_url(notification.card) }
assert_no_selector "div##{dom_id(notif)}"
assert_no_selector "div##{dom_id(notification)}"
end
test "dragging card to a new column" do
-2583
View File
File diff suppressed because it is too large Load Diff