Merge branch 'main' into focus-hover-styles

This commit is contained in:
Andy Smith
2025-07-03 15:14:14 -05:00
committed by GitHub
142 changed files with 1013400 additions and 468 deletions
+30 -30
View File
@@ -1,13 +1,13 @@
GIT
remote: https://github.com/basecamp/actiontext-lexical
revision: 8a2cede49ca51e40004a0f03100b3f189b60fdd8
revision: 38e8d4e5781667b7d1c17eaa5cca503bbc63ee67
specs:
actiontext-lexical (0.1.0)
rails (>= 8.0.2)
GIT
remote: https://github.com/basecamp/active_record-tenanted
revision: df260821fcc6fa7aee355623c70cdd4fe21d2d71
revision: 8e33088cdbd69ab7b4cf4100d4202a54aa97e4c0
branch: flavorjones/path-based-tenanting
specs:
active_record-tenanted (0.1.0)
@@ -18,9 +18,9 @@ GIT
GIT
remote: https://github.com/basecamp/file_repository
revision: 17e90fc35ccb9f0a0dae1ed8623f01386696002d
revision: 8c8d1594f3ec7e8c0938e6a7f7def521692344e4
specs:
file_repository (1.4.5)
file_repository (1.4.6)
GIT
remote: https://github.com/basecamp/kamal
@@ -41,7 +41,7 @@ GIT
GIT
remote: https://github.com/basecamp/portfolio
revision: 26dd0ccbe4aad5a5c539f90c00e82b6175443636
revision: 34b48dd3c6dc78abd162ef26481971a228084d04
specs:
portfolio (5.2.0)
activesupport
@@ -82,7 +82,7 @@ GIT
GIT
remote: https://github.com/basecamp/signal_id
revision: 8908b2550ff55325b04c108146e00b4408a64e93
revision: 32758d0bff9f17e938905ff5bec53fff359e21b0
branch: rails4
specs:
signal_id (4.3.2)
@@ -112,7 +112,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 53a9a3181a59f4e6dff73ba9453734edd7f7e076
revision: 58a2187f7b2ac391eba841e9ec6bb1a6d4e486dd
branch: main
specs:
actioncable (8.1.0.alpha)
@@ -227,18 +227,18 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3)
aws-eventstream (1.4.0)
aws-partitions (1.1119.0)
aws-sdk-core (3.226.0)
aws-partitions (1.1124.0)
aws-sdk-core (3.226.2)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.105.0)
aws-sdk-kms (1.106.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.190.0)
aws-sdk-s3 (1.192.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -291,11 +291,11 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-multipart (1.1.0)
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (3.4.0)
faraday-net_http (3.4.1)
net-http (>= 0.5.0)
faraday-retry (2.3.1)
faraday-retry (2.3.2)
faraday (~> 2.0)
ffi (1.17.2)
ffi (1.17.2-arm64-darwin)
@@ -331,7 +331,7 @@ GEM
activesupport (>= 5.0.0)
jmespath (1.6.2)
json (2.12.2)
jwt (2.10.1)
jwt (3.1.2)
base64
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
@@ -345,7 +345,7 @@ GEM
net-pop
net-smtp
marcel (1.0.4)
matrix (0.4.2)
matrix (0.4.3)
mini_magick (5.2.0)
benchmark
logger
@@ -394,7 +394,7 @@ GEM
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4)
ostruct (0.6.1)
ostruct (0.6.2)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
@@ -457,7 +457,7 @@ GEM
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rubocop (1.75.8)
rubocop (1.77.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -465,10 +465,10 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-ast (>= 1.45.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.44.1)
rubocop-ast (1.45.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.25.0)
@@ -486,25 +486,25 @@ GEM
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
ruby-vips (2.2.3)
ruby-vips (2.2.4)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
securerandom (0.4.1)
selenium-webdriver (4.33.0)
selenium-webdriver (4.34.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.25.0)
sentry-rails (5.26.0)
railties (>= 5.0)
sentry-ruby (~> 5.25.0)
sentry-ruby (5.25.0)
sentry-ruby (~> 5.26.0)
sentry-ruby (5.26.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
solid_cable (3.0.8)
solid_cable (3.0.11)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
@@ -520,11 +520,11 @@ GEM
fugit (~> 1.11.0)
railties (>= 7.1)
thor (~> 1.3.1)
sqlite3 (2.7.0)
sqlite3 (2.7.1)
mini_portile2 (~> 2.8.0)
sqlite3 (2.7.0-arm64-darwin)
sqlite3 (2.7.0-x86_64-darwin)
sqlite3 (2.7.0-x86_64-linux-gnu)
sqlite3 (2.7.1-arm64-darwin)
sqlite3 (2.7.1-x86_64-darwin)
sqlite3 (2.7.1-x86_64-linux-gnu)
sshkit (1.24.0)
base64
logger
+2 -2
View File
@@ -42,8 +42,8 @@ Comment.find_each(&:refresh_search_embedding)
### Tests
For testing OpenAI API requests, we use [VCR](https://github.com/vcr/vcr). If you want to test AI features exercising the API, you need to place the `config/credentials/test.key`
that you can get from 1Password in "Fizzy - test.key". Then, when running tests that use Open AI API, you must either set the env variable VCR_RECORD=1
or to add `vcr_record!` to the test. See `VcrTestHelper`.
that you can get from 1Password in "Fizzy - test.key". Then, when running tests that use Open AI API, you must either set the env variable `VCR_RECORD=1`
or to add `vcr_record!` to the test. See `VcrTestHelper`. Due to Open AI quotas, you may need to limit the parallelization. E.g: `PARALLEL_WORKERS=2`
## Environments
+13 -1
View File
@@ -4,7 +4,7 @@
position: relative;
overflow: visible;
.node--selected:not(.attachment--custom) {
figure.node--selected {
&:not(:has(img)) {
box-shadow: 0 0 0 var(--focus-ring-size) var(--focus-ring-color);
}
@@ -148,6 +148,8 @@
background-color: var(--color-canvas);
border-radius: calc(var(--lexical-prompt-padding) * 2);
box-shadow: var(--shadow);
color: var(--color-ink);
font-family: var(--font-sans);
font-size: var(--text-small);
list-style: none;
margin: 0;
@@ -155,6 +157,9 @@
padding: var(--lexical-prompt-padding);
visibility: hidden;
z-index: var(--z-popup);
max-height: 200px;
overflow: scroll;
}
.lexical-prompt-menu--visible {
@@ -188,6 +193,13 @@
+ & {
margin-top: 2px;
}
code {
background-color: var(--color-terminal-text-light);
border-radius: 0.25ch;
font-size: 0.95em;
padding-inline: 0.5ch;
}
}
/* Empty state */
+6
View File
@@ -50,4 +50,10 @@
@keyframes zoom-fade {
100% { transform: translateY(-1.5em); scale: 2; opacity: 0; }
}
@keyframes blink {
50% {
border-color: transparent;
}
}
}
+6 -2
View File
@@ -85,7 +85,7 @@
.card__background {
.card:has(& img:not([src=""])) {
.card__body,
.card__content,
.card__meta,
.workflow-stage:not(.workflow-stage--current) {
opacity: 0;
@@ -108,6 +108,10 @@
}
}
}
.card__steps {
display: flex;
}
}
/* Column elements
@@ -200,7 +204,7 @@
.card__header {
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
margin-inline-start: calc(-0.5 * var(--card-padding-inline));
margin-inline: calc(-0.5 * var(--card-padding-inline)) calc(-0.25 * var(--card-padding-inline));
}
.card__people-label {
+2 -2
View File
@@ -136,8 +136,8 @@
flex-wrap: nowrap;
gap: var(--inline-space);
margin-block-start: calc(-1 * var(--card-padding-block));
margin-inline-start: calc(-1 * var(--card-padding-inline));
max-inline-size: 100%;
margin-inline: calc(-1 * var(--card-padding-inline)) calc(-0.5 * var(--card-padding-inline));
max-inline-size: unset;
min-inline-size: 0;
.workflow-stage {
+8 -2
View File
@@ -37,8 +37,14 @@
}
.comment__author {
.comment--system & strong {
display: none;
.comment--system & {
h3 {
margin-inline: auto;
}
strong {
display: none;
}
}
.btn {
+1
View File
@@ -30,6 +30,7 @@
.icon--camera { --svg: url("camera.svg "); }
.icon--caret-down { --svg: url("caret-down.svg "); }
.icon--check { --svg: url("check.svg "); }
.icon--check-all { --svg: url("check-all.svg "); }
.icon--clipboard { --svg: url("clipboard.svg "); }
.icon--close { --svg: url("close.svg "); }
.icon--collection { --svg: url("collection.svg "); }
+4 -4
View File
@@ -31,7 +31,6 @@
}
.input--file {
border: 0;
cursor: pointer;
display: grid;
inline-size: auto;
@@ -46,6 +45,7 @@
}
input[type="file"] {
--hover-size: 0;
--input-border-color: transparent;
--input-border-radius: 8px;
@@ -63,9 +63,9 @@
&:focus,
&:focus-visible {
--input-border-color: var(--color-selected-dark);
--input-border-radius: 8px;
--input-border-size: 0.15rem;
.input--file:has(&) {
outline: 0.15rem solid var(--color-selected-dark);
}
}
}
+208 -192
View File
@@ -20,7 +20,7 @@
:where(h5) { font-size: 0.83em; }
:where(h6) { font-size: 0.67em; }
:where(p, ul, ol, dl, blockquote, figure:not(.attachment--custom), .attachment:not(.attachment--custom)) {
:where(p, ul, ol, dl, blockquote, figure, .attachment) {
margin-block: var(--block-margin);
overflow-wrap: break-word;
text-wrap: pretty;
@@ -53,167 +53,6 @@
}
}
/* Attachments */
.attachment:not(.attachment--custom) {
block-size: auto;
display: inline-block;
inline-size: 100%;
position: relative;
margin-inline: 0;
max-inline-size: 100%;
&:is(figure) img,
&:is(figure) video {
block-size: auto;
display: block;
margin-inline: auto;
max-inline-size: 100%;
user-select: none;
}
:where(progress) {
inline-size: 100%;
margin: auto;
}
}
.attachment__caption {
color: var(--color-ink-dark);
display: flex;
font-size: var(--text-small);
flex-wrap: wrap;
line-height: 1.3;
column-gap: 0.5ch;
justify-content: center;
margin: var(--block-space-half) var(--inline-space);
text-align: center;
.input {
inline-size: 100%;
max-inline-size: 100%;
text-align: center;
@supports (field-sizing: content) {
field-sizing: content;
inline-size: auto;
min-inline-size: 20ch;
}
}
}
.attachment--file {
align-items: center;
display: inline-flex;
.attachment__caption {
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-start;
text-align: start;
strong {
color: var(--color-ink);
}
}
}
.attachment__icon {
aspect-ratio: 4/5;
background-color: var(--color-canvas);
block-size: calc(2.5lh + 0.5ch);
border: 2px solid var(--color-ink-medium);
border-radius: 0.3em;
color: var(--color-ink-dark);
display: inline-grid;
font-size: var(--text-small);
font-weight: 800;
inline-size: auto;
overflow: clip;
padding-block-start: 0.4lh;
place-content: center;
position: relative;
text-transform: uppercase;
&::before {
background-color: var(--color-ink-medium);
block-size: 0.5lh;
content: "";
display: block;
inline-size: 100%;
inset: 0 0 auto;
position: absolute;
}
.attachment--psd &,
.attachment--key &,
.attachment--sketch &,
.attachment--ai &,
.attachment--eps &,
.attachment--indd &,
.attachment--svg &,
.attachment--ppt &,
.attachment--pptx & {
color: oklch(var(--lch-red-dark));
background-color: oklch(var(--lch-red-lightest));
border-color: oklch(var(--lch-red-medium));
&::before {
background-color: oklch(var(--lch-red-medium));
}
}
.attachment--css &,
.attachment--php &,
.attachment--json &,
.attachment--htm &,
.attachment--html &,
.attachment--rb &,
.attachment--erb &,
.attachment--ts &,
.attachment--js & {
color: oklch(var(--lch-purple-dark));
background-color: oklch(var(--lch-purple-lightest));
border-color: oklch(var(--lch-purple-medium));
&::before {
background-color: oklch(var(--lch-purple-medium));
}
}
.attachment--txt &,
.attachment--pages &,
.attachment--rtf &,
.attachment--md &,
.attachment--doc &,
.attachment--docx & {
color: oklch(var(--lch-blue-dark));
background-color: oklch(var(--lch-blue-lightest));
border-color: oklch(var(--lch-blue-medium));
&::before {
background-color: oklch(var(--lch-blue-medium));
}
}
.attachment--csv &,
.attachment--numbers &,
.attachment--xls &,
.attachment--xlsx & {
color: oklch(var(--lch-green-dark));
background-color: oklch(var(--lch-green-lightest));
border-color: oklch(var(--lch-green-medium));
&::before {
background-color: oklch(var(--lch-green-medium));
}
}
}
.attachment__link {
color: var(--color-link);
text-decoration: underline;
}
/* Avoid extra space due to empty paragraphs */
p:empty {
display: none;
@@ -314,44 +153,221 @@
}
}
}
}
/* Custom attachments such as mentions, etc. */
.attachment--custom {
display: inline;
padding: 0;
margin: 0;
white-space: normal;
/* Attachments
/* ------------------------------------------------------------------------ */
.attachment {
block-size: auto;
display: inline-block;
inline-size: 100%;
position: relative;
margin-inline: 0;
max-inline-size: 100%;
&:is(figure) img,
&:is(figure) video {
block-size: auto;
display: block;
margin-inline: auto;
max-inline-size: 100%;
user-select: none;
}
.attachment--custom,
action-text-attachment[content-type^='application/vnd.actiontext'] {
--attachment-image-size: 1em;
--attachment-selected-color: oklch(var(--lch-blue-dark));
:where(progress) {
inline-size: 100%;
margin: auto;
}
}
align-items: center;
display: inline-flex;
gap: 0.25ch;
position: relative;
vertical-align: bottom;
.attachment__caption {
color: var(--color-ink-dark);
display: flex;
font-size: var(--text-small);
flex-wrap: wrap;
line-height: 1.3;
column-gap: 0.5ch;
justify-content: center;
margin: var(--block-space-half) var(--inline-space);
text-align: center;
lexical-editor & {
cursor: pointer;
}
.input {
--input-border-radius: 0.3em;
--input-border-size: 0;
--input-padding: 0;
img {
block-size: var(--attachment-image-size);
border-radius: 50%;
inline-size: var(--attachment-image-size);
}
color: var(--color-ink-dark);
inline-size: 100%;
max-inline-size: 100%;
text-align: center;
&.node--selected {
background: var(--attachment-selected-color);
box-shadow:
-0.25ch 0 0 var(--attachment-selected-color),
0.5ch 0 0 var(--attachment-selected-color);
border-radius: 99rem;
color: var(--color-ink-inverted);
@supports (field-sizing: content) {
field-sizing: content;
inline-size: auto;
min-inline-size: 20ch;
}
}
}
.attachment--file {
align-items: center;
display: inline-flex;
.attachment__caption {
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-start;
text-align: start;
strong {
color: var(--color-ink);
}
}
}
.attachment__icon {
aspect-ratio: 4/5;
background-color: var(--color-canvas);
block-size: calc(2.5lh + 0.5ch);
border: 2px solid var(--color-ink-medium);
border-radius: 0.3em;
color: var(--color-ink-dark);
display: inline-grid;
font-size: var(--text-small);
font-weight: 800;
inline-size: auto;
overflow: clip;
padding-block-start: 0.4lh;
place-content: center;
position: relative;
text-transform: uppercase;
&::before {
background-color: var(--color-ink-medium);
block-size: 0.5lh;
content: "";
display: block;
inline-size: 100%;
inset: 0 0 auto;
position: absolute;
}
.attachment--psd &,
.attachment--key &,
.attachment--sketch &,
.attachment--ai &,
.attachment--eps &,
.attachment--indd &,
.attachment--svg &,
.attachment--ppt &,
.attachment--pptx & {
color: oklch(var(--lch-red-dark));
background-color: oklch(var(--lch-red-lightest));
border-color: oklch(var(--lch-red-medium));
&::before {
background-color: oklch(var(--lch-red-medium));
}
}
.attachment--css &,
.attachment--php &,
.attachment--json &,
.attachment--htm &,
.attachment--html &,
.attachment--rb &,
.attachment--erb &,
.attachment--ts &,
.attachment--js & {
color: oklch(var(--lch-purple-dark));
background-color: oklch(var(--lch-purple-lightest));
border-color: oklch(var(--lch-purple-medium));
&::before {
background-color: oklch(var(--lch-purple-medium));
}
}
.attachment--txt &,
.attachment--pages &,
.attachment--rtf &,
.attachment--md &,
.attachment--doc &,
.attachment--docx & {
color: oklch(var(--lch-blue-dark));
background-color: oklch(var(--lch-blue-lightest));
border-color: oklch(var(--lch-blue-medium));
&::before {
background-color: oklch(var(--lch-blue-medium));
}
}
.attachment--csv &,
.attachment--numbers &,
.attachment--xls &,
.attachment--xlsx & {
color: oklch(var(--lch-green-dark));
background-color: oklch(var(--lch-green-lightest));
border-color: oklch(var(--lch-green-medium));
&::before {
background-color: oklch(var(--lch-green-medium));
}
}
}
.attachment__link {
color: var(--color-link);
text-decoration: underline;
}
/* Custom attachments such as mentions, etc. */
action-text-attachment[content-type^='application/vnd.actiontext'] {
--attachment-bg-color: transparent;
--attachment-image-size: 1em;
--attachment-text-color: currentColor;
display: inline;
padding: 0;
margin: 0;
white-space: normal;
align-items: center;
color: var(--attachment-text-color);
display: inline-flex;
gap: 0.25ch;
position: relative;
vertical-align: bottom;
background: var(--attachment-bg-color);
box-shadow:
-0.25ch 0 0 var(--attachment-bg-color),
0.5ch 0 0 var(--attachment-bg-color);
border-radius: 99rem;
lexical-editor & {
cursor: pointer;
}
img {
block-size: var(--attachment-image-size);
border-radius: 50%;
inline-size: var(--attachment-image-size);
}
&.node--selected {
--attachment-bg-color: oklch(var(--lch-blue-dark));
--attachment-text-color: var(--color-ink-inverted);
}
}
[data-lexical-cursor] {
animation: blink 1s step-end infinite;
block-size: 1lh;
border-inline-start: 1px solid currentColor;
line-height: inherit;
margin-block: 1em;
}
}
+108
View File
@@ -0,0 +1,108 @@
@layer components {
.step {
display: grid;
grid-template-columns: 1em auto auto;
gap: calc(var(--inline-space) * 2/3);
inline-size: auto;
}
.step__checkbox {
--hover-color: var(--card-color);
appearance: none;
background-color: var(--color-canvas);
block-size: 1.1em;
border: 1px solid currentColor;
border-radius: 0.15em;
color: currentColor;
display: grid;
font: inherit;
inline-size: 1.1em;
margin: 0;
place-content: center;
transform: translateY(0.1em);
&::before {
background-color: CanvasText;
block-size: 0.65em;
box-shadow: inset 1em 1em currentColor;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
content: "";
inline-size: 0.65em;
transform: scale(0);
transform-origin: center;
transition: 150ms transform ease-in-out;
}
&:checked::before {
transform: scale(1) rotate(10deg);
}
&:where([disabled]):not(:hover):not(:active) {
filter: none;
opacity: 0.5;
}
}
.step__content {
--input-border-radius: 0;
--input-border-size: 0;
--input-padding: 0;
border-bottom: 1px solid transparent;
color: currentColor;
font-weight: 500;
margin-block-end: calc(var(--block-space) * 1/3);
&:is(a, input[type=text]) {
--hover-size: 0;
}
.step:has(:checked) & {
opacity: 0.7;
text-decoration: line-through;
}
&::placeholder {
color: var(--card-color);
}
&:is(input) {
max-inline-size: 70ch;
min-inline-size: 30ch;
@supports (field-sizing: content) {
field-sizing: content;
min-inline-size: 15ch;
}
}
}
.step__content--edit {
border-bottom-color: currentColor;
}
.steps {
contain: inline-size;
display: grid;
list-style: none;
margin: 0;
max-inline-size: 100%;
padding: 0;
}
.steps__icon {
background-color: var(--card-color);
block-size: 1.3em;
border-radius: 50%;
display: grid;
inline-size: 1.3em;
place-content: center;
.icon {
background-color: var(--color-ink-inverted);
block-size: 0.8em;
inline-size: 0.8em;
}
}
}
+26
View File
@@ -117,6 +117,22 @@
caret-color: var(--color-negative);
color: var(--color-negative);
}
.lexical-editor__content {
min-block-size: initial;
margin: 0;
b, strong, i, em {
font-weight: inherit;
font-style: inherit;
}
ul, li {
list-style: none;
margin: 0;
padding: 0;
}
}
}
.terminal__label {
@@ -183,4 +199,14 @@
}
}
}
/* Lexical prompt insertions
/* ------------------------------------------------------------------------ */
.terminal {
action-text-attachment:not(.node--selected) {
--attachment-bg-color: oklch(var(--lch-white) / 15%);
--attachment-text-color: var(--color-terminal-text);
}
}
}
+1
View File
@@ -310,6 +310,7 @@
.card__meta .btn,
.card__meta-item:not(.card__meta-item--updated),
.card__stages,
.card__steps,
.card__closed {
display: none;
}
+35
View File
@@ -0,0 +1,35 @@
class Cards::StepsController < ApplicationController
include CardScoped
before_action :set_step, only: %i[ show edit update destroy ]
def create
@step = @card.steps.create!(step_params)
render_card_replacement
end
def show
end
def edit
end
def update
@step.update!(step_params)
render_card_replacement
end
def destroy
@step.destroy!
render_card_replacement
end
private
def set_step
@step = @card.steps.find(params[:id])
end
def step_params
params.expect(step: [ :content, :completed ])
end
end
@@ -0,0 +1,14 @@
class Collections::EntropyConfigurationsController < ApplicationController
include CollectionScoped
def update
@collection.entropy_configuration.update!(entropy_configuration_params)
render turbo_stream: turbo_stream.replace([ @collection, :entropy_configuration ], partial: "collections/edit/auto_close", locals: { collection: @collection })
end
private
def entropy_configuration_params
params.expect(collection: [ :auto_close_period, :auto_reconsider_period ])
end
end
@@ -3,11 +3,12 @@ class Collections::PublicationsController < ApplicationController
def create
@collection.publish
redirect_to edit_collection_path(@collection)
render turbo_stream: turbo_stream.replace([ @collection, :publication ], partial: "collections/edit/publication", locals: { collection: @collection })
end
def destroy
@collection.unpublish
redirect_to edit_collection_path(@collection)
@collection.reload
render turbo_stream: turbo_stream.replace([ @collection, :publication ], partial: "collections/edit/publication", locals: { collection: @collection })
end
end
@@ -5,11 +5,12 @@ class Collections::WorkflowsController < ApplicationController
def update
@collection.update! workflow: @workflow
redirect_to cards_path(collection_ids: [ @collection ])
render turbo_stream: turbo_stream.replace([ @collection, :workflows ], partial: "collections/edit/workflows", locals: { collection: @collection })
end
private
def set_workflow
@workflow = Workflow.find(params[:collection][:workflow_id])
workflow_id = params[:collection][:workflow_id]
@workflow = workflow_id.present? ? Workflow.find(workflow_id) : nil
end
end
+3
View File
@@ -37,6 +37,9 @@ class CommandsController < ApplicationController
end
def respond_with_execution_result(result)
# This saves unnecessary back and forth between server and client (e.g: to redirect)-
result = result.is_a?(Array) && result.one? ? result.first : result
case result
when Array
respond_with_composite_response(result)
+9 -1
View File
@@ -2,6 +2,9 @@ module Authentication
extend ActiveSupport::Concern
included do
# Checking for tenant must happen first so we redirect before trying to access the db.
before_action :require_tenant
before_action :set_current_account
before_action :require_authentication
helper_method :authenticated?
@@ -19,6 +22,7 @@ module Authentication
end
def require_untenanted_access(**options)
skip_before_action :require_tenant, **options
skip_before_action :set_current_account, **options
skip_before_action :require_authentication, **options
before_action :redirect_tenanted_request, **options
@@ -30,6 +34,10 @@ module Authentication
Current.session.present?
end
def require_tenant
ApplicationRecord.current_tenant.present? || request_authentication
end
def require_authentication
resume_session || request_authentication
end
@@ -48,7 +56,7 @@ module Authentication
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to Launchpad.login_url(account: Current.account), allow_other_host: true
redirect_to Launchpad.login_url(product: true, account: Current.account), allow_other_host: true
end
def after_authentication_url
@@ -6,6 +6,9 @@ class Notifications::ReadingsController < ApplicationController
def create_all
Current.user.notifications.unread.read_all
redirect_to notifications_path
respond_to do |format|
format.html { redirect_to notifications_path }
format.turbo_stream { } # No action needed, readings will have been broadcast
end
end
end
@@ -2,7 +2,7 @@ class Prompts::Collections::UsersController < ApplicationController
include CollectionScoped
def index
@users = @collection.users
@users = @collection.users.alphabetically
if stale? etag: @users
render layout: false
@@ -0,0 +1,22 @@
class Prompts::CommandsController < ApplicationController
def index
@commands = [
[ "/add", "Add a new card", "/add " ],
[ "/assign", "Assign cards to people", "/assign @" ],
[ "/close", "Close cards (with optional reason)", "/close " ],
[ "/reopen", "Reopen cards", "/reopen" ],
[ "/clear", "Clear all filters", "/clear" ],
[ "/consider", "Move cards back to Considering", "/consider" ],
[ "/do", "Move cards to Doing", "/do" ],
[ "/reconsider", "Move cards back to Considering", "/reconsider" ],
[ "/search", "Search cards and comments", "/search " ],
[ "/tag", "Tag selected cards", "/tag #" ],
[ "/stage", "Move cards to a Workflow Stage", "/stage " ],
[ "/help", "Show help menu", "/help" ]
]
if stale? etag: @commands
render layout: false
end
end
end
@@ -0,0 +1,9 @@
class Prompts::TagsController < ApplicationController
def index
@tags = Tag.all.alphabetically
if stale? etag: @tags
render layout: false
end
end
end
@@ -0,0 +1,9 @@
class Prompts::UsersController < ApplicationController
def index
@users = User.all.alphabetically
if stale? etag: @users
render layout: false
end
end
end
+1 -1
View File
@@ -3,6 +3,6 @@ class SearchesController < ApplicationController
def show
@search_results = Current.user.search(@query_terms).limit(50)
@recent_search_queries = Current.user.search_queries.order(created_at: :desc).limit(10)
@recent_search_queries = Current.user.search_queries.order(created_at: :desc).limit(50).uniq(&:terms)
end
end
+1 -1
View File
@@ -37,7 +37,7 @@ module EventsHelper
def event_cluster_tag(hour, col, &)
row = 25 - hour
tag.ul class: "events__time-block", style: "grid-area: #{row}/#{col}", &
tag.div class: "events__time-block", style: "grid-area: #{row}/#{col}", &
end
def event_next_page_link(next_day)
+1 -1
View File
@@ -40,7 +40,7 @@ module FiltersHelper
end
def any_filters?(filter)
filter.tags.any? || filter.assignees.any? || filter.creators.any? ||
filter.tags.any? || filter.assignees.any? || filter.creators.any? || filter.closers.any? ||
filter.stages.any? || filter.terms.any? || filter.card_ids&.any? ||
filter.assignment_status.unassigned? || !filter.indexed_by.latest?
end
+12
View File
@@ -3,6 +3,18 @@ module RichTextHelper
content_tag "lexical-prompt", "", trigger: "@", src: prompts_collection_users_path(collection), name: "mention"
end
def global_mentions_prompt
content_tag "lexical-prompt", "", trigger: "@", src: prompts_users_path, name: "mention"
end
def tags_prompt
content_tag "lexical-prompt", "", trigger: "#", src: prompts_tags_path, name: "tag"
end
def commands_prompt
content_tag "lexical-prompt", "", trigger: "/", src: prompts_commands_path, name: "command", "insert-editable-text": true
end
def cards_prompt
content_tag "lexical-prompt", "", trigger: "#", src: prompts_cards_path, name: "card", "insert-editable-text": true, "remote-filtering": true
end
+1 -1
View File
@@ -2,6 +2,6 @@ module TenantingHelper
def tenanted_action_cable_meta_tag
tag "meta",
name: "action-cable-url",
content: [ request.script_name, ActionCable.server.config.mount_path ].join("/")
content: "#{request.script_name}#{ActionCable.server.config.mount_path}"
end
end
@@ -6,6 +6,10 @@ export default class extends Controller {
modal: { type: Boolean, default: false }
}
connect() {
this.dialogTarget.setAttribute('aria-hidden', 'true')
}
open() {
const modal = this.modalValue
@@ -14,6 +18,7 @@ export default class extends Controller {
} else {
this.dialogTarget.show()
}
this.dialogTarget.setAttribute('aria-hidden', 'false')
}
toggle() {
@@ -26,6 +31,7 @@ export default class extends Controller {
close() {
this.dialogTarget.close()
this.dialogTarget.setAttribute('aria-hidden', 'true')
this.dialogTarget.blur()
}
@@ -86,10 +86,10 @@ export default class extends Controller {
}
}
#handleArrowKey(event, fn) {
#handleArrowKey(event, fn, preventDefault = true) {
if (event.shiftKey || event.metaKey || event.ctrlKey) { return }
fn.call()
event.preventDefault()
if (preventDefault) { event.preventDefault() }
}
#clickCurrentItem(event) {
@@ -119,10 +119,10 @@ export default class extends Controller {
this.#handleArrowKey(event, this.#selectPrevious.bind(this))
},
ArrowRight(event) {
this.#handleArrowKey(event, this.#selectNext.bind(this))
this.#handleArrowKey(event, this.#selectNext.bind(this), false)
},
ArrowLeft(event) {
this.#handleArrowKey(event, this.#selectPrevious.bind(this))
this.#handleArrowKey(event, this.#selectPrevious.bind(this), false)
},
Enter(event) {
if (event.shiftKey) {
@@ -2,6 +2,7 @@ import { Controller } from "@hotwired/stimulus"
import { HttpStatus } from "helpers/http_helpers"
import { isMultiLineString } from "helpers/text_helpers";
import { marked } from "marked"
import { nextFrame } from "helpers/timing_helpers";
export default class extends Controller {
static targets = [ "input", "form", "output", "confirmation", "recentCommands" ]
@@ -14,9 +15,11 @@ export default class extends Controller {
// Actions
focus() {
this.inputTarget.setSelectionRange(this.inputTarget.value.length, this.inputTarget.value.length)
async focus() {
await nextFrame()
this.inputTarget.focus()
this.inputTarget.selection.placeCursorAtTheEnd()
}
executeCommand(event) {
@@ -38,6 +41,12 @@ export default class extends Controller {
this.#hideOutput()
}
submitCommand({ target }) {
if (!this.#showHelpCommandEntered) {
this.#submitCommand()
}
}
handleKeyPress(event) {
if (this.waitingForConfirmationValue) {
this.#handleConfirmationKey(event.key.toLowerCase())
@@ -84,7 +93,7 @@ export default class extends Controller {
}
get #showHelpCommandEntered() {
return [ "/help", "/?" ].includes(this.inputTarget.value)
return [ "/help", "/?" ].find(command => this.inputTarget.value.includes(command))
}
get #isHelpMenuOpened() {
@@ -175,6 +184,10 @@ export default class extends Controller {
this.inputTarget.value = this.originalInputValue
this.confirmationTarget.value = "confirmed"
this.#hideOutput()
this.#submitCommand()
}
#submitCommand() {
this.formTarget.requestSubmit()
this.#reset()
}
+1 -1
View File
@@ -1,6 +1,6 @@
class Card < ApplicationRecord
include Assignable, Colored, Engageable, Entropic, Eventable,
Golden, Mentions, Pinnable, Closeable, Readable, Searchable,
Golden, Mentions, Multistep, Pinnable, Closeable, Readable, Searchable,
Staged, Stallable, Statuses, Taggable, Watchable
belongs_to :collection, touch: true
+1
View File
@@ -9,6 +9,7 @@ module Card::Closeable
scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) }
scope :closed_at_window, ->(window) { closed.where("closures.created_at": window) }
scope :closed_by, ->(users) { closed.where("closures.user_id": Array(users)) }
end
def closed?
+1
View File
@@ -44,6 +44,7 @@ module Card::Engageable
transaction do
reopen
engagement&.destroy
activity_spike&.destroy
touch_last_active_at
end
end
+7
View File
@@ -0,0 +1,7 @@
module Card::Multistep
extend ActiveSupport::Concern
included do
has_many :steps, dependent: :destroy
end
end
+2
View File
@@ -4,6 +4,8 @@ module Card::Statuses
included do
enum :status, %w[ creating drafted published ].index_by(&:itself)
after_create -> { track_event :published }, if: :published?
scope :published_or_drafted_by, ->(user) { where(status: :published).or(where(status: :drafted, creator: user)) }
end
+7 -6
View File
@@ -12,7 +12,7 @@ class Command::Ai::Parser
def parse(query)
normalized_query = resolve_named_params_to_ids command_translator.translate(query)
build_composite_command_for normalized_query, query
build_command_for normalized_query, query
end
private
@@ -20,7 +20,7 @@ class Command::Ai::Parser
Command::Ai::Translator.new(context)
end
def build_composite_command_for(normalized_query, query)
def build_command_for(normalized_query, query)
query_context = context_from_query(normalized_query)
resolved_context = query_context || context
@@ -43,10 +43,11 @@ class Command::Ai::Parser
def resolve_named_params_to_ids(normalized_query)
normalized_query.tap do |query_json|
if query_context = query_json[:context].presence
query_context[:assignee_ids] = query_context[:assignee_ids]&.filter_map { |name| assignee_from(name)&.id }
query_context[:creator_ids] = query_context[:creator_ids]&.filter_map { |name| assignee_from(name)&.id }
query_context[:collection_ids] = query_context[:collection_ids]&.filter_map { |name| Collection.where("lower(name) like ?", "%#{name.downcase}%").first&.id }
query_context[:tag_ids] = query_context[:tag_ids]&.filter_map { |name| ::Tag.find_by_title(name)&.id }
query_context[:assignee_ids] = query_context[:assignee_ids]&.filter_map { |name| context.find_user(name)&.id }
query_context[:creator_ids] = query_context[:creator_ids]&.filter_map { |name| context.find_user(name)&.id }
query_context[:closer_ids] = query_context[:closer_ids]&.filter_map { |name| context.find_user(name)&.id }
query_context[:collection_ids] = query_context[:collection_ids]&.filter_map { |name| context.find_collection(name)&.id }
query_context[:tag_ids] = query_context[:tag_ids]&.filter_map { |name| context.find_tag(name)&.id }
query_context.compact!
end
end
+49 -13
View File
@@ -16,9 +16,15 @@ class Command::Ai::Translator
end
private
# We don't inject +user.to_gid+ directly in the prompts because of testing and VCR. The URL changes
# depending on the tenant, which is not deterministic during tests with parallel tests.
ME_REFERENCE = "<fizzy:ME>"
def translate_query_with_llm(query)
response = Rails.cache.fetch(cache_key_for(query)) { chat.ask query }
response.content
response
.content
.gsub(ME_REFERENCE, user.to_gid.to_s)
end
def cache_key_for(query)
@@ -45,6 +51,7 @@ class Command::Ai::Translator
"assignment_status": "unassigned",
"card_ids": number[],
"creator_ids": string[],
"closer_ids": string[],
"collection_ids": string[],
"tag_ids": string[],
"creation": "today" | "yesterday" | "thisweek" | "thismonth" | "thisyear"
@@ -77,7 +84,7 @@ class Command::Ai::Translator
Cards have comments and live inside collections.
Context filters describe card state already true.
Commands (/assign, /tag, /close, /search, /clear, /do, /consider, /stage, /visit, /add_card, /user) apply new actions.
Commands (/assign, /tag, /close, /reopen, /search, /clear, /do, /consider, /stage, /visit, /add, /user) apply new actions.
Context properties you may use
* terms array of keywords
@@ -86,6 +93,7 @@ class Command::Ai::Translator
* assignment_status "unassigned". Important: ONLY when the user asks for unassigned cards.
* card_ids array of card IDs
* creator_ids array of creators names
* closer_ids array of closers names: people who closed or completed the card
* collection_ids array of collections
* tag_ids array of tag names
* creation relative range when the card was **created** (values listed above). Use it only
@@ -100,7 +108,7 @@ class Command::Ai::Translator
* Use terms only if the query explicitly refers to cards; plain-text searches go to /search.
* Numbers without the word "card(s)" default to terms **unless the number is the direct object of an
action verb that operates on cards (move, assign, tag, close, stage, consider, do, etc.).**
action verb that operates on cards (move, assign, tag, close, reopen, stage, consider, do, etc.).**
"123" (with no action verb) context: { terms: ["123"] }
"card 123" context: { card_ids: [123] }
"card 1,2" context: { card_ids: [1, 2] }
@@ -125,14 +133,24 @@ class Command::Ai::Translator
* "Stagnated or stalled cards" indexed_by: "stalled"
* "Closing soon" cards indexed_by: "closing_soon"
* "Falling back soon" cards indexed_by: "falling_back_soon"
* Completed by X closer_ids: ["X"]
* Cards I completed closer_ids: ["#{ME_REFERENCE}"]
* **Past-tense** tagged with #X”, “#X cards” → tag_ids: ["X"] (filter)
* **Imperative** tag , tag with #X”, “add the #X tag”, “apply #X” → command /tag #X (never a filter)
* For command that acts on cards, you can reference those by their ID (number). Use the filter "card_ids" when the user passes numbers as command arguments.
- User can reference with numbers to a single card or to a group of cards. E.g:
- "close 123 and 456" context: { card_ids: [123, 456] }, commands: [ "/close" ]
- "assign 789 to jz" context: { card_ids: [789] }, commands: [ "/assign jz" ]
- "assign 3 and 4 to myself" context: { card_ids: [3, 4] }, commands: [ "/assign #{ME_REFERENCE}" ]
- "reopen 789" context: { card_ids: [789] }, commands: [ "/reopen" ]
- "close 321" context: { card_ids: [321] }, commands: [ "/close" ]
- "assign 5, 82 and 9 to jz" context: { card_ids: [5, 82 and 9] }, commands: [ "/assign jz" ]
* "Unassigned cards" (or not assigned, with no assignee) assignment_status: "unassigned".
IMPORTANT: Only set assignment_status when the user **explicitly** asks for an unassigned state
Do NOT infer unassigned just because an assignment follows
Do NOT infer unassigned just because an assignment follows.
* **Possessive my in front of card or cards***
assignee_ids: [ #{user.name} ] — applies **even when other filters are present***
(e.g., my cards closing soon, my stalled cards, my cards created yesterday).
assignee_ids: [ "#{ME_REFERENCE}" ] applies **even when other filters are present***
(e.g., my cards closing soon, my stalled cards, my cards created yesterday, "cards assigned to me").
* Recent cards (i.e., newly created) indexed_by: "newest"
* Cards with recent activity, recently updated cards indexed_by: "latest"
Only use "latest" if the user mentions activity, updates, or changes
@@ -146,25 +164,26 @@ class Command::Ai::Translator
* Once you produce a valid context **or** command list, do not add a fallback /search.
-------------------- COMMAND INTERPRETATION RULES --------------------
* /user <Name> → open that persons profile or activity feed.
Phrases like visit user <Name>, view user <Name>, see <Name>s profile must map to **/user**, **never** to /visit.
* /visit <url|path> → open any other URL or internal path (cards, settings, etc.).
* /do → engage with card and move it to "doing"
* /consider → move card back to "considering" (reconsider)
* /reopen → reopen closed cards (moves them back to "open")
* Unless a clear command applies, fallback to /search with the verbatim text.
* When searching for nouns (non-person), prefer /search over terms.
* Respect the spoken order of commands.
* "close as [reason]" or "close because [reason]" /close [reason]
Remove "as" or "because" from the actual command
* Lone "close" /close (acts on current context)
* Lone "reopen" /reopen (acts on current context)
* /close must **only** be produced if the request explicitly contains the verb “close”.
* /reopen must **only** be produced if the request explicitly contains the verb “reopen”.
* /stage [workflow stage]→ assign the card to the given stage (never takes card IDs).
* Move <ID(s)> to <Stage> context.card_ids = [IDs]; command /stage <Stage>
* Move <ID(s)> to doing context.card_ids = [IDs]; command /do
* Move <ID(s)> to considering context.card_ids = [IDs]; command /consider
* /add_card → Create a new card with a blank title
* /add_card [title] → Create a new card with the provided title
* /add → Create a new card with a blank title
* /add [title] → Create a new card with the provided title
---------------------------- VISIT SCREENS ---------------------------
@@ -178,18 +197,22 @@ class Command::Ai::Translator
------------------------- VISIT USER PROFILES ------------------------
Use **/user <Name>** (not /visit) whenever the request is about viewing a persons profile or activity:
Use **/user <Name>** (not /visit) whenever the request is about viewing a persons profile, activity
or what that person is up to:
visit user mike /user mike*
view user kevin /user kevin*
see mikes profile /user mike
check what david has been up to /user david
---------------------------- CRUCIAL DONTS ---------------------------
* When the query contains active verbs such as "assign", "close", or "reopen", always use the corresponding command, NEVER a filter.
* Dont output /visit /users/<name>. Profile requests must use **/user <name>**.
* Never use names, tags, or stage names mentioned **inside commands** (like /assign, /tag, /stage) as filters.
* Never duplicate the assignee in both commands and context.
* Never add properties tied to UI view ("card", "list", etc.).
* When using /user DON'T add a filter "assignee_ids" with the same user.
* To filter completed or closed cards, use "indexed_by: closed"; don't set a "closure" filter unless the user is asking for cards completed in a specific window of time.
* When you see a word with a # prefix, assume it refers to a tag (either a filter or a command argument, but don't search for it).
* All filters, including terms, must live **inside** context.
@@ -223,6 +246,19 @@ class Command::Ai::Translator
"context": { "assignee_ids": ["jz"] }
}
User: assign to me
Output:
{
"commands": ["/assign #{ME_REFERENCE}"]
}
User: reopen cards closed this week
Output:
{
"context": { "closure": "thisweek", "indexed_by": "closed" },
"commands": ["/reopen"]
}
User: tag with #design*
Output:
{
@@ -299,7 +335,7 @@ class Command::Ai::Translator
def custom_context
<<~PROMPT
The name of the user making requests is #{user.first_name.downcase}.
The user making requests is "#{ME_REFERENCE}".
## Current view:
+4 -4
View File
@@ -37,11 +37,11 @@ class Command::Assign < Command
end
end
private
def assignees
User.where(id: assignee_ids)
end
def assignees
User.where(id: assignee_ids)
end
private
def assign(assignee, card, toggled_assignees_by_card)
unless card.assigned_to?(assignee)
toggled_assignees_by_card[card.id] << assignee.id
+33 -27
View File
@@ -10,42 +10,66 @@ class Command::Parser
def parse(string)
parse_command(string).tap do |command|
command.user = user
command.line ||= string
command.line ||= as_plain_text(string)
command.context ||= context
command.default_url_options[:script_name] = script_name
end
end
private
def as_plain_text_with_attachable_references(string)
ActionText::Content.new(string).render_attachments(&:to_gid).fragment.to_plain_text
end
def as_plain_text(string)
ActionText::Content.new(string).to_plain_text
end
def parse_command(string)
command_name, *command_arguments = string.strip.split(" ")
combined_arguments = command_arguments.join(" ")
rich_text_command = as_plain_text_with_attachable_references(string)
plain_text_command = as_plain_text(string)
parse_plain_text_command(plain_text_command) || parse_rich_text_command(rich_text_command)
end
def parse_plain_text_command(string)
command_name, *_ = string.strip.split(" ")
case command_name
when /^#/
Command::FilterByTag.new(tag_title: tag_title_from(string), params: filter.as_params)
when /^@/
Command::GoToUser.new(user_id: assignee_from(command_name)&.id)
Command::GoToUser.new(user_id: context.find_user(command_name)&.id)
end
end
def parse_rich_text_command(string)
command_name, *command_arguments = string.strip.split(" ")
combined_arguments = command_arguments.join(" ")
case command_name
when "/user"
Command::GoToUser.new(user_id: assignee_from(combined_arguments)&.id)
Command::GoToUser.new(user_id: context.find_user(combined_arguments)&.id)
when "/assign", "/assignto"
Command::Assign.new(assignee_ids: assignees_from(command_arguments).collect(&:id), card_ids: cards.ids)
when "/clear"
Command::ClearFilters.new(params: filter.as_params)
when "/close"
Command::Close.new(card_ids: cards.ids, reason: combined_arguments)
when "/reopen"
Command::Reopen.new(card_ids: cards.ids)
when "/consider", "/reconsider"
Command::Consider.new(card_ids: cards.ids)
when "/do"
Command::Do.new(card_ids: cards.ids)
when "/insight"
Command::GetInsight.new(query: combined_arguments, card_ids: cards.ids)
when "/add_card"
when "/add"
Command::AddCard.new(card_title: combined_arguments, collection_id: guess_collection&.id)
when "/search"
Command::Search.new(terms: combined_arguments)
when "/stage"
Command::Stage.new(stage_id: stage_from(combined_arguments)&.id, card_ids: cards.ids)
Command::Stage.new(stage_id: context.find_workflow_stage(combined_arguments)&.id, card_ids: cards.ids)
when "/visit"
Command::VisitUrl.new(url: command_arguments.first)
when "/tag"
@@ -55,23 +79,9 @@ class Command::Parser
end
end
private
def assignees_from(strings)
Array(strings).filter_map do |string|
assignee_from(string)
end
end
# TODO: This is temporary as it can be ambiguous. We should inject the user ID in the command
# under the hood instead, as determined by the user picker. E.g: @1234.
def assignee_from(string)
string_without_at = string.delete_prefix("@")
User.all.find { |user| user.mentionable_handles.include?(string_without_at.downcase) }
end
def stage_from(combined_arguments)
candidate_stages.find do |stage|
stage.name.downcase.include?(combined_arguments.downcase)
context.find_user(string)
end
end
@@ -79,12 +89,8 @@ class Command::Parser
cards.first&.collection || Collection.first
end
def candidate_stages
Workflow::Stage.where(workflow_id: cards.joins(:collection).select("collections.workflow_id").distinct)
end
def tag_title_from(string)
string.gsub(/^#/, "")
context.find_tag(string)&.title || string.gsub(/^#/, "")
end
def parse_free_string(string)
+33
View File
@@ -29,6 +29,35 @@ class Command::Parser::Context
viewing_cards_index? || viewing_search_results?
end
def find_user(string)
string = string.delete_prefix("@")
if string.starts_with?("gid://")
User.find_by_id(GlobalID::Locator.locate(string).id)
else
User.all.find { |user| user.mentionable_handles.include?(string.downcase) }
end
end
def find_workflow_stage(string)
candidate_stages.find do |stage|
stage.name.downcase.include?(string.downcase)
end
end
def find_tag(string)
string = string.delete_prefix("#")
if string.starts_with?("gid://")
Tag.find_by_id(GlobalID::Locator.locate(string).id)
else
Tag.find_by_title(string)
end
end
def find_collection(string)
Collection.where("lower(name) like ?", "%#{string.downcase}%").first
end
private
attr_reader :controller, :action, :params
@@ -59,4 +88,8 @@ class Command::Parser::Context
@action = route[:action]
@params = ActionController::Parameters.new(Rack::Utils.parse_nested_query(uri.query).merge(route.except(:controller, :action)))
end
def candidate_stages
Workflow::Stage.where(workflow_id: cards.joins(:collection).select("collections.workflow_id").distinct)
end
end
+35
View File
@@ -0,0 +1,35 @@
class Command::Reopen < Command
include Command::Cards
store_accessor :data, :reopened_card_ids
def title
"Reopen #{cards_description}"
end
def execute
reopened_card_ids = []
transaction do
cards.find_each do |card|
reopened_card_ids << card.id
card.reopen(user: user)
end
update! reopened_card_ids: reopened_card_ids
end
end
def undo
transaction do
reopened_cards.find_each do |card|
card.close(user: user)
end
end
end
private
def reopened_cards
user.accessible_cards.where(id: reopened_card_ids)
end
end
+7 -1
View File
@@ -20,7 +20,7 @@ class Filter < ApplicationRecord
@cards ||= begin
result = creator.accessible_cards.indexed_by(indexed_by)
result = result.where(id: card_ids) if card_ids.present?
result = result.open unless indexed_by.closed? || closure_window || card_ids.present?
result = result.open unless include_closed_cards?
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?
@@ -30,6 +30,7 @@ class Filter < ApplicationRecord
result = result.tagged_with(tags.ids) if tags.present?
result = result.where("cards.created_at": creation_window) if creation_window
result = result.closed_at_window(closure_window) if closure_window
result = result.closed_by(closers) if closers.present?
result = terms.reduce(result) do |result, term|
result.mentioning(term)
end
@@ -57,4 +58,9 @@ class Filter < ApplicationRecord
def cache_key
ActiveSupport::Cache.expand_cache_key collections.cache_key_with_version, super
end
private
def include_closed_cards?
indexed_by.closed? || closure_window || closers.present? || card_ids.present?
end
end
+2
View File
@@ -10,6 +10,7 @@ module Filter::Params
card_ids: [],
assignee_ids: [],
creator_ids: [],
closer_ids: [],
collection_ids: [],
stage_ids: [],
tag_ids: [],
@@ -50,6 +51,7 @@ module Filter::Params
params[:card_ids] = card_ids
params[:assignee_ids] = assignees.ids
params[:creator_ids] = creators.ids
params[:closer_ids] = closers.ids
end.compact_blank.reject(&method(:default_value?))
end
+1
View File
@@ -7,6 +7,7 @@ module Filter::Resources
has_and_belongs_to_many :stages, class_name: "Workflow::Stage", join_table: "filters_stages"
has_and_belongs_to_many :assignees, class_name: "User", join_table: "assignees_filters", association_foreign_key: "assignee_id"
has_and_belongs_to_many :creators, class_name: "User", join_table: "creators_filters", association_foreign_key: "creator_id"
has_and_belongs_to_many :closers, class_name: "User", join_table: "closers_filters", association_foreign_key: "closer_id"
end
def resource_removed(resource)
+1 -1
View File
@@ -13,7 +13,7 @@ class Notification < ApplicationRecord
delegate :card, to: :source
def self.read_all
update!(read_at: Time.current)
all.each { |notification| notification.read }
end
def read
+9
View File
@@ -0,0 +1,9 @@
class Step < ApplicationRecord
belongs_to :card, touch: true
scope :completed, -> { where(completed: true) }
def completed?
completed
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
class Tag < ApplicationRecord
include Filterable
include Attachable, Filterable
has_many :taggings, dependent: :destroy
has_many :cards, through: :taggings
+11
View File
@@ -0,0 +1,11 @@
module Tag::Attachable
extend ActiveSupport::Concern
included do
include ActionText::Attachable
end
def attachable_plain_text_representation(...)
"##{title}"
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
class User < ApplicationRecord
include Accessor, ActionText::Attachable, Assignee, Mentionable, Named, Role, Searcher,
include Accessor, Attachable, Assignee, Mentionable, Named, Role, Searcher,
SignalUser, Transferable
include Timelined # Depends on Accessor
+11
View File
@@ -0,0 +1,11 @@
module User::Attachable
extend ActiveSupport::Concern
included do
include ActionText::Attachable
end
def attachable_plain_text_representation(...)
"@#{first_name.downcase}"
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
<li class="flex align-center gap-half pad-inline" data-filter-target="item">
<%= avatar_tag user %>
<%= avatar_tag user, hidden_for_screen_reader: true %>
<strong class="overflow-ellipsis">
<%= link_to user.name, user, class: "txt-ink btn btn--plain" %>
+2
View File
@@ -19,6 +19,8 @@
<%= render "cards/stagings/stages", card: card if card.doing? %>
</div>
<%= render "cards/display/perma/steps", card: card %>
<footer class="card__footer full-width flex align-start gap">
<%= render "cards/display/perma/meta", card: card %>
<%= render "cards/display/perma/background", card: card %>
+9 -7
View File
@@ -8,14 +8,16 @@
<div class="comment__content flex flex-column flex-item-grow full-width">
<div class="comment__author flex align-center gap-half">
<strong>
<%= link_to comment.creator.name, comment.creator, class: "txt-ink btn btn--plain fill-transparent", data: { turbo_frame: "_top" } %>
</strong>
<h3 class="font-weight-normal txt-normal flex-item-justify-start">
<strong>
<%= link_to comment.creator.name, comment.creator, class: "txt-ink btn btn--plain fill-transparent", data: { turbo_frame: "_top" } %>
</strong>
<%= link_to comment, class: "txt-undecorated txt-ink translucent txt-normal txt-capitalize flex-item-justify-start" do %>
<%= local_datetime_tag comment.created_at, style: :agoorweekday %>,
<%= local_datetime_tag comment.created_at, style: :time %>
<% end %>
<%= link_to comment, class: "txt-undecorated txt-ink translucent txt-normal txt-capitalize" do %>
<%= local_datetime_tag comment.created_at, style: :agoorweekday %>,
<%= local_datetime_tag comment.created_at, style: :time %>
<% end %>
</h3>
<%= link_to edit_card_comment_path(comment.card, comment),
class: "comment__edit btn btn--circle borderless fill-transparent translucent" do %>
@@ -1,4 +1,4 @@
<div class="quick-reaction" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<div class="quick-reaction" data-controller="dialog" data-action="keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside">
<%= yield %>
<dialog class="quick-reaction__popup popup panel fill-white shadow" data-dialog-target="dialog">
+1 -1
View File
@@ -7,7 +7,7 @@
<% end %>
</div>
<% else %>
<details class="expander" data-controller="details" data-details-target="details" data-action="keydown.esc->details#close click@document->details#closeOnClickOutside">
<details class="expander" data-controller="details" data-details-target="details" data-action="keydown.esc->details#close:stop click@document->details#closeOnClickOutside">
<summary class="expander__button btn borderless">
Close as
<%= icon_tag "caret-down" %>
+2 -3
View File
@@ -5,13 +5,12 @@
<% end %>
<% elsif !card.closed? %>
<%= form_with model: card, url: collection_card_path(card.collection, card), data: { controller: "form" } do |form| %>
<label class="btn">
<label class="btn input--file">
<%= icon_tag "picture-add" %>
<span class="for-screen-reader">Add background image</span>
<%= form.file_field :image, class: "input",
accept: "image/png, image/jpeg, image/jpg, image/webp",
data: { action: "upload-preview#previewImage form#submit", upload_preview_target: "input" },
hidden: true %>
data: { action: "upload-preview#previewImage form#submit", upload_preview_target: "input" } %>
</label>
<% end %>
<% end %>
+3 -2
View File
@@ -6,6 +6,7 @@
<header class="card__header">
<%= render "cards/display/preview/collection", card: card %>
<%= render "cards/display/preview/tags", card: card %>
<%= render "cards/display/preview/steps", card: card %>
<% if card.staged? && card.doing? %>
<span class="btn justify-start workflow-stage workflow-stage--current txt-uppercase min-width max-width">
@@ -16,9 +17,9 @@
<div class="card__body justify-space-between">
<div class="card__content">
<h1 class="card__title overflow-line-clamp">
<h3 class="card__title overflow-line-clamp">
<%= card.title %>
</h1>
</h3>
<%= link_to collection_card_path(card.collection, card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %>
<span class="for-screen-reader"><%= card.title %></span>
@@ -0,0 +1,10 @@
<ol class="steps txt-small margin-block">
<%= render partial: "cards/steps/step", collection: card.steps, as: :step %>
<div class="step">
<input type="checkbox" class="step__checkbox" disabled>
<%= form_with model: [card, Step.new], url: card_steps_path(card) do |form| %>
<%= form.text_field :content, class: "input step__content", placeholder: "Add a step…", required: true, autocomplete: "off" %>
<% end %>
</div>
</ol>
@@ -0,0 +1,8 @@
<% if card.steps.any? %>
<div class="card__steps align-center gap-half flex-item-justify-end flex-item-no-shrink">
<span class="steps__icon">
<%= icon_tag "check" %>
</span>
<strong><%= "#{card.steps.completed.count}/#{card.steps.count}" %></strong>
</div>
<% end %>
+3 -3
View File
@@ -1,4 +1,4 @@
<% @page_title = @filter.summary %>
<% @page_title = filter_title @filter %>
<%= render "filters/broadcasts", filter: @filter %>
@@ -7,9 +7,9 @@
<% end %>
<header class="margin-block-end">
<span class="txt-large overflow-ellipsis margin-none font-black" style="view-transistion-name: card-collection-title">
<h1 class="txt-large overflow-ellipsis margin-none font-black" style="view-transistion-name: card-collection-title">
<span><%= filter_title @filter %></span>
</span>
</h1>
<%= render "filters/settings", filter: @filter %>
</header>
+8
View File
@@ -0,0 +1,8 @@
<%= turbo_frame_tag step do %>
<li class="step">
<%= form_with model: [step.card, step], data: { controller: "form" } do |form| %>
<%= form.check_box :completed, { class: "step__checkbox", data: { action: "change->form#submit" } } %>
<% end %>
<%= link_to step.content, edit_card_step_path(step.card, step), class: "step__content" %>
</li>
<% end %>
+18
View File
@@ -0,0 +1,18 @@
<%= turbo_frame_tag @step do %>
<div class="flex align-center gap-half">
<%= form_with model: [@card, @step], class: "step", data: { controller: "form" } do |form| %>
<%= form.check_box :completed, { class: "step__checkbox", checked: @step.completed?, disabled: true } %>
<%= form.text_field :content, class: "input step__content step__content--edit", placeholder: "Name this step…", required: true, autofocus: true, autocomplete: "off",
data: { action: "keydown.esc->form#cancel focus->form#select" } %>
<%= form.button type: "submit", class: "btn btn--positive txt-xx-small" do %>
<%= icon_tag "check" %>
<span class="for-screen-reader">Save changes</span>
<% end %>
<%= link_to "Cancel changes", card_step_path(@card, @step), data: { form_target: "cancel" }, hidden: true %>
<% end %>
<%= button_to card_step_path(@card, @step), method: :delete, class: "btn btn--negative txt-xx-small" do %>
<%= icon_tag "trash" %>
<span class="for-screen-reader">Delete this step</span>
<% end %>
</div>
<% end %>
+3
View File
@@ -0,0 +1,3 @@
<%= turbo_frame_tag @step do %>
<%= render "cards/steps/step", step: @step %>
<% end %>
@@ -1,4 +1,6 @@
<div class="margin-block-end">
<strong class="divider txt-large">Do or Die</strong>
<%= render "entropy/auto_close", model: collection %>
</div>
<%= turbo_frame_tag @collection, :entropy_configuration do %>
<div class="margin-block-end">
<strong class="divider txt-large">Do or Die</strong>
<%= render "entropy/auto_close", model: collection, url: collection_entropy_configuration_path(collection) %>
</div>
<% end %>
@@ -1,54 +1,55 @@
<div class="margin-block">
<strong class="divider txt-large">Public link</strong>
<p class="margin-none-block-start">Turn on the Public link to share this collection with anyone in the world. They wont need to log in and they wont be able to see anything else in Fizzy.</p>
<%= turbo_frame_tag @collection, :publication do %>
<div class="margin-block">
<strong class="divider txt-large">Public link</strong>
<p class="margin-none-block-start">Turn on the Public link to share this collection with anyone in the world. They wont need to log in and they wont be able to see anything else in Fizzy.</p>
<% if collection.published? %>
<div class="border-radius pad fill-selected">
<div class="flex-inline center justify-between gap">
<span class="txt-large"><%= icon_tag "lock" %></span>
<label class="switch flex align-center justify-between">
<%= form_with url: collection_publication_path(collection), method: :delete, data: { controller: "form" } do |form| %>
<%= form.check_box :published, class: "switch__input", checked: true, data: { action: "change->form#submit" } %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Turn off the public link</span>
<% end %>
</label>
<span class="txt-large"><%= icon_tag "world" %></span>
</div>
<% if collection.published? %>
<div class="border-radius pad fill-selected">
<div class="flex-inline center justify-between gap">
<span class="txt-large"><%= icon_tag "lock" %></span>
<label class="switch flex align-center justify-between">
<%= form_with url: collection_publication_path(collection), method: :delete, data: { controller: "form" } do |form| %>
<%= form.check_box :published, class: "switch__input", checked: true, data: { action: "change->form#submit" } %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Turn off the public link</span>
<% end %>
</label>
<span class="txt-large"><%= icon_tag "world" %></span>
</div>
<div class="flex align-center gap-half margin-block">
<%= text_field_tag :publication_url, published_collection_url(collection), readonly: true, class: "full-width input fill-white" %>
<div class="flex align-center justify-center gap-half">
<%= button_to_copy_to_clipboard(published_collection_url(collection)) do %>
<%= icon_tag "copy-paste" %>
<span class="for-screen-reader">Copy public link</span>
<div class="flex align-center gap-half margin-block">
<%= text_field_tag :publication_url, published_collection_url(collection), readonly: true, class: "full-width input fill-white" %>
<div class="flex align-center justify-center gap-half">
<%= button_to_copy_to_clipboard(published_collection_url(collection)) do %>
<%= icon_tag "copy-paste" %>
<span class="for-screen-reader">Copy public link</span>
<% end %>
</div>
</div>
<strong class="margin-block-end-half flex justify-center txt-small">Add an optional description to the public page</strong>
<div class="border-radius input fill-white">
<%= form_with model: collection, class: "txt-align-start", data: { controller: "form" } do |form| %>
<%= form.rich_textarea :public_description, class: "rich-text-content txt-small",
placeholder: "Add a public note about this collection…",
data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } %>
<%= form.button "Save changes", type: :submit, class: "btn txt-small" %>
<% end %>
</div>
</div>
<strong class="margin-block-end-half flex justify-center txt-small">Add an optional description to the public page</strong>
<div class="border-radius input fill-white">
<%= form_with model: collection, class: "txt-align-start", data: { controller: "form" } do |form| %>
<%= form.rich_textarea :public_description, class: "rich-text-content txt-small",
placeholder: "Add a public note about this collection…",
data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } %>
<%= form.button "Save changes", type: :submit, class: "btn txt-small" %>
<% end %>
<% else %>
<div class="border-radius pad fill-shade">
<div class="flex-inline center justify-between gap">
<span class="txt-large"><%= icon_tag "lock" %></span>
<label class="switch flex align-center justify-between">
<%= form_with url: collection_publication_path(collection), method: :post, data: { controller: "form" } do |form| %>
<%= form.check_box :published, class: "switch__input", checked: false, data: { action: "change->form#submit" } %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Turn on the public link</span>
<% end %>
</label>
<span class="txt-large"><%= icon_tag "world" %></span>
</div>
</div>
</div>
<% else %>
<div class="border-radius pad fill-shade">
<div class="flex-inline center justify-between gap">
<span class="txt-large"><%= icon_tag "lock" %></span>
<label class="switch flex align-center justify-between">
<%= form_with url: collection_publication_path(collection), method: :post, data: { controller: "form" } do |form| %>
<%= form.check_box :published, class: "switch__input", checked: false, data: { action: "change->form#submit" } %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Turn on the public link</span>
<% end %>
</label>
<span class="txt-large"><%= icon_tag "world" %></span>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
+23 -21
View File
@@ -1,21 +1,23 @@
<strong class="txt-large">Workflows</strong>
<div class="flex justify-center margin-block-end">Use a Workflow to track progress in this Collection.</div>
<div class="flex flex-wrap gap justify-center">
<% Workflow.all.each do |workflow| %>
<div class="workflow-preview txt-align-start border-radius <%= 'workflow-preview--selected' if workflow == collection.workflow %>">
<%= button_to collection_workflow_path(collection), method: :patch do %>
<%= hidden_field_tag "collection[workflow_id]", workflow.id %>
<strong class="txt-small overflow-ellipsis flex-item-no-shrink"><%= workflow.name %></strong>
<ul class="list-style-none txt-xx-small margin-none flex flex-column gap-half">
<% workflow.stages.each do |stage| %>
<li class="overflow-ellipsis flex align-center gap-half min-width">
<span class="workflow-preview__swatch btn btn--circle" style="--btn-background: <%= stage.color.present? ? stage.color : Card::Colored::DEFAULT_COLOR %>;"></span>
<span class="overflow-ellipsis"><%= stage.name %></span>
</li>
<% end %>
</ul>
<% end %>
</div>
<% end %>
</div>
<p class="margin-block-end-double"><%= link_to "Manage workflows", workflows_path, class: "btn btn--plain txt-link" %></p>
<%= turbo_frame_tag @collection, :workflows do %>
<strong class="txt-large">Workflows</strong>
<div class="flex justify-center margin-block-end">Use a Workflow to track progress in this Collection.</div>
<div class="flex flex-wrap gap justify-center">
<% Workflow.all.each do |workflow| %>
<div class="workflow-preview txt-align-start border-radius <%= 'workflow-preview--selected' if workflow == collection.workflow %>">
<%= button_to collection_workflow_path(collection), method: :patch, aria: { label: workflow == collection.workflow ? "Stop using this workflow" : "Use this workflow" } do %>
<%= hidden_field_tag "collection[workflow_id]", workflow == collection.workflow ? "" : workflow.id %>
<strong class="txt-small overflow-ellipsis flex-item-no-shrink"><%= workflow.name %></strong>
<ul class="list-style-none txt-xx-small margin-none flex flex-column gap-half">
<% workflow.stages.each do |stage| %>
<li class="overflow-ellipsis flex align-center gap-half min-width">
<span class="workflow-preview__swatch btn btn--circle" style="--btn-background: <%= stage.color.present? ? stage.color : Card::Colored::DEFAULT_COLOR %>;"></span>
<span class="overflow-ellipsis"><%= stage.name %></span>
</li>
<% end %>
</ul>
<% end %>
</div>
<% end %>
</div>
<p class="margin-block-end-double"><%= link_to "Manage workflows", workflows_path, class: "btn btn--plain txt-link", data: { turbo_frame: "_top" } %></p>
<% end %>
+26 -11
View File
@@ -4,21 +4,36 @@
data: {
controller: "form",
terminal_target: "form",
action: "turbo:submit-start->terminal#commandSubmitted turbo:submit-end->terminal#focus keydown.meta+k@document->terminal#focus:prevent keydown.ctrl+k@document->terminal#focus:prevent keydown.enter->terminal#executeCommand keydown.esc->terminal#hideMenus turbo:submit-end->terminal#handleCommandResponse"
action: "
keydown.enter->terminal#submitCommand
keydown.up->toggle-class#add:prevent
keydown.up->navigable-list#selectCurrentOrReset:stop
terminal#hideError
turbo:submit-start->terminal#commandSubmitted
turbo:submit-end->terminal#focus
keydown.meta+k@document->terminal#focus:prevent
keydown.ctrl+k@document->terminal#focus:prevent
keydown.enter->terminal#executeCommand
keydown.esc->terminal#hideMenus
turbo:submit-end->terminal#handleCommandResponse
"
} do %>
<label class="terminal__label txt-nowrap" for="fizzy_do">Fizzy do &gt;</label>
<label class="terminal__label txt-nowrap" for="command">Fizzy do &gt;</label>
<%= text_field_tag "command", nil,
autocomplete: "off",
autocorrect: "off",
autocapitalize: "off",
<%= rich_textarea_tag "command", nil,
toolbar: false,
class: "terminal__input input hide-focus-ring fill-transparent unpad",
"single-line": true,
attachments: false,
data: {
terminal_target: "input",
action: "keydown.up->toggle-class#add:prevent keydown.up->navigable-list#selectCurrentOrReset:stop terminal#hideError"
terminal_target: "input"
},
placeholder: platform.desktop? ? "Press #{ hotkey_label(["ctrl", "K"]) } to search or type commands…" : "Search or type commands…",
spellcheck: "false" %>
placeholder: platform.desktop? ? "Press #{ hotkey_label(["ctrl", "K"]) } to search or type /commands…" : "Search or type /commands…",
spellcheck: "false" do %>
<%= global_mentions_prompt %>
<%= tags_prompt %>
<%= commands_prompt %>
<% end %>
<%= hidden_field_tag "confirmed", nil, data: { terminal_target: "confirmation" } %>
<%= hidden_field_tag "confirmed", nil, data: { terminal_target: "confirmation" } %>
<% end %>
+1 -1
View File
@@ -18,7 +18,7 @@
<dd>Clear all filters</dd>
<h3>Cards</h3>
<dt><code>/add_card [title]</code></dt>
<dt><code>/add [title]</code></dt>
<dd>Create a new card</dd>
<dt><code>/assign [person] [person]…</code></dt>
<dd>Assign visible card(s)</dd>
+2 -2
View File
@@ -6,12 +6,12 @@
related_element_group_value: card.id,
action: "mouseover->related-element#highlight mouseout->related-element#unhighlight" } do %>
<div class="card__header flex align-start gap full-width">
<div class="card__collection flex-inline align-start">
<h4 class="card__collection flex-inline align-start">
<span class="card__id"><%= card.id %></span>
<span class="card__collection-name">
<span class="overflow-ellipsis"><%= event.collection.name %></span>
</span>
</div>
</h4>
<% if event_column(event) == 2 %>
<span class="event__icon flex-item-justify-end txt-reversed">
+1 -1
View File
@@ -36,5 +36,5 @@
</header>
<div class="events" id="activity">
<%= render "events/day", day_timeline: @day_timeline %>
<%= event_next_page_link(@day_timeline.next_day) %>
</div>
<%= event_next_page_link(@day_timeline.next_day) %>
+62
View File
@@ -0,0 +1,62 @@
<% if filter.closers.any? || @expand_all %>
<div class="quick-filter position-relative" data-controller="dialog"
data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<button class="btn input input--select flex-inline txt-x-small" data-action="click->dialog#open:stop">
<span class="overflow-ellipsis">
<% if filter.closers.any? %>
<%= "Closed by #{filter.closers.map(&:familiar_name).to_sentence(two_words_connector: " or ", last_word_connector: ", or ")}" %>
<% else %>
Closed by…
<% end %>
</span>
</button>
<%= tag.dialog class: "margin-block-start-half popup panel flex-column align-start gap-half fill-white shadow txt-small", data: {
action: "turbo:before-cache@document->dialog#close keydown->navigable-list#navigate filter:changed->navigable-list#reset toggle->filter#filter",
aria: { label: "Closed by…", aria_description: "Closed by…" },
controller: "filter navigable-list",
dialog_target: "dialog",
navigable_list_focus_on_selection_value: false,
navigable_list_actionable_items_value: true } do %>
<strong class="popup__title txt-nowrap">Closed by…</strong>
<% if User.active.many? %>
<%= text_field_tag :search, nil, placeholder: "Filter…", class: "input input--transparent txt-small", autofocus: true,
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
<% end %>
<ul class="popup__list" data-filter-target="list">
<% User.active.order(:name).each do |user| %>
<li class="popup__group flex align-center" data-filter-target="item" data-navigable-list-target="item">
<%= form_with url: cards_path, method: :get, class: "full-width", data: { controller: "form" } do |form| %>
<% filter.as_params.except(:closer_ids).each do |key, value| %>
<%= filter_hidden_field_tag key, value %>
<% end %>
<% filter.closers.each do |existing_closer| %>
<% unless existing_closer == user %>
<%= hidden_field_tag "closer_ids[]", existing_closer.id %>
<% end %>
<% end %>
<% unless filter.closers.include?(user) %>
<%= hidden_field_tag "closer_ids[]", user.id %>
<% end %>
<% if @expand_all %>
<%= hidden_field_tag :expand_all, @expand_all %>
<% end %>
<%= form.button type: "submit", class: "btn popup__item min-width full-width unpad-inline" do %>
<span class="overflow-ellipsis flex-item-grow"><%= user.name %></span>
<% if filter.closers.include?(user) %>
<%= icon_tag "check", class: "checked flex-item-justify-end" %>
<% end %>
<% end %>
<% end %>
</li>
<% end %>
</ul>
<% end %>
</div>
<% end %>
+2 -1
View File
@@ -18,6 +18,7 @@
<%= render "filters/tags", filter: filter %>
<%= render "filters/assignees", filter: filter %>
<%= render "filters/creators", filter: filter %>
<%= render "filters/closers", filter: filter %>
<%= render "filters/time_window", filter: filter, name: :creation, label: "Created" %>
<%= render "filters/time_window", filter: filter, name: :closure, label: "Closed" %>
<%# FIXME LATER: this needs to be able to allow selection of unique stages across all active workflows %>
@@ -43,7 +44,7 @@
<% end %>
<% end %>
<%= link_to cards_path(filter.as_params.except(:assignee_ids, :assignment_status, :card_ids, :creator_ids, :stage_ids, :tag_ids, :terms, :indexed_by, :creation, :closure)), class: "btn btn--remove txt-x-small" do %>
<%= link_to cards_path(filter.as_params.except(:assignee_ids, :assignment_status, :card_ids, :creator_ids, :closer_ids, :stage_ids, :tag_ids, :terms, :indexed_by, :creation, :closure)), class: "btn btn--remove txt-x-small" do %>
<%= icon_tag "close" %>
<span class="for-screen-reader">Clear all</span>
<% end %>
+1 -1
View File
@@ -4,7 +4,7 @@
<body data-controller="lightbox local-time timezone-cookie" data-action="turbo:morph@window->local-time#refreshAll">
<header id="header">
<a href="#main-content" class="skip-navigation btn">Skip to main content</a>
<a href="#main" class="skip-navigation btn" data-turbo="false">Skip to main content</a>
<%= yield :header %>
</header>
+1 -1
View File
@@ -4,7 +4,7 @@
<body class="public" data-controller="local-time timezone-cookie" data-action="turbo:morph@window->local-time#refreshAll">
<header id="header">
<a href="#main-content" class="skip-navigation btn">Skip to main content</a>
<a href="#main" class="skip-navigation btn" data-turbo="false">Skip to main content</a>
<%= yield :header %>
</header>
+1 -1
View File
@@ -18,7 +18,7 @@
<%= tenanted_action_cable_meta_tag %>
<%= yield :head %>
<link rel="manifest" href="/manifest.json">
<link rel="manifest" href="<%= pwa_manifest_path(format: :json) %>">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
+1 -1
View File
@@ -12,7 +12,7 @@
</button>
</div>
<button class="tray__expander" data-action="dialog#toggle" aria-label="Expand pins stack">
<button class="tray__expander" data-action="dialog#toggle" aria-label="Expand pins stack" aria-haspopup="true">
<%= icon_tag "pinned" %>
</button>
</section>
@@ -10,9 +10,9 @@
<%= avatar_image_tag notification.creator %>
</div>
<div class="flex flex-column min-width flex-item-grow">
<h3 class="flex flex-column min-width flex-item-grow font-weight-normal">
<%= render "notifications/notification/#{notification.source_type.underscore}/body", notification: notification %>
</div>
</h3>
</div>
<% end %>
<% end %>
+2 -2
View File
@@ -16,7 +16,7 @@
<div class="tray__actions">
<%= button_to read_all_notifications_path,
class: "tray__notification-read-action btn btn--reversed fill-transparent borderless flex-item-justify-start txt-xx-small",
title: "Mark all as read",
title: "Mark all notifications as read",
data: { action: "click->dialog#close", turbo_frame: "notifications" } do %>
<%= icon_tag "check" %>
<% end %>
@@ -34,7 +34,7 @@
<% end %>
</div>
<button class="tray__expander" data-action="dialog#toggle" aria-label="Expand notifications stack">
<button class="tray__expander" data-action="dialog#toggle" aria-label="Expand notifications stack" aria-haspopup="true">
<%= icon_tag "bell" %>
</button>
</section>
@@ -1,4 +1,4 @@
<lexical-prompt-item search="<%= "#{user.name} #{user.initials}" %>" sgid="<%= user.attachable_sgid %>">
<lexical-prompt-item search="<%= "#{user.name} #{user.initials} #{"me" if user == Current.user}" %>" sgid="<%= user.attachable_sgid %>">
<template type="menu">
<%= avatar_image_tag user %>
<%= user.name %>
@@ -0,0 +1,10 @@
<% command, description, editor_version = command %>
<lexical-prompt-item search="<%= description %> <%= command %>">
<template type="menu">
<code><%= command %></code> <%= description %>
</template>
<template type="editor">
<%= editor_version.gsub(/ +$/, "&nbsp;").html_safe %>
</template>
</lexical-prompt-item>
@@ -0,0 +1 @@
<%= render partial: "prompts/commands/command", collection: @commands %>
+8
View File
@@ -0,0 +1,8 @@
<lexical-prompt-item search="<%= tag.title %>" sgid="<%= tag.attachable_sgid %>">
<template type="menu">
#<%= tag.title %>
</template>
<template type="editor">
#<%= tag.title %>
</template>
</lexical-prompt-item>
+1
View File
@@ -0,0 +1 @@
<%= render partial: "prompts/tags/tag", collection: @tags %>
+1
View File
@@ -0,0 +1 @@
<%= render partial: "prompts/collections/users/user", collection: @users %>
+3 -3
View File
@@ -18,7 +18,7 @@
"purpose": "maskable"
}
],
"start_url": "/",
"start_url": "<%= root_path %>",
"display": "standalone",
"scope": "/",
"description": "Card-up the biggest issues in your projects",
@@ -27,13 +27,13 @@
{
"name": "Notifications",
"description": "Catch up on recent notifications",
"url": "/notifications",
"url": "<%= notifications_path %>",
"icons": [{ "src": "<%= image_url("bell.svg") %>", "sizes": "any" }]
},
{
"name": "Latest Activity",
"description": "See whats new",
"url": "/",
"url": "<%= root_path %>",
"icons": [{ "src": "<%= image_url("activity.svg") %>", "sizes": "any" }]
}
],
+6
View File
@@ -18,6 +18,7 @@ Rails.application.routes.draw do
resource :workflow, only: :update
resource :involvement
resource :publication
resource :entropy_configuration
end
resources :cards
@@ -44,6 +45,7 @@ Rails.application.routes.draw do
resources :assignments
resources :taggings
resources :steps
resources :comments do
resources :reactions, module: :comments
@@ -116,6 +118,10 @@ Rails.application.routes.draw do
namespace :prompts do
resources :cards
resources :users
resources :tags
resources :commands
resources :collections do
scope module: :collections do
resources :users
@@ -0,0 +1,13 @@
class AddStepsToCards < ActiveRecord::Migration[8.1]
def change
create_table :steps do |t|
t.references :card, null: false, foreign_key: true
t.text :content, null: false
t.boolean :completed, default: false, null: false
t.timestamps
end
add_index :steps, :completed
end
end
@@ -0,0 +1,11 @@
class CreateClosersFiltersJoinTable < ActiveRecord::Migration[8.1]
def change
create_table :closers_filters, id: false do |t|
t.integer :filter_id, null: false
t.integer :closer_id, null: false
end
add_index :closers_filters, :filter_id
add_index :closers_filters, :closer_id
end
end
@@ -0,0 +1,6 @@
class ChangeStepsIndex < ActiveRecord::Migration[8.1]
def change
remove_index :steps, :completed
add_index :steps, %i[ card_id completed ]
end
end
Generated
+19 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_07_02_160230) do
ActiveRecord::Schema[8.1].define(version: 2025_07_02_211937) do
create_table "accesses", force: :cascade do |t|
t.datetime "accessed_at"
t.integer "collection_id", null: false
@@ -133,6 +133,13 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_02_160230) do
t.index ["stage_id"], name: "index_cards_on_stage_id"
end
create_table "closers_filters", id: false, force: :cascade do |t|
t.integer "closer_id", null: false
t.integer "filter_id", null: false
t.index ["closer_id"], name: "index_closers_filters_on_closer_id"
t.index ["filter_id"], name: "index_closers_filters_on_filter_id"
end
create_table "closure_reasons", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "label"
@@ -325,6 +332,16 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_02_160230) do
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "steps", force: :cascade do |t|
t.integer "card_id", null: false
t.boolean "completed", default: false, null: false
t.text "content", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["card_id", "completed"], name: "index_steps_on_card_id_and_completed"
t.index ["card_id"], name: "index_steps_on_card_id"
end
create_table "taggings", force: :cascade do |t|
t.integer "card_id", null: false
t.datetime "created_at", null: false
@@ -400,6 +417,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_02_160230) do
add_foreign_key "pins", "users"
add_foreign_key "search_queries", "users"
add_foreign_key "sessions", "users"
add_foreign_key "steps", "cards"
add_foreign_key "taggings", "cards"
add_foreign_key "taggings", "tags"
add_foreign_key "watches", "cards"
+119 -11
View File
@@ -451,7 +451,7 @@ columns:
default_function:
collation:
comment:
- &31 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: title
cast_type: *7
@@ -462,6 +462,18 @@ columns:
collation:
comment:
- *9
closers_filters:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: closer_id
cast_type: *3
sql_type_metadata: *4
'null': false
default:
default_function:
collation:
comment:
- *19
closure_reasons:
- *5
- *6
@@ -520,11 +532,11 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: all_access
cast_type: &32 !ruby/object:ActiveModel::Type::Boolean
cast_type: &30 !ruby/object:ActiveModel::Type::Boolean
precision:
scale:
limit:
sql_type_metadata: &33 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &31 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: boolean
type: :boolean
limit:
@@ -744,7 +756,7 @@ columns:
comment:
filters_tags:
- *19
- &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &32 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: tag_id
cast_type: *3
@@ -940,23 +952,48 @@ columns:
collation:
comment:
- *25
steps:
- *22
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: completed
cast_type: *30
sql_type_metadata: *31
'null': false
default: false
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: content
cast_type: *15
sql_type_metadata: *16
'null': false
default:
default_function:
collation:
comment:
- *5
- *6
- *9
taggings:
- *22
- *5
- *6
- *30
- *32
- *9
tags:
- *5
- *6
- *31
- *33
- *9
users:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: active
cast_type: *32
sql_type_metadata: *33
cast_type: *30
sql_type_metadata: *31
'null': false
default: true
default_function:
@@ -1015,8 +1052,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: watching
cast_type: *32
sql_type_metadata: *33
cast_type: *30
sql_type_metadata: *31
'null': false
default: true
default_function:
@@ -1067,6 +1104,7 @@ primary_keys:
card_engagements: id
card_goldnesses: id
cards: id
closers_filters:
closure_reasons: id
closures: id
collection_publications: id
@@ -1088,6 +1126,7 @@ primary_keys:
search_queries: id
search_results: id
sessions: id
steps: id
taggings: id
tags: id
users: id
@@ -1109,6 +1148,7 @@ data_sources:
card_engagements: true
card_goldnesses: true
cards: true
closers_filters: true
closure_reasons: true
closures: true
collection_publications: true
@@ -1130,6 +1170,7 @@ data_sources:
search_queries: true
search_results: true
sessions: true
steps: true
taggings: true
tags: true
users: true
@@ -1528,6 +1569,39 @@ indexes:
nulls_not_distinct:
comment:
valid: true
closers_filters:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: closers_filters
name: index_closers_filters_on_closer_id
unique: false
columns:
- closer_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: closers_filters
name: index_closers_filters_on_filter_id
unique: false
columns:
- filter_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
closure_reasons: []
closures:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
@@ -2256,6 +2330,40 @@ indexes:
nulls_not_distinct:
comment:
valid: true
steps:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: steps
name: index_steps_on_card_id
unique: false
columns:
- card_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: steps
name: index_steps_on_card_id_and_completed
unique: false
columns:
- card_id
- completed
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
taggings:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: taggings
@@ -2391,4 +2499,4 @@ indexes:
comment:
valid: true
workflows: []
version: 20250702160230
version: 20250702211937
+3
View File
@@ -1,5 +1,8 @@
raise "Seeding is just for development" unless Rails.env.development?
require "active_support/testing/time_helpers"
include ActiveSupport::Testing::TimeHelpers
# Seed DSL
def seed_account(name)
print " #{name}"
+15 -5
View File
@@ -50,19 +50,29 @@ collections = [
"Documentation"
]
time_range = (60 .. 30.days.in_minutes)
collections.each_with_index do |collection_name, index|
create_collection(collection_name, access_to: authors.sample(3)).tap do |collection|
# Create 20 unique cards for each collection
card_titles.each do |title|
create_card(title,
description: "#{title} for #{collection_name} phase #{index + 1}.",
collection: collection
).tap do |card|
travel(-rand(time_range).minutes) do
card = create_card title,
description: "#{title} for #{collection_name} phase #{index + 1}.",
collection: collection,
creator: authors.sample
# Randomly assign to 1-2 authors
travel rand(0..20).minutes
card.toggle_assignment(authors.sample)
card.toggle_assignment(authors.sample) if rand > 0.5
if rand > 0.5
travel rand(0..20).minutes
card.toggle_assignment(authors.sample)
end
# Randomly set card state
travel rand(0..20).minutes
case rand(3)
when 0
card.engage
@@ -0,0 +1,55 @@
require "test_helper"
class Cards::StepsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "create" do
card = cards(:logo)
assert_difference -> { card.steps.count }, +1 do
post card_steps_path(card), params: { step: { content: "Research alternatives" } }, as: :turbo_stream
assert_card_container_rerendered(card)
end
assert_equal "Research alternatives", card.steps.last.content
end
test "update" do
card = cards(:logo)
step = card.steps.create!(content: "Original content")
assert_changes -> { step.reload.content }, from: "Original content", to: "Updated content" do
put card_step_path(card, step), params: { step: { content: "Updated content" } }, as: :turbo_stream
assert_card_container_rerendered(card)
end
end
test "destroy" do
card = cards(:logo)
step = card.steps.create!(content: "Step to delete")
assert_difference -> { card.steps.count }, -1 do
delete card_step_path(card, step), as: :turbo_stream
assert_card_container_rerendered(card)
end
end
test "toggle completion" do
card = cards(:logo)
step = card.steps.create!(content: "Test step", completed: false)
# Toggle to completed
assert_changes -> { step.reload.completed? }, from: false, to: true do
put card_step_path(card, step), params: { step: { completed: "1" } }, as: :turbo_stream
assert_card_container_rerendered(card)
end
# Toggle back to incomplete
assert_changes -> { step.reload.completed? }, from: true, to: false do
put card_step_path(card, step), params: { step: { completed: "0" } }, as: :turbo_stream
assert_card_container_rerendered(card)
end
end
end
@@ -0,0 +1,17 @@
require "test_helper"
class Collections::EntropyConfigurationsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
@collection = collections(:writebook)
end
test "update" do
put collection_entropy_configuration_path(@collection), params: { collection: { auto_close_period: 1.day, auto_reconsider_period: 2.days } }
assert_equal 1.day, @collection.entropy_configuration.reload.auto_close_period
assert_equal 2.days, @collection.entropy_configuration.reload.auto_reconsider_period
assert_turbo_stream action: :replace, target: dom_id(@collection, :entropy_configuration)
end
end
@@ -13,7 +13,7 @@ class Collections::PublicationsControllerTest < ActionDispatch::IntegrationTest
post collection_publication_path(@collection)
end
assert_redirected_to edit_collection_path(@collection)
assert_turbo_stream action: :replace, target: dom_id(@collection, :publication)
end
test "unpublish a collection" do
@@ -24,6 +24,6 @@ class Collections::PublicationsControllerTest < ActionDispatch::IntegrationTest
delete collection_publication_path(@collection)
end
assert_redirected_to edit_collection_path(@collection)
assert_turbo_stream action: :replace, target: dom_id(@collection, :publication)
end
end
@@ -10,7 +10,7 @@ class Collections::WorkflowsControllerTest < ActionDispatch::IntegrationTest
patch collection_workflow_path(collection), params: { collection: { workflow_id: workflows(:on_call).id } }
assert_redirected_to cards_path(collection_ids: [ collection.id ])
assert_turbo_stream action: :replace, target: dom_id(collection, :workflows)
assert_equal workflows(:on_call), collection.reload.workflow
end
end
@@ -0,0 +1,25 @@
require "test_helper"
class ControllerAuthenticationTest < ActionDispatch::IntegrationTest
test "access without an account slug redirects to launchpad" do
integration_session.default_url_options[:script_name] = "" # no tenant
get cards_path
assert_redirected_to Launchpad.login_url(product: true)
end
test "access with an account slug but no session redirects to launchpad" do
get cards_path
assert_redirected_to Launchpad.login_url(product: true, account: Account.sole)
end
test "access with an account slug and a session allows functional access" do
sign_in_as :kevin
get cards_path
assert_response :success
end
end

Some files were not shown because too many files have changed in this diff Show More