Merge branch 'main' into colors
* main: (284 commits) Polish closed stamp Add new card button to considering column Let's leave inside the menu for now Improve terminal history behavior More space between Add a navigation hint, make "All collections" link navigate like the others Reorder methods Fix: dismiss notifications from comments Fix card icons Filter collections menus, add keyboard shortcuts Style Space between Remove debug Consistent sizing Configure deployment for load balancing Configure the autoclose period at the collection level Rename to avoid using the same name Add brakeman rule Collections can be private now too Not used outside the class anymore ...
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
[env]
|
||||
ARTENANT = "development-tenant"
|
||||
+15
-15
@@ -1,6 +1,6 @@
|
||||
GIT
|
||||
remote: https://github.com/basecamp/active_record-tenanted
|
||||
revision: 8758240be33a6f6f7e7f56486a2e78008c193030
|
||||
revision: f04f4f0a153ae5da65b400021aa97fdb28ac86b5
|
||||
specs:
|
||||
active_record-tenanted (0.1.0)
|
||||
activerecord (>= 8.1.alpha)
|
||||
@@ -18,7 +18,7 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: 32d96235810e268eb5335a7837b13cda3f8b653d
|
||||
revision: 0ed3cd46d0038f8108ed92edb308c81fa6cc3730
|
||||
branch: main
|
||||
specs:
|
||||
actioncable (8.1.0.alpha)
|
||||
@@ -145,7 +145,7 @@ GEM
|
||||
xpath (~> 3.2)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.0)
|
||||
connection_pool (2.5.2)
|
||||
crass (1.0.6)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
@@ -208,7 +208,7 @@ GEM
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.5)
|
||||
msgpack (1.8.0)
|
||||
net-imap (0.5.6)
|
||||
net-imap (0.5.7)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -223,24 +223,24 @@ GEM
|
||||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.7)
|
||||
nokogiri (1.18.8)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-aarch64-linux-gnu)
|
||||
nokogiri (1.18.8-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-aarch64-linux-musl)
|
||||
nokogiri (1.18.8-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-arm-linux-gnu)
|
||||
nokogiri (1.18.8-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-arm-linux-musl)
|
||||
nokogiri (1.18.8-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-arm64-darwin)
|
||||
nokogiri (1.18.8-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-x86_64-darwin)
|
||||
nokogiri (1.18.8-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-x86_64-linux-gnu)
|
||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.7-x86_64-linux-musl)
|
||||
nokogiri (1.18.8-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.26.3)
|
||||
@@ -264,7 +264,7 @@ GEM
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.12)
|
||||
rack (3.1.13)
|
||||
rack-contrib (2.5.0)
|
||||
rack (< 4)
|
||||
rack-session (2.1.0)
|
||||
@@ -373,7 +373,7 @@ GEM
|
||||
ostruct
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.6)
|
||||
stringio (3.1.7)
|
||||
thor (1.3.2)
|
||||
thruster (0.1.12)
|
||||
thruster (0.1.12-aarch64-linux)
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
/* Text */
|
||||
--text-xx-small: 0.55rem;
|
||||
--text-x-small: 0.7rem;
|
||||
--text-small: 0.8rem;
|
||||
--text-x-small: 0.75rem;
|
||||
--text-small: 0.85rem;
|
||||
--text-normal: 1rem;
|
||||
--text-medium: 1.1rem;
|
||||
--text-large: 1.5rem;
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
margin: 0;
|
||||
place-items: center;
|
||||
|
||||
img {
|
||||
img,
|
||||
.icon {
|
||||
aspect-ratio: 1;
|
||||
block-size: auto;
|
||||
border-radius: var(--avatar-border-radius);
|
||||
|
||||
@@ -85,6 +85,19 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
kbd {
|
||||
border: 1px solid;
|
||||
border-radius: .3em;
|
||||
box-shadow: 0 0.1em 0 currentColor;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-x-small);
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
padding: 0em 0.4em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
video {
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
|
||||
border-color: var(--card-color) !important;
|
||||
border-style: dashed !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.card__background {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"qw notch-top we"
|
||||
"actions-left card actions-right"
|
||||
"er notch-bottom rt";
|
||||
grid-template-columns: 48px minmax(0, 960px) 48px;
|
||||
grid-template-columns: 48px minmax(0, 1120px) 48px;
|
||||
inline-size: fit-content;
|
||||
max-inline-size: 100%;
|
||||
margin-inline: auto;
|
||||
@@ -31,6 +31,15 @@
|
||||
|
||||
border: none;
|
||||
}
|
||||
|
||||
.card__meta,
|
||||
.card__stages {
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
|
||||
.card__stages {
|
||||
max-inline-size: 32ch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Children
|
||||
@@ -100,7 +109,7 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.btn:not(.popup__item, .btn--plain) {
|
||||
.btn:not(.popup__item, .btn--plain, .btn--reversed) {
|
||||
--btn-background: var(--card-color);
|
||||
--btn-color: var(--color-ink-inverted);
|
||||
}
|
||||
@@ -110,6 +119,11 @@
|
||||
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn--reversed {
|
||||
--btn-background: var(--color-bg);
|
||||
--btn-color: var(--card-color);
|
||||
}
|
||||
}
|
||||
|
||||
.card-perma__closure-message {
|
||||
@@ -119,29 +133,4 @@
|
||||
padding-left: 1ch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Variants
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.card-perma--pointing {
|
||||
--arrow-size: 20em;
|
||||
--aspect-ratio: 7;
|
||||
|
||||
position: relative;
|
||||
margin-block-end: calc(var(--arrow-size) / var(--aspect-ratio));
|
||||
|
||||
&::after {
|
||||
aspect-ratio: var(--aspect-ratio);
|
||||
background-color: var(--color-container);
|
||||
clip-path: polygon(50% 100%, 100% 0, 0 0);
|
||||
inline-size: var(--arrow-size);
|
||||
content: "";
|
||||
display: block;
|
||||
inset-block-start: 99%;
|
||||
inset-inline-start: 50%;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,18 +105,45 @@
|
||||
padding-block: var(--card-padding-block);
|
||||
}
|
||||
|
||||
.card__description {
|
||||
house-md-toolbar,
|
||||
.house-md-content {
|
||||
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
|
||||
}
|
||||
|
||||
.house-md-content {
|
||||
min-block-size: 3lh;
|
||||
padding: var(--block-space-half) var(--inline-space) 0 0;
|
||||
}
|
||||
|
||||
& ~ .btn {
|
||||
--btn-background: var(--card-color);
|
||||
--btn-color: var(--color-ink-reversed);
|
||||
}
|
||||
}
|
||||
|
||||
.card__content {
|
||||
flex: 2 1 auto;
|
||||
}
|
||||
|
||||
.card__title {
|
||||
--hover-size: 0;
|
||||
--input-border-radius: 0;
|
||||
--input-color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
|
||||
--lines: 3;
|
||||
|
||||
flex: 2 1 75%;
|
||||
color: inherit;
|
||||
font-size: var(--text-xx-large);
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
line-height: 1.2;
|
||||
min-block-size: 3lh;
|
||||
text-wrap: balance;
|
||||
|
||||
.card__title-field {
|
||||
.card-perma__bg & {
|
||||
min-block-size: unset;
|
||||
}
|
||||
|
||||
.card-field__title {
|
||||
&:is(textarea)::placeholder {
|
||||
color: inherit;
|
||||
opacity: 0.5;
|
||||
@@ -124,23 +151,34 @@
|
||||
}
|
||||
|
||||
.card__title-link {
|
||||
--lines: 3;
|
||||
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.card__stages {
|
||||
display: flex;
|
||||
flex: 1 1 25%;
|
||||
flex: 0 1 auto;
|
||||
flex-direction: column;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding-block: var(--block-space);
|
||||
justify-self: end;
|
||||
max-inline-size: 20ch;
|
||||
padding-block: var(--block-space-half);
|
||||
}
|
||||
|
||||
.card--new-card-button:is(.btn) {
|
||||
--btn-padding: var(--block-space) var(--inline-space-double);
|
||||
--btn-border-size: 0;
|
||||
--btn-color: var(--color-link);
|
||||
--btn-icon-size: 1em;
|
||||
--card-padding-block: calc(var(--block-space) * 1.25);
|
||||
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-link) 20%, var(--color-bg));
|
||||
color: var(--btn-color);
|
||||
font-weight: 700;
|
||||
|
||||
}
|
||||
|
||||
.card__move-button {
|
||||
--btn-background: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
|
||||
--btn-background: color-mix(in srgb, inherit 40%, var(--color-ink));
|
||||
|
||||
margin-block-start: calc(var(--block-space) * 1.2);
|
||||
margin-inline-end: calc(var(--inline-space) * -4);
|
||||
@@ -192,16 +230,25 @@
|
||||
|
||||
.card__closed {
|
||||
align-items: center;
|
||||
aspect-ratio: 7/4;
|
||||
aspect-ratio: 8/5;
|
||||
background-color: var(--color-bg);
|
||||
border: var(--block-space-half) solid var(--card-color);
|
||||
border-radius: 0.2em;
|
||||
color: var(--card-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
inset: auto var(--block-space) var(--block-space-double) auto;
|
||||
justify-content: space-between;
|
||||
max-inline-size: 25ch;
|
||||
min-inline-size: 18ch;
|
||||
mix-blend-mode: multiply;
|
||||
padding: var(--block-space-half) calc(var(--inline-space) * 1.5);
|
||||
position: absolute;
|
||||
transform: rotate(-10deg);
|
||||
z-index: -1;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
}
|
||||
|
||||
.card__closed-by {
|
||||
@@ -210,8 +257,10 @@
|
||||
|
||||
.card__closed-title {
|
||||
color: var(--color-ink-inverted);
|
||||
font-size: var(--text-rel-large);
|
||||
font-size: 1.5em;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
text-shadow:
|
||||
-2px -2px 0 var(--card-color),
|
||||
2px -2px 0 var(--card-color),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
text-align: center;
|
||||
|
||||
@media (min-width: 160ch) {
|
||||
padding-inline: calc(var(--tray-size) + var(--inline-space) * 1.5);
|
||||
padding-inline: var(--tray-size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
padding:
|
||||
var(--comment-padding-block)
|
||||
var(--comment-padding-inline);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.comment__edit {
|
||||
|
||||
@@ -11,9 +11,8 @@
|
||||
--column-gap: clamp(var(--inline-space), 3cqmin, calc(var(--inline-space) * 3));
|
||||
--grid-lines: 0.1rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 5fr 1fr;
|
||||
margin: auto;
|
||||
max-inline-size: min(105ch, 100%);
|
||||
}
|
||||
|
||||
.events__day-header {
|
||||
@@ -61,16 +60,7 @@
|
||||
}
|
||||
|
||||
.events__popup {
|
||||
.panel:is(&) {
|
||||
max-block-size: calc(100dvh - (2 * var(--block-space-double)));
|
||||
max-inline-size: calc(100dvw - (2 * var(--block-space-double)));
|
||||
}
|
||||
|
||||
#header:has(&) {
|
||||
max-inline-size: 100%;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
margin-block-start: var(--block-space-double);
|
||||
}
|
||||
|
||||
.event {
|
||||
@@ -93,6 +83,12 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.event__collection {
|
||||
font-size: var(--text-x-small);
|
||||
font-weight: 500;
|
||||
opacity: 0.66;
|
||||
}
|
||||
|
||||
.event__grid-item {
|
||||
background-color: var(--color-container);
|
||||
block-size: 100%;
|
||||
|
||||
@@ -107,3 +107,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter--active li {
|
||||
display: none;
|
||||
|
||||
&.selected {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@layer components {
|
||||
/* Editor */
|
||||
house-md {
|
||||
--placeholder: attr(placeholder);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
@@ -58,7 +60,6 @@
|
||||
|
||||
/* Markdown Content */
|
||||
.house-md-content {
|
||||
caret-color: var(--color-link);
|
||||
flex-grow: 1;
|
||||
font-family: var(--font-sans);
|
||||
text-align: left;
|
||||
@@ -66,8 +67,8 @@
|
||||
padding: 1rem;
|
||||
|
||||
&.house-md-content-empty::before {
|
||||
content: attr(placeholder);
|
||||
color: var(--color-ink);
|
||||
content: var(--placeholder);
|
||||
color: currentColor;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
|
||||
@@ -94,6 +94,11 @@
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
}
|
||||
|
||||
.input--textarea {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
@layer components {
|
||||
.comments {
|
||||
.markdown-content {
|
||||
:where(h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6) {
|
||||
font-weight: 800;
|
||||
hyphens: auto;
|
||||
@@ -94,3 +94,17 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notifications__empty-message {
|
||||
.notificiations-list:has(.notification) & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.notificiations-list--read {
|
||||
display: none;
|
||||
|
||||
&:has(.notification) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,20 @@
|
||||
max-inline-size: 100%;
|
||||
padding: var(--panel-padding, calc(var(--block-space) * 2));
|
||||
}
|
||||
|
||||
.panels--two-up {
|
||||
--panel-size: auto;
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--block-space-double);
|
||||
align-content: start;
|
||||
max-inline-size: min(100ch, 100%);
|
||||
margin: auto;
|
||||
|
||||
.panel {
|
||||
flex: 1 1 33%;
|
||||
min-inline-size: 36ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
--text-xx-large: 2em;
|
||||
|
||||
border: 1px solid var(--color-canvas);
|
||||
view-transition-name: unset !important;
|
||||
}
|
||||
|
||||
.card__collection {
|
||||
@@ -42,16 +43,18 @@
|
||||
padding-block-end: 0;
|
||||
}
|
||||
|
||||
.card__link {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card__title {
|
||||
--lines: 1;
|
||||
|
||||
font-size: var(--text-small);
|
||||
font-weight: bold;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.card__title-link {
|
||||
--lines: 1;
|
||||
}
|
||||
|
||||
.card__stages {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
@layer components {
|
||||
.popup {
|
||||
--panel-border-radius: 0.5em;
|
||||
--panel-padding: 0.4em 0.5em 0.5em;
|
||||
--panel-padding: var(--block-space);
|
||||
--panel-size: auto;
|
||||
|
||||
inline-size: auto;
|
||||
inset: 0 auto auto 0;
|
||||
min-inline-size: 20ch;
|
||||
max-inline-size: 40ch;
|
||||
inset: 0 auto auto 50%;
|
||||
min-inline-size: min(25ch, 90vw);
|
||||
max-inline-size: min(50ch, 90vw);
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
|
||||
&[open] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#header:has(&) {
|
||||
max-inline-size: 100%;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.popup__group {
|
||||
background: var(--color-subtle-light);
|
||||
max-inline-size: 100%;
|
||||
padding-inline: var(--inline-space);
|
||||
|
||||
.btn {
|
||||
--btn-background: transparent;
|
||||
}
|
||||
|
||||
@media (any-hover: hover) {
|
||||
&:hover {
|
||||
background: var(--color-selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.popup__item {
|
||||
@@ -25,17 +48,10 @@
|
||||
--hover-size: 0;
|
||||
--outline-color: transparent;
|
||||
|
||||
flex: 1 1 auto;
|
||||
justify-content: start;
|
||||
|
||||
&::after {
|
||||
border-radius: var(--btn-border-radius);
|
||||
border: var(--btn-border-size) solid var(--btn-border-color, var(--color-ink-lighter));
|
||||
content: "";
|
||||
display: block;
|
||||
inline-size: calc(100% - var(--btn-padding) * 2);
|
||||
block-size: calc(100% - var(--btn-padding) * 2);
|
||||
|
||||
}
|
||||
max-inline-size: 100%;
|
||||
text-align: start;
|
||||
|
||||
@media (any-hover: hover) {
|
||||
&:where(:not(:active):hover) {
|
||||
@@ -75,10 +91,67 @@
|
||||
}
|
||||
|
||||
.popup__list {
|
||||
max-block-size: 60dvh;
|
||||
overflow: auto;
|
||||
row-gap: 2px;
|
||||
}
|
||||
|
||||
.popup__new {
|
||||
--btn-icon-size: 1em;
|
||||
|
||||
.icon {
|
||||
margin-inline-end: 0.5em;
|
||||
}
|
||||
|
||||
padding-inline-start: var(--inline-space-half);
|
||||
}
|
||||
|
||||
.popup__title {
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
|
||||
.popup--animated {
|
||||
--backdrop-speed: 150ms;
|
||||
--panel-size: max-content;
|
||||
--speed: 150ms;
|
||||
|
||||
opacity: 0;
|
||||
transform: scale(0.2) translateX(-50%);
|
||||
transform-origin: top left;
|
||||
transition:
|
||||
display var(--speed) allow-discrete,
|
||||
opacity var(--speed),
|
||||
overlay var(--speed) allow-discrete,
|
||||
transform var(--speed);
|
||||
|
||||
&::backdrop {
|
||||
background-color: var(--color-always-black);
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
transition:
|
||||
display var(--backdrop-speed) allow-discrete,
|
||||
opacity var(--backdrop-speed),
|
||||
overlay var(--backdrop-speed) allow-discrete;
|
||||
}
|
||||
|
||||
&[open] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateX(-50%);
|
||||
|
||||
&::backdrop {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
&[open] {
|
||||
opacity: 0;
|
||||
transform: scale(0.2) translateX(-50%);
|
||||
}
|
||||
|
||||
&[open]::backdrop {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,9 @@
|
||||
}
|
||||
|
||||
.tag-picker__button {
|
||||
font-size: 0.7em;
|
||||
margin-inline-start: 0.5em;
|
||||
font-size: 0.6em;
|
||||
margin-block-start: 0.1em;
|
||||
margin-inline-start: 0.2em;
|
||||
|
||||
+ .panel {
|
||||
--panel-size: 18ch;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.users-list {
|
||||
max-block-size: 40dvh;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
.txt-nowrap { white-space: nowrap; }
|
||||
.txt-break { word-break: break-word; }
|
||||
.txt-uppercase { text-transform: uppercase; }
|
||||
.txt-capitalize { text-transform: capitalize; }
|
||||
.txt-link { color: var(--color-link); text-decoration: underline; }
|
||||
|
||||
/* Flexbox and Grid */
|
||||
@@ -107,6 +108,7 @@
|
||||
.pad-inline-double { padding-inline: var(--inline-space-double); }
|
||||
|
||||
.unpad { padding: 0; }
|
||||
.unpad-block-end { padding-block-end: 0; }
|
||||
.unpad-inline { padding-inline: 0; }
|
||||
|
||||
/* Margins */
|
||||
|
||||
@@ -27,13 +27,42 @@
|
||||
background-color: color(from var(--card-color) srgb r g b / 0.65);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ticket & {
|
||||
--btn-background: var(--color-ink);
|
||||
.workflow-preview {
|
||||
--border-size: 3px;
|
||||
|
||||
background: var(--card-color);
|
||||
border-radius: 0.2em;
|
||||
color: var(--color-ink-inverted);
|
||||
aspect-ratio: 1;
|
||||
flex: 1 1 30%;
|
||||
max-inline-size: 33%;
|
||||
padding: 0.5em 1em;
|
||||
|
||||
button {
|
||||
--hover-size: 0;
|
||||
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
inline-size: auto;
|
||||
margin: 0;
|
||||
max-inline-size: 100%;
|
||||
outline: 0;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-preview--selected {
|
||||
--border-color: var(--color-link);
|
||||
}
|
||||
|
||||
.workflow-preview__swatch {
|
||||
--btn-size: 1em;
|
||||
--btn-border-radius: 50%;
|
||||
--btn-border-size: 0;
|
||||
--hover-size: 0;
|
||||
|
||||
cursor: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@ class Cards::AssignmentsController < ApplicationController
|
||||
|
||||
def create
|
||||
@card.toggle_assignment @collection.users.active.find(params[:assignee_id])
|
||||
render_card_replacement
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,11 +3,11 @@ class Cards::ClosuresController < ApplicationController
|
||||
|
||||
def create
|
||||
@card.close(user: Current.user, reason: params[:reason])
|
||||
redirect_to @card
|
||||
render_card_replacement
|
||||
end
|
||||
|
||||
def destroy
|
||||
@card.reopen
|
||||
redirect_to @card
|
||||
render_card_replacement
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,11 +3,11 @@ class Cards::EngagementsController < ApplicationController
|
||||
|
||||
def create
|
||||
@card.engage
|
||||
redirect_to @card
|
||||
render_card_replacement
|
||||
end
|
||||
|
||||
def destroy
|
||||
@card.reconsider
|
||||
redirect_to @card
|
||||
render_card_replacement
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,11 +3,11 @@ class Cards::GoldnessesController < ApplicationController
|
||||
|
||||
def create
|
||||
@card.gild
|
||||
redirect_to @card
|
||||
render_card_replacement
|
||||
end
|
||||
|
||||
def destroy
|
||||
@card.ungild
|
||||
redirect_to @card
|
||||
render_card_replacement
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,6 @@ class Cards::ImagesController < ApplicationController
|
||||
|
||||
def destroy
|
||||
@card.image.purge_later
|
||||
redirect_to @card
|
||||
render_card_replacement
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,25 +5,29 @@ class Cards::PinsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
pin = @card.pin_by Current.user
|
||||
@pin = @card.pin_by Current.user
|
||||
|
||||
broadcast_my_new pin
|
||||
redirect_to card_pin_path(@card)
|
||||
broadcast_add_pin_to_tray
|
||||
render_pin_button_replacement
|
||||
end
|
||||
|
||||
def destroy
|
||||
pin = @card.unpin_by Current.user
|
||||
@pin = @card.unpin_by Current.user
|
||||
|
||||
broadcast_my_removed pin
|
||||
redirect_to card_pin_path(@card)
|
||||
broadcast_remove_pin_from_tray
|
||||
render_pin_button_replacement
|
||||
end
|
||||
|
||||
private
|
||||
def broadcast_my_new(pin)
|
||||
pin.card.broadcast_prepend_later_to [ Current.user, :pins ], target: "pins", partial: "cards/display/preview"
|
||||
def broadcast_add_pin_to_tray
|
||||
@pin.broadcast_prepend_to [ Current.user, :pins_tray ], target: "pins", partial: "my/pins/pin"
|
||||
end
|
||||
|
||||
def broadcast_my_removed(pin)
|
||||
pin.broadcast_remove_to [ Current.user, :pins ]
|
||||
def broadcast_remove_pin_from_tray
|
||||
@pin.broadcast_remove_to [ Current.user, :pins_tray ]
|
||||
end
|
||||
|
||||
def render_pin_button_replacement
|
||||
render turbo_stream: turbo_stream.replace([ @card, :pin_button ], partial: "cards/pins/pin_button", locals: { card: @card })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,6 @@ class Cards::ReadingsController < ApplicationController
|
||||
include CardScoped
|
||||
|
||||
def create
|
||||
@notification = Current.user.notifications.find_by(card: @card)
|
||||
@notification.read
|
||||
@notifications = @card.read_by(Current.user)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
class Cards::StagingsController < ApplicationController
|
||||
include CardScoped
|
||||
|
||||
before_action :set_stage
|
||||
|
||||
def create
|
||||
if @stage
|
||||
@card.toggle_stage @stage
|
||||
else
|
||||
@card.update!(stage: nil)
|
||||
end
|
||||
def update
|
||||
@card.change_stage_to @collection.workflow.stages.find(params[:stage_id])
|
||||
render_card_replacement
|
||||
end
|
||||
|
||||
private
|
||||
def set_stage
|
||||
@stage = Workflow::Stage.find_by(id: params[:stage_id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,6 +7,7 @@ class Cards::TaggingsController < ApplicationController
|
||||
|
||||
def create
|
||||
@card.toggle_tag_with sanitized_tag_title_param
|
||||
render_card_replacement
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -25,16 +25,21 @@ class CardsController < ApplicationController
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@card.update! card_params
|
||||
|
||||
if @card.published?
|
||||
render_card_replacement
|
||||
else
|
||||
redirect_to @card
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@card.destroy!
|
||||
redirect_to cards_path(collection_ids: [ @card.collection ]), notice: ("Card deleted" unless @card.creating?)
|
||||
end
|
||||
|
||||
def update
|
||||
@card.update! card_params
|
||||
redirect_to @card
|
||||
end
|
||||
|
||||
private
|
||||
def set_card
|
||||
@card = @collection.cards.find params[:id]
|
||||
@@ -49,6 +54,10 @@ class CardsController < ApplicationController
|
||||
end
|
||||
|
||||
def card_params
|
||||
params.expect(card: [ :status, :title, :color, :due_on, :image, :draft_comment, tag_ids: [] ])
|
||||
params.expect(card: [ :status, :title, :description, :image, tag_ids: [] ])
|
||||
end
|
||||
|
||||
def render_card_replacement
|
||||
render turbo_stream: turbo_stream.replace([ @card, :card_container ], partial: "cards/container", locals: { card: @card.reload })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,6 @@ class Collections::WorkflowsController < ApplicationController
|
||||
|
||||
private
|
||||
def set_workflow
|
||||
@workflow = Workflow.find(params.expect(collection: [ :workflow_id ]).require(:workflow_id))
|
||||
@workflow = Workflow.find(params[:collection][:workflow_id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ class CollectionsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
@collection = Collection.create! collection_params
|
||||
@collection = Collection.create! collection_params.with_defaults(all_access: true)
|
||||
redirect_to cards_path(collection_ids: [ @collection ])
|
||||
end
|
||||
|
||||
@@ -19,7 +19,7 @@ class CollectionsController < ApplicationController
|
||||
@collection.update! collection_params
|
||||
@collection.accesses.revise granted: grantees, revoked: revokees
|
||||
|
||||
redirect_to cards_path(collection_ids: [ @collection ])
|
||||
redirect_to edit_collection_path(@collection), notice: "Collection updated"
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -33,7 +33,7 @@ class CollectionsController < ApplicationController
|
||||
end
|
||||
|
||||
def collection_params
|
||||
params.expect(collection: [ :name, :all_access ]).with_defaults(all_access: true)
|
||||
params.expect(collection: [ :name, :all_access, :auto_close_period ])
|
||||
end
|
||||
|
||||
def grantees
|
||||
|
||||
@@ -13,4 +13,8 @@ module CardScoped
|
||||
def set_collection
|
||||
@collection = @card.collection
|
||||
end
|
||||
|
||||
def render_card_replacement
|
||||
render turbo_stream: turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
module CollectionFilterable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_collection_filter
|
||||
end
|
||||
|
||||
private
|
||||
def set_collection_filter
|
||||
params[:collection_ids] ||= cookies[:collection_filter]&.split(",") unless params[:clear_filter]
|
||||
end
|
||||
|
||||
def collection_filter
|
||||
params[:collection_ids].presence || Current.user.collection_ids
|
||||
end
|
||||
|
||||
def update_collection_filter
|
||||
if params[:clear_filter]
|
||||
cookies.delete(:collection_filter)
|
||||
elsif params[:collection_ids].present?
|
||||
cookies[:collection_filter] = params[:collection_ids].join(",")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
module DayTimelinesScoped
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include FilterScoped
|
||||
|
||||
before_action :restore_collections_filter_from_cookie
|
||||
before_action :set_day_timeline
|
||||
after_action :save_collections_filter_to_cookie
|
||||
end
|
||||
|
||||
private
|
||||
def restore_collections_filter_from_cookie
|
||||
if params[:clear_filter]
|
||||
delete_collections_filter_cookie
|
||||
else
|
||||
set_collections_filter_from_cookie
|
||||
end
|
||||
end
|
||||
|
||||
def delete_collections_filter_cookie
|
||||
cookies.delete(:collection_filter)
|
||||
end
|
||||
|
||||
def set_collections_filter_from_cookie
|
||||
if cookies[:collection_filter].present? && @filter.collections.blank?
|
||||
@filter.collection_ids = cookies[:collection_filter].split(",")
|
||||
end
|
||||
end
|
||||
|
||||
def save_collections_filter_to_cookie
|
||||
cookies[:collection_filter] = @filter.collection_ids.join(",")
|
||||
end
|
||||
|
||||
def set_day_timeline
|
||||
@day_timeline = Current.user.timeline_for(day, filter: @filter)
|
||||
end
|
||||
|
||||
def day
|
||||
if params[:day].present?
|
||||
Time.zone.parse(params[:day])
|
||||
else
|
||||
Time.current
|
||||
end
|
||||
rescue ArgumentError
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
@@ -1,40 +0,0 @@
|
||||
module EventsTimeline
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include CollectionFilterable
|
||||
|
||||
included do
|
||||
before_action :set_activity_day
|
||||
end
|
||||
|
||||
private
|
||||
def set_activity_day
|
||||
@activity_day = if params[:day].present?
|
||||
Time.zone.parse(params[:day])
|
||||
else
|
||||
Time.zone.now
|
||||
end
|
||||
rescue ArgumentError
|
||||
raise ActionController::RoutingError
|
||||
end
|
||||
|
||||
def events_for_activity_day
|
||||
user_events.where(created_at: @activity_day.all_day)
|
||||
.group_by { |event| [ event.created_at.hour, helpers.event_column(event) ] }
|
||||
.map { |hour_col, events| [ hour_col, events.uniq { |e| e.id } ] }
|
||||
end
|
||||
|
||||
def latest_event_before_activity_day
|
||||
user_events.where(created_at: ...@activity_day.beginning_of_day).chronologically.last
|
||||
end
|
||||
|
||||
def user_events
|
||||
Event.where(card: user_cards)
|
||||
end
|
||||
|
||||
def user_cards
|
||||
Current.user.accessible_cards
|
||||
.published_or_drafted_by(Current.user)
|
||||
.where(collection_id: collection_filter)
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,6 @@
|
||||
class Events::DaysController < ApplicationController
|
||||
include EventsTimeline
|
||||
include DayTimelinesScoped
|
||||
|
||||
def index
|
||||
@events = events_for_activity_day
|
||||
@next_day = latest_event_before_activity_day&.created_at
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
class EventsController < ApplicationController
|
||||
include EventsTimeline
|
||||
include DayTimelinesScoped
|
||||
|
||||
def index
|
||||
update_collection_filter
|
||||
|
||||
@collections = Current.user.collections.alphabetically
|
||||
@filters = Current.user.filters.all
|
||||
|
||||
@events = events_for_activity_day
|
||||
@next_day = latest_event_before_activity_day&.created_at
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Notifications::TraysController < ApplicationController
|
||||
def show
|
||||
@notifications = Current.user.notifications.unread.ordered.limit(20)
|
||||
@notifications = Current.user.notifications.unread.ordered.limit(100)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class TerminalsController < ApplicationController
|
||||
def show
|
||||
@events = Event.where(card: Current.user.accessible_cards, creator: Current.user).chronologically.reverse_order.limit(20)
|
||||
@events = Current.user.accessible_events.chronologically.reverse_order.limit(20)
|
||||
end
|
||||
|
||||
def edit
|
||||
|
||||
@@ -11,8 +11,6 @@ class WorkflowsController < ApplicationController
|
||||
|
||||
def create
|
||||
@workflow = Workflow.create! workflow_params
|
||||
# FIXME: this should definitely change.
|
||||
[ "Triage", "In progress", "On Hold", "Review" ].each { |name| @workflow.stages.create! name: name }
|
||||
redirect_to workflows_path
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module AccessesHelper
|
||||
def access_menu_tag(collection, &)
|
||||
tag.menu class: [ "flex flex-column gap margin-none pad txt-medium", { "toggler--toggled": collection.all_access? } ], data: {
|
||||
tag.menu class: [ "flex flex-column gap-half unpad margin-none txt-medium", { "toggler--toggled": collection.all_access? } ], data: {
|
||||
controller: "filter toggle-class",
|
||||
filter_active_class: "filter--active", filter_selected_class: "selected",
|
||||
toggle_class_toggle_class: "toggler--toggled" }, &
|
||||
|
||||
@@ -15,11 +15,12 @@ module CardsHelper
|
||||
**options
|
||||
end
|
||||
|
||||
def card_article_tag(card, id: dom_id(card, :ticket), **options, &block)
|
||||
def card_article_tag(card, id: dom_id(card, :article), **options, &block)
|
||||
classes = [
|
||||
options.delete(:class),
|
||||
("card--golden" if card.golden?),
|
||||
("card--doing" if card.doing?)
|
||||
("card--doing" if card.doing?),
|
||||
("card--drafted" if card.drafted?)
|
||||
].compact.join(" ")
|
||||
|
||||
tag.article \
|
||||
@@ -29,4 +30,12 @@ module CardsHelper
|
||||
**options,
|
||||
&block
|
||||
end
|
||||
|
||||
def button_to_delete_card(card)
|
||||
button_to collection_card_path(card.collection, card),
|
||||
method: :delete, class: "btn txt-negative borderless txt-small", data: { turbo_frame: "_top", turbo_confirm: "Are you sure you want to permanently delete this card?" } do
|
||||
concat(icon_tag("trash"))
|
||||
concat(tag.span("Delete this card"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
module CollectionsHelper
|
||||
def collection_auto_close_options
|
||||
[
|
||||
[ "30 days", 30.days ],
|
||||
[ "60 days", 60.days ],
|
||||
[ "90 days", 90.days ],
|
||||
[ "6 months", 180.days ],
|
||||
[ "1 year", 365.days ],
|
||||
[ "Never", nil ]
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -1,20 +1,9 @@
|
||||
module EventsHelper
|
||||
def event_day_title(day)
|
||||
case
|
||||
when day.today?
|
||||
"Today"
|
||||
when day.yesterday?
|
||||
"Yesterday"
|
||||
else
|
||||
day.strftime("%A, %B %e")
|
||||
end
|
||||
end
|
||||
|
||||
def event_column(event)
|
||||
case event.action
|
||||
when "closed"
|
||||
when "card_closed"
|
||||
3
|
||||
when "published"
|
||||
when "card_published"
|
||||
1
|
||||
else
|
||||
2
|
||||
@@ -33,7 +22,7 @@ module EventsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def render_event_grid_cells(day, columns: 4, rows: 24)
|
||||
def render_event_grid_cells(columns: 4, rows: 24)
|
||||
safe_join((2..rows + 1).map do |row|
|
||||
(1..columns).map do |col|
|
||||
tag.div class: class_names("event__grid-item"), style: "grid-area: #{row}/#{col};"
|
||||
@@ -41,19 +30,11 @@ module EventsHelper
|
||||
end.flatten)
|
||||
end
|
||||
|
||||
def render_column_headers(day = Date.current)
|
||||
start_time = day.beginning_of_day
|
||||
end_time = day.end_of_day
|
||||
|
||||
accessible_events = Event.joins(card: :collection)
|
||||
.merge(Current.user.collections)
|
||||
.where(created_at: start_time..end_time)
|
||||
.where(cards: { collection_id: params[:collection_ids].presence || Current.user.collection_ids })
|
||||
|
||||
def render_column_headers(day_timeline)
|
||||
headers = {
|
||||
"Added" => accessible_events.where(action: "published").count,
|
||||
"Added" => day_timeline.events.where(action: "card_published").count,
|
||||
"Updated" => nil,
|
||||
"Closed" => accessible_events.where(action: "closed").count
|
||||
"Closed" => day_timeline.events.where(action: "card_closed").count
|
||||
}
|
||||
|
||||
headers.map do |header, count|
|
||||
@@ -63,47 +44,64 @@ module EventsHelper
|
||||
end
|
||||
|
||||
def event_action_sentence(event)
|
||||
if event.action.comment_created?
|
||||
comment_event_action_sentence(event)
|
||||
else
|
||||
card_event_action_sentence(event)
|
||||
end
|
||||
end
|
||||
|
||||
def comment_event_action_sentence(event)
|
||||
"#{ event_creator_name(event) } commented on <span style='color: var(--card-color)'>#{ event.eventable.card.title }</span>".html_safe
|
||||
end
|
||||
|
||||
def event_creator_name(event)
|
||||
event.creator == Current.user ? "You" : event.creator.name
|
||||
end
|
||||
|
||||
def card_event_action_sentence(event)
|
||||
card = event.eventable
|
||||
title = card.title
|
||||
|
||||
case event.action
|
||||
when "assigned"
|
||||
when "card_assigned"
|
||||
if event.assignees.include?(Current.user)
|
||||
"#{ event.creator == Current.user ? "You" : event.creator.name } will handle <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
|
||||
"#{ event_creator_name(event) } will handle <span style='color: var(--card-color)'>#{ title }</span>".html_safe
|
||||
else
|
||||
"#{ event.creator == Current.user ? "You" : event.creator.name } assigned #{ event.assignees.pluck(:name).to_sentence } to <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
|
||||
"#{ event_creator_name(event) } assigned #{ event.assignees.pluck(:name).to_sentence } to <span style='color: var(--card-color)'>#{ title }</span>".html_safe
|
||||
end
|
||||
when "unassigned"
|
||||
"#{ event.creator == Current.user ? "You" : event.creator.name } unassigned #{ event.assignees.include?(Current.user) ? "yourself" : event.assignees.pluck(:name).to_sentence } from <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
|
||||
when "commented"
|
||||
"#{ event.creator == Current.user ? "You" : event.creator.name } commented on <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
|
||||
when "published"
|
||||
"#{ event.creator == Current.user ? "You" : event.creator.name } added <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
|
||||
when "closed"
|
||||
"#{ event.creator == Current.user ? "You" : event.creator.name } closed <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
|
||||
when "staged"
|
||||
"#{event.creator == Current.user ? "You" : event.creator.name} moved <span style='color: var(--card-color)'>#{ event.card.title }</span> to the #{event.stage_name} stage".html_safe
|
||||
when "unstaged"
|
||||
"#{event.creator == Current.user ? "You" : event.creator.name} moved <span style='color: var(--card-color)'>#{ event.card.title }</span> out ofthe #{event.stage_name} stage".html_safe
|
||||
when "due_date_added"
|
||||
"#{event.creator == Current.user ? "You" : event.creator.name} set the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
|
||||
when "due_date_changed"
|
||||
"#{event.creator == Current.user ? "You" : event.creator.name} changed the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
|
||||
when "due_date_removed"
|
||||
"#{event.creator == Current.user ? "You" : event.creator.name} removed the date on <span style='color: var(--card-color)'>#{ event.card.title }</span>"
|
||||
when "title_changed"
|
||||
"#{event.creator == Current.user ? "You" : event.creator.name} renamed <span style='color: var(--card-color)'>#{ event.card.title }</span> (was: '#{event.particulars.dig('particulars', 'old_title')})'".html_safe
|
||||
when "card_unassigned"
|
||||
"#{ event_creator_name(event) } unassigned #{ event.assignees.include?(Current.user) ? "yourself" : event.assignees.pluck(:name).to_sentence } from <span style='color: var(--card-color)'>#{ title }</span>".html_safe
|
||||
when "card_published"
|
||||
"#{ event_creator_name(event) } added <span style='color: var(--card-color)'>#{ title }</span>".html_safe
|
||||
when "card_closed"
|
||||
"#{ event_creator_name(event) } closed <span style='color: var(--card-color)'>#{ title }</span>".html_safe
|
||||
when "card_staged"
|
||||
"#{event_creator_name(event)} moved <span style='color: var(--card-color)'>#{ title }</span> to the #{event.stage_name} stage".html_safe
|
||||
when "card_unstaged"
|
||||
"#{event_creator_name(event)} moved <span style='color: var(--card-color)'>#{ title }</span> out ofthe #{event.stage_name} stage".html_safe
|
||||
when "card_due_date_added"
|
||||
"#{event_creator_name(event)} set the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on <span style='color: var(--card-color)'>#{ title }</span>".html_safe
|
||||
when "card_due_date_changed"
|
||||
"#{event_creator_name(event)} changed the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on <span style='color: var(--card-color)'>#{ title }</span>".html_safe
|
||||
when "card_due_date_removed"
|
||||
"#{event_creator_name(event)} removed the date on <span style='color: var(--card-color)'>#{ title }</span>"
|
||||
when "card_title_changed"
|
||||
"#{event_creator_name(event)} renamed <span style='color: var(--card-color)'>#{ title }</span> (was: '#{event.particulars.dig('particulars', 'old_title')})'".html_safe
|
||||
end
|
||||
end
|
||||
|
||||
def event_action_icon(event)
|
||||
case event.action
|
||||
when "assigned"
|
||||
when "card_assigned"
|
||||
"assigned"
|
||||
when "staged"
|
||||
when "card_staged"
|
||||
"bolt"
|
||||
when "unstaged"
|
||||
when "card_unstaged"
|
||||
"bolt"
|
||||
when "commented"
|
||||
when "comment_created"
|
||||
"comment"
|
||||
when "title_changed"
|
||||
when "card_title_changed"
|
||||
"rename"
|
||||
else
|
||||
"person"
|
||||
|
||||
@@ -21,9 +21,19 @@ module FiltersHelper
|
||||
hidden_field_tag name, value, id: nil
|
||||
end
|
||||
|
||||
def filter_selected_collections_label(filter)
|
||||
selected_collections = if filter.collections.any?
|
||||
filter.collections.collect { "<strong>#{it.name}</strong>" }.uniq.sort.to_sentence
|
||||
else
|
||||
"all collections"
|
||||
end
|
||||
|
||||
"Activity in #{selected_collections}".html_safe
|
||||
end
|
||||
|
||||
def any_filters?(filter)
|
||||
filter.tags.any? || filter.assignees.any? || filter.creators.any? ||
|
||||
filter.stages.any? || filter.terms.any? ||
|
||||
filter.assignment_status.unassigned? || !filter.default_indexed_by?
|
||||
filter.stages.any? || filter.terms.any? ||
|
||||
filter.assignment_status.unassigned? || !filter.default_indexed_by?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
module NotificationsHelper
|
||||
def notification_title(notification)
|
||||
if notification.resource.is_a? Comment
|
||||
"RE: " + notification.card.title
|
||||
elsif notification_event_action(notification) == "assigned"
|
||||
"Assigned to you: " + notification.card.title
|
||||
else
|
||||
notification.card.title
|
||||
def event_notification_title(event)
|
||||
case event_notification_action(event)
|
||||
when "comment_created" then "RE: " + event.eventable.card.title
|
||||
when "card_assigned" then "Assigned to you: " + event.eventable.title
|
||||
else event.eventable.title
|
||||
end
|
||||
end
|
||||
|
||||
def notification_body(notification)
|
||||
name = notification.creator.name
|
||||
def event_notification_body(event)
|
||||
name = event.creator.name
|
||||
|
||||
case notification_event_action(notification)
|
||||
when "closed" then "Closed by #{name}"
|
||||
when "published" then "Added by #{name}"
|
||||
case event_notification_action(event)
|
||||
when "card_closed" then "Closed by #{name}"
|
||||
when "card_published" then "Added by #{name}"
|
||||
when "comment_created" then comment_notification_body(event)
|
||||
else name
|
||||
end
|
||||
end
|
||||
@@ -22,7 +21,7 @@ module NotificationsHelper
|
||||
def notification_tag(notification, &)
|
||||
tag.div id: dom_id(notification), class: "notification tray__item border-radius txt-normal" do
|
||||
concat(
|
||||
link_to(notification.resource,
|
||||
link_to(notification,
|
||||
class: "notification__content border-radius shadow fill-white flex align-start txt-align-start gap flex-item-grow max-width border txt-ink",
|
||||
data: { action: "click->dialog#close", turbo_frame: "_top" },
|
||||
&)
|
||||
@@ -33,11 +32,11 @@ module NotificationsHelper
|
||||
|
||||
def notification_mark_read_button(notification)
|
||||
button_to read_notification_path(notification),
|
||||
class: "notification__unread_indicator btn borderless",
|
||||
title: "Mark as read",
|
||||
data: { turbo_frame: "_top" } do
|
||||
concat(image_tag("remove-med.svg", class: "unread_icon", size: 12, aria: { hidden: true }))
|
||||
concat(tag.span("Mark as read", class: "for-screen-reader"))
|
||||
class: "notification__unread_indicator btn borderless",
|
||||
title: "Mark as read",
|
||||
data: { turbo_frame: "_top" } do
|
||||
concat(image_tag("remove-med.svg", class: "unread_icon", size: 12, aria: { hidden: true }))
|
||||
concat(tag.span("Mark as read", class: "for-screen-reader"))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,15 +47,16 @@ module NotificationsHelper
|
||||
end
|
||||
|
||||
private
|
||||
def notification_event_action(notification)
|
||||
if notification_is_for_initial_assignement?(notification)
|
||||
"assigned"
|
||||
def event_notification_action(event)
|
||||
if event.action.card_published? && event.eventable.assigned_to?(event.creator)
|
||||
"card_assigned"
|
||||
else
|
||||
notification.event.action
|
||||
event.action
|
||||
end
|
||||
end
|
||||
|
||||
def notification_is_for_initial_assignement?(notification)
|
||||
notification.event.action == "published" && notification.card.assigned_to?(notification.user)
|
||||
def comment_notification_body(event)
|
||||
comment = event.eventable
|
||||
"#{strip_tags(comment.body_html).blank? ? "#{event.creator.name} replied" : "#{event.creator.name}:" } #{strip_tags(comment.body_html).truncate(200)}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
module WorkflowsHelper
|
||||
def button_to_set_stage(card, stage)
|
||||
button_to card_stagings_path(card, stage_id: stage),
|
||||
method: :post, class: [ "btn justify-start workflow-stage txt-uppercase workflow-stage", { "workflow-stage--current": stage == card.stage } ],
|
||||
form_class: "flex align-center gap-half",
|
||||
data: { turbo_frame: "_top" } do
|
||||
tag.span class: "overflow-ellipsis" do
|
||||
stage.name
|
||||
end
|
||||
end
|
||||
button_to \
|
||||
tag.span(stage.name, class: "overflow-ellipsis"),
|
||||
card_staging_path(card, stage_id: stage),
|
||||
method: :put,
|
||||
class: [ "btn justify-start workflow-stage txt-uppercase workflow-stage", { "workflow-stage--current": stage == card.stage } ],
|
||||
form_class: "flex align-center gap-half",
|
||||
data: { turbo_frame: "_top" }
|
||||
end
|
||||
|
||||
def stage_color(stage)
|
||||
stage.color.presence || Colorable::DEFAULT_COLOR
|
||||
stage.color.presence || Card::DEFAULT_COLOR
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { nextFrame } from "helpers/timing_helpers"
|
||||
|
||||
export default class extends Controller {
|
||||
static classes = [ "play" ]
|
||||
static values = { playOnLoad: { type: Boolean, default: false } }
|
||||
|
||||
connect() {
|
||||
if (this.playOnLoadValue) {
|
||||
this.play()
|
||||
}
|
||||
}
|
||||
|
||||
async play() {
|
||||
await nextFrame()
|
||||
this.element.classList.remove(this.playClass)
|
||||
this.#forceReflow()
|
||||
this.element.classList.add(this.playClass)
|
||||
}
|
||||
|
||||
#forceReflow() {
|
||||
this.element.offsetWidth
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "element" ]
|
||||
|
||||
connect() {
|
||||
this.observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) this.elementTarget.focus()
|
||||
})
|
||||
this.observer.observe(this.elementTarget)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.observer.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
const SIZES = [ "one", "two", "three", "four", "five" ]
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "card" ]
|
||||
|
||||
connect() {
|
||||
this.resize()
|
||||
}
|
||||
|
||||
resize() {
|
||||
const [ min, max ] = this.#getScoreRange()
|
||||
|
||||
this.cardTargets.forEach(card => {
|
||||
const score = this.#currentCardScore(card)
|
||||
const idx = Math.round((score - min) / (max - min) * (SIZES.length - 1))
|
||||
|
||||
card.style.setProperty("--card-size", `var(--card-size-${SIZES[idx]})`)
|
||||
})
|
||||
}
|
||||
|
||||
#getScoreRange() {
|
||||
var min = 0, max = 1;
|
||||
|
||||
this.cardTargets.forEach(card => {
|
||||
const score = this.#currentCardScore(card)
|
||||
|
||||
min = Math.min(min, score)
|
||||
max = Math.max(max, score)
|
||||
})
|
||||
|
||||
return [ min, max ]
|
||||
}
|
||||
|
||||
#currentCardScore(el) {
|
||||
const score = el.dataset.activityScore
|
||||
const scoreAt = el.dataset.activityScoreAt
|
||||
const daysAgo = (Date.now() / 1000 - scoreAt) / (60 * 60 * 24)
|
||||
|
||||
return score / (2**daysAgo)
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export default class extends Controller {
|
||||
#triggerChangeEvent(newValue) {
|
||||
if (this.inputTarget.tagName === "HOUSE-MD") {
|
||||
this.inputTarget.dispatchEvent(new CustomEvent('house-md:change', {
|
||||
cards: true,
|
||||
bubbles: true,
|
||||
detail: {
|
||||
previousContent: '',
|
||||
newContent: newValue
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Controller } from "@hotwired/stimulus"
|
||||
import { differenceInDays } from "helpers/date_helpers"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "time", "date", "datetime", "shortdate", "ago", "indays", "daysago" ]
|
||||
static targets = [ "time", "date", "datetime", "shortdate", "ago", "indays", "daysago", "agoorweekday" ]
|
||||
static values = { refreshInterval: Number }
|
||||
static classes = [ "local-time-value"]
|
||||
|
||||
#timer
|
||||
|
||||
@@ -13,7 +15,10 @@ export default class extends Controller {
|
||||
this.dateTimeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short", dateStyle: "short" })
|
||||
this.agoFormatter = new AgoFormatter()
|
||||
this.daysagoFormatter = new DaysAgoFormatter()
|
||||
this.datewithweekdayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric" })
|
||||
this.datewithweekdayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric" })
|
||||
this.indaysFormatter = new InDaysFormatter()
|
||||
this.agoorweekdayFormatter = new DaysAgoOrWeekdayFormatter()
|
||||
}
|
||||
|
||||
connect() {
|
||||
@@ -60,6 +65,10 @@ export default class extends Controller {
|
||||
this.#formatTime(this.daysagoFormatter, target)
|
||||
}
|
||||
|
||||
agoorweekdayTargetConnected(target) {
|
||||
this.#formatTime(this.agoorweekdayFormatter, target)
|
||||
}
|
||||
|
||||
#refreshRelativeTimes() {
|
||||
this.agoTargets.forEach(target => {
|
||||
this.#formatTime(this.agoFormatter, target)
|
||||
@@ -111,6 +120,18 @@ class DaysAgoFormatter {
|
||||
}
|
||||
}
|
||||
|
||||
class DaysAgoOrWeekdayFormatter {
|
||||
format(date) {
|
||||
const days = differenceInDays(date, new Date())
|
||||
|
||||
if (days <= 1) {
|
||||
return new DaysAgoFormatter().format(date)
|
||||
} else {
|
||||
return new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric" }).format(date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class InDaysFormatter {
|
||||
format(date) {
|
||||
const days = differenceInDays(new Date(), date)
|
||||
|
||||
@@ -12,7 +12,7 @@ export default class extends Controller {
|
||||
|
||||
for (const file of files) {
|
||||
const uploadEvent = new CustomEvent("house-md:before-upload", {
|
||||
cards: true,
|
||||
bubbles: true,
|
||||
detail: { file },
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { nextEventNamed } from "helpers/timing_helpers"
|
||||
import { isTouchDevice } from "helpers/navigator_helpers"
|
||||
|
||||
export default class extends Controller {
|
||||
// Only load for touch devices
|
||||
static get shouldLoad() {
|
||||
return isTouchDevice()
|
||||
return "ontouchstart" in window && navigator.maxTouchPoints > 0
|
||||
}
|
||||
|
||||
// Use a fake input to trigger the soft keyboard on actions that load async content
|
||||
|
||||
@@ -6,4 +6,12 @@ export default class extends Controller {
|
||||
toggle() {
|
||||
this.element.classList.toggle(this.toggleClass)
|
||||
}
|
||||
|
||||
add() {
|
||||
this.element.classList.add(this.toggleClass)
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.element.classList.remove(this.toggleClass)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function isTouchDevice() {
|
||||
return "ontouchstart" in window && navigator.maxTouchPoints > 0
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Turbo } from "@hotwired/turbo-rails"
|
||||
|
||||
Turbo.StreamActions.set_css_variable = function() {
|
||||
const name = this.getAttribute("name")
|
||||
const value = this.getAttribute("value")
|
||||
|
||||
this.targetElements.forEach(element => element.style.setProperty(name, value))
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
import "initializers/current"
|
||||
import "initializers/custom_stream_actions"
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Card::AutoCloseAllDueJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
ApplicationRecord.with_each_tenant do |tenant|
|
||||
Card.auto_close_all_due
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Card::AutoReconsiderAllStagnatedJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
ApplicationRecord.with_each_tenant do |tenant|
|
||||
Card.auto_reconsider_all_stagnated
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
class GenerateNotificationsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(event)
|
||||
event.generate_notifications
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class Mention::CreateJob < ApplicationJob
|
||||
def perform(record, mentioner:)
|
||||
record.create_mentions(mentioner:)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class NotifyRecipientsJob < ApplicationJob
|
||||
def perform(notifiable)
|
||||
notifiable.notify_recipients
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,4 @@
|
||||
class RemoveAbandonedCreationsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
ApplicationRecord.with_each_tenant do |tenant|
|
||||
Card.remove_abandoned_creations
|
||||
|
||||
+9
-52
@@ -1,39 +1,26 @@
|
||||
class Card < ApplicationRecord
|
||||
include Assignable, Colored, DraftCommenting, Engageable, Eventable, Golden,
|
||||
Messages, Notifiable, Pinnable, Closeable, Scorable, Searchable, Staged,
|
||||
include Assignable, Colored, Engageable, Eventable, Golden, Mentions,
|
||||
Messages, Pinnable, Closeable, Readable, Searchable, Staged,
|
||||
Statuses, Taggable, Watchable
|
||||
|
||||
belongs_to :collection, touch: true
|
||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||
|
||||
has_many :notifications, dependent: :destroy
|
||||
|
||||
has_one_attached :image, dependent: :purge_later
|
||||
|
||||
after_save :track_due_date_change, if: :saved_change_to_due_on?
|
||||
after_save :track_title_change, if: :saved_change_to_title?
|
||||
after_create :assign_initial_stage
|
||||
has_markdown :description
|
||||
|
||||
scope :reverse_chronologically, -> { order created_at: :desc, id: :desc }
|
||||
scope :chronologically, -> { order created_at: :asc, id: :asc }
|
||||
scope :latest, -> { order updated_at: :desc, id: :desc }
|
||||
scope :in_collection, ->(collection) { where collection: collection }
|
||||
|
||||
scope :indexed_by, ->(index) do
|
||||
case index
|
||||
when "most_active" then ordered_by_activity
|
||||
when "newest" then reverse_chronologically
|
||||
when "oldest" then chronologically
|
||||
when "latest" then latest
|
||||
when "stalled" then ordered_by_staleness
|
||||
when "closed" then closed
|
||||
end
|
||||
end
|
||||
|
||||
scope :by_engagement_status, ->(status) do
|
||||
case status.to_s
|
||||
when "considering" then considering
|
||||
when "doing" then doing.with_golden_first
|
||||
when "newest" then reverse_chronologically
|
||||
when "oldest" then chronologically
|
||||
when "latest" then latest
|
||||
when "stalled" then chronologically
|
||||
when "closed" then closed
|
||||
end
|
||||
end
|
||||
|
||||
@@ -42,36 +29,6 @@ class Card < ApplicationRecord
|
||||
end
|
||||
|
||||
def cache_key
|
||||
[ super, collection&.name ].compact.join("/")
|
||||
[ super, collection.name ].compact.join("/")
|
||||
end
|
||||
|
||||
private
|
||||
def track_due_date_change
|
||||
if due_on.present?
|
||||
if due_on_before_last_save.nil?
|
||||
track_event("due_date_added", particulars: { due_date: due_on })
|
||||
else
|
||||
track_event("due_date_changed", particulars: { due_date: due_on })
|
||||
end
|
||||
elsif due_on_before_last_save.present?
|
||||
track_event("due_date_removed")
|
||||
end
|
||||
end
|
||||
|
||||
def track_title_change
|
||||
if title_before_last_save.present?
|
||||
track_event("title_changed", particulars: {
|
||||
old_title: title_before_last_save,
|
||||
new_title: title
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
def assign_initial_stage
|
||||
if workflow_stage = collection.workflow&.stages&.first
|
||||
self.stage = workflow_stage
|
||||
save! touch: false
|
||||
track_event :staged, stage_id: workflow_stage.id, stage_name: workflow_stage.name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
module Card::Closeable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
AUTO_CLOSE_AFTER = 30.days
|
||||
|
||||
included do
|
||||
has_one :closure, dependent: :destroy
|
||||
|
||||
scope :closed, -> { joins(:closure) }
|
||||
scope :active, -> { where.missing(:closure) }
|
||||
scope :open, -> { where.missing(:closure) }
|
||||
|
||||
scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) }
|
||||
scope :due_to_be_closed, -> { considering.where(last_active_at: ..AUTO_CLOSE_AFTER.ago) }
|
||||
scope :in_auto_closing_collection, -> { joins(:collection).merge(Collection.auto_closing) }
|
||||
scope :due_to_be_closed, -> { considering.in_auto_closing_collection.where("last_active_at <= DATETIME('now', '-' || auto_close_period || ' seconds')") }
|
||||
|
||||
delegate :auto_closing?, :auto_close_period, to: :collection
|
||||
end
|
||||
|
||||
class_methods do
|
||||
@@ -22,14 +23,14 @@ module Card::Closeable
|
||||
end
|
||||
|
||||
def auto_close_at
|
||||
last_active_at + AUTO_CLOSE_AFTER if last_active_at
|
||||
last_active_at + auto_close_period if auto_closing? && last_active_at
|
||||
end
|
||||
|
||||
def closed?
|
||||
closure.present?
|
||||
end
|
||||
|
||||
def active?
|
||||
def open?
|
||||
!closed?
|
||||
end
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
module Card::Colored
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
COLORS = %w[ #3b3633 #67695e #eb7a32 #bf7c2b #c09c6f #746b1e #2c6da8 #5d618f #663251 #ff63a8 ]
|
||||
DEFAULT_COLOR = "#2c6da8"
|
||||
|
||||
def color
|
||||
color_from_stage || Colorable::DEFAULT_COLOR
|
||||
color_from_stage || DEFAULT_COLOR
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
module Card::DraftCommenting
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_save :capture_draft_comment
|
||||
end
|
||||
|
||||
def draft_comment
|
||||
find_or_build_initial_comment.body.content
|
||||
end
|
||||
|
||||
def draft_comment=(body)
|
||||
if body.present?
|
||||
@draft_comment = body
|
||||
else
|
||||
messages.comments.destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def find_or_build_initial_comment
|
||||
message = messages.comments.first || messages.new(messageable: Comment.new)
|
||||
message.comment
|
||||
end
|
||||
|
||||
def capture_draft_comment
|
||||
if @draft_comment.present?
|
||||
find_or_build_initial_comment.update! body: @draft_comment, creator: creator
|
||||
end
|
||||
|
||||
@draft_comment = nil
|
||||
end
|
||||
end
|
||||
@@ -6,9 +6,16 @@ module Card::Engageable
|
||||
included do
|
||||
has_one :engagement, dependent: :destroy, class_name: "Card::Engagement"
|
||||
|
||||
scope :considering, -> { published_or_drafted_by(Current.user).active.where.missing(:engagement) }
|
||||
scope :doing, -> { published.active.joins(:engagement) }
|
||||
scope :considering, -> { published_or_drafted_by(Current.user).open.where.missing(:engagement) }
|
||||
scope :doing, -> { published.open.joins(:engagement) }
|
||||
scope :stagnated, -> { doing.where(last_active_at: ..STAGNATED_AFTER.ago) }
|
||||
|
||||
scope :by_engagement_status, ->(status) do
|
||||
case status.to_s
|
||||
when "considering" then considering
|
||||
when "doing" then doing.with_golden_first
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
@@ -22,11 +29,11 @@ module Card::Engageable
|
||||
end
|
||||
|
||||
def doing?
|
||||
active? && published? && engagement.present?
|
||||
open? && published? && engagement.present?
|
||||
end
|
||||
|
||||
def considering?
|
||||
active? && published? && engagement.blank?
|
||||
open? && published? && engagement.blank?
|
||||
end
|
||||
|
||||
def engage
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
module Card::Eventable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include ::Eventable
|
||||
|
||||
included do
|
||||
has_many :events, dependent: :destroy
|
||||
before_create { self.last_active_at = Time.current }
|
||||
|
||||
after_save :track_due_date_change, if: :saved_change_to_due_on?
|
||||
after_save :track_title_change, if: :saved_change_to_title?
|
||||
end
|
||||
|
||||
def touch_last_active_at
|
||||
touch :last_active_at
|
||||
end
|
||||
|
||||
def track_event(action, creator: Current.user, **particulars)
|
||||
if published?
|
||||
event = find_or_capture_event_summary.events.create! action: action, creator: creator, card: self, particulars: particulars
|
||||
event.generate_notifications_later
|
||||
def event_was_created(event)
|
||||
transaction do
|
||||
find_or_capture_event_summary.events << event
|
||||
touch(:last_active_at)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def should_track_event?
|
||||
published?
|
||||
end
|
||||
|
||||
def find_or_capture_event_summary
|
||||
transaction do
|
||||
messages.last&.event_summary || capture(EventSummary.new).event_summary
|
||||
messages.last&.event_summary || capture(EventSummary.new).event_summary
|
||||
end
|
||||
|
||||
def track_due_date_change
|
||||
if due_on.present?
|
||||
if due_on_before_last_save.nil?
|
||||
track_event("card_due_date_added", particulars: { due_date: due_on })
|
||||
else
|
||||
track_event("card_due_date_changed", particulars: { due_date: due_on })
|
||||
end
|
||||
elsif due_on_before_last_save.present?
|
||||
track_event("card_due_date_removed")
|
||||
end
|
||||
end
|
||||
|
||||
def track_title_change
|
||||
if title_before_last_save.present?
|
||||
track_event "card_title_changed", particulars: { old_title: title_before_last_save, new_title: title }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,10 +2,9 @@ module Card::Golden
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :golden, -> { joins(:goldness) }
|
||||
|
||||
has_one :goldness, dependent: :destroy, class_name: "Card::Goldness"
|
||||
|
||||
scope :golden, -> { joins(:goldness) }
|
||||
scope :with_golden_first, -> { left_outer_joins(:goldness).prepend_order("card_goldnesses.id IS NULL") }
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
module Card::Readable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def read_by(user)
|
||||
notifications_for(user).tap do |notifications|
|
||||
notifications.each(&:read)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def notifications_for(user)
|
||||
user.notifications.unread.where(source: notification_sources)
|
||||
end
|
||||
|
||||
def notification_sources
|
||||
event_notification_sources + mention_notification_sources
|
||||
end
|
||||
|
||||
def event_notification_sources
|
||||
events + comment_creation_events
|
||||
end
|
||||
|
||||
def comment_creation_events
|
||||
Event.where(eventable: comments)
|
||||
end
|
||||
|
||||
def mention_notification_sources
|
||||
mentions + comment_mentions
|
||||
end
|
||||
|
||||
def comment_mentions
|
||||
Mention.where(source: comments)
|
||||
end
|
||||
|
||||
def comments
|
||||
@comments ||= Comment.where(id: messages.comments.pluck(:messageable_id))
|
||||
end
|
||||
end
|
||||
@@ -1,64 +0,0 @@
|
||||
module Card::Scorable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
REFERENCE_DATE = Time.utc(2025, 1, 1)
|
||||
|
||||
included do
|
||||
scope :ordered_by_activity, -> { order activity_score_order: :desc }
|
||||
|
||||
# Staleness is measured by the amount of activity_score lost since it was last updated.
|
||||
# The factor 0.9 is chosen to make the decay curve closer to linear; an exponential
|
||||
# curve would make recent items appear stale very quickly, due to the sharp dropoff.
|
||||
scope :ordered_by_staleness, -> { order Arel.sql(
|
||||
"coalesce(activity_score * (1 - power(0.9, julianday('now') - julianday(activity_score_at))), 0) desc"
|
||||
) }
|
||||
end
|
||||
|
||||
def rescore
|
||||
score = calculate_activity_score
|
||||
score_at = last_scorable_activity_at
|
||||
|
||||
update! \
|
||||
activity_score: score,
|
||||
activity_score_at: score_at,
|
||||
activity_score_order: event_score_reference(score, score_at)
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_activity_score
|
||||
scorable_events.sum { |event| event_score(event) }
|
||||
end
|
||||
|
||||
def event_score(event)
|
||||
days_ago = (last_scorable_activity_at - event.created_at) / 1.day
|
||||
event_weight(event) / (2**days_ago)
|
||||
end
|
||||
|
||||
def event_weight(event)
|
||||
case
|
||||
when event.comment&.first_by_author_on_card? then 20
|
||||
when event.comment&.follows_comment_by_another_author? then 15
|
||||
when event.commented? then 10
|
||||
else 0
|
||||
end
|
||||
end
|
||||
|
||||
def event_score_reference(score, activity_at)
|
||||
# The reference score is used to make the activity score comparable
|
||||
# across different cards, since it represents the card's activity
|
||||
# level at a consistent point in time.
|
||||
#
|
||||
# We store this as log2 to tame the huge/tiny numbers we'd otherwise get
|
||||
# when activity is far from the reference date.
|
||||
days_diff = (activity_at - REFERENCE_DATE) / 1.day
|
||||
Math.log2(1.0 + score) + days_diff
|
||||
end
|
||||
|
||||
def last_scorable_activity_at
|
||||
scorable_events.maximum(:created_at) || created_at
|
||||
end
|
||||
|
||||
def scorable_events
|
||||
events.where(action: :commented)
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,7 @@ module Card::Searchable
|
||||
included do
|
||||
include ::Searchable
|
||||
|
||||
searchable_by :title, using: :cards_search_index
|
||||
searchable_by :title_and_description, using: :cards_search_index, as: :title
|
||||
|
||||
scope :mentioning, ->(query) do
|
||||
if query = sanitize_query_syntax(query)
|
||||
@@ -18,25 +18,31 @@ module Card::Searchable
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def sanitize_query_syntax(terms)
|
||||
terms = terms.to_s
|
||||
terms = remove_invalid_search_characters(terms)
|
||||
terms = remove_unbalanced_quotes(terms)
|
||||
terms.presence
|
||||
class_methods do
|
||||
def sanitize_query_syntax(terms)
|
||||
terms = terms.to_s
|
||||
terms = remove_invalid_search_characters(terms)
|
||||
terms = remove_unbalanced_quotes(terms)
|
||||
terms.presence
|
||||
end
|
||||
|
||||
private
|
||||
def remove_invalid_search_characters(terms)
|
||||
terms.gsub(/[^\w"]/, " ")
|
||||
end
|
||||
|
||||
private
|
||||
def remove_invalid_search_characters(terms)
|
||||
terms.gsub(/[^\w"]/, " ")
|
||||
def remove_unbalanced_quotes(terms)
|
||||
if terms.count("\"").even?
|
||||
terms
|
||||
else
|
||||
terms.gsub("\"", " ")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def remove_unbalanced_quotes(terms)
|
||||
if terms.count("\"").even?
|
||||
terms
|
||||
else
|
||||
terms.gsub("\"", " ")
|
||||
end
|
||||
end
|
||||
private
|
||||
# TODO: Temporary until we stabilize the search API
|
||||
def title_and_description
|
||||
[ title, description.to_plain_text ].join(" ")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,8 @@ module Card::Staged
|
||||
included do
|
||||
belongs_to :stage, class_name: "Workflow::Stage", optional: true
|
||||
|
||||
before_create :assign_initial_stage
|
||||
|
||||
scope :in_stage, ->(stage) { where stage: stage }
|
||||
end
|
||||
|
||||
@@ -11,12 +13,15 @@ module Card::Staged
|
||||
stage&.workflow
|
||||
end
|
||||
|
||||
def toggle_stage(stage)
|
||||
new_stage, event = self.stage_id == stage.id ? [ nil, :unstaged ] : [ stage, :staged ]
|
||||
|
||||
def change_stage_to(new_stage)
|
||||
transaction do
|
||||
update! stage: new_stage
|
||||
track_event event, stage_id: stage.id, stage_name: stage.name
|
||||
track_event :staged, stage_id: new_stage.id, stage_name: new_stage.name
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def assign_initial_stage
|
||||
self.stage = collection.initial_workflow_stage
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
class Collection < ApplicationRecord
|
||||
include Accessible, Broadcastable, Filterable
|
||||
include AutoClosing, Accessible, Broadcastable, Filterable, Workflowing
|
||||
|
||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||
belongs_to :workflow, optional: true
|
||||
|
||||
has_many :cards, dependent: :destroy
|
||||
has_many :tags, -> { distinct }, through: :cards
|
||||
|
||||
validates_presence_of :name
|
||||
|
||||
after_save :update_cards_workflow, if: :saved_change_to_workflow_id?
|
||||
has_many :events
|
||||
|
||||
scope :alphabetically, -> { order("lower(name)") }
|
||||
|
||||
private
|
||||
def update_cards_workflow
|
||||
cards.update_all(stage_id: workflow&.stages&.first&.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
module Collection::AutoClosing
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :auto_closing, -> { where.not(auto_close_period: nil) }
|
||||
before_create :set_default_auto_close_period
|
||||
end
|
||||
|
||||
def auto_closing?
|
||||
auto_close_period.present?
|
||||
end
|
||||
|
||||
private
|
||||
DEFAULT_AUTO_CLOSE_PERIOD = 30.days
|
||||
|
||||
def set_default_auto_close_period
|
||||
self.auto_close_period ||= DEFAULT_AUTO_CLOSE_PERIOD unless attribute_present?(:auto_close_period)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
module Collection::Workflowing
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
belongs_to :workflow, optional: true
|
||||
|
||||
after_update_commit :set_all_cards_to_initial_workflow_stage, if: :saved_change_to_workflow_id?
|
||||
end
|
||||
|
||||
def initial_workflow_stage
|
||||
workflow&.stages&.first
|
||||
end
|
||||
|
||||
private
|
||||
def set_all_cards_to_initial_workflow_stage
|
||||
cards.update_all(stage_id: initial_workflow_stage&.id)
|
||||
end
|
||||
end
|
||||
@@ -1,23 +0,0 @@
|
||||
module Colorable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# COLORS = %w[ #3b3633 #67695e #eb7a32 #bf7c2b #c09c6f #746b1e #2c6da8 #5d618f #663251 #ff63a8 ]
|
||||
# DEFAULT_COLOR = "#2c6da8"
|
||||
|
||||
COLORS = [
|
||||
"var(--color-card-1)",
|
||||
"var(--color-card-2)",
|
||||
"var(--color-card-3)",
|
||||
"var(--color-card-4)",
|
||||
"var(--color-card-5)",
|
||||
"var(--color-card-6)",
|
||||
"var(--color-card-7)",
|
||||
"var(--color-card-8)",
|
||||
"var(--color-card-9)",
|
||||
]
|
||||
DEFAULT_COLOR = "var(--color-card-6)"
|
||||
|
||||
included do
|
||||
attribute :color, default: DEFAULT_COLOR
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class Comment < ApplicationRecord
|
||||
include Messageable, Searchable
|
||||
include Eventable, Mentions, Messageable, Searchable
|
||||
|
||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||
has_many :reactions, dependent: :delete_all
|
||||
@@ -10,9 +10,11 @@ class Comment < ApplicationRecord
|
||||
# FIXME: Not a fan of this. Think all references to comment should come directly from the message.
|
||||
scope :belonging_to_card, ->(card) { joins(:message).where(messages: { card_id: card.id }) }
|
||||
|
||||
after_create_commit :watch_card_by_creator, :track_commented_card
|
||||
after_create_commit :watch_card_by_creator
|
||||
after_destroy_commit :cleanup_events
|
||||
|
||||
delegate :watch_by, to: :card
|
||||
|
||||
def to_partial_path
|
||||
"cards/#{super}"
|
||||
end
|
||||
@@ -22,10 +24,6 @@ class Comment < ApplicationRecord
|
||||
card.watch_by creator
|
||||
end
|
||||
|
||||
def track_commented_card
|
||||
card.track_event :commented, comment_id: id
|
||||
end
|
||||
|
||||
# FIXME: This isn't right. We need to introduce an eventable polymorphic association for this.
|
||||
def cleanup_events
|
||||
# Delete events that reference directly in particulars
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
module Comment::Eventable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include ::Eventable
|
||||
|
||||
included do
|
||||
after_create_commit :track_creation
|
||||
end
|
||||
|
||||
def event_was_created(event)
|
||||
card.touch(:last_active_at)
|
||||
end
|
||||
|
||||
private
|
||||
def track_creation
|
||||
track_event("created", collection: card.collection)
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,9 @@ module Filterable
|
||||
end
|
||||
|
||||
private
|
||||
# FIXME: This is too inefficient to have part of a destroy transaction.
|
||||
# Need to find a way to use a job or a single query.
|
||||
def remove_from_filters
|
||||
filters.each { |filter| filter.resource_removed self }
|
||||
filters.each { it.resource_removed self }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
module Mentions
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :mentions, as: :source, dependent: :destroy
|
||||
has_many :mentionees, through: :mentions
|
||||
after_save_commit :create_mentions_later, if: :mentionable_content_changed?
|
||||
end
|
||||
|
||||
def create_mentions(mentioner: Current.user)
|
||||
scan_mentionees.each do |mentionee|
|
||||
mentionee.mentioned_by mentioner, at: self
|
||||
end
|
||||
end
|
||||
|
||||
def mentionable_content
|
||||
markdown_associations.collect { send(it.name)&.to_plain_text }.compact.join(" ")
|
||||
end
|
||||
|
||||
private
|
||||
def scan_mentionees
|
||||
scan_mentioned_handles.filter_map do |mention|
|
||||
mentionable_users.find { |user| user.mentionable_handles.include?(mention) }
|
||||
end
|
||||
end
|
||||
|
||||
def scan_mentioned_handles
|
||||
mentionable_content.scan(/(?<!\w)@(\w+)/).flatten.uniq(&:downcase)
|
||||
end
|
||||
|
||||
def mentionable_users
|
||||
collection.users
|
||||
end
|
||||
|
||||
def markdown_associations
|
||||
self.class.reflect_on_all_associations(:has_one).filter { it.klass == ActionText::Markdown }
|
||||
end
|
||||
|
||||
def mentionable_content_changed?
|
||||
markdown_associations.any? { send(it.name).content_previously_changed? }
|
||||
end
|
||||
|
||||
def create_mentions_later
|
||||
Mention::CreateJob.perform_later(self, mentioner: Current.user)
|
||||
end
|
||||
end
|
||||
@@ -6,5 +6,7 @@ module Messageable
|
||||
included do
|
||||
has_one :message, as: :messageable, touch: true, dependent: :destroy
|
||||
has_one :card, through: :message
|
||||
|
||||
delegate :collection, to: :card
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,21 @@ module Notifiable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :notifications, as: :resource, dependent: :destroy
|
||||
has_many :notifications, as: :source, dependent: :destroy
|
||||
|
||||
after_create_commit :notify_recipients_later
|
||||
end
|
||||
|
||||
def notify_recipients
|
||||
Notifier.for(self)&.notify
|
||||
end
|
||||
|
||||
def notifiable_target
|
||||
self
|
||||
end
|
||||
|
||||
private
|
||||
def notify_recipients_later
|
||||
NotifyRecipientsJob.perform_later self
|
||||
end
|
||||
end
|
||||
|
||||
+9
-15
@@ -1,26 +1,20 @@
|
||||
class Event < ApplicationRecord
|
||||
include Particulars
|
||||
include Notifiable, Particulars
|
||||
|
||||
belongs_to :collection
|
||||
belongs_to :creator, class_name: "User"
|
||||
belongs_to :summary, touch: true, class_name: "EventSummary"
|
||||
belongs_to :card
|
||||
|
||||
has_one :message, through: :summary
|
||||
has_one :comment, through: :message, source: :messageable, source_type: "Comment"
|
||||
belongs_to :eventable, polymorphic: true
|
||||
belongs_to :summary, touch: true, class_name: "EventSummary", optional: true
|
||||
|
||||
scope :chronologically, -> { order created_at: :asc, id: :desc }
|
||||
|
||||
after_create -> { card.touch_last_active_at }
|
||||
after_create -> { eventable.event_was_created(self) }
|
||||
|
||||
def commented?
|
||||
action == "commented"
|
||||
def action
|
||||
super.inquiry
|
||||
end
|
||||
|
||||
def generate_notifications
|
||||
Notifier.for(self)&.generate
|
||||
end
|
||||
|
||||
def generate_notifications_later
|
||||
GenerateNotificationsJob.perform_later self
|
||||
def notifiable_target
|
||||
eventable
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,14 +2,10 @@ module Event::Particulars
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
store_accessor :particulars, :assignee_ids, :comment_id, :stage_id, :stage_name
|
||||
store_accessor :particulars, :assignee_ids, :stage_id, :stage_name
|
||||
end
|
||||
|
||||
def assignees
|
||||
@assignees ||= User.where id: assignee_ids
|
||||
end
|
||||
|
||||
def comment
|
||||
@comment ||= Comment.find_by id: comment_id
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,25 +13,23 @@ class EventSummary < ApplicationRecord
|
||||
|
||||
def summarize(event)
|
||||
case event.action
|
||||
when "published"
|
||||
"Added by #{event.creator.name}."
|
||||
when "assigned"
|
||||
when "card_assigned"
|
||||
"Assigned to #{event.assignees.pluck(:name).to_sentence}."
|
||||
when "unassigned"
|
||||
when "card_unassigned"
|
||||
"Unassigned from #{event.assignees.pluck(:name).to_sentence}."
|
||||
when "staged"
|
||||
when "card_staged"
|
||||
"#{event.creator.name} moved this to '#{event.stage_name}'."
|
||||
when "closed"
|
||||
when "card_closed"
|
||||
"Closed by #{ event.creator.name }"
|
||||
when "unstaged"
|
||||
when "card_unstaged"
|
||||
"#{event.creator.name} removed this from '#{event.stage_name}'."
|
||||
when "due_date_added"
|
||||
when "card_due_date_added"
|
||||
"#{event.creator.name} set due date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')}."
|
||||
when "due_date_changed"
|
||||
when "card_due_date_changed"
|
||||
"#{event.creator.name} changed due date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')}."
|
||||
when "due_date_removed"
|
||||
when "card_due_date_removed"
|
||||
"#{event.creator.name} removed the date."
|
||||
when "title_changed"
|
||||
when "card_title_changed"
|
||||
"#{event.creator.name} changed title from '#{event.particulars.dig('particulars', 'old_title')}' to '#{event.particulars.dig('particulars', 'new_title')}'."
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
module Eventable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :events, as: :eventable, dependent: :destroy
|
||||
end
|
||||
|
||||
def track_event(action, creator: Current.user, collection: self.collection, **particulars)
|
||||
if should_track_event?
|
||||
collection.events.create!(action: "#{eventable_prefix}_#{action}", creator:, collection:, eventable: self, particulars:)
|
||||
end
|
||||
end
|
||||
|
||||
def event_was_created(event)
|
||||
end
|
||||
|
||||
private
|
||||
def should_track_event?
|
||||
true
|
||||
end
|
||||
|
||||
def eventable_prefix
|
||||
self.class.name.demodulize.underscore
|
||||
end
|
||||
end
|
||||
@@ -19,12 +19,12 @@ class Filter < ApplicationRecord
|
||||
def cards
|
||||
@cards ||= begin
|
||||
result = creator.accessible_cards.indexed_by(indexed_by)
|
||||
result = result.active unless indexed_by.closed?
|
||||
result = result.open unless indexed_by.closed?
|
||||
result = result.by_engagement_status(engagement_status) if engagement_status.present?
|
||||
result = result.unassigned if assignment_status.unassigned?
|
||||
result = result.assigned_to(assignees.ids) if assignees.present?
|
||||
result = result.where(creator_id: creators.ids) if creators.present?
|
||||
result = result.in_collection(collections.ids) if collections.present?
|
||||
result = result.where(collection: collections.ids) if collections.present?
|
||||
result = result.in_stage(stages.ids) if stages.present?
|
||||
result = result.tagged_with(tags.ids) if tags.present?
|
||||
result = terms.reduce(result) do |result, term|
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class Mention < ApplicationRecord
|
||||
include Notifiable
|
||||
|
||||
belongs_to :source, polymorphic: true
|
||||
belongs_to :mentioner, class_name: "User"
|
||||
belongs_to :mentionee, class_name: "User", inverse_of: :mentions
|
||||
|
||||
after_create_commit :watch_source_by_mentionee
|
||||
|
||||
def self_mention?
|
||||
mentioner == mentionee
|
||||
end
|
||||
|
||||
def notifiable_target
|
||||
source
|
||||
end
|
||||
|
||||
private
|
||||
def watch_source_by_mentionee
|
||||
source.watch_by(mentionee)
|
||||
end
|
||||
end
|
||||
@@ -1,16 +1,16 @@
|
||||
class Notification < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :event
|
||||
belongs_to :card
|
||||
belongs_to :resource, polymorphic: true
|
||||
belongs_to :creator, class_name: "User"
|
||||
belongs_to :source, polymorphic: true
|
||||
|
||||
scope :unread, -> { where(read_at: nil) }
|
||||
scope :read, -> { where.not(read_at: nil) }
|
||||
scope :ordered, -> { order(read_at: :desc, created_at: :desc) }
|
||||
|
||||
delegate :creator, to: :event
|
||||
after_create_commit :broadcast_unread
|
||||
|
||||
delegate :notifiable_target, to: :source
|
||||
|
||||
def self.read_all
|
||||
update!(read_at: Time.current)
|
||||
end
|
||||
|
||||
+13
-22
@@ -1,40 +1,31 @@
|
||||
class Notifier
|
||||
attr_reader :event
|
||||
|
||||
delegate :creator, to: :event
|
||||
attr_reader :source
|
||||
|
||||
class << self
|
||||
def for(event)
|
||||
"Notifier::#{event.action.classify}".safe_constantize&.new(event)
|
||||
def for(source)
|
||||
case source
|
||||
when Event
|
||||
"Notifier::#{source.eventable.class}EventNotifier".safe_constantize&.new(source)
|
||||
when Mention
|
||||
MentionNotifier.new(source)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate
|
||||
def notify
|
||||
if should_notify?
|
||||
recipients.map do |recipient|
|
||||
Notification.create! user: recipient, event: event, card: card, resource: resource
|
||||
Notification.create! user: recipient, source: source, creator: creator
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def initialize(event)
|
||||
@event = event
|
||||
def initialize(source)
|
||||
@source = source
|
||||
end
|
||||
|
||||
def should_notify?
|
||||
!event.creator.system?
|
||||
end
|
||||
|
||||
def recipients
|
||||
card.watchers_and_subscribers.without(creator)
|
||||
end
|
||||
|
||||
def resource
|
||||
card
|
||||
end
|
||||
|
||||
def card
|
||||
event.summary.message.card
|
||||
!creator.system?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
class Notifier::Assigned < Notifier
|
||||
private
|
||||
def recipients
|
||||
event.assignees.excluding(card.collection.access_only_users)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
class Notifier::CardEventNotifier < Notifier
|
||||
delegate :creator, to: :source
|
||||
delegate :watchers_and_subscribers, to: :card
|
||||
|
||||
private
|
||||
def recipients
|
||||
case source.action
|
||||
when "card_assigned"
|
||||
source.assignees.excluding(source.collection.access_only_users)
|
||||
when "card_published"
|
||||
watchers_and_subscribers(include_only_watching: true).without(creator, *card.mentionees)
|
||||
when "comment_created"
|
||||
watchers_and_subscribers.without(creator, *source.eventable.mentionees)
|
||||
else
|
||||
watchers_and_subscribers.without(creator)
|
||||
end
|
||||
end
|
||||
|
||||
def card
|
||||
source.eventable
|
||||
end
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
class Notifier::Closed < Notifier
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
class Notifier::CommentEventNotifier < Notifier
|
||||
delegate :creator, to: :source
|
||||
|
||||
private
|
||||
def recipients
|
||||
card.watchers_and_subscribers.without(creator, *source.eventable.mentionees)
|
||||
end
|
||||
|
||||
def card
|
||||
source.eventable.card
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user