diff --git a/.mise.toml b/.mise.toml
new file mode 100644
index 000000000..78434a1e0
--- /dev/null
+++ b/.mise.toml
@@ -0,0 +1,2 @@
+[env]
+ARTENANT = "development-tenant"
diff --git a/Gemfile.lock b/Gemfile.lock
index 8fbd0f756..44c91583d 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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)
diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css
index 5497c7aa5..b00fa22ab 100644
--- a/app/assets/stylesheets/_global.css
+++ b/app/assets/stylesheets/_global.css
@@ -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;
diff --git a/app/assets/stylesheets/avatars.css b/app/assets/stylesheets/avatars.css
index a55112b8c..d30bb984f 100644
--- a/app/assets/stylesheets/avatars.css
+++ b/app/assets/stylesheets/avatars.css
@@ -10,7 +10,8 @@
margin: 0;
place-items: center;
- img {
+ img,
+ .icon {
aspect-ratio: 1;
block-size: auto;
border-radius: var(--avatar-border-radius);
diff --git a/app/assets/stylesheets/base.css b/app/assets/stylesheets/base.css
index 6bebd44d5..faaba06c9 100644
--- a/app/assets/stylesheets/base.css
+++ b/app/assets/stylesheets/base.css
@@ -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%;
}
diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css
index ad64cda2d..4311f1007 100644
--- a/app/assets/stylesheets/card-columns.css
+++ b/app/assets/stylesheets/card-columns.css
@@ -92,6 +92,7 @@
border-color: var(--card-color) !important;
border-style: dashed !important;
+ box-shadow: none !important;
}
.card__background {
diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css
index 77be4096d..cd4973c7f 100644
--- a/app/assets/stylesheets/card-perma.css
+++ b/app/assets/stylesheets/card-perma.css
@@ -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;
- }
- }
}
diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css
index f61b37ed0..fb8465e31 100644
--- a/app/assets/stylesheets/cards.css
+++ b/app/assets/stylesheets/cards.css
@@ -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),
diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css
index a22069fc9..9fc3f9ac2 100644
--- a/app/assets/stylesheets/comments.css
+++ b/app/assets/stylesheets/comments.css
@@ -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 {
diff --git a/app/assets/stylesheets/events.css b/app/assets/stylesheets/events.css
index dd2e8dc80..849540e5b 100644
--- a/app/assets/stylesheets/events.css
+++ b/app/assets/stylesheets/events.css
@@ -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%;
diff --git a/app/assets/stylesheets/filters.css b/app/assets/stylesheets/filters.css
index 612a1b4a1..139a3a8ce 100644
--- a/app/assets/stylesheets/filters.css
+++ b/app/assets/stylesheets/filters.css
@@ -107,3 +107,11 @@
}
}
}
+
+.filter--active li {
+ display: none;
+
+ &.selected {
+ display: flex;
+ }
+}
diff --git a/app/assets/stylesheets/house.css b/app/assets/stylesheets/house.css
index 810fc7b17..4ab37ba0e 100644
--- a/app/assets/stylesheets/house.css
+++ b/app/assets/stylesheets/house.css
@@ -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;
diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css
index 341d9a059..e8d396a59 100644
--- a/app/assets/stylesheets/inputs.css
+++ b/app/assets/stylesheets/inputs.css
@@ -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 {
diff --git a/app/assets/stylesheets/comment-content.css b/app/assets/stylesheets/markdown-content.css
similarity index 98%
rename from app/assets/stylesheets/comment-content.css
rename to app/assets/stylesheets/markdown-content.css
index a11b8dbf8..7a67ec288 100644
--- a/app/assets/stylesheets/comment-content.css
+++ b/app/assets/stylesheets/markdown-content.css
@@ -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;
diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css
index a1bb0f911..43dd95d5e 100644
--- a/app/assets/stylesheets/notifications.css
+++ b/app/assets/stylesheets/notifications.css
@@ -94,3 +94,17 @@
}
}
}
+
+.notifications__empty-message {
+ .notificiations-list:has(.notification) & {
+ display: none;
+ }
+}
+
+.notificiations-list--read {
+ display: none;
+
+ &:has(.notification) {
+ display: flex;
+ }
+}
diff --git a/app/assets/stylesheets/panels.css b/app/assets/stylesheets/panels.css
index 6ce5e10b6..19609aced 100644
--- a/app/assets/stylesheets/panels.css
+++ b/app/assets/stylesheets/panels.css
@@ -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;
+ }
+ }
}
diff --git a/app/assets/stylesheets/pins.css b/app/assets/stylesheets/pins.css
index b8e44d7e2..8756e2391 100644
--- a/app/assets/stylesheets/pins.css
+++ b/app/assets/stylesheets/pins.css
@@ -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;
}
diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css
index a9bccdf4d..3013ac534 100644
--- a/app/assets/stylesheets/popup.css
+++ b/app/assets/stylesheets/popup.css
@@ -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;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/tags.css b/app/assets/stylesheets/tags.css
index 2b667e771..90d51e011 100644
--- a/app/assets/stylesheets/tags.css
+++ b/app/assets/stylesheets/tags.css
@@ -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;
diff --git a/app/assets/stylesheets/users.css b/app/assets/stylesheets/users.css
new file mode 100644
index 000000000..e3e427779
--- /dev/null
+++ b/app/assets/stylesheets/users.css
@@ -0,0 +1,4 @@
+.users-list {
+ max-block-size: 40dvh;
+ overflow: auto;
+}
diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css
index 94a2badd7..a07d98422 100644
--- a/app/assets/stylesheets/utilities.css
+++ b/app/assets/stylesheets/utilities.css
@@ -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 */
diff --git a/app/assets/stylesheets/workflows.css b/app/assets/stylesheets/workflows.css
index ce36985de..f69b7ff7c 100644
--- a/app/assets/stylesheets/workflows.css
+++ b/app/assets/stylesheets/workflows.css
@@ -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;
+ }
}
diff --git a/app/controllers/cards/assignments_controller.rb b/app/controllers/cards/assignments_controller.rb
index 63dce613c..0dd3d460e 100644
--- a/app/controllers/cards/assignments_controller.rb
+++ b/app/controllers/cards/assignments_controller.rb
@@ -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
diff --git a/app/controllers/cards/closures_controller.rb b/app/controllers/cards/closures_controller.rb
index 3682552ee..29dcfd30c 100644
--- a/app/controllers/cards/closures_controller.rb
+++ b/app/controllers/cards/closures_controller.rb
@@ -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
diff --git a/app/controllers/cards/engagements_controller.rb b/app/controllers/cards/engagements_controller.rb
index 72025d16a..50a6daa83 100644
--- a/app/controllers/cards/engagements_controller.rb
+++ b/app/controllers/cards/engagements_controller.rb
@@ -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
diff --git a/app/controllers/cards/goldnesses_controller.rb b/app/controllers/cards/goldnesses_controller.rb
index 4be611d14..1a0912a24 100644
--- a/app/controllers/cards/goldnesses_controller.rb
+++ b/app/controllers/cards/goldnesses_controller.rb
@@ -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
diff --git a/app/controllers/cards/images_controller.rb b/app/controllers/cards/images_controller.rb
index fad419c52..f5a3f02b6 100644
--- a/app/controllers/cards/images_controller.rb
+++ b/app/controllers/cards/images_controller.rb
@@ -3,6 +3,6 @@ class Cards::ImagesController < ApplicationController
def destroy
@card.image.purge_later
- redirect_to @card
+ render_card_replacement
end
end
diff --git a/app/controllers/cards/pins_controller.rb b/app/controllers/cards/pins_controller.rb
index 76c5aa723..6ac118543 100644
--- a/app/controllers/cards/pins_controller.rb
+++ b/app/controllers/cards/pins_controller.rb
@@ -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
diff --git a/app/controllers/cards/readings_controller.rb b/app/controllers/cards/readings_controller.rb
index bbc4d53c0..2334cb8f5 100644
--- a/app/controllers/cards/readings_controller.rb
+++ b/app/controllers/cards/readings_controller.rb
@@ -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
diff --git a/app/controllers/cards/stagings_controller.rb b/app/controllers/cards/stagings_controller.rb
index 071d19f5a..714427ecf 100644
--- a/app/controllers/cards/stagings_controller.rb
+++ b/app/controllers/cards/stagings_controller.rb
@@ -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
diff --git a/app/controllers/cards/taggings_controller.rb b/app/controllers/cards/taggings_controller.rb
index 710b59fdd..a8fe7ecf9 100644
--- a/app/controllers/cards/taggings_controller.rb
+++ b/app/controllers/cards/taggings_controller.rb
@@ -7,6 +7,7 @@ class Cards::TaggingsController < ApplicationController
def create
@card.toggle_tag_with sanitized_tag_title_param
+ render_card_replacement
end
private
diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb
index e62146d4e..d535f26d9 100644
--- a/app/controllers/cards_controller.rb
+++ b/app/controllers/cards_controller.rb
@@ -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
diff --git a/app/controllers/collections/workflows_controller.rb b/app/controllers/collections/workflows_controller.rb
index ae50b90f9..28b691f11 100644
--- a/app/controllers/collections/workflows_controller.rb
+++ b/app/controllers/collections/workflows_controller.rb
@@ -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
diff --git a/app/controllers/collections_controller.rb b/app/controllers/collections_controller.rb
index 36698afdf..f5eb8c9fc 100644
--- a/app/controllers/collections_controller.rb
+++ b/app/controllers/collections_controller.rb
@@ -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
diff --git a/app/controllers/concerns/card_scoped.rb b/app/controllers/concerns/card_scoped.rb
index ef40d56e3..5438871c1 100644
--- a/app/controllers/concerns/card_scoped.rb
+++ b/app/controllers/concerns/card_scoped.rb
@@ -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
diff --git a/app/controllers/concerns/collection_filterable.rb b/app/controllers/concerns/collection_filterable.rb
deleted file mode 100644
index 4bd5ff9f1..000000000
--- a/app/controllers/concerns/collection_filterable.rb
+++ /dev/null
@@ -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
diff --git a/app/controllers/concerns/day_timelines_scoped.rb b/app/controllers/concerns/day_timelines_scoped.rb
new file mode 100644
index 000000000..d7bf80a5a
--- /dev/null
+++ b/app/controllers/concerns/day_timelines_scoped.rb
@@ -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
diff --git a/app/controllers/concerns/events_timeline.rb b/app/controllers/concerns/events_timeline.rb
deleted file mode 100644
index 0d50d2093..000000000
--- a/app/controllers/concerns/events_timeline.rb
+++ /dev/null
@@ -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
diff --git a/app/controllers/events/days_controller.rb b/app/controllers/events/days_controller.rb
index c2966357d..31ec80d39 100644
--- a/app/controllers/events/days_controller.rb
+++ b/app/controllers/events/days_controller.rb
@@ -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
diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb
index a3c8a9157..1340def1d 100644
--- a/app/controllers/events_controller.rb
+++ b/app/controllers/events_controller.rb
@@ -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
diff --git a/app/controllers/notifications/trays_controller.rb b/app/controllers/notifications/trays_controller.rb
index 4a01a1ee9..7c7a52bb3 100644
--- a/app/controllers/notifications/trays_controller.rb
+++ b/app/controllers/notifications/trays_controller.rb
@@ -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
diff --git a/app/controllers/terminals_controller.rb b/app/controllers/terminals_controller.rb
index 728ff9983..552d285cd 100644
--- a/app/controllers/terminals_controller.rb
+++ b/app/controllers/terminals_controller.rb
@@ -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
diff --git a/app/controllers/workflows_controller.rb b/app/controllers/workflows_controller.rb
index 49abcd26f..f8b54254d 100644
--- a/app/controllers/workflows_controller.rb
+++ b/app/controllers/workflows_controller.rb
@@ -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
diff --git a/app/helpers/accesses_helper.rb b/app/helpers/accesses_helper.rb
index a139ac3d4..8715b8165 100644
--- a/app/helpers/accesses_helper.rb
+++ b/app/helpers/accesses_helper.rb
@@ -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" }, &
diff --git a/app/helpers/cards_helper.rb b/app/helpers/cards_helper.rb
index e13defa96..79338d622 100644
--- a/app/helpers/cards_helper.rb
+++ b/app/helpers/cards_helper.rb
@@ -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
diff --git a/app/helpers/collections_helper.rb b/app/helpers/collections_helper.rb
new file mode 100644
index 000000000..5f872079c
--- /dev/null
+++ b/app/helpers/collections_helper.rb
@@ -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
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index 53732b327..b00408cc3 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -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 #{ event.eventable.card.title }".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 #{ event.card.title }".html_safe
+ "#{ event_creator_name(event) } will handle #{ title }".html_safe
else
- "#{ event.creator == Current.user ? "You" : event.creator.name } assigned #{ event.assignees.pluck(:name).to_sentence } to #{ event.card.title }".html_safe
+ "#{ event_creator_name(event) } assigned #{ event.assignees.pluck(:name).to_sentence } to #{ title }".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 #{ event.card.title }".html_safe
- when "commented"
- "#{ event.creator == Current.user ? "You" : event.creator.name } commented on #{ event.card.title }".html_safe
- when "published"
- "#{ event.creator == Current.user ? "You" : event.creator.name } added #{ event.card.title }".html_safe
- when "closed"
- "#{ event.creator == Current.user ? "You" : event.creator.name } closed #{ event.card.title }".html_safe
- when "staged"
- "#{event.creator == Current.user ? "You" : event.creator.name} moved #{ event.card.title } to the #{event.stage_name} stage".html_safe
- when "unstaged"
- "#{event.creator == Current.user ? "You" : event.creator.name} moved #{ event.card.title } 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 #{ event.card.title }".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 #{ event.card.title }".html_safe
- when "due_date_removed"
- "#{event.creator == Current.user ? "You" : event.creator.name} removed the date on #{ event.card.title }"
- when "title_changed"
- "#{event.creator == Current.user ? "You" : event.creator.name} renamed #{ event.card.title } (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 #{ title }".html_safe
+ when "card_published"
+ "#{ event_creator_name(event) } added #{ title }".html_safe
+ when "card_closed"
+ "#{ event_creator_name(event) } closed #{ title }".html_safe
+ when "card_staged"
+ "#{event_creator_name(event)} moved #{ title } to the #{event.stage_name} stage".html_safe
+ when "card_unstaged"
+ "#{event_creator_name(event)} moved #{ title } 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 #{ title }".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 #{ title }".html_safe
+ when "card_due_date_removed"
+ "#{event_creator_name(event)} removed the date on #{ title }"
+ when "card_title_changed"
+ "#{event_creator_name(event)} renamed #{ title } (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"
diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb
index c2c1d0dc5..aae65bf6b 100644
--- a/app/helpers/filters_helper.rb
+++ b/app/helpers/filters_helper.rb
@@ -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 { "#{it.name}" }.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
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index 656edad54..3c36cc51a 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -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
diff --git a/app/helpers/workflows_helper.rb b/app/helpers/workflows_helper.rb
index b78482c9f..560e1aebf 100644
--- a/app/helpers/workflows_helper.rb
+++ b/app/helpers/workflows_helper.rb
@@ -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
diff --git a/app/javascript/controllers/animation_controller.js b/app/javascript/controllers/animation_controller.js
deleted file mode 100644
index f63d36e97..000000000
--- a/app/javascript/controllers/animation_controller.js
+++ /dev/null
@@ -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
- }
-}
diff --git a/app/javascript/controllers/autofocus_controller.js b/app/javascript/controllers/autofocus_controller.js
deleted file mode 100644
index 75e5c4de9..000000000
--- a/app/javascript/controllers/autofocus_controller.js
+++ /dev/null
@@ -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()
- }
-}
diff --git a/app/javascript/controllers/card_size_controller.js b/app/javascript/controllers/card_size_controller.js
deleted file mode 100644
index e688358da..000000000
--- a/app/javascript/controllers/card_size_controller.js
+++ /dev/null
@@ -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)
- }
-}
diff --git a/app/javascript/controllers/local_save_controller.js b/app/javascript/controllers/local_save_controller.js
index c0c5892aa..5d9b89a72 100644
--- a/app/javascript/controllers/local_save_controller.js
+++ b/app/javascript/controllers/local_save_controller.js
@@ -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
diff --git a/app/javascript/controllers/local_time_controller.js b/app/javascript/controllers/local_time_controller.js
index 3bc77b769..7fe903c85 100644
--- a/app/javascript/controllers/local_time_controller.js
+++ b/app/javascript/controllers/local_time_controller.js
@@ -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)
diff --git a/app/javascript/controllers/paste_controller.js b/app/javascript/controllers/paste_controller.js
index 41fc77d2c..79db7d7db 100644
--- a/app/javascript/controllers/paste_controller.js
+++ b/app/javascript/controllers/paste_controller.js
@@ -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
})
diff --git a/app/javascript/controllers/soft_keyboard_controller.js b/app/javascript/controllers/soft_keyboard_controller.js
index becc36c52..75fb4f2ee 100644
--- a/app/javascript/controllers/soft_keyboard_controller.js
+++ b/app/javascript/controllers/soft_keyboard_controller.js
@@ -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
diff --git a/app/javascript/controllers/toggle_class_controller.js b/app/javascript/controllers/toggle_class_controller.js
index 694106ea2..ca29ad771 100644
--- a/app/javascript/controllers/toggle_class_controller.js
+++ b/app/javascript/controllers/toggle_class_controller.js
@@ -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)
+ }
}
diff --git a/app/javascript/helpers/navigator_helpers.js b/app/javascript/helpers/navigator_helpers.js
deleted file mode 100644
index b75f62875..000000000
--- a/app/javascript/helpers/navigator_helpers.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export function isTouchDevice() {
- return "ontouchstart" in window && navigator.maxTouchPoints > 0
-}
diff --git a/app/javascript/initializers/custom_stream_actions.js b/app/javascript/initializers/custom_stream_actions.js
deleted file mode 100644
index fecb25ff5..000000000
--- a/app/javascript/initializers/custom_stream_actions.js
+++ /dev/null
@@ -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))
-}
diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js
index 01fdd9220..10fb36975 100644
--- a/app/javascript/initializers/index.js
+++ b/app/javascript/initializers/index.js
@@ -1,2 +1 @@
import "initializers/current"
-import "initializers/custom_stream_actions"
diff --git a/app/jobs/card/auto_close_all_due_job.rb b/app/jobs/card/auto_close_all_due_job.rb
index 6cb4f646b..d964dae23 100644
--- a/app/jobs/card/auto_close_all_due_job.rb
+++ b/app/jobs/card/auto_close_all_due_job.rb
@@ -1,6 +1,4 @@
class Card::AutoCloseAllDueJob < ApplicationJob
- queue_as :default
-
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.auto_close_all_due
diff --git a/app/jobs/card/auto_reconsider_all_stagnated_job.rb b/app/jobs/card/auto_reconsider_all_stagnated_job.rb
index 83f87a648..b7178f916 100644
--- a/app/jobs/card/auto_reconsider_all_stagnated_job.rb
+++ b/app/jobs/card/auto_reconsider_all_stagnated_job.rb
@@ -1,6 +1,4 @@
class Card::AutoReconsiderAllStagnatedJob < ApplicationJob
- queue_as :default
-
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.auto_reconsider_all_stagnated
diff --git a/app/jobs/generate_notifications_job.rb b/app/jobs/generate_notifications_job.rb
deleted file mode 100644
index e386b7b37..000000000
--- a/app/jobs/generate_notifications_job.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-class GenerateNotificationsJob < ApplicationJob
- queue_as :default
-
- def perform(event)
- event.generate_notifications
- end
-end
diff --git a/app/jobs/mention/create_job.rb b/app/jobs/mention/create_job.rb
new file mode 100644
index 000000000..816782754
--- /dev/null
+++ b/app/jobs/mention/create_job.rb
@@ -0,0 +1,5 @@
+class Mention::CreateJob < ApplicationJob
+ def perform(record, mentioner:)
+ record.create_mentions(mentioner:)
+ end
+end
diff --git a/app/jobs/notify_recipients_job.rb b/app/jobs/notify_recipients_job.rb
new file mode 100644
index 000000000..6083622fd
--- /dev/null
+++ b/app/jobs/notify_recipients_job.rb
@@ -0,0 +1,5 @@
+class NotifyRecipientsJob < ApplicationJob
+ def perform(notifiable)
+ notifiable.notify_recipients
+ end
+end
diff --git a/app/jobs/remove_abandoned_creations_job.rb b/app/jobs/remove_abandoned_creations_job.rb
index 1444313a2..6ca63169d 100644
--- a/app/jobs/remove_abandoned_creations_job.rb
+++ b/app/jobs/remove_abandoned_creations_job.rb
@@ -1,6 +1,4 @@
class RemoveAbandonedCreationsJob < ApplicationJob
- queue_as :default
-
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.remove_abandoned_creations
diff --git a/app/models/card.rb b/app/models/card.rb
index e2191aa41..4344d89b1 100644
--- a/app/models/card.rb
+++ b/app/models/card.rb
@@ -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
diff --git a/app/models/card/closeable.rb b/app/models/card/closeable.rb
index ce0a63e33..b599b6a03 100644
--- a/app/models/card/closeable.rb
+++ b/app/models/card/closeable.rb
@@ -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
diff --git a/app/models/card/colored.rb b/app/models/card/colored.rb
index 99e934a94..9a16ee4a4 100644
--- a/app/models/card/colored.rb
+++ b/app/models/card/colored.rb
@@ -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
diff --git a/app/models/card/draft_commenting.rb b/app/models/card/draft_commenting.rb
deleted file mode 100644
index 615d23916..000000000
--- a/app/models/card/draft_commenting.rb
+++ /dev/null
@@ -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
diff --git a/app/models/card/engageable.rb b/app/models/card/engageable.rb
index 800088f72..6671bc5a1 100644
--- a/app/models/card/engageable.rb
+++ b/app/models/card/engageable.rb
@@ -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
diff --git a/app/models/card/eventable.rb b/app/models/card/eventable.rb
index c8098708b..67c8fcff9 100644
--- a/app/models/card/eventable.rb
+++ b/app/models/card/eventable.rb
@@ -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
diff --git a/app/models/card/golden.rb b/app/models/card/golden.rb
index 759179415..4f2f52a60 100644
--- a/app/models/card/golden.rb
+++ b/app/models/card/golden.rb
@@ -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
diff --git a/app/models/card/readable.rb b/app/models/card/readable.rb
new file mode 100644
index 000000000..b37538b9b
--- /dev/null
+++ b/app/models/card/readable.rb
@@ -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
diff --git a/app/models/card/scorable.rb b/app/models/card/scorable.rb
deleted file mode 100644
index 91b0856b5..000000000
--- a/app/models/card/scorable.rb
+++ /dev/null
@@ -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
diff --git a/app/models/card/searchable.rb b/app/models/card/searchable.rb
index 3627a555b..2e01fef9b 100644
--- a/app/models/card/searchable.rb
+++ b/app/models/card/searchable.rb
@@ -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
diff --git a/app/models/card/staged.rb b/app/models/card/staged.rb
index b669aeddd..f8036d3e3 100644
--- a/app/models/card/staged.rb
+++ b/app/models/card/staged.rb
@@ -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
diff --git a/app/models/collection.rb b/app/models/collection.rb
index 319b2bb7d..29e07a98b 100644
--- a/app/models/collection.rb
+++ b/app/models/collection.rb
@@ -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
diff --git a/app/models/collection/auto_closing.rb b/app/models/collection/auto_closing.rb
new file mode 100644
index 000000000..e3a9c8990
--- /dev/null
+++ b/app/models/collection/auto_closing.rb
@@ -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
diff --git a/app/models/collection/workflowing.rb b/app/models/collection/workflowing.rb
new file mode 100644
index 000000000..658087fe2
--- /dev/null
+++ b/app/models/collection/workflowing.rb
@@ -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
diff --git a/app/models/colorable.rb b/app/models/colorable.rb
deleted file mode 100644
index fed768a27..000000000
--- a/app/models/colorable.rb
+++ /dev/null
@@ -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
diff --git a/app/models/comment.rb b/app/models/comment.rb
index 8922b30b7..bdc674a9c 100644
--- a/app/models/comment.rb
+++ b/app/models/comment.rb
@@ -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
diff --git a/app/models/comment/eventable.rb b/app/models/comment/eventable.rb
new file mode 100644
index 000000000..4e90777e0
--- /dev/null
+++ b/app/models/comment/eventable.rb
@@ -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
diff --git a/app/models/concerns/filterable.rb b/app/models/concerns/filterable.rb
index b3cb07c46..85a33a015 100644
--- a/app/models/concerns/filterable.rb
+++ b/app/models/concerns/filterable.rb
@@ -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
diff --git a/app/models/concerns/mentions.rb b/app/models/concerns/mentions.rb
new file mode 100644
index 000000000..08e0362cb
--- /dev/null
+++ b/app/models/concerns/mentions.rb
@@ -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(/(? { 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
diff --git a/app/models/event/particulars.rb b/app/models/event/particulars.rb
index 389eda8ea..dd92fa4da 100644
--- a/app/models/event/particulars.rb
+++ b/app/models/event/particulars.rb
@@ -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
diff --git a/app/models/event_summary.rb b/app/models/event_summary.rb
index fcfaabd83..f04841e66 100644
--- a/app/models/event_summary.rb
+++ b/app/models/event_summary.rb
@@ -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
diff --git a/app/models/eventable.rb b/app/models/eventable.rb
new file mode 100644
index 000000000..e14803f09
--- /dev/null
+++ b/app/models/eventable.rb
@@ -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
diff --git a/app/models/filter.rb b/app/models/filter.rb
index 1ec1a5e6c..e5ce39383 100644
--- a/app/models/filter.rb
+++ b/app/models/filter.rb
@@ -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|
diff --git a/app/models/mention.rb b/app/models/mention.rb
new file mode 100644
index 000000000..ac5cb336c
--- /dev/null
+++ b/app/models/mention.rb
@@ -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
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 9fbb94a5b..01444ce61 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -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
diff --git a/app/models/notifier.rb b/app/models/notifier.rb
index 19626be5f..81e69ce07 100644
--- a/app/models/notifier.rb
+++ b/app/models/notifier.rb
@@ -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
diff --git a/app/models/notifier/assigned.rb b/app/models/notifier/assigned.rb
deleted file mode 100644
index 843376435..000000000
--- a/app/models/notifier/assigned.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-class Notifier::Assigned < Notifier
- private
- def recipients
- event.assignees.excluding(card.collection.access_only_users)
- end
-end
diff --git a/app/models/notifier/card_event_notifier.rb b/app/models/notifier/card_event_notifier.rb
new file mode 100644
index 000000000..899ee53fb
--- /dev/null
+++ b/app/models/notifier/card_event_notifier.rb
@@ -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
diff --git a/app/models/notifier/closed.rb b/app/models/notifier/closed.rb
deleted file mode 100644
index f090b0308..000000000
--- a/app/models/notifier/closed.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class Notifier::Closed < Notifier
-end
diff --git a/app/models/notifier/comment_event_notifier.rb b/app/models/notifier/comment_event_notifier.rb
new file mode 100644
index 000000000..40a40f4e5
--- /dev/null
+++ b/app/models/notifier/comment_event_notifier.rb
@@ -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
diff --git a/app/models/notifier/commented.rb b/app/models/notifier/commented.rb
deleted file mode 100644
index 42ad4e5c8..000000000
--- a/app/models/notifier/commented.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-class Notifier::Commented < Notifier
- private
- def resource
- event.comment
- end
-end
diff --git a/app/models/notifier/mention_notifier.rb b/app/models/notifier/mention_notifier.rb
new file mode 100644
index 000000000..c3920df8a
--- /dev/null
+++ b/app/models/notifier/mention_notifier.rb
@@ -0,0 +1,16 @@
+class Notifier::MentionNotifier < Notifier
+ alias mention source
+
+ private
+ def recipients
+ if mention.self_mention?
+ []
+ else
+ [ mention.mentionee ]
+ end
+ end
+
+ def creator
+ mention.mentioner
+ end
+end
diff --git a/app/models/notifier/published.rb b/app/models/notifier/published.rb
deleted file mode 100644
index ce3917e8f..000000000
--- a/app/models/notifier/published.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-class Notifier::Published < Notifier
- private
- def recipients
- card.watchers_and_subscribers(include_only_watching: true).without(creator)
- end
-end
diff --git a/app/models/user.rb b/app/models/user.rb
index a22841c42..370aae05c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,5 +1,8 @@
class User < ApplicationRecord
- include Accessor, Assignee, Avatar, Role, Transferable
+ include Accessor, Assignee, Mentionable, Named, Role, Transferable
+ include Timelined # Depends on Accessor
+
+ has_one_attached :avatar
has_many :sessions, dependent: :destroy
has_secure_password validations: false
@@ -15,18 +18,10 @@ class User < ApplicationRecord
normalizes :email_address, with: ->(value) { value.strip.downcase }
- scope :alphabetically, -> { order("lower(name)") }
-
- def initials
- name.to_s.scan(/\b\p{L}/).join.upcase
- end
-
def deactivate
- transaction do
- sessions.delete_all
- accesses.destroy_all
- update! active: false, email_address: deactived_email_address
- end
+ sessions.delete_all
+ accesses.destroy_all
+ update! active: false, email_address: deactived_email_address
end
private
diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb
deleted file mode 100644
index a9d2ad12e..000000000
--- a/app/models/user/avatar.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-module User::Avatar
- extend ActiveSupport::Concern
-
- included do
- has_one_attached :avatar
- end
-
- class_methods do
- def from_avatar_token(sid)
- find_signed!(sid, purpose: :avatar)
- end
- end
-
- def avatar_token
- signed_id(purpose: :avatar)
- end
-end
diff --git a/app/models/user/day_timeline.rb b/app/models/user/day_timeline.rb
new file mode 100644
index 000000000..32aad0814
--- /dev/null
+++ b/app/models/user/day_timeline.rb
@@ -0,0 +1,44 @@
+class User::DayTimeline
+ attr_reader :user, :day, :filter
+
+ def initialize(user, day, filter)
+ @user, @day, @filter = user, day, filter
+ end
+
+ def has_activity?
+ events.any?
+ end
+
+ def events
+ filtered_events.where(created_at: window).order(created_at: :desc)
+ end
+
+ def next_day
+ latest_event_before&.created_at
+ end
+
+ def earliest_time
+ next_day&.tomorrow&.beginning_of_day
+ end
+
+ def latest_time
+ day.yesterday.beginning_of_day
+ end
+
+ private
+ def filtered_events
+ @filtered_events ||= Event.where(collection: collections)
+ end
+
+ def collections
+ filter.collections.presence || user.collections
+ end
+
+ def latest_event_before
+ filtered_events.where(created_at: ...day.beginning_of_day).chronologically.last
+ end
+
+ def window
+ day.all_day
+ end
+end
diff --git a/app/models/user/mentionable.rb b/app/models/user/mentionable.rb
new file mode 100644
index 000000000..7a930ab02
--- /dev/null
+++ b/app/models/user/mentionable.rb
@@ -0,0 +1,20 @@
+module User::Mentionable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :mentions, dependent: :destroy, inverse_of: :mentionee
+ end
+
+ def mentioned_by(mentioner, at:)
+ mentions.find_or_create_by! source: at, mentioner: mentioner
+ end
+
+ def mentionable_handles
+ [ initials, first_name, first_name_with_last_name_initial ].collect(&:downcase)
+ end
+
+ private
+ def first_name_with_last_name_initial
+ "#{first_name}#{last_name&.first}"
+ end
+end
diff --git a/app/models/user/named.rb b/app/models/user/named.rb
new file mode 100644
index 000000000..67420ec72
--- /dev/null
+++ b/app/models/user/named.rb
@@ -0,0 +1,19 @@
+module User::Named
+ extend ActiveSupport::Concern
+
+ included do
+ scope :alphabetically, -> { order("lower(name)") }
+ end
+
+ def first_name
+ name.split(/\s/).first
+ end
+
+ def last_name
+ name.split(/\s/, 2).last
+ end
+
+ def initials
+ name.scan(/\b\p{L}/).join.upcase
+ end
+end
diff --git a/app/models/user/timelined.rb b/app/models/user/timelined.rb
new file mode 100644
index 000000000..1f5f2c8fe
--- /dev/null
+++ b/app/models/user/timelined.rb
@@ -0,0 +1,11 @@
+module User::Timelined
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :accessible_events, through: :collections, source: :events
+ end
+
+ def timeline_for(day, filter:)
+ User::DayTimeline.new(self, day, filter)
+ end
+end
diff --git a/app/models/workflow.rb b/app/models/workflow.rb
index df896cf09..3174289dd 100644
--- a/app/models/workflow.rb
+++ b/app/models/workflow.rb
@@ -1,3 +1,12 @@
class Workflow < ApplicationRecord
+ DEFAULT_STAGES = [ "Triage", "In progress", "On Hold", "Review" ]
+
has_many :stages, dependent: :delete_all
+
+ after_create_commit :create_default_stages
+
+ private
+ def create_default_stages
+ Workflow::Stage.insert_all(DEFAULT_STAGES.map { { name: it, workflow_id: id } })
+ end
end
diff --git a/app/presenters/collection_filter_presenter.rb b/app/presenters/collection_filter_presenter.rb
deleted file mode 100644
index 730e38e83..000000000
--- a/app/presenters/collection_filter_presenter.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-class CollectionFilterPresenter
- def initialize(collections, params, cookies)
- @collections = collections
- @params = params
- @cookies = cookies
- end
-
- def filter_text
- if selected_collection_ids.present?
- "Showing activity for #{selected_collection_names_bold}".html_safe
- else
- "Showing everything"
- end
- end
-
- private
- def selected_collection_ids
- @params[:collection_ids].presence || @cookies[:collection_filter]&.split(",")
- end
-
- def selected_collection_names
- @collections.where(id: selected_collection_ids).pluck(:name).to_sentence
- end
-
- def selected_collection_names_bold
- names = @collections.where(id: selected_collection_ids).pluck(:name)
- names.map { |name| "#{name}" }.to_sentence
- end
-end
diff --git a/app/views/cards/_container.html.erb b/app/views/cards/_container.html.erb
new file mode 100644
index 000000000..ce7482d9c
--- /dev/null
+++ b/app/views/cards/_container.html.erb
@@ -0,0 +1,37 @@
+
@@ -22,7 +24,7 @@ diff --git a/app/views/cards/_doing_toggle.html.erb b/app/views/cards/container/_engagement.html.erb similarity index 100% rename from app/views/cards/_doing_toggle.html.erb rename to app/views/cards/container/_engagement.html.erb diff --git a/app/views/cards/_golden_toggle.html.erb b/app/views/cards/container/_gild.html.erb similarity index 100% rename from app/views/cards/_golden_toggle.html.erb rename to app/views/cards/container/_gild.html.erb diff --git a/app/views/cards/_image.html.erb b/app/views/cards/container/_image.html.erb similarity index 100% rename from app/views/cards/_image.html.erb rename to app/views/cards/container/_image.html.erb diff --git a/app/views/cards/container/_title.html.erb b/app/views/cards/container/_title.html.erb new file mode 100644 index 000000000..4e4c2c9f5 --- /dev/null +++ b/app/views/cards/container/_title.html.erb @@ -0,0 +1,26 @@ +<% if card.published? %> + <%= turbo_frame_tag card, :edit do %> +
+
+ <%= sanitize card.description_html %>
+
+
+ <% end %>
+<% else %>
+ <%= form_with model: card, url: collection_card_path(card.collection, card), id: "card_form", class: "card__content", data: { controller: "paste auto-save" } do |form| %>
+
+ <%= form.markdown_area :description, class: "input input--textarea full-width borderless txt-align-start card-field__description",
+ placeholder: "Add some notes, context, pictures, or video about this…",
+ data: { action: "house-md:change->auto-save#change focusout->auto-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop paste->paste#pasteFiles" } %>
+
+ <% end %>
+<% end %>
diff --git a/app/views/cards/container/footer/_draft.html.erb b/app/views/cards/container/footer/_draft.html.erb
new file mode 100644
index 000000000..f46e08b41
--- /dev/null
+++ b/app/views/cards/container/footer/_draft.html.erb
@@ -0,0 +1,4 @@
+
+ <%= button_to "Create card", card_publish_path(card), class: "btn" %>
+ <%= button_to "Save as draft", collection_card_path(card.collection, card), name: "card[status]", value: "drafted", method: :put, class: "btn btn--reversed" %>
+
diff --git a/app/views/cards/container/footer/_published.html.erb b/app/views/cards/container/footer/_published.html.erb
new file mode 100644
index 000000000..c4e0a0cdf
--- /dev/null
+++ b/app/views/cards/container/footer/_published.html.erb
@@ -0,0 +1,7 @@
+<%# FIXME: Let's move this aside outside of the card container section so these frames don't reload/flicker when card is replaced %>
+
+
+<%= render "cards/container/closure", card: card %>
diff --git a/app/views/cards/display/_perma.html.erb b/app/views/cards/display/_perma.html.erb
deleted file mode 100644
index f70b9d2c8..000000000
--- a/app/views/cards/display/_perma.html.erb
+++ /dev/null
@@ -1,39 +0,0 @@
-<% cache card do %>
- <%= card_article_tag card, class: "card" do %>
-
- <%= link_to card.collection.name, cards_path(collection_ids: [card.collection]),
- class: "card__collection txt-uppercase overflow-ellipsis txt-reversed" %>
-
- <%= render "cards/display/perma/tags", card: card %>
-
-
-
-
-
-
-
- <%= render "cards/display/common/background", card: card %>
- <% end %>
-<% end %>
diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb
index 7d673308c..a909089cb 100644
--- a/app/views/cards/display/_preview.html.erb
+++ b/app/views/cards/display/_preview.html.erb
@@ -1,18 +1,20 @@
-<% cache [ card ] do %>
+<% cache card do %>
<%= card_article_tag card, class: "card" do %>
<%= card.collection.name %>
<%= render "cards/display/preview/tags", card: card %>
-
-
+
+
<%= render "cards/stagings/stages", card: card if card.doing? %>
diff --git a/app/views/cards/display/common/_assignees.html.erb b/app/views/cards/display/common/_assignees.html.erb
index 7cfa48447..c89cdcc57 100644
--- a/app/views/cards/display/common/_assignees.html.erb
+++ b/app/views/cards/display/common/_assignees.html.erb
@@ -4,7 +4,7 @@
<% end %>
>
-
diff --git a/app/views/cards/display/perma/_tags.html.erb b/app/views/cards/display/perma/_tags.html.erb
index cecde410e..ff6c97b8e 100644
--- a/app/views/cards/display/perma/_tags.html.erb
+++ b/app/views/cards/display/perma/_tags.html.erb
@@ -1,7 +1,7 @@
-
+
<% if card.tags.any? || card.creating? %>
- #
+ #
<% card.tags.each_with_index do |tag, index| %>
<%= link_to cards_path(collection_ids: [ card.collection ], tag_ids: [ tag.id ]),
class: "card__tag btn btn--plain min-width txt-uppercase fill-transparent" do %>
@@ -12,7 +12,7 @@
>
- " data-action="click->dialog#open:stop">
+
<%= icon_tag "tag" %>
Add a tag
diff --git a/app/views/cards/edit.html.erb b/app/views/cards/edit.html.erb
index 5e5e0d1d9..499563bb4 100644
--- a/app/views/cards/edit.html.erb
+++ b/app/views/cards/edit.html.erb
@@ -1,13 +1,19 @@
<%= turbo_frame_tag @card, :edit do %>
- <%= form_with model: @card, url: collection_card_path(@card.collection, @card), class: "flex flex-column gap full-width", data: { controller: "form" } do |form| %>
- <%= form.label :title, class: "flex flex-column align-center" do %>
- <%= form.text_area :title, class: "input input--textarea full-width borderless txt-align-start card__title-field",
- required: true, autofocus: true, placeholder: "Name it…",
- rows: 3,
- data: { action: "keydown.enter->form#submit:prevent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel focus->form#select" },
- style: "color: var(--spat-color); --input-border-radius: 0" %>
- <% end %>
- <%= form.button "Save", type: :submit, hidden: true %>
+ <%= form_with model: @card, url: collection_card_path(@card.collection, @card), class: "card__content", data: { controller: "form paste" } do |form| %>
+
+ <%= form.markdown_area :description, class: "input input--textarea full-width borderless txt-align-start card-field__description",
+ placeholder: "Add some notes, context, pictures, or video about this…",
+ data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop paste->paste#pasteFiles" } %>
+
+ <%= form.button "Save changes", type: :submit, class: "btn" %>
<%= link_to "Close editor and discard changes", collection_card_path(@card.collection, @card), data: { form_target: "cancel" }, hidden: true %>
<% end %>
<% end %>
diff --git a/app/views/cards/index/_collections_filter.html.erb b/app/views/cards/index/_collections_filter.html.erb
index b81481ad7..c61ef98d0 100644
--- a/app/views/cards/index/_collections_filter.html.erb
+++ b/app/views/cards/index/_collections_filter.html.erb
@@ -1,23 +1,44 @@
-<%= form_with url: cards_path, method: :get, class: "flex flex-column full-width popup__list",
+<%= form_with url: cards_path, method: :get, class: "flex flex-column max-width popup__list full-width",
data: { controller: "form" } do |form| %>
<% filter.as_params.except(:collection_ids).each do |key, value| %>
<%= filter_hidden_field_tag key, value %>
<% end %>
- <%= link_to cards_path(filter.as_params.except(:collection_ids)), class: "btn popup__item" do %>
- All collections
+ <%= link_to cards_path(filter.as_params.except(:collection_ids)), class: "popup__group flex align-center", style: "--hover-size: 0" do %>
+
+ >
+
+
+ See everything in all collections
+
+ <%= icon_tag "check", size: 18, class: "checked" %>
+
+ <%= tag.div class: "btn popup__item min-width flex-item-grow" do %>
+ All collections
+ <% end %>
+ GO TO ›
<% end %>
<% Current.user.collections.order(:name).each do |collection| %>
-
- <%= form.check_box "collection_ids[]", {
- checked: filter.collections.include?(collection),
- data: { action: "change->form#submit" },
- include_hidden: false,
- }, collection.id %>
+
+
+ <%= form.check_box "collection_ids[]", {
+ checked: filter.collections.include?(collection),
+ data: { action: "change->form#submit" },
+ include_hidden: false,
+ }, collection.id %>
- <%= form.label "collection_ids[]", collection.name, for: dom_id(collection, :filter), class: "overflow-ellipsis" %>
- <%= icon_tag "check", size: 18, class: "checked flex-item-justify-end" %>
-
+
+ Toggle filter for <%= collection.name %>
+
+ <%= icon_tag "check", size: 18, class: "checked" %>
+
+
+ <%= link_to cards_path(collection_ids: [ collection ]), class: "btn popup__item min-width flex-item-grow" do %>
+ <%= collection.name %>
+ <% end %>
+
+ ›
+
<% end %>
<% end %>
diff --git a/app/views/cards/index/_header.html.erb b/app/views/cards/index/_header.html.erb
index 0eef62cce..b5b9fb59d 100644
--- a/app/views/cards/index/_header.html.erb
+++ b/app/views/cards/index/_header.html.erb
@@ -1,22 +1,46 @@
+ <%= link_to card.title, edit_collection_card_path(card.collection, card), class: "card__title-link" %> +
++ <%= form.text_area :title, placeholder: "Name it…", + class: "input input--textarea full-width borderless txt-align-start", + autofocus: card.title.blank?, + data: { action: "auto-save#change blur->auto-save#submit keydown.enter->auto-save#submit:prevent keydown.ctrl+enter->auto-save#submit:prevent" } %> +
+- <% if card.published? %> - <%= turbo_frame_tag card, :edit do %> - <%= link_to card.title, edit_collection_card_path(card.collection, card), class: "card__title-link overflow-line-clamp" %> - <% end %> - <% else %> - <%= form_with model: card, url: collection_card_path(card.collection, card), id: "card_form", data: { controller: "auto-save" } do |form| %> - <%= form.text_area :title, placeholder: "Name it…", - class: "input input--textarea full-width borderless txt-align-start #{ "fill-highlight" if card.creating? }", - autofocus: card.title.blank?, - data: { action: "auto-save#change blur->auto-save#submit keydown.enter->auto-save#submit:prevent keydown.ctrl+enter->auto-save#submit:prevent" } %> - <% end %> - <% end %> -
- - <% if card.doing? %> - <%= turbo_frame_tag dom_id(@card, :stages) do %> - <%= render "cards/stagings/stages", card: card %> - <% end %> - <% end %> -- <%= card.title %> -
++ <%= card.title %> +
- <%= link_to collection_card_path(card.collection, card), class: "card__link" do %> - <%= card.title %> - <% end %> + <%= link_to collection_card_path(card.collection, card), class: "card__link", data: { turbo_frame: "_top" } do %> + <%= card.title %> + <% end %> ++ <%= form.label :title, class: "flex flex-column align-center" do %> + <%= form.text_area :title, class: "input input--textarea full-width borderless txt-align-start card-field__title", + required: true, autofocus: true, placeholder: "Name it…", + rows: 1, + data: { action: "keydown.enter->form#submit:prevent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel focus->form#select" } %> + <% end %> +
+