diff --git a/.kamal/secrets.dhh b/.kamal/secrets.dhh new file mode 100644 index 000000000..b427fe0c2 --- /dev/null +++ b/.kamal/secrets.dhh @@ -0,0 +1,5 @@ +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Production/SECRET_KEY_BASE) + +GITHUB_TOKEN=$(gh config get -h github.com oauth_token) +BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) +SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) diff --git a/Dockerfile b/Dockerfile index 63e46037c..e0712a39a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,10 @@ RUN bundle exec bootsnap precompile app/ lib/ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile +# Fetch beamer library +FROM basecamp/beamer:latest AS beamer + + # Final stage for app image FROM base @@ -49,6 +53,7 @@ RUN apt-get update -qq && \ # Copy built artifacts: gems, application COPY --from=build /usr/local/bundle /usr/local/bundle COPY --from=build /rails /rails +COPY --from=beamer /home/beamer/bin/beamer.so /rails/bin/lib/beamer.so # Run and own only the runtime files as a non-root user for security RUN useradd rails --create-home --shell /bin/bash && \ diff --git a/Gemfile.lock b/Gemfile.lock index 8fbd0f756..a88385fa5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/basecamp/active_record-tenanted - revision: 8758240be33a6f6f7e7f56486a2e78008c193030 + revision: 612f5e8b7f77b7076a7eb21521920630161461aa 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: bc7f77b32d7f3f26db654cae506c26a959852506 branch: main specs: actioncable (8.1.0.alpha) @@ -145,13 +145,13 @@ GEM xpath (~> 3.2) chunky_png (1.4.0) concurrent-ruby (1.3.5) - connection_pool (2.5.0) + connection_pool (2.5.3) crass (1.0.6) date (3.4.1) debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) - dotenv (3.1.7) + dotenv (3.1.8) drb (2.2.1) ed25519 (1.3.0) erubi (1.13.1) @@ -179,7 +179,7 @@ GEM jbuilder (2.13.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) - json (2.10.2) + json (2.11.3) kamal (2.5.3) activesupport (>= 7.0) base64 (~> 0.2) @@ -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,28 +223,28 @@ 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) - parser (3.3.7.4) + parallel (1.27.0) + parser (3.3.8.0) ast (~> 2.4.1) racc pp (0.6.2) @@ -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) @@ -290,12 +290,12 @@ GEM reline (0.6.1) io-console (~> 0.5) rexml (3.4.1) - rouge (4.5.1) - rqrcode (2.2.0) + rouge (4.5.2) + rqrcode (3.1.0) chunky_png (~> 1.0) - rqrcode_core (~> 1.0) - rqrcode_core (1.2.0) - rubocop (1.75.2) + rqrcode_core (~> 2.0) + rqrcode_core (2.0.0) + rubocop (1.75.4) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -306,7 +306,7 @@ GEM rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.44.0) + rubocop-ast (1.44.1) parser (>= 3.3.7.2) prism (~> 1.4) rubocop-performance (1.25.0) @@ -347,7 +347,7 @@ GEM activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_queue (1.1.4) + solid_queue (1.1.5) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) @@ -373,13 +373,13 @@ 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) - thruster (0.1.12-arm64-darwin) - thruster (0.1.12-x86_64-darwin) - thruster (0.1.12-x86_64-linux) + thruster (0.1.13) + thruster (0.1.13-aarch64-linux) + thruster (0.1.13-arm64-darwin) + thruster (0.1.13-x86_64-darwin) + thruster (0.1.13-x86_64-linux) timeout (0.4.3) turbo-rails (2.0.13) actionpack (>= 7.1.0) diff --git a/app/assets/images/person-add.svg b/app/assets/images/person-add.svg new file mode 100644 index 000000000..9c8e13b8c --- /dev/null +++ b/app/assets/images/person-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 3dbe697a3..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; @@ -20,78 +20,253 @@ --text-xx-large: 2.5rem; /* Borders */ - --border: 1px solid var(--color-subtle); + --border: 1px solid var(--color-ink-lighter); /* Shadows */ - --shadow: 0 0 0 1px oklch(var(--lch-always-black) / 0.02), - 0 .2em 1.6em -0.8em oklch(var(--lch-always-black) / 0.2), - 0 .4em 2.4em -1em oklch(var(--lch-always-black) / 0.3), - 0 .4em .8em -1.2em oklch(var(--lch-always-black) / 0.4), - 0 .8em 1.2em -1.6em oklch(var(--lch-always-black) / 0.5), - 0 1.2em 1.6em -2em oklch(var(--lch-always-black) / 0.6); + --shadow: 0 0 0 1px oklch(var(--lch-black) / 0.02), + 0 .2em 1.6em -0.8em oklch(var(--lch-black) / 0.2), + 0 .4em 2.4em -1em oklch(var(--lch-black) / 0.3), + 0 .4em .8em -1.2em oklch(var(--lch-black) / 0.4), + 0 .8em 1.2em -1.6em oklch(var(--lch-black) / 0.5), + 0 1.2em 1.6em -2em oklch(var(--lch-black) / 0.6); /* Components */ --btn-size: 2.65em; - /* Color values */ - --lch-black: 27.13% 0.1035 284.77; + /* OKLCH colors: Fixed */ + --lch-black: 0% 0 0; --lch-white: 100% 0 0; - --lch-gray-light: 96% 0.005 96; - --lch-gray: 92% 0.005 96; - --lch-gray-dark: 75% 0.005 96; - --lch-blue: 54% 0.15 255; - --lch-blue-light: 95% 0.03 255; - --lch-blue-dark: 80% 0.08 255; - --lch-golden: 83% 0.1128 72.43; - --lch-orange: 70% 0.2 44; - --lch-red: 51% 0.2 31; - --lch-green: 65.59% 0.234 142.49; - --lch-green-light: 95% 0.03 142.49; - --lch-yellow: 92.62% 0.1 91.5; - --lch-always-black: 0% 0 0; - --lch-always-white: 100% 0 0; - --lch-light-blue: 84.94% 0.048 216.62; - /* Color abstractions */ - --color-negative: oklch(var(--lch-red)); - --color-positive: oklch(var(--lch-green)); - --color-positive-light: oklch(var(--lch-green-light)); - --color-bg: oklch(var(--lch-white)); - --color-ink: oklch(var(--lch-black)); - --color-ink-reversed: oklch(var(--lch-white)); - --color-link: oklch(var(--lch-blue)); - --color-subtle-light: oklch(var(--lch-gray-light)); - --color-subtle: oklch(var(--lch-gray)); - --color-subtle-dark: oklch(var(--lch-gray-dark)); - --color-selected: oklch(var(--lch-blue-light)); - --color-selected-dark: oklch(var(--lch-blue-dark)); - --color-highlight: oklch(var(--lch-yellow)); - --color-marker: oklch(var(--lch-orange)); - --color-always-black: oklch(var(--lch-always-black)); - --color-always-white: oklch(var(--lch-always-white)); - --color-container: oklch(var(--lch-light-blue)); - --color-golden: oklch(var(--lch-golden)); + /* OKLCH colors: Light mode */ + --lch-canvas: var(--lch-white); + --lch-ink-inverted: var(--lch-white); + + --lch-ink-darkest: 26% 0.05 264; + --lch-ink-darker: 40% 0.026 262; + --lch-ink-dark: 57% 0.014 260; + --lch-ink-medium: 70% 0.008 258; + --lch-ink-light: 84% 0.005 256; + --lch-ink-lighter: 92% 0.003 254; + --lch-ink-lightest: 96% 0.002 252; + + --lch-uncolor-darkest: 26% 0.018 40; + --lch-uncolor-darker: 40% 0.03 50; + --lch-uncolor-dark: 57% 0.04 60; + --lch-uncolor-medium: 70% 0.044 70; + --lch-uncolor-light: 84% 0.028 80; + --lch-uncolor-lighter: 92% 0.014 90; + --lch-uncolor-lightest: 96% 0.012 100; + + --lch-red-darkest: 26% 0.105 34; + --lch-red-darker: 40% 0.154 36; + --lch-red-dark: 59% 0.19 38; + --lch-red-medium: 70.4% 0.19 40; + --lch-red-light: 84% 0.07 42; + --lch-red-lighter: 92% 0.03 44; + --lch-red-lightest: 96% 0.013 46; + + --lch-yellow-darkest: 26% 0.0729 40; + --lch-yellow-darker: 40% 0.12 50; + --lch-yellow-dark: 58% 0.156 60; + --lch-yellow-medium: 76% 0.17 70; + --lch-yellow-light: 84% 0.12 80; + --lch-yellow-lighter: 92% 0.076 90; + --lch-yellow-lightest: 96% 0.034 100; + + --lch-lime-darkest: 26% 0.064 109; + --lch-lime-darker: 40% 0.101 110; + --lch-lime-dark: 56.5% 0.142 111; + --lch-lime-medium: 71% 0.158 112; + --lch-lime-light: 84% 0.08 113; + --lch-lime-lighter: 92% 0.046 114; + --lch-lime-lightest: 96% 0.034 115; + + --lch-green-darkest: 26% 0.071 149; + --lch-green-darker: 40% 0.12 148; + --lch-green-dark: 55% 0.162 147; + --lch-green-medium: 70% 0.19 146; + --lch-green-light: 84% 0.07 145; + --lch-green-lighter: 92% 0.044 144; + --lch-green-lightest: 96% 0.022 143; + + --lch-aqua-darkest: 26% 0.059 214; + --lch-aqua-darker: 40% 0.093 212; + --lch-aqua-dark: 55.5% 0.122 210; + --lch-aqua-medium: 70% 0.13 208; + --lch-aqua-light: 84% 0.04 206; + --lch-aqua-lighter: 92% 0.02 204; + --lch-aqua-lightest: 96% 0.012 202; + + --lch-blue-darkest: 26% 0.126 264; + --lch-blue-darker: 40% 0.166 262; + --lch-blue-dark: 57% 0.19 260; + --lch-blue-medium: 70% 0.169 258; + --lch-blue-light: 84% 0.06 256; + --lch-blue-lighter: 92% 0.026 254; + --lch-blue-lightest: 96% 0.016 252; + + --lch-violet-darkest: 26% 0.148 292; + --lch-violet-darker: 40% 0.2 290; + --lch-violet-dark: 58% 0.216 288; + --lch-violet-medium: 70% 0.178 286; + --lch-violet-light: 84% 0.07 284; + --lch-violet-lighter: 92% 0.03 282; + --lch-violet-lightest: 96% 0.015 280; + + --lch-purple-darkest: 26% 0.131 314; + --lch-purple-darker: 40% 0.178 312; + --lch-purple-dark: 58% 0.21 310; + --lch-purple-medium: 70% 0.22 308; + --lch-purple-light: 84% 0.07 306; + --lch-purple-lighter: 92% 0.03 304; + --lch-purple-lightest: 96% 0.019 302; + + --lch-pink-darkest: 26% 0.12 348; + --lch-pink-darker: 40% 0.16 346; + --lch-pink-dark: 59% 0.188 344; + --lch-pink-medium: 70% 0.198 342; + --lch-pink-light: 84% 0.07 340; + --lch-pink-lighter: 92% 0.03 338; + --lch-pink-lightest: 96% 0.02 336; + + /* Colors: Named */ + --color-black: oklch(var(--lch-black)); + --color-white: oklch(var(--lch-white)); + + --color-ink: oklch(var(--lch-ink-darkest)); + --color-ink-darkest: oklch(var(--lch-ink-darkest)); + --color-ink-darker: oklch(var(--lch-ink-darker)); + --color-ink-dark: oklch(var(--lch-ink-dark)); + --color-ink-medium: oklch(var(--lch-ink-medium)); + --color-ink-light: oklch(var(--lch-ink-light)); + --color-ink-lighter: oklch(var(--lch-ink-lighter)); + --color-ink-lightest: oklch(var(--lch-ink-lightest)); + + --color-ink-inverted: oklch(var(--lch-ink-inverted)); + + /* Colors: Abstractions */ + --color-canvas: oklch(var(--lch-canvas)); + --color-negative: oklch(var(--lch-red-dark)); + --color-positive: oklch(var(--lch-green-dark)); + --color-link: oklch(var(--lch-blue-dark)); + --color-selected: oklch(var(--lch-blue-lighter)); + --color-selected-dark: oklch(var(--lch-blue-light)); + --color-highlight: oklch(var(--lch-yellow-lighter)); + --color-marker: oklch(var(--lch-red-medium)); + --color-container: oklch(var(--lch-aqua-light)); + --color-terminal-bg: oklch(var(--lch-blue-darkest)); + --color-terminal-text: oklch(var(--lch-green-medium)); + + /* Colors: Cards */ + --color-card-1: oklch(var(--lch-ink-dark)); + --color-card-2: oklch(var(--lch-uncolor-dark)); + --color-card-3: oklch(var(--lch-yellow-dark)); + --color-card-4: oklch(var(--lch-lime-dark)); + --color-card-5: oklch(var(--lch-aqua-dark)); + --color-card-6: oklch(var(--lch-blue-dark)); + --color-card-7: oklch(var(--lch-violet-dark)); + --color-card-8: oklch(var(--lch-purple-dark)); + --color-card-9: oklch(var(--lch-pink-dark)); @media (prefers-color-scheme: dark) { - --lch-black: 100% 0 0; - --lch-white: 0% 0 0; - --lch-gray-light: 25.2% 0 0; - --lch-gray: 30.12% 0 0; - --lch-gray-dark: 44.95% 0 0; - --lch-blue: 72.25% 0.16 248; - --lch-blue-light: 28.11% 0.053 248; - --lch-blue-dark: 42.25% 0.07 248; - --lch-red: 73.8% 0.184 29.18; - --lch-green: 75% 0.21 141.89; - --lch-green-light: 28.11% 0.02 142.49; - --lch-yellow: 40.9% 0.06 88.9; - --lch-light-blue: 20.62% 0.048 216.62; + --lch-canvas: 20% 0.0195 232.58; + --lch-ink-inverted: var(--lch-black); - --shadow: 0 0 0 1px oklch(var(--lch-always-black) / 0.42), - 0 .2em 1.6em -0.8em oklch(var(--lch-always-black) / 0.6), - 0 .4em 2.4em -1em oklch(var(--lch-always-black) / 0.7), - 0 .4em .8em -1.2em oklch(var(--lch-always-black) / 0.8), - 0 .8em 1.2em -1.6em oklch(var(--lch-always-black) / 0.9), - 0 1.2em 1.6em -2em oklch(var(--lch-always-black) / 1); + --lch-ink-darkest: 96.02% 0.0034 260; + --lch-ink-darker: 86% 0.0061 260; + --lch-ink-dark: 73.97% 0.009 260; + --lch-ink-medium: 62% 0.0122 260; + --lch-ink-light: 40% 0.0148 260; + --lch-ink-lighter: 30% 0.0178 260; + --lch-ink-lightest: 25% 0.0204 260; + + --lch-uncolor-darkest: 96.09% 0.0076 100; + --lch-uncolor-darker: 86% 0.021 90; + --lch-uncolor-dark: 73.93% 0.041 80; + --lch-uncolor-medium: 62% 0.0552 70; + --lch-uncolor-light: 40% 0.0387 60; + --lch-uncolor-lighter: 30% 0.012 50; + --lch-uncolor-lightest: 25% 0.0017 40; + + --lch-red-darkest: 95.85% 0.0218 46; + --lch-red-darker: 86% 0.086 44; + --lch-red-dark: 73.95% 0.139 42; + --lch-red-medium: 62% 0.154 40; + --lch-red-light: 40% 0.088 38; + --lch-red-lighter: 30% 0.032 36; + --lch-red-lightest: 25% 0.011 34; + + --lch-yellow-darkest: 96% 0.056 100; + --lch-yellow-darker: 86% 0.103 90; + --lch-yellow-dark: 74.06% 0.136 80; + --lch-yellow-medium: 62.1% 0.146 70; + --lch-yellow-light: 40% 0.0736 60; + --lch-yellow-lighter: 30% 0.026 50; + --lch-yellow-lightest: 25% 0.01 40; + + --lch-lime-darkest: 96.04% 0.066 115; + --lch-lime-darker: 86% 0.098 114; + --lch-lime-dark: 73.97% 0.121 113; + --lch-lime-medium: 62% 0.128 112; + --lch-lime-light: 40% 0.0637 111; + --lch-lime-lighter: 30% 0.024 110; + --lch-lime-lightest: 25% 0.012 109; + + --lch-green-darkest: 96.12% 0.035 143; + --lch-green-darker: 86% 0.082 144; + --lch-green-dark: 73.99% 0.117 145; + --lch-green-medium: 62% 0.1261 146; + --lch-green-light: 40% 0.065 147; + --lch-green-lighter: 30% 0.03 148; + --lch-green-lightest: 25% 0.018 149; + + --lch-aqua-darkest: 96.15% 0.0244 202; + --lch-aqua-darker: 86% 0.06 204; + --lch-aqua-dark: 73.92% 0.095 206; + --lch-aqua-medium: 62% 0.106 208; + --lch-aqua-light: 40% 0.0594 210; + --lch-aqua-lighter: 30% 0.028 212; + --lch-aqua-lightest: 25% 0.017 214; + + --lch-blue-darkest: 95.93% 0.0217 252; + --lch-blue-darker: 86% 0.068 254; + --lch-blue-dark: 74% 0.1293 256; + --lch-blue-medium: 62% 0.159 258; + --lch-blue-light: 40% 0.094 260; + --lch-blue-lighter: 30% 0.0452 262; + --lch-blue-lightest: 25% 0.0318 264; + + --lch-violet-darkest: 95.97% 0.019 280; + --lch-violet-darker: 86% 0.068 282; + --lch-violet-dark: 74.08% 0.142 284; + --lch-violet-medium: 62% 0.184 286; + --lch-violet-light: 40% 0.108 288; + --lch-violet-lighter: 30% 0.048 290; + --lch-violet-lightest: 25% 0.025 292; + + --lch-purple-darkest: 95.99% 0.0217 302; + --lch-purple-darker: 86% 0.068 304; + --lch-purple-dark: 73.98% 0.141 306; + --lch-purple-medium: 62% 0.177 308; + --lch-purple-light: 40% 0.099 310; + --lch-purple-lighter: 30% 0.04 312; + --lch-purple-lightest: 25% 0.017 314; + + --lch-pink-darkest: 95.84% 0.0308 336; + --lch-pink-darker: 86% 0.074 338; + --lch-pink-dark: 74.04% 0.1294 340; + --lch-pink-medium: 62% 0.166 342; + --lch-pink-light: 40% 0.085 344; + --lch-pink-lighter: 30% 0.03 346; + --lch-pink-lightest: 25% 0.011 348; + + --color-container: oklch(var(--lch-aqua-lighter)); + + --shadow: 0 0 0 1px oklch(var(--lch-black) / 0.42), + 0 .2em 1.6em -0.8em oklch(var(--lch-black) / 0.6), + 0 .4em 2.4em -1em oklch(var(--lch-black) / 0.7), + 0 .4em .8em -1.2em oklch(var(--lch-black) / 0.8), + 0 .8em 1.2em -1.6em oklch(var(--lch-black) / 0.9), + 0 1.2em 1.6em -2em oklch(var(--lch-black) / 1); } } 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 9301bbc61..faaba06c9 100644 --- a/app/assets/stylesheets/base.css +++ b/app/assets/stylesheets/base.css @@ -11,14 +11,14 @@ --font-sans: system-ui; --font-serif: ui-serif, serif; --font-mono: ui-monospace, monospace; - --hover-color: var(--color-subtle-dark); + --hover-color: var(--color-ink-medium); --hover-size: 0.15rem; --hover-filter: brightness(1); -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; - background: var(--color-bg); + background: var(--color-canvas); color: var(--color-ink); font-family: var(--font-sans); interpolate-size: allow-keywords; @@ -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/buttons.css b/app/assets/stylesheets/buttons.css index 210c81bd7..6ec87c99a 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -4,9 +4,9 @@ --btn-border-radius: 2em; align-items: center; - background-color: var(--btn-background, var(--color-bg)); + background-color: var(--btn-background, var(--color-canvas)); border-radius: var(--btn-border-radius); - border: var(--btn-border-size, 1px) solid var(--btn-border-color, var(--color-subtle-dark)); + border: var(--btn-border-size, 1px) solid var(--btn-border-color, var(--color-ink-light)); color: var(--btn-color, var(--color-ink)); cursor: pointer; display: inline-flex; @@ -112,7 +112,7 @@ &:has(input:checked) { --btn-background: var(--color-ink); - --btn-color: var(--color-ink-reversed); + --btn-color: var(--color-ink-inverted); --outline-color: var(--color-ink); .checked { @@ -137,13 +137,13 @@ /* Variants */ .btn--link { --btn-background: var(--color-link); - --btn-color: var(--color-ink-reversed); + --btn-color: var(--color-ink-inverted); --outline-color: var(--color-link); } .btn--negative { --btn-background: var(--color-negative); - --btn-color: var(--color-ink-reversed); + --btn-color: var(--color-ink-inverted); --outline-color: var(--color-negative); } @@ -163,7 +163,7 @@ .btn--positive { --btn-background: var(--color-positive); - --btn-color: var(--color-ink-reversed); + --btn-color: var(--color-ink-inverted); --outline-color: var(--color-positive); } @@ -173,12 +173,12 @@ .btn--reversed { --btn-background: var(--color-ink); - --btn-color: var(--color-bg); + --btn-color: var(--color-canvas); --outline-color: var(--color-ink); } :is(.btn--link, .btn--negative, .btn--positive, .btn--reversed) { - --btn-border-color: var(--color-bg); + --btn-border-color: var(--color-canvas); } .btn--success { diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 022201a26..4311f1007 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -10,7 +10,7 @@ } .card-columns { - border-block-start: 1px solid var(--color-subtle); + border-block-start: 1px solid var(--color-ink-lighter); display: grid; grid-template-columns: repeat(2, 50%); margin: auto; @@ -77,14 +77,10 @@ --text-xx-large: 1.6em; --text-small: 1.1em; - background-color: var(--color-bg); - box-shadow: 0 0 0 1px var(--color-subtle); + background-color: var(--color-canvas); + box-shadow: 0 0 0 1px var(--color-ink-lighter); line-height: 1.2; - @media (prefers-color-scheme: dark) { - box-shadow: 0 0 0 1px var(--color-subtle-dark); - } - .avatar { block-size: 2.75em; inline-size: 2.75em; @@ -92,10 +88,11 @@ } .card--drafted { - --card-color: var(--color-subtle-dark) !important; + --card-color: var(--color-ink-medium) !important; border-color: var(--card-color) !important; border-style: dashed !important; + box-shadow: none !important; } .card__background { @@ -124,7 +121,7 @@ /* -------------------------------------------------------------------------- */ .cards--closed { - border-block-start: 1px solid var(--color-subtle); + border-block-start: 1px solid var(--color-ink-lighter); justify-content: center; flex-direction: row; flex-wrap: wrap; diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 3b64b2200..28dd20768 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -4,9 +4,9 @@ .card-perma { --actions-block-inset: 1.5rem; --actions-inline-inset: 4rem; - --color-container: color-mix(in srgb, var(--card-color) 33%, var(--color-bg)); + --color-container: color-mix(in srgb, var(--card-color) 33%, var(--color-canvas)); --padding-inline: calc(var(--block-space-double) + var(--block-space)); - --padding-block: var(--block-space-double) calc(var(--block-space-double) + var(--block-space-half)); + --padding-block: calc(var(--block-space-double) + var(--block-space-half)); align-items: start; column-gap: var(--inline-space); @@ -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 @@ -60,12 +69,7 @@ /* -------------------------------------------------------------------------- */ .card-perma__notch { - --card-notch-padding: 0.5ch; - --border-radius: 1em; - align-items: center; - background-color: var(--color-bg); - border-radius: 99rem; color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); display: inline-flex; inline-size: auto; @@ -92,23 +96,22 @@ &:has(input:checked) { --btn-background: var(--card-color); - --btn-color: var(--color-ink-reversed); + --btn-color: var(--color-ink-inverted); } } } .card-perma__notch--bottom { grid-area: notch-bottom; - padding: calc(var(--block-space) * 0.75) calc(var(--block-space) * 0.66); - translate: 0 -50%; + translate: 0 -25%; &:has([open]) { 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-reversed); + --btn-color: var(--color-ink-inverted); } .btn--plain { @@ -116,6 +119,11 @@ text-decoration: underline; } + + .btn--reversed { + --btn-background: var(--color-canvas); + --btn-color: var(--card-color); + } } .card-perma__closure-message { @@ -125,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 f316b1d90..be10a0a52 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -7,7 +7,7 @@ --border-size: 0; aspect-ratio: var(--card-aspect-ratio, auto); - background-color: color-mix(in srgb, var(--card-color) 4%, var(--color-bg)); + background-color: color-mix(in srgb, var(--card-color) 4%, var(--color-canvas)); border-radius: 0.5em; box-shadow: var(--shadow); color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); @@ -22,21 +22,27 @@ .avatar { --avatar-size: 2.75em; } - - @media (prefers-color-scheme: dark) { - --border-color: var(--color-subtle-dark); - } } .card--golden.card--doing { - --border-color: var(--color-bg) !important; + --border-color: var(--color-canvas) !important; + --lch-golden: var(--lch-yellow-light); - background-color: var(--color-bg); - background-image: linear-gradient(90deg, oklch(var(--lch-golden) / 0.5) 0%, oklch(var(--lch-golden) / 0.1) 50%, oklch(var(--lch-golden) / 0.75) 100%); + background-color: var(--color-canvas); + background-image: linear-gradient(60deg, + oklch(var(--lch-golden) / 0.35) 0%, + oklch(var(--lch-golden) / 0.15) 20%, + oklch(var(--lch-golden) / 0) 60%, + oklch(var(--lch-golden) / 0.5) 100% + ); box-shadow: - 0 0 0.5em 0.2em var(--color-golden), - 0 0 1em 0.4em var(--color-golden) !important; - outline: 1px solid var(--color-golden); + 0 0 0.5em 0.2em oklch(var(--lch-golden) / 0.35), + 0 0 1em 0.4em oklch(var(--lch-golden) / 0.35) !important; + outline: 1px solid oklch(var(--lch-golden) / 0.35); + + @media (prefers-color-scheme: dark) { + --lch-golden: var(--lch-blue-medium); + } } .card__background { @@ -99,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-inverted); + } + } + + .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; @@ -118,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-canvas)); + 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); @@ -186,16 +230,25 @@ .card__closed { align-items: center; - aspect-ratio: 7/4; + aspect-ratio: 8/5; + background-color: var(--color-canvas); 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 { @@ -203,9 +256,11 @@ } .card__closed-title { - color: var(--color-ink-reversed); - font-size: var(--text-rel-large); + color: var(--color-ink-inverted); + 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/color-picker.css b/app/assets/stylesheets/color-picker.css index 7b6ff640f..a0dd8e2af 100644 --- a/app/assets/stylesheets/color-picker.css +++ b/app/assets/stylesheets/color-picker.css @@ -2,34 +2,17 @@ --panel-border-color: var(--card-color); --panel-border-radius: 2em; --panel-border-size: 2px; - --panel-padding: 0.5em 0.7em; + --panel-padding: 0.5em; --panel-size: auto; inline-size: auto !important; - inset: 0 auto auto 0; + inset: 50% auto auto calc(-1 * var(--panel-padding)); max-inline-size: var(--panel-size) !important; position: absolute; + translate: 0 -50%; z-index: 2; &[open] { display: flex; } - - .color-picker__color { - --btn-size: 2em; - } -} - -.color-picker__button { - font-size: 0.75em; - - .cards & { - display: none; - } - - + .panel { - --panel-size: auto; - - inset: 0 0 auto auto; - } } diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 2db3016db..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); } } @@ -63,11 +63,12 @@ } .comment__content { - background-color: var(--color-subtle-light); + background-color: var(--color-ink-lightest); border-radius: 0.2em; padding: var(--comment-padding-block) var(--comment-padding-inline); + word-wrap: break-word; } .comment__edit { diff --git a/app/assets/stylesheets/dialog.css b/app/assets/stylesheets/dialog.css index ea7e10e15..93d40ee1d 100644 --- a/app/assets/stylesheets/dialog.css +++ b/app/assets/stylesheets/dialog.css @@ -15,7 +15,7 @@ transform var(--speed); &::backdrop { - background-color: var(--color-always-black); + background-color: var(--color-black); opacity: 0; transform: scale(1); transition: diff --git a/app/assets/stylesheets/events.css b/app/assets/stylesheets/events.css index 0b19fe853..849540e5b 100644 --- a/app/assets/stylesheets/events.css +++ b/app/assets/stylesheets/events.css @@ -11,13 +11,12 @@ --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 { - background-color: var(--color-bg); + background-color: var(--color-canvas); block-size: var(--grid-lines); display: block; position: relative; @@ -25,7 +24,7 @@ } .events__day-header-content { - background-color: var(--color-bg); + background-color: var(--color-canvas); display: inline-flex; font-size: 0.8em; inset: calc(var(--grid-lines) - 1em) auto -1em; @@ -55,22 +54,13 @@ .events--none { background-color: var(--color-container); - border-block-start: var(--grid-lines) solid var(--color-bg); + border-block-start: var(--grid-lines) solid var(--color-canvas); padding-block: 3em; margin: auto var(--grid-lines) calc(var(--grid-lines) * -1) auto; } .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/expander.css b/app/assets/stylesheets/expander.css index e7be6c769..291851b34 100644 --- a/app/assets/stylesheets/expander.css +++ b/app/assets/stylesheets/expander.css @@ -107,6 +107,6 @@ &:hover, &:focus-visible { - background-color: oklch(var(--lch-white) / 15%); + background-color: oklch(var(--lch-canvas) / 15%); } } diff --git a/app/assets/stylesheets/filters.css b/app/assets/stylesheets/filters.css index cf7c894f0..139a3a8ce 100644 --- a/app/assets/stylesheets/filters.css +++ b/app/assets/stylesheets/filters.css @@ -67,7 +67,7 @@ block-size: 100%; border-block: 0; border-inline-end: 0; - border-inline-start: 1px solid var(--color-subtle); + border-inline-start: 1px solid var(--color-ink-lighter); content: ""; display: inline-flex; inline-size: 0; @@ -107,3 +107,11 @@ } } } + +.filter--active li { + display: none; + + &.selected { + display: flex; + } +} diff --git a/app/assets/stylesheets/flash.css b/app/assets/stylesheets/flash.css index 8e5d08d67..8eaddb0aa 100644 --- a/app/assets/stylesheets/flash.css +++ b/app/assets/stylesheets/flash.css @@ -12,7 +12,7 @@ animation: appear-then-fade 3s 300ms both; background-color: var(--flash-background, var(--color-ink)); border-radius: 4em; - color: var(--flash-color, var(--color-ink-reversed)); + color: var(--flash-color, var(--color-ink-inverted)); display: inline-flex; font-size: var(--font-size-medium); margin: 0 auto; diff --git a/app/assets/stylesheets/house.css b/app/assets/stylesheets/house.css index 30982ad76..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; @@ -13,7 +15,7 @@ /* Toolbar */ house-md-toolbar { background-color: inherit; - border-block-end: 1px solid var(--color-subtle-dark); + border-block-end: 1px solid var(--color-ink-medium); border-radius: 0; color: currentColor; display: inline-flex; @@ -47,7 +49,7 @@ } &:is(:focus, :hover) { - background-color: var(--color-subtle); + background-color: var(--color-ink-lighter); } &:is(:active) { @@ -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; @@ -93,7 +94,7 @@ &[status="failed"] { background-color: var(--color-negative); - color: var(--color-ink-reversed); + color: var(--color-ink-inverted); font-weight: bold; padding: 0.5em; } @@ -112,13 +113,13 @@ cursor: pointer; house-md-upload[status="failed"] & { - color: var(--color-ink-reversed); + color: var(--color-ink-inverted); display: inline-block; } &::before { content: '×'; - color: var(--color-ink-reversed); + color: var(--color-ink-inverted); font-size: 24px; font-weight: bold; position: absolute; @@ -139,7 +140,7 @@ .md-progress-bar { -webkit-appearance: none; appearance: none; - background-color: var(--color-subtle); + background-color: var(--color-ink-lighter); border-radius: 0.5em; block-size: 1ex; display: block; @@ -154,7 +155,7 @@ &::-webkit-progress-bar { border-radius: 0.5em; - background-color: var(--color-subtle); + background-color: var(--color-ink-lighter); } &::-webkit-progress-inner-element { diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 987210def..bc6f9e77f 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -50,6 +50,7 @@ .icon--password { --svg: url("password.svg "); } .icon--pencil { --svg: url("pencil.svg "); } .icon--person { --svg: url("person.svg "); } + .icon--person-add { --svg: url("person-add.svg "); } .icon--picture-add { --svg: url("picture-add.svg "); } .icon--picture-double { --svg: url("picture-double.svg "); } .icon--picture-remove { --svg: url("picture-remove.svg "); } diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index 5e3ba772c..6b412707b 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -4,7 +4,7 @@ accent-color: var(--input-accent-color, var(--color-ink)); background-color: var(--input-background, transparent); border-radius: var(--input-border-radius, 0.5em); - border: var(--input-border-size, 1px) solid var(--input-border-color, var(--color-subtle-dark)); + border: var(--input-border-size, 1px) solid var(--input-border-color, var(--color-ink-medium)); color: var(--input-color, var(--color-ink)); font-size: max(16px, 1em); inline-size: 100%; @@ -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-canvas); + color: var(--color-ink); + } } .input--textarea { @@ -135,7 +140,7 @@ &:has(:focus-visible) { .switch__btn { box-shadow: - 0 0 0 var(--outline-size) var(--color-bg), + 0 0 0 var(--outline-size) var(--color-canvas), 0 0 0 calc(var(--outline-size) * 2) var(--color-ink); } } @@ -148,7 +153,7 @@ } .switch__btn { - background-color: var(--color-subtle-dark); + background-color: var(--color-ink-medium); border-radius: 2em; cursor: pointer; inset: 0; @@ -156,7 +161,7 @@ transition: 150ms ease; &::before { - background-color: var(--color-ink-reversed); + background-color: var(--color-ink-inverted); block-size: 1.35em; border-radius: 50%; content: ""; @@ -168,7 +173,7 @@ } .switch__input:disabled + & { - background-color: var(--color-subtle-dark) !important; + background-color: var(--color-ink-medium) !important; cursor: not-allowed; } diff --git a/app/assets/stylesheets/layout.css b/app/assets/stylesheets/layout.css index c38100393..adbccc90f 100644 --- a/app/assets/stylesheets/layout.css +++ b/app/assets/stylesheets/layout.css @@ -53,7 +53,7 @@ } a:not([class]) { - background-color: var(--color-subtle-light); + background-color: var(--color-ink-lightest); border-radius: 2em; color: var(--color-ink); font-weight: 500; @@ -62,7 +62,7 @@ @media (hover: hover) { &:hover { - background-color: var(--color-subtle); + background-color: var(--color-ink-lighter); text-decoration: none; } } diff --git a/app/assets/stylesheets/lightbox.css b/app/assets/stylesheets/lightbox.css index 6dabfefc4..a8e41db2b 100644 --- a/app/assets/stylesheets/lightbox.css +++ b/app/assets/stylesheets/lightbox.css @@ -2,7 +2,7 @@ .lightbox { --backdrop-speed: 150ms; - background-color: oklch(var(--lch-white) / 0.66); + background-color: oklch(var(--lch-canvas) / 0.66); block-size: 100dvh; border: 0; inline-size: 100dvw; diff --git a/app/assets/stylesheets/comment-content.css b/app/assets/stylesheets/markdown-content.css similarity index 89% rename from app/assets/stylesheets/comment-content.css rename to app/assets/stylesheets/markdown-content.css index 9944ed45f..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; @@ -27,7 +27,7 @@ } :where(hr) { - border-color: var(--color-subtle-dark); + border-color: var(--color-ink-medium); border-style: var(--border-style, solid) none none; margin: 2lh auto; } @@ -37,8 +37,8 @@ } :is(pre, code, .pre, .code) { - background-color: var(--color-subtle-light); - border: 1px solid var(--color-subtle); + background-color: var(--color-ink-lightest); + border: 1px solid var(--color-ink-lighter); border-radius: 0.3em; font-family: var(--font-mono); font-size: 0.85em; @@ -80,7 +80,7 @@ } :where(table, th, td) { - border: 1px solid var(--color-subtle-dark); + border: 1px solid var(--color-ink-medium); } :where(table) { diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css index d8779c2e6..43dd95d5e 100644 --- a/app/assets/stylesheets/notifications.css +++ b/app/assets/stylesheets/notifications.css @@ -46,11 +46,11 @@ padding: var(--padding); @media (prefers-color-scheme: dark) { - --border-color: var(--color-subtle-dark); + --border-color: var(--color-ink-medium); } .notificiations-list--read & { - --border-color: var(--color-subtle); + --border-color: var(--color-ink-lighter); box-shadow: none !important; } @@ -62,7 +62,7 @@ .notification__unread_indicator { --btn-background: var(--color-marker); - --btn-border-color: var(--color-bg); + --btn-border-color: var(--color-canvas); --btn-icon-size: 0.7em; --btn-padding: 0; --btn-size: 1.2em; @@ -78,8 +78,8 @@ @media (hover: hover) { &:hover { - --btn-background: var(--color-subtle-dark); - --btn-border-color: var(--color-subtle-dark); + --btn-background: var(--color-ink-medium); + --btn-border-color: var(--color-ink-medium); scale: 1; @@ -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 108d7c42a..19609aced 100644 --- a/app/assets/stylesheets/panels.css +++ b/app/assets/stylesheets/panels.css @@ -1,15 +1,27 @@ @layer components { .panel { - background-color: var(--color-bg); - border: var(--panel-border-size, 1px) solid var(--panel-border-color, var(--color-subtle)); + background-color: var(--color-canvas); + border: var(--panel-border-size, 1px) solid var(--panel-border-color, var(--color-ink-lighter)); border-radius: var(--panel-border-radius, 1em); color: var(--color-ink); inline-size: var(--panel-size, 60ch); max-inline-size: 100%; padding: var(--panel-padding, calc(var(--block-space) * 2)); + } - @media (prefers-color-scheme: dark) { - --panel-border-color: var(--color-subtle-dark); + .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 848410931..d14ee1e28 100644 --- a/app/assets/stylesheets/pins.css +++ b/app/assets/stylesheets/pins.css @@ -30,7 +30,8 @@ --block-space-half: 0.5em; --text-xx-large: 2em; - border: 1px solid var(--color-bg); + 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 5c8ec1b95..45db824ae 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -1,41 +1,75 @@ @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 { + min-inline-size: 40ch; + max-inline-size: 40ch; + } + } + + .card & { + min-inline-size: 25ch; + max-inline-size: 25ch; + } + } + + .popup__group { + background: var(--color-ink-lightest); + max-inline-size: 100%; + padding-inline: var(--inline-space); + + .btn { + --btn-background: transparent; + } + + @media (any-hover: hover) { + &:hover { + background: var(--color-selected); + } + } + + &:has(:focus-visible) { + background: var(--color-selected-dark); + + .popup__item { + --outline-size: 0; + } + } } .popup__item { - --btn-background: var(--color-subtle-light); + --btn-background: var(--color-ink-lightest); --btn-border-radius: 0.3em; --btn-border-size: 0; --btn-font-weight: 500; - --btn-padding: var(--inline-space-half) var(--inline-space-half) var(--inline-space-half) var(--inline-space); + --btn-padding: var(--inline-space-half) 0 var(--inline-space-half) var(--inline-space); --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-subtle)); - 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 +109,67 @@ } .popup__list { + max-block-size: 40dvh; + 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/reactions.css b/app/assets/stylesheets/reactions.css index 9c0a4e410..7414bbcd8 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -1,7 +1,7 @@ @layer components { .reactions { --column-gap: 0.4ch; - --reaction-border-color: var(--color-subtle); + --reaction-border-color: var(--color-ink-lighter); --row-gap: 0; margin-block-start: calc(var(--block-space-half) / 2); @@ -30,7 +30,7 @@ @media (any-hover: hover) { &:where(:not(:active):hover) { - --reaction-border-color: var(--color-subtle-dark); + --reaction-border-color: var(--color-ink-medium); box-shadow: 0 0 0 var(--hover-size) var(--hover-color); } @@ -52,7 +52,7 @@ @media (any-hover: hover) { &:where(:not(:active):hover) { - --reaction-border-color: var(--color-subtle-dark); + --reaction-border-color: var(--color-ink-medium); } } } diff --git a/app/assets/stylesheets/syntax.css b/app/assets/stylesheets/syntax.css index 0fb7e193b..7ddc2cf42 100644 --- a/app/assets/stylesheets/syntax.css +++ b/app/assets/stylesheets/syntax.css @@ -39,7 +39,7 @@ } .gr { - color: var(--color-subtle-light); + color: var(--color-ink-lightest); } .gd { @@ -83,7 +83,7 @@ } .err { - color: var(--color-ink-reversed); + color: var(--color-ink-inverted); background-color: var(--markup-deleted); } 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/terminals.css b/app/assets/stylesheets/terminals.css index d431d3cfa..cd310c2c1 100644 --- a/app/assets/stylesheets/terminals.css +++ b/app/assets/stylesheets/terminals.css @@ -2,8 +2,8 @@ .terminal { --row-gap: 0.2lh; - background-color: var(--color-ink); - color: var(--color-positive); + background-color: var(--color-terminal-bg); + color: var(--color-terminal-text); font-family: var(--font-mono); font-size: var(--text-normal); inset: auto 0 0 0; @@ -12,29 +12,29 @@ z-index: 1; @media (prefers-color-scheme: dark) { - background-color: var(--color-always-black); - border-block-start: 1px solid var(--color-subtle-dark); + background-color: var(--color-black); + border-block-start: 1px solid var(--color-ink-lighter); } .terminal__input { --input-border-color: transparent; --input-border-radius: 0.3em; --hover-color: transparent; - --input-color: var(--color-positive); + --input-color: var(--color-terminal-text); --outline-size: 0; --outline-color: transparent; - accent-color: var(--color-positive); - caret-color: var(--color-positive); + accent-color: var(--color-terminal-text); + caret-color: var(--color-terminal-text); font-family: inherit; &::placeholder { - color: var(--color-always-white); + color: var(--color-white); opacity: 0.5; } .terminal__link & { - color: var(--color-always-white); + color: var(--color-white); opacity: 0.5; } } @@ -43,7 +43,7 @@ .terminal__link { --hover-size: 0; - color: var(--color-positive); + color: var(--color-terminal-text); } .terminal__menu { diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css index c37c6ae9c..6c21ab0b5 100644 --- a/app/assets/stylesheets/trays.css +++ b/app/assets/stylesheets/trays.css @@ -4,7 +4,8 @@ @layer components { .tray { - --height: calc(2.48lh + var(--block-space)); + /* FIXME: Magic number */ + --height: calc(2.625lh + var(--block-space)); background-color: transparent; border: 0; @@ -61,11 +62,11 @@ .tray__actions { --border-style: dashed; - --border-color: color-mix(in srgb, var(--color-always-white) 40%, var(--color-ink)); + --border-color: color-mix(in srgb, var(--color-white) 40%, var(--color-ink)); --hover-size: 0; align-items: center; - color: var(--color-ink-reversed); + color: var(--color-ink-inverted); display: flex; gap: var(--inline-space-half); justify-content: space-between; 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 6ec13a630..a07d98422 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -14,15 +14,16 @@ .txt-align-end { text-align: end; } .txt-ink { color: var(--color-ink); } - .txt-reversed { color: var(--color-ink-reversed); } + .txt-reversed { color: var(--color-ink-inverted); } .txt-negative { color: var(--color-negative); } - .txt-subtle { color: var(--color-subtle-dark); } + .txt-subtle { color: var(--color-ink-medium); } .txt-alert { color: var(--color-marker); } .txt-undecorated { text-decoration: none; } .txt-tight-lines { line-height: 1.2; } .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 */ @@ -146,10 +148,10 @@ .position-sticky { position: sticky; inset: var(--inset, 0 auto auto auto); z-index: var(--z, 1); } /* Fills */ - .fill { background-color: var(--color-bg); } + .fill { background-color: var(--color-canvas); } .fill-black { background-color: var(--color-ink); } - .fill-white { background-color: var(--color-ink-reversed); } - .fill-shade { background-color: var(--color-subtle-light); } + .fill-white { background-color: var(--color-ink-inverted); } + .fill-shade { background-color: var(--color-ink-lightest); } .fill-selected { background-color: var(--color-selected); } .fill-highlight { background-color: var(--color-highlight); } .fill-transparent { background-color: transparent; } @@ -157,10 +159,10 @@ .translucent { opacity: var(--opacity, 0.66); } /* Borders */ - .border { border: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); } - .border-block { border-block: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); } - .border-bottom { border-block-end: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); } - .border-top { border-block-start: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); } + .border { border: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); } + .border-block { border-block: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); } + .border-bottom { border-block-end: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); } + .border-top { border-block-start: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); } .borderless { border: 0; } /* Border radius */ diff --git a/app/assets/stylesheets/workflows.css b/app/assets/stylesheets/workflows.css index d69dbfe3b..b9eeccc6b 100644 --- a/app/assets/stylesheets/workflows.css +++ b/app/assets/stylesheets/workflows.css @@ -20,20 +20,53 @@ .workflow-stage--current { --btn-background: var(--card-color); - color: var(--color-ink-reversed); + color: var(--color-ink-inverted); @media (hover: hover) { &:hover { 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-reversed); + aspect-ratio: 1; + flex: 1 1 30%; + max-inline-size: 33%; + + button { + --hover-size: 0; + + appearance: none; + aspect-ratio: 1; + background: transparent; + block-size: 100%; + border: none; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 0.5em; + inline-size: 100%; + margin: 0; + max-inline-size: 100%; + outline: 0; + padding: 1em; + text-align: start; } } + + .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/comments/reactions_controller.rb b/app/controllers/cards/comments/reactions_controller.rb index fb0c4fa55..0a728fbb2 100644 --- a/app/controllers/cards/comments/reactions_controller.rb +++ b/app/controllers/cards/comments/reactions_controller.rb @@ -25,7 +25,7 @@ class Cards::Comments::ReactionsController < ApplicationController private def set_comment - @comment = Comment.belonging_to_card(@card).find(params[:comment_id]) + @comment = @card.comments.find(params[:comment_id]) end def broadcast_create(reaction) diff --git a/app/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb index 33437eb24..d437c53fa 100644 --- a/app/controllers/cards/comments_controller.rb +++ b/app/controllers/cards/comments_controller.rb @@ -5,7 +5,7 @@ class Cards::CommentsController < ApplicationController before_action :ensure_creatorship, only: %i[ edit update destroy ] def create - @card.capture Comment.new(comment_params) + @card.comments.create!(comment_params) end def show @@ -25,7 +25,7 @@ class Cards::CommentsController < ApplicationController private def set_comment - @comment = Comment.belonging_to_card(@card).find(params[:id]) + @comment = @card.comments.find(params[:id]) end def ensure_creatorship 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 669d959d8..2334cb8f5 100644 --- a/app/controllers/cards/readings_controller.rb +++ b/app/controllers/cards/readings_controller.rb @@ -2,12 +2,6 @@ class Cards::ReadingsController < ApplicationController include CardScoped def create - mark_card_notifications_read - @notifications = Current.user.notifications.unread.ordered.limit(20) + @notifications = @card.read_by(Current.user) end - - private - def mark_card_notifications_read - Current.user.notifications.unread.where(card: @card).read_all - 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..597e35f7d --- /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 set_day_timeline + @day_timeline = Current.user.timeline_for(day, filter: @filter) + end + + def save_collections_filter_to_cookie + cookies[:collection_filter] = @filter.collection_ids.join(",") + 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..ee040c720 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1,13 +1,7 @@ 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/mark_all_reads_controller.rb b/app/controllers/notifications/mark_all_reads_controller.rb deleted file mode 100644 index 8b6450515..000000000 --- a/app/controllers/notifications/mark_all_reads_controller.rb +++ /dev/null @@ -1,18 +0,0 @@ -class Notifications::MarkAllReadsController < ApplicationController - def create - Current.user.notifications.unread.read_all - - set_page_and_extract_portion_from Current.user.notifications.read.ordered if request.format.turbo_stream? - - Turbo::StreamsChannel.broadcast_update_to( - [ Current.user, :notifications ], - target: "notifications", - html: "" - ) - - respond_to do |format| - format.turbo_stream - format.html - end - end -end diff --git a/app/controllers/notifications/readings_controller.rb b/app/controllers/notifications/readings_controller.rb new file mode 100644 index 000000000..5f593fa95 --- /dev/null +++ b/app/controllers/notifications/readings_controller.rb @@ -0,0 +1,11 @@ +class Notifications::ReadingsController < ApplicationController + def create + @notification = Current.user.notifications.find(params[:id]) + @notification.read + end + + def create_all + Current.user.notifications.unread.read_all + redirect_to notifications_path + end +end diff --git a/app/controllers/notifications/settings_controller.rb b/app/controllers/notifications/settings_controller.rb index a94345746..5dbf1a867 100644 --- a/app/controllers/notifications/settings_controller.rb +++ b/app/controllers/notifications/settings_controller.rb @@ -1,7 +1,5 @@ -module Notifications - class SettingsController < ApplicationController - def show - @collections = Current.user.collections.alphabetically - end +class Notifications::SettingsController < ApplicationController + def show + @collections = Current.user.collections.alphabetically 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/notifications_controller.rb b/app/controllers/notifications_controller.rb index 403828c2b..d188e3a1e 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -1,19 +1,11 @@ class NotificationsController < ApplicationController def index + @unread = Current.user.notifications.unread.ordered unless current_page_param set_page_and_extract_portion_from Current.user.notifications.read.ordered - if @page.first? - @unread = Current.user.notifications.unread.ordered - end - end - - def mark_read - @notification = Current.user.notifications.find(params[:id]) - @notification.update!(read_at: Time.current) - respond_to do |format| - format.html { redirect_back fallback_location: notifications_path } - format.turbo_stream + format.turbo_stream if current_page_param # Allows read-all action to side step pagination + format.html end 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/users_controller.rb b/app/controllers/users_controller.rb index 4069e5aaa..3dfd0d26a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -2,8 +2,8 @@ class UsersController < ApplicationController require_unauthenticated_access only: %i[ new create ] before_action :set_user, only: %i[ show edit update destroy ] - before_action :set_account_from_join_code, only: %i[ new create ] - before_action :ensure_permission_to_administer_user, only: %i[ update destroy ] + before_action :ensure_join_code_is_valid, only: %i[ new create ] + before_action :ensure_permission_to_change_user, only: %i[ update destroy ] def index @users = User.active @@ -36,16 +36,16 @@ class UsersController < ApplicationController end private - def set_account_from_join_code - @account = Account.find_by_join_code!(params[:join_code]) + def ensure_join_code_is_valid + head :forbidden unless Account.sole.join_code == params[:join_code] end def set_user @user = User.active.find(params[:id]) end - def ensure_permission_to_administer_user - head :forbidden unless Current.user.can_administer?(@user) + def ensure_permission_to_change_user + head :forbidden unless Current.user.can_change?(@user) end def user_params 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..08edb27d9 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,22 @@ 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 + + def card_title_tag(card) + title = [ + card.title, + "added by #{card.creator.name}", + "in #{card.collection.name}" + ] + title << "assigned to #{card.assignees.map(&:name).to_sentence}" if card.assignees.any? + title.join(" ") + 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/comments_helper.rb b/app/helpers/comments_helper.rb index 122e8d551..cda77af9c 100644 --- a/app/helpers/comments_helper.rb +++ b/app/helpers/comments_helper.rb @@ -1,6 +1,6 @@ module CommentsHelper def new_comment_placeholder(card) - if card.creator == Current.user && card.messages.comments.empty? + if card.creator == Current.user && card.comments.empty? "Next, add some notes, context, pictures, or video about this…" else "Type your comment…" 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..6fd5554f8 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? end end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index ca62626cf..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" }, &) @@ -32,12 +31,12 @@ module NotificationsHelper end def notification_mark_read_button(notification) - button_to mark_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")) + 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")) 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/users_helper.rb b/app/helpers/users_helper.rb index e0a2c4422..58b3e12d6 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -2,10 +2,4 @@ module UsersHelper def prepend_current_user_to(users_scope) users_scope.to_a.prepend(Current.user).uniq end - - def familiar_name_for(user) - names = user.name.split - return user.name if names.length == 1 - "#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}" - 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/jobs/sqlite_backups_job.rb b/app/jobs/sqlite_backups_job.rb new file mode 100644 index 000000000..6e7559d80 --- /dev/null +++ b/app/jobs/sqlite_backups_job.rb @@ -0,0 +1,101 @@ +# +# This job backs up all the tenant databases using the SQLite Backup API, which should allow the +# application to continue running against the database while it is backed up. +# +# ref: https://www.sqlite.org/c3ref/backup_finish.html +# +# It will keep N files around, like this: +# +# storage/tenants/development/honcho/db/ +# ├─ main.sqlite3 +# ├─ main.sqlite3.1 +# ├─ main.sqlite3.2 +# ├─ main.sqlite3.3 +# ├─ main.sqlite3.4 +# └─ main.sqlite3.5 +# +class SQLiteBackupsJob < ApplicationJob + DEFAULT_NUMBER_OF_BACKUPS = 5 + DEFAULT_STEP_PAGES = 1024 + + def perform(keep: DEFAULT_NUMBER_OF_BACKUPS, step: DEFAULT_STEP_PAGES) + @failures = [] + + ApplicationRecord.with_each_tenant do |tenant| + perform_file_rollover tenant, keep: keep + perform_backup tenant, step: step + end + + if @failures.present? + raise "SQLiteBackupsJob: failed to backup tenants: #{@failures.join(", ")}" + end + end + + private + def perform_file_rollover(tenant, keep:) + keep.downto(2) do |j| + fresher = backup_path(tenant, j - 1) + staler = backup_path(tenant, j) + + if j == keep && File.exist?(staler) + FileUtils.rm(staler) + end + + if File.exist?(fresher) + # TODO: It may be worth benchmarking whether backing up into the previous backup is faster + # than backing up into an empty file. + FileUtils.mv(fresher, staler) + end + end + end + + def perform_backup(tenant, step:) + ApplicationRecord.with_connection do |conn| + current_adapter = conn.raw_connection + backup_db = backup_path(tenant, 1) + backup_adapter = SQLite3::Database.new(backup_db) + backup = SQLite3::Backup.new(backup_adapter, "main", current_adapter, "main") + + pages = 0 + elapsed = ActiveSupport::Benchmark.realtime(:float_millisecond) do + loop do + status = backup.step(step) + case status + when SQLite3::Constants::ErrorCode::DONE + break + when SQLite3::Constants::ErrorCode::OK + total = backup.pagecount + progress = total - backup.remaining + Rails.logger.debug { "SQLiteBackupsJob: #{tenant.inspect}: Wrote #{progress} of #{total} pages." } + when SQLite3::Constants::ErrorCode::BUSY, SQLite3::Constants::ErrorCode::LOCKED + Rails.logger.debug { "SQLiteBackupsJob: #{tenant.inspect}: Busy, retrying." } + else + Rails.logger.error "SQLiteBackupsJob: #{tenant.inspect}: Failed with status #{status}." + @failures << tenant + end + end + + pages = backup.pagecount + backup.finish + end + + message = sprintf( + "SQLiteBackupsJob: %{tenant}: Backup complete in %.1f ms. Wrote %{pages} pages to %{path}", + tenant: tenant.inspect, path: backup_db.inspect, pages: pages, elapsed: elapsed + ) + Rails.logger.info message + end + end + + def backup_path(tenant, index) + db_path(tenant) + ".#{index}" + end + + def db_path(tenant) + db_config.database + end + + def db_config + ApplicationRecord.connection_pool.db_config + end +end diff --git a/app/models/card.rb b/app/models/card.rb index e2191aa41..00a6312a2 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,39 +1,27 @@ class Card < ApplicationRecord - include Assignable, Colored, DraftCommenting, Engageable, Eventable, Golden, - Messages, Notifiable, Pinnable, Closeable, Scorable, Searchable, Staged, + include Assignable, Colored, Engageable, Eventable, Golden, + Mentions, 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_many :comments, 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 +30,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 2fec6ba6f..5f69e39bb 100644 --- a/app/models/card/closeable.rb +++ b/app/models/card/closeable.rb @@ -8,10 +8,13 @@ module Card::Closeable 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 @@ -23,7 +26,7 @@ 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 days_until_close @@ -38,7 +41,7 @@ module Card::Closeable 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 f811b639a..6671bc5a1 100644 --- a/app/models/card/engageable.rb +++ b/app/models/card/engageable.rb @@ -6,17 +6,21 @@ module Card::Engageable included do has_one :engagement, dependent: :destroy, class_name: "Card::Engagement" - scope :doing, -> { published.active.joins(:engagement) } - scope :considering, -> { published_or_drafted_by(Current.user).active.where.missing(: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 :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 def auto_reconsider_all_stagnated - stagnated.find_each do |card| - card.reconsider - end + stagnated.find_each(&:reconsider) end end @@ -25,11 +29,11 @@ module Card::Engageable end def doing? - active? && published? && engagement.present? + open? && published? && engagement.present? end def considering? - active? && published? && !doing? + open? && published? && engagement.blank? end def engage diff --git a/app/models/card/eventable.rb b/app/models/card/eventable.rb index c8098708b..b9815fd93 100644 --- a/app/models/card/eventable.rb +++ b/app/models/card/eventable.rb @@ -1,26 +1,33 @@ 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_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 + create_system_comment_for(event) + touch(:last_active_at) end end private - def find_or_capture_event_summary - transaction do - messages.last&.event_summary || capture(EventSummary.new).event_summary + def should_track_event? + published? + 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 create_system_comment_for(event) + SystemCommenter.new(self, event).comment + end end diff --git a/app/models/card/eventable/system_commenter.rb b/app/models/card/eventable/system_commenter.rb new file mode 100644 index 000000000..5c06c272a --- /dev/null +++ b/app/models/card/eventable/system_commenter.rb @@ -0,0 +1,57 @@ +class Card::Eventable::SystemCommenter + attr_reader :card, :event + + def initialize(card, event) + @card, @event = card, event + end + + def comment + return unless comment_body.present? + + if comment = find_replaceable_system_comment + comment.update! body: comment_body + else + card.comments.create! creator: User.system, body: comment_body, created_at: event.created_at + end + end + + private + RELATED_EVENTS = [ + %w[ card_assigned card_unassigned ] + ] + + def comment_body + case event.action + when "card_assigned" + "Assigned to #{card.assignees.pluck(:name).to_sentence}." + when "card_unassigned" + if card.assignees.empty? + "Unassigned from #{event.assignees.pluck(:name).to_sentence}." + else + "Assigned to #{card.assignees.pluck(:name).to_sentence}." + end + when "card_staged" + "#{event.creator.name} moved this to '#{event.stage_name}'." + when "card_closed" + "Closed by #{ event.creator.name }" + 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 + + def find_replaceable_system_comment + previous_comment, previous_event = card.comments.last, card.events[-2] + if previous_comment&.creator&.system? && replaceable_event?(previous_event, event) + previous_comment + end + end + + def replaceable_event?(candidate_event, replacement_event) + candidate_event && replacement_event && + (candidate_event.action == replacement_event.action || related_events?(candidate_event, replacement_event)) + end + + def related_events?(candidate_event, replacement_event) + RELATED_EVENTS.find { |related_actions| related_actions.include?(candidate_event.action) && related_actions.include?(replacement_event.action) } + 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/messages.rb b/app/models/card/messages.rb deleted file mode 100644 index 89579b78f..000000000 --- a/app/models/card/messages.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Card::Messages - extend ActiveSupport::Concern - - included do - has_many :messages, -> { chronologically }, dependent: :destroy - end - - def capture(messageable) - messages.create! messageable: messageable - end -end diff --git a/app/models/card/readable.rb b/app/models/card/readable.rb new file mode 100644 index 000000000..84db015ed --- /dev/null +++ b/app/models/card/readable.rb @@ -0,0 +1,34 @@ +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 +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..f28e964cf 100644 --- a/app/models/card/searchable.rb +++ b/app/models/card/searchable.rb @@ -4,39 +4,45 @@ 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) cards = Card.search(query).select(:id).to_sql comments = Comment.search(query).select(:id).to_sql - left_joins(:messages).where("cards.id in (#{cards}) or messages.messageable_id in (#{comments})").distinct + left_joins(:comments).where("cards.id in (#{cards}) or comments.id in (#{comments})").distinct else none end 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 4fba4556d..000000000 --- a/app/models/colorable.rb +++ /dev/null @@ -1,10 +0,0 @@ -module Colorable - extend ActiveSupport::Concern - - COLORS = %w[ #3b3633 #67695e #eb7a32 #bf7c2b #c09c6f #746b1e #2c6da8 #5d618f #663251 #ff63a8 ] - DEFAULT_COLOR = "#2c6da8" - - included do - attribute :color, default: DEFAULT_COLOR - end -end diff --git a/app/models/comment.rb b/app/models/comment.rb index 8922b30b7..f22d95b3e 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,5 +1,6 @@ class Comment < ApplicationRecord - include Messageable, Searchable + include Eventable, Mentions, Searchable + belongs_to :card, touch: true belongs_to :creator, class_name: "User", default: -> { Current.user } has_many :reactions, dependent: :delete_all @@ -7,11 +8,11 @@ class Comment < ApplicationRecord has_markdown :body searchable_by :body_plain_text, using: :comments_search_index, as: :body - # 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 }) } + scope :chronologically, -> { order created_at: :asc, id: :desc } - after_create_commit :watch_card_by_creator, :track_commented_card - after_destroy_commit :cleanup_events + after_create_commit :watch_card_by_creator + + delegate :collection, :watch_by, to: :card def to_partial_path "cards/#{super}" @@ -21,14 +22,4 @@ class Comment < ApplicationRecord def watch_card_by_creator 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 - Event.where(particulars: { comment_id: id }).destroy_all - end end diff --git a/app/models/comment/eventable.rb b/app/models/comment/eventable.rb new file mode 100644 index 000000000..f3a108505 --- /dev/null +++ b/app/models/comment/eventable.rb @@ -0,0 +1,22 @@ +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 should_track_event? + !creator.system? + end + + 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 deleted file mode 100644 index fcfaabd83..000000000 --- a/app/models/event_summary.rb +++ /dev/null @@ -1,38 +0,0 @@ -class EventSummary < ApplicationRecord - include Messageable - - has_many :events, -> { chronologically }, dependent: :delete_all, inverse_of: :summary - - # FIXME: Consider persisting the body and compute at write time. - def body - events.map { |event| summarize(event) }.join(" ") - end - - private - delegate :time_ago_in_words, to: "ApplicationController.helpers" - - def summarize(event) - case event.action - when "published" - "Added by #{event.creator.name}." - when "assigned" - "Assigned to #{event.assignees.pluck(:name).to_sentence}." - when "unassigned" - "Unassigned from #{event.assignees.pluck(:name).to_sentence}." - when "staged" - "#{event.creator.name} moved this to '#{event.stage_name}'." - when "closed" - "Closed by #{ event.creator.name }" - when "unstaged" - "#{event.creator.name} removed this from '#{event.stage_name}'." - when "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" - "#{event.creator.name} changed due date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')}." - when "due_date_removed" - "#{event.creator.name} removed the date." - when "title_changed" - "#{event.creator.name} changed title from '#{event.particulars.dig('particulars', 'old_title')}' to '#{event.particulars.dig('particulars', 'new_title')}'." - end - 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/message.rb b/app/models/message.rb deleted file mode 100644 index 7d950b3b7..000000000 --- a/app/models/message.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Message < ApplicationRecord - belongs_to :card, touch: true - - delegated_type :messageable, types: Messageable::TYPES, inverse_of: :message, dependent: :destroy - - scope :chronologically, -> { order created_at: :asc, id: :desc } -end diff --git a/app/models/notification.rb b/app/models/notification.rb index 3980c3d8d..01444ce61 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -1,24 +1,30 @@ 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 - broadcasts_to ->(notification) { [ notification.user, :notifications ] }, inserts_by: :prepend + delegate :notifiable_target, to: :source - class << self - def read_all - update!(read_at: Time.current) - end + def self.read_all + update!(read_at: Time.current) + end + + def read + update!(read_at: Time.current) end def read? read_at.present? end + + private + def broadcast_unread + broadcast_prepend_later_to user, :notifications, target: "notifications" + end 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..aa4f9b585 --- /dev/null +++ b/app/models/user/named.rb @@ -0,0 +1,25 @@ +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 + + def familiar_name + names = name.split + return name if names.length == 1 + "#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}" + end +end diff --git a/app/models/user/role.rb b/app/models/user/role.rb index 1b5648679..4a90410b5 100644 --- a/app/models/user/role.rb +++ b/app/models/user/role.rb @@ -5,8 +5,7 @@ module User::Role enum :role, %i[ admin member system ].index_by(&:itself), scopes: false scope :member, -> { where(role: :member) } - scope :without_system, -> { where.not(role: :system) } - scope :active, -> { without_system.where(active: true) } + scope :active, -> { where(active: true, role: %i[ admin member ]) } end class_methods do @@ -15,6 +14,10 @@ module User::Role end end + def can_change?(other) + admin? || other == self + end + def can_administer?(other) admin? && other != self 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/_comments.html.erb b/app/views/cards/_comments.html.erb index fd4829974..9ed43c777 100644 --- a/app/views/cards/_comments.html.erb +++ b/app/views/cards/_comments.html.erb @@ -1,6 +1,6 @@ -<% if card.messages.comments.any? %> +<% if card.comments.any? %>
- - <%= pluralize(card.messages.comments.count, "comment") %> + + <%= pluralize(card.comments.count, "comment") %>
<% 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 @@ +
+ <%= render "cards/container/engagement", card: card %> + + + +
+ <%= 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/container/title", card: card %> + <%= render "cards/stagings/stages", card: card if card.doing? %> +
+ +
+ <%= render "cards/display/perma/meta", card: card %> +
+ + <%= render "cards/display/common/background", card: card %> + <% end %> +
+ + <% if card.published? %> + <%= render "cards/container/footer/published", card: card %> + <% elsif card.creating? %> + <%= render "cards/container/footer/draft", card: card %> + <% end %> +
diff --git a/app/views/cards/_date.html.erb b/app/views/cards/_date.html.erb deleted file mode 100644 index cc19afe32..000000000 --- a/app/views/cards/_date.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -<% if card.due_on.present? %> - <%= form_with model: card, url: collection_card_path(card.collection, card), data: { controller: "form" } do |form| %> - - <% end %> -<% end %> diff --git a/app/views/cards/_messages.html.erb b/app/views/cards/_messages.html.erb index 4b4298759..7fd12e1c2 100644 --- a/app/views/cards/_messages.html.erb +++ b/app/views/cards/_messages.html.erb @@ -1,5 +1,5 @@ <%= messages_tag(card) do %> - <%= render partial: "cards/messages/message", collection: card.messages, cached: true %> + <%= render partial: "cards/comments/comment", collection: card.comments.chronologically, cached: true %> <%= render "cards/comments/new", card: card %>
@@ -14,5 +14,10 @@ <%= avatar_tag watcher %> <% end %>
+ +
+
+ <%= button_to_delete_card(@card) %> +
<% end %> diff --git a/app/views/cards/_publish.html.erb b/app/views/cards/_publish.html.erb deleted file mode 100644 index 1f2199ece..000000000 --- a/app/views/cards/_publish.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= button_to card_publish_path(card), class: "btn txt-small btn--link", style: "--btn-background: #{card.color}" do %> - Post to project -<% end %> diff --git a/app/views/cards/assignments/create.turbo_stream.erb b/app/views/cards/assignments/create.turbo_stream.erb deleted file mode 100644 index f05f15d8d..000000000 --- a/app/views/cards/assignments/create.turbo_stream.erb +++ /dev/null @@ -1 +0,0 @@ -<%= turbo_stream.replace([ @card, :meta ], partial: "cards/display/perma/meta", locals: { card: @card }) %> diff --git a/app/views/cards/assignments/new.html.erb b/app/views/cards/assignments/new.html.erb index 1be2c77a2..83fbeea7e 100644 --- a/app/views/cards/assignments/new.html.erb +++ b/app/views/cards/assignments/new.html.erb @@ -1,12 +1,26 @@ <%= turbo_frame_tag @card, :assignment do %> - Assign this to… + <%= tag.div class: "max-width",data: { + controller: "filter", + dialog_target: "dialog", + filter_active_class: "filter--active", + filter_selected_class: "selected"} do %> + Assign this to… - + + + <% @collection.users.active.alphabetically.each do |user| %> + + <% end %> + + <% end %> <% end %> diff --git a/app/views/cards/comments/_comment.html.erb b/app/views/cards/comments/_comment.html.erb index 427fae8ad..695b8588a 100644 --- a/app/views/cards/comments/_comment.html.erb +++ b/app/views/cards/comments/_comment.html.erb @@ -11,17 +11,18 @@ <%= link_to comment.creator.name, comment.creator, class: "txt-ink btn btn--plain fill-transparent", data: { turbo_frame: "_top" } %> - <%= link_to card_path(comment.card, anchor: "comment_#{comment.id}"), class: "txt-undecorated txt-uppercase" do %> - <%= local_datetime_tag comment.created_at, style: :shortdate, class: "txt-ink translucent" %> + <%= 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 %> <%= link_to edit_card_comment_path(comment.card, comment), - class: "comment__edit btn btn--plain txt-xx-small fill-transparent translucent flex-item-justify-end" do %> + class: "comment__edit btn btn--plain txt-xx-small fill-transparent translucent flex-item-justify-end" do %> <%= icon_tag "menu-dots-horizontal" %> Edit this comment <% end %> -
+
<%= sanitize comment.body_html %>
diff --git a/app/views/cards/comments/_new.html.erb b/app/views/cards/comments/_new.html.erb index 2352d1797..c4645fe86 100644 --- a/app/views/cards/comments/_new.html.erb +++ b/app/views/cards/comments/_new.html.erb @@ -4,7 +4,7 @@
-
+
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width", data: { controller: "form paste local-save", local_save_key_value: "comment-#{card.id}", diff --git a/app/views/cards/comments/edit.html.erb b/app/views/cards/comments/edit.html.erb index e3a2e783d..d07b50334 100644 --- a/app/views/cards/comments/edit.html.erb +++ b/app/views/cards/comments/edit.html.erb @@ -1,6 +1,6 @@ <%= turbo_frame_tag dom_id(@comment) do %>
-
+
<%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width", data: { controller: "form paste", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop paste->paste#pasteFiles" } do |form| %> <%= form.markdown_area :body, class: "input comment__input", required: true, autofocus: true, placeholder: new_comment_placeholder(@card) %> @@ -9,7 +9,7 @@ Save changes <% end %> <%= link_to card_comment_path(@card, @comment), class: "btn", data: { form_target: "cancel" } do %> - Cancel + Cancel <% end %> <%= tag.button type: :submit, class: "btn btn--negative flex-item-justify-end", form: dom_id(@comment, :delete_form), diff --git a/app/views/cards/_closure_toggle.html.erb b/app/views/cards/container/_closure.html.erb similarity index 61% rename from app/views/cards/_closure_toggle.html.erb rename to app/views/cards/container/_closure.html.erb index 76edfa091..f69cab694 100644 --- a/app/views/cards/_closure_toggle.html.erb +++ b/app/views/cards/container/_closure.html.erb @@ -1,9 +1,11 @@ -
"> +
<% if card.closed? %> - Completed by <%= card.closed_by.name %> on <%= local_datetime_tag(card.closed_at, style: :shortdate) %>. - <%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %> - Un-do - <% end %> +
+ Completed by <%= card.closed_by.name %> on <%= local_datetime_tag(card.closed_at, style: :shortdate) %>. + <%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %> + Un-do + <% end %> +
<% else %>
@@ -22,7 +24,7 @@ <% if card.doing? %> Returns to Considering if no activity <%= local_datetime_tag(card.auto_reconsider_at, style: :indays) -%>. - <% else %> + <% elsif card.auto_closing? %> Auto-closes if no activity <%= local_datetime_tag(card.auto_close_at, style: :indays) -%>. <% end %> 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 %> +
+

+ <%= link_to card.title, edit_collection_card_path(card.collection, card), class: "card__title-link" %> +

+
+ <%= 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.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" } %> +

+
+ <%= 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 %> -
- -
-

- <% 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 %> -
- -
- <%= render "cards/display/perma/meta", 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 cdfe91764..6a550c15b 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 %>
-
-

- <%= 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", title: card_title_tag(card), data: { turbo_frame: "_top" } do %> + <%= card.title %> + <% end %> +
<%= 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 58b4b67c5..c89cdcc57 100644 --- a/app/views/cards/display/common/_assignees.html.erb +++ b/app/views/cards/display/common/_assignees.html.erb @@ -4,8 +4,8 @@ <% end %>
> - diff --git a/app/views/cards/display/common/_background.html.erb b/app/views/cards/display/common/_background.html.erb index 08a3c557a..4db6a088a 100644 --- a/app/views/cards/display/common/_background.html.erb +++ b/app/views/cards/display/common/_background.html.erb @@ -5,7 +5,7 @@ <% if card.closed? %>
<%= card.closure.reason %> - <%= card.closed_at.strftime("%b %d, %Y") %> - by <%= card.closed_by.name %> + <%= card.closed_at.strftime("%b %d, %Y") %> + by <%= card.closed_by.familiar_name %>
<% end %> diff --git a/app/views/cards/display/common/_meta.html.erb b/app/views/cards/display/common/_meta.html.erb index d2ba79b98..86640f3cc 100644 --- a/app/views/cards/display/common/_meta.html.erb +++ b/app/views/cards/display/common/_meta.html.erb @@ -1,7 +1,7 @@
- <%= avatar_image_tag card.creator, class: "avatar margin-inline-end" %> + <%= avatar_tag card.creator %> -
+
Added <%= local_datetime_tag(card.created_at, style: :daysago) %> <% if card.drafted? %> @@ -17,10 +17,11 @@ <% end %> - Reported by <%= familiar_name_for(card.creator) %> + Added by <%= card.creator.familiar_name %> - Assigned to <%= card.assignees.any? ? card.assignees.map { |assignee| "#{familiar_name_for(assignee)}" }.to_sentence.html_safe : "no one" %> + <%= card.assignees.any? ? "Assigned to" : "Not assigned" %> + <%= card.assignees.map { |assignee| "#{assignee.familiar_name}" }.to_sentence.html_safe %>
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 @@
> - 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.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 %> +

+
+ <%= 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..8b07eab87 100644 --- a/app/views/cards/index/_collections_filter.html.erb +++ b/app/views/cards/index/_collections_filter.html.erb @@ -1,23 +1,43 @@ -<%= 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 + GO TO + <% end %> <% end %> <% Current.user.collections.order(:name).each do |collection| %> - + + <%= 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..218554519 100644 --- a/app/views/cards/index/_header.html.erb +++ b/app/views/cards/index/_header.html.erb @@ -1,22 +1,48 @@
+ diff --git a/app/views/collections/edit/_auto_close.html.erb b/app/views/collections/edit/_auto_close.html.erb new file mode 100644 index 000000000..84be06644 --- /dev/null +++ b/app/views/collections/edit/_auto_close.html.erb @@ -0,0 +1,6 @@ +Auto-Close Cards + + +<%= form_with model: collection, data: { controller: "form" } do |form| %> + <%= form.select :auto_close_period, collection_auto_close_options, {}, { class: "input input--select full-width margin-block-end", data: { action: "change->form#submit" } } %> +<% end %> diff --git a/app/views/collections/edit/_name.html.erb b/app/views/collections/edit/_name.html.erb new file mode 100644 index 000000000..e08e885ce --- /dev/null +++ b/app/views/collections/edit/_name.html.erb @@ -0,0 +1,7 @@ +
+ +
diff --git a/app/views/collections/edit/_users.html.erb b/app/views/collections/edit/_users.html.erb new file mode 100644 index 000000000..f38d2924c --- /dev/null +++ b/app/views/collections/edit/_users.html.erb @@ -0,0 +1,40 @@ +
+ <%= access_menu_tag collection do %> +
  • +
    + <%= icon_tag "everyone" %> + Everyone +
    + +
    +
    Everyone
    +
    + + + + +
  • + + + + <% if User.active.count > 20 %> +
    + +
    + <% end %> + +
    + <%= access_toggles_for selected_users, selected: true %> + + <% if selected_users.any? && unselected_users.any? %> +
    + <% end %> + + <%= access_toggles_for unselected_users, selected: false %> +
    + <% end %> +
    diff --git a/app/views/collections/edit/_workflows.html.erb b/app/views/collections/edit/_workflows.html.erb new file mode 100644 index 000000000..092b6195f --- /dev/null +++ b/app/views/collections/edit/_workflows.html.erb @@ -0,0 +1,21 @@ +Workflows +
    Use a Workflow to track progress in this Collection.
    +
    + <% Workflow.all.each do |workflow| %> +
    + <%= button_to collection_workflow_path(collection), method: :patch do %> + <%= hidden_field_tag "collection[workflow_id]", workflow.id %> + <%= workflow.name %> +
      + <% workflow.stages.each do |stage| %> +
    • + + <%= stage.name %> +
    • + <% end %> +
    + <% end %> +
    + <% end %> +
    +

    <%= link_to "Manage workflows", workflows_path, class: "btn btn--plain txt-link" %>

    diff --git a/app/views/collections/new.html.erb b/app/views/collections/new.html.erb index f2c4b9e3d..0b391b4e2 100644 --- a/app/views/collections/new.html.erb +++ b/app/views/collections/new.html.erb @@ -10,7 +10,7 @@ <%= form_with model: @collection, class: "flex flex-column gap txt-large", data: { controller: "form" } do |form| %>

    Create a new collection

    - - - Filter by Collection… - <%= form_with url: events_path, method: :get, class: "flex flex-column full-width popup__list", - data: { controller: "form" } do |form| %> - <%= link_to "Show everything", events_path(clear_filter: true), class: "btn popup__item txt-nowrap" %> - <% @collections.each do |collection| %> - - <% end %> <% end %> - + + <%= tag.dialog class: "events__popup popup popup--animated panel flex-column align-start gap-half fill-white shadow", + style: "z-index: 5;", + data: { + action: "turbo:before-cache@document->dialog#close", + controller: "filter", + dialog_target: "dialog", + filter_active_class: "filter--active", + filter_selected_class: "selected" } do %> + <%= render "events/filter/header" %> + <%= render "events/filter/new_collection_option" %> + <% if Current.user.collections.count > 15 %> + + <% end %> + <%= render "events/filter/custom_collections" %> + <%= form_with url: events_path, method: :get, class: "flex flex-column max-width popup__list full-width", + data: { controller: "form" } do |form| %> + <%= render "events/filter/all_collections_option", form: form, filter: filter %> + + <%= render partial: "events/filter/collection_option", collection: Current.user.collections.alphabetically, as: :collection, locals: { form: form, filter: filter } %> + + <% end %> + +

    + Press ⌘j anytime to open this, esc to close. +

    + <% end %>
    diff --git a/app/views/events/days/index.turbo_stream.erb b/app/views/events/days/index.turbo_stream.erb index 984b01ea3..cceb2d352 100644 --- a/app/views/events/days/index.turbo_stream.erb +++ b/app/views/events/days/index.turbo_stream.erb @@ -1,7 +1,7 @@ <%= turbo_stream.append :activity do %> - <%= render "events/day", activity_day: @activity_day, next_day: @next_day, events: @events %> + <%= render "events/day", day_timeline: @day_timeline %> <% end %> <%= turbo_stream.replace :next_page do %> - <%= event_next_page_link(@next_day) %> + <%= event_next_page_link(@day_timeline.next_day) %> <% end %> diff --git a/app/views/events/event/_card.html.erb b/app/views/events/event/_card.html.erb new file mode 100644 index 000000000..74864434f --- /dev/null +++ b/app/views/events/event/_card.html.erb @@ -0,0 +1 @@ +<%= render "events/event/layout", card: event.eventable, event: event %> diff --git a/app/views/events/event/_comment.html.erb b/app/views/events/event/_comment.html.erb new file mode 100644 index 000000000..22f4596e8 --- /dev/null +++ b/app/views/events/event/_comment.html.erb @@ -0,0 +1,5 @@ +<%= render "events/event/layout", card: event.eventable.card, event: event do %> + + <%= strip_tags(event&.eventable.body_html).truncate(200) -%> + +<% end %> diff --git a/app/views/events/event/_layout.html.erb b/app/views/events/event/_layout.html.erb new file mode 100644 index 000000000..44e9e526a --- /dev/null +++ b/app/views/events/event/_layout.html.erb @@ -0,0 +1,29 @@ +<%= link_to event.notifiable_target, + class: "event event--#{ event.action } panel center center-block flex-inline align-start justify-start gap position-relative", + style: "--card-color: #{ card.color }; background-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); + color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));", + data: { related_element_target: "related", + related_element_group_value: card.id, + action: "mouseover->related-element#highlight mouseout->related-element#unhighlight" } do %> +
    + <%= avatar_image_tag(event.creator) %> +
    + +
    +
    + + <%= event_action_sentence(event) %> + + + <% if event_column(event) == 2 %> + + <%= icon_tag event_action_icon(event) %> + + <% end %> +
    + + <%= yield %> + + <%= event.collection.name %> +
    +<% end %> diff --git a/app/views/events/filter/_all_collections_option.html.erb b/app/views/events/filter/_all_collections_option.html.erb new file mode 100644 index 000000000..27857fb1f --- /dev/null +++ b/app/views/events/filter/_all_collections_option.html.erb @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/app/views/events/filter/_collection_option.html.erb b/app/views/events/filter/_collection_option.html.erb new file mode 100644 index 000000000..bdc2877b6 --- /dev/null +++ b/app/views/events/filter/_collection_option.html.erb @@ -0,0 +1,19 @@ + diff --git a/app/views/events/filter/_custom_collections.html.erb b/app/views/events/filter/_custom_collections.html.erb new file mode 100644 index 000000000..0c1f056e5 --- /dev/null +++ b/app/views/events/filter/_custom_collections.html.erb @@ -0,0 +1,27 @@ + \ No newline at end of file diff --git a/app/views/events/filter/_header.html.erb b/app/views/events/filter/_header.html.erb new file mode 100644 index 000000000..a10a4ab23 --- /dev/null +++ b/app/views/events/filter/_header.html.erb @@ -0,0 +1,7 @@ +
    + Collections + +
    diff --git a/app/views/events/filter/_new_collection_option.html.erb b/app/views/events/filter/_new_collection_option.html.erb new file mode 100644 index 000000000..71914e7df --- /dev/null +++ b/app/views/events/filter/_new_collection_option.html.erb @@ -0,0 +1,7 @@ + diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 8bcd35e80..2c4678fde 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -6,7 +6,7 @@

    <%= Account.sole.name %> / Latest activity

    - <%= render "events/filter" %> + <%= render "events/filter", filter: @filter %>
    <%= link_to users_path, class: "btn flex-item-justify-end" do %> @@ -17,36 +17,10 @@ <% end %>
    -
    -

    Custom views

    -
    - <% @filters.each do |filter| %> -

    - <%= link_to filter.summary, cards_path(**filter.as_params) %> -

    - <% end %> -
    -
    - <%= render "events/day", activity_day: @activity_day, next_day: @next_day, events: @events %> -
    - <%= event_next_page_link(@next_day) %> -
    - -
    -
    -

    Collections

    - <%= link_to new_collection_path, class: "btn txt-xx-small flex-item-justify-end flex-item-no-shrink", style: "view-transition-name: new-collection" do %> - <%= icon_tag "add" %> - New - <% end %> -
    - -
    - <% @collections.each do |collection| %> -

    <%= link_to collection.name, cards_path(collection_ids: [ collection ]) %>

    - <% end %> + <%= render "events/day", day_timeline: @day_timeline %>
    + <%= event_next_page_link(@day_timeline.next_day) %>
    diff --git a/app/views/filters/_assignees.html.erb b/app/views/filters/_assignees.html.erb index cb219d9c9..eb2eca270 100644 --- a/app/views/filters/_assignees.html.erb +++ b/app/views/filters/_assignees.html.erb @@ -1,11 +1,11 @@ <% if filter.assignees.any? %>
    - - - - In Collection - <%= form_with url: cards_path, method: :get, class: "flex flex-column full-width popup__list", - data: { controller: "form" } do |form| %> - <% filter.as_params.except(:collection_ids).each do |key, value| %> - <%= filter_hidden_field_tag key, value %> - <% end %> - - <% Current.user.collections.order(:name).each do |collection| %> - - <% end %> - <% end %> - -
    diff --git a/app/views/filters/_creators.html.erb b/app/views/filters/_creators.html.erb index e46931110..d6dbd31fa 100644 --- a/app/views/filters/_creators.html.erb +++ b/app/views/filters/_creators.html.erb @@ -1,10 +1,10 @@ <% if filter.creators.any? %>
    - - +
    - <%= render "filters/collections", filter: filter %> - <%= render "filters/indexed_by", filter: filter %> - <%= render "filters/stages", filter: filter %> <%= render "filters/tags", filter: filter %> <%= render "filters/assignees", filter: filter %> <%= render "filters/creators", filter: filter %> @@ -17,7 +9,7 @@ <% end %> <% if any_filters?(filter) %> - <%= link_to cards_path, class: "btn btn--remove txt-small" do %> + <%= link_to cards_path, class: "btn btn--remove txt-x-small" do %> <%= icon_tag "close" %> Clear all <% end %> diff --git a/app/views/filters/_tags.html.erb b/app/views/filters/_tags.html.erb index f6a58225d..3a9b35e6e 100644 --- a/app/views/filters/_tags.html.erb +++ b/app/views/filters/_tags.html.erb @@ -1,6 +1,6 @@ <% if filter.tags.any? %>
    - - - +
    +<% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e886c519f..fd857fafe 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -46,10 +46,12 @@
    <%= yield :footer %> - <% if Current.user %> - <%= render "notifications/tray" %> - <%= render "my/pins/tray" %> - <%= turbo_frame_tag "terminal", src: terminal_path, data: { turbo_permanent: true } %> + <% if Current.user && !@hide_footer_frames %> + <% end %>
    diff --git a/app/views/my/pins/_pin.html.erb b/app/views/my/pins/_pin.html.erb new file mode 100644 index 000000000..66d177b28 --- /dev/null +++ b/app/views/my/pins/_pin.html.erb @@ -0,0 +1,3 @@ +
    + <%= render "cards/display/preview", card: pin.card %> +
    diff --git a/app/views/my/pins/_tray.html.erb b/app/views/my/pins/_tray.html.erb index 70c1d5a16..ddd39fdfe 100644 --- a/app/views/my/pins/_tray.html.erb +++ b/app/views/my/pins/_tray.html.erb @@ -1,4 +1,4 @@ -<%= turbo_stream_from Current.user, :pins %> +<%= turbo_stream_from Current.user, :pins_tray %> <%= tag.dialog id: "pin-tray", class: "tray pin-tray", data: { controller: "dialog", turbo_permanent: true, dialog_modal_value: false, dialog_target: "dialog", action: "keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside" } do %> diff --git a/app/views/my/pins/index.html.erb b/app/views/my/pins/index.html.erb index 519070aec..bee9fe10b 100644 --- a/app/views/my/pins/index.html.erb +++ b/app/views/my/pins/index.html.erb @@ -1,7 +1,3 @@ <%= turbo_frame_tag "pins" do %> - <% @pins.map(&:card).each do |card| %> -
    - <%= render partial: "cards/display/preview", locals: { card: card } %> -
    - <% end %> + <%= render partial: "my/pins/pin", collection: @pins %> <% end %> diff --git a/app/views/notifications/_notification.html.erb b/app/views/notifications/_notification.html.erb index 8cb22eeb1..36ec74370 100644 --- a/app/views/notifications/_notification.html.erb +++ b/app/views/notifications/_notification.html.erb @@ -5,15 +5,7 @@
    - <%= notification_title(notification) %> -
    - <% if notification.event.action == "commented" %> - <%= "#{strip_tags(notification.event.comment.body_html).blank? ? "#{notification.event.creator.name} replied" : "#{notification.event.creator.name}:" } #{strip_tags(notification.event.comment.body_html).truncate(200)}" %> - <% else %> - <%= notification_body(notification) %> - <% end %> -
    -
    <%= notification.card.collection.name %> · <%= local_datetime_tag(notification.created_at, style: :ago) %>
    + <%= render "notifications/notification/#{notification.source_type.underscore}", notification: notification %>
    <% end %> <% end %> diff --git a/app/views/notifications/_tray.html.erb b/app/views/notifications/_tray.html.erb index 8e12e58f4..9095f1946 100644 --- a/app/views/notifications/_tray.html.erb +++ b/app/views/notifications/_tray.html.erb @@ -1,14 +1,11 @@ <%= turbo_stream_from Current.user, :notifications %> -<%= tag.dialog id: "notification-tray", class: "tray notification-tray", - data: { controller: "dialog", - turbo_permanent: true, - dialog_modal_value: false, - dialog_target: "dialog", - action: "keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside" } do %> - <%= turbo_frame_tag("notifications", src: notifications_tray_path) -%> + + <%= turbo_frame_tag "notifications", src: tray_notifications_path %> - <%= link_to notifications_path, class: "tray__overflow border-radius txt-normal", data: { action: "click->dialog#close"} do %> + <%= link_to notifications_path, class: "tray__overflow border-radius txt-normal", data: { action: "click->dialog#close" } do %>
    + More… @@ -17,22 +14,18 @@ <% end %>
    - <%= button_to notifications_mark_all_read_path, - class: "notification-tray__read_action btn btn--reversed fill-transparent borderless flex-item-justify-start shadow txt-xx-small", - data: { action: "click->dialog#close", turbo_frame: "notifications" } do %> - <%= icon_tag "check" %> - Mark all read + <%= button_to read_all_notifications_path, + class: "notification-tray__read_action btn btn--reversed fill-transparent borderless flex-item-justify-start shadow txt-xx-small", + data: { action: "click->dialog#close", turbo_frame: "notifications" } do %> + <%= icon_tag "check" %> Mark all read <% end %> - <%= link_to notifications_path, - class: "btn btn--reversed borderless fill-transparent translucent shadow center txt-uppercase txt-x-small", - data: { action: "click->dialog#close"} do %> - Notifications - <% end %> + <%= link_to tag.span("Notifications"), notifications_path, + class: "btn btn--reversed borderless fill-transparent translucent shadow center txt-uppercase txt-x-small", + data: { action: "click->dialog#close" } %>
    - -<% end %> + +
    diff --git a/app/views/notifications/index.html.erb b/app/views/notifications/index.html.erb index bab485363..43db397d9 100644 --- a/app/views/notifications/index.html.erb +++ b/app/views/notifications/index.html.erb @@ -1,4 +1,5 @@ <% @page_title = "Notifications" %> +<% @hide_footer_frames = true %> <% content_for :header do %> <% end %> -
    - <%= turbo_frame_tag "notifications_list" do %> - <% if @unread.any? %> -
    -

    New for you

    - <%= button_to notifications_mark_all_read_path, class: "btn txt-small" do %> - Mark all as read - <% end %> -
    +
    + <% if @unread.any? %> +
    +

    New for you

    + <%= button_to "Mark all as read", read_all_notifications_path, class: "btn txt-small" %> +
    - <%= render @unread %> - <% else %> -
    - Nothing new for you. -
    - <% end %> + <%= render partial: "notifications/notification", collection: @unread, cached: true %> + <% else %> +
    + Nothing new for you. +
    <% end %>
    -<% if @page.records.any? %> -
    -

    Previously seen

    - <%= render @page.records %> -
    +
    +

    Previously seen

    - <%= notifications_next_page_link(@page) %> -<% end %> +
    + <%= render partial: "notifications/notification", collection: @page.records, cached: true %> +
    +
    + +<%= notifications_next_page_link(@page) if @page.records.any? %> diff --git a/app/views/notifications/index.turbo_stream.erb b/app/views/notifications/index.turbo_stream.erb index 00d744dcd..4d74e0941 100644 --- a/app/views/notifications/index.turbo_stream.erb +++ b/app/views/notifications/index.turbo_stream.erb @@ -1,7 +1,2 @@ -<%= turbo_stream.append :notifications_list_read do %> - <%= render @page.records %> -<% end %> - -<%= turbo_stream.replace :next_page do %> - <%= notifications_next_page_link(@page) %> -<% end %> +<%= turbo_stream.append :notifications_list_read, partial: "notifications/notification", collection: @page.records %> +<%= turbo_stream.replace :next_page, notifications_next_page_link(@page) %> diff --git a/app/views/notifications/mark_all_reads/create.html.erb b/app/views/notifications/mark_all_reads/create.html.erb deleted file mode 100644 index 52d6bcd10..000000000 --- a/app/views/notifications/mark_all_reads/create.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= turbo_frame_tag "notifications" %> diff --git a/app/views/notifications/mark_all_reads/create.turbo_stream.erb b/app/views/notifications/mark_all_reads/create.turbo_stream.erb deleted file mode 100644 index 6d8b0f0a6..000000000 --- a/app/views/notifications/mark_all_reads/create.turbo_stream.erb +++ /dev/null @@ -1,14 +0,0 @@ -<%= turbo_stream.update "notifications_list" do %> -
    - Nothing new for you. -
    -<% end %> - -<% if @page&.records&.any? %> - <%= turbo_stream.update "notifications_list_read" do %> -

    Previously seen

    - <%= render partial: "notifications/notification", collection: @page.records %> - <% end %> -<% end %> - -<%= turbo_stream.update "notifications" %> \ No newline at end of file diff --git a/app/views/notifications/mark_read.turbo_stream.erb b/app/views/notifications/mark_read.turbo_stream.erb deleted file mode 100644 index 78996e2d3..000000000 --- a/app/views/notifications/mark_read.turbo_stream.erb +++ /dev/null @@ -1,9 +0,0 @@ -<%= turbo_stream.remove @notification %> - -<% if Current.user.notifications.unread.none? %> - <%= turbo_stream.update "notifications_list" do %> -
    - Nothing new for you. -
    - <% end %> -<% end %> \ No newline at end of file diff --git a/app/views/notifications/notification/_event.html.erb b/app/views/notifications/notification/_event.html.erb new file mode 100644 index 000000000..2dd8d16ee --- /dev/null +++ b/app/views/notifications/notification/_event.html.erb @@ -0,0 +1,7 @@ +<% event = notification.source %> + +<%= event_notification_title(event) %> +
    + <%= event_notification_body(event) %> +
    +
    <%= notification.source.collection.name %> · <%= local_datetime_tag(notification.created_at, style: :ago) %>
    diff --git a/app/views/notifications/notification/_mention.html.erb b/app/views/notifications/notification/_mention.html.erb new file mode 100644 index 000000000..7ed8ffdc5 --- /dev/null +++ b/app/views/notifications/notification/_mention.html.erb @@ -0,0 +1,7 @@ +<% mention = notification.source %> + +<%= mention.mentioner.first_name %> mentioned you +
    + <%= mention.source.mentionable_content.truncate(200) %> +
    +
    <%= local_datetime_tag(notification.created_at, style: :ago) %>
    diff --git a/app/views/notifications/readings/create.turbo_stream.erb b/app/views/notifications/readings/create.turbo_stream.erb new file mode 100644 index 000000000..44bf1e545 --- /dev/null +++ b/app/views/notifications/readings/create.turbo_stream.erb @@ -0,0 +1,2 @@ +<%= turbo_stream.remove @notification %> +<%= turbo_stream.prepend :notifications_list_read, partial: "notifications/notification", locals: { notification: @notification } %> diff --git a/app/views/notifications/settings/_collection.html.erb b/app/views/notifications/settings/_collection.html.erb index ad680363a..a9d421e05 100644 --- a/app/views/notifications/settings/_collection.html.erb +++ b/app/views/notifications/settings/_collection.html.erb @@ -1,12 +1,12 @@ <%= turbo_frame_tag dom_id(collection, :involvement) do %> -
    +

    <%= collection.name %>

    - + -
    diff --git a/app/views/notifications/trays/show.html.erb b/app/views/notifications/trays/show.html.erb index 051055670..f61c52d51 100644 --- a/app/views/notifications/trays/show.html.erb +++ b/app/views/notifications/trays/show.html.erb @@ -1,3 +1,3 @@ <%= turbo_frame_tag "notifications" do %> - <%= render collection: @notifications, partial: "notifications/notification" %> + <%= render partial: "notifications/notification", collection: @notifications, cached: true %> <% end %> diff --git a/app/views/tags/index.html.erb b/app/views/tags/index.html.erb index 0ca56680c..a89e94c86 100644 --- a/app/views/tags/index.html.erb +++ b/app/views/tags/index.html.erb @@ -2,7 +2,7 @@

    All tags

      <% @tags.each do |tag| %> -
    • +
    • <%= tag.title %> diff --git a/app/views/terminals/_event.html.erb b/app/views/terminals/_event.html.erb index fd67cd405..041ca94c4 100644 --- a/app/views/terminals/_event.html.erb +++ b/app/views/terminals/_event.html.erb @@ -1,5 +1,5 @@
    • - <%= link_to event_action_sentence(event), collection_card_path(event.card.collection, event.card), - class: "terminal__link", - data: { turbo_frame: "_top" } %> -
    • \ No newline at end of file + <%= link_to event_action_sentence(event), collection_card_path(event.collection, event.eventable), + class: "terminal__link", + data: { action: "toggle-class#remove", turbo_frame: "_top" } %> + diff --git a/app/views/terminals/edit.html.erb b/app/views/terminals/edit.html.erb index 38496e662..735162d56 100644 --- a/app/views/terminals/edit.html.erb +++ b/app/views/terminals/edit.html.erb @@ -12,8 +12,7 @@ autocapitalize: "off", class: "terminal__input input fill-transparent unpad", data: { - controller: "filter", - action: "keydown.up->toggle-class#toggle:prevent input->filter#filter keydown.esc->form#cancel:prevent" + action: "keydown.up->toggle-class#add:prevent keydown.down->toggle-class#remove:prevent keydown.esc->form#cancel:prevent" }, id: "fizzy_do", placeholder: "Search or type commands…" %> diff --git a/app/views/terminals/show.html.erb b/app/views/terminals/show.html.erb index 58a7f663d..843d79039 100644 --- a/app/views/terminals/show.html.erb +++ b/app/views/terminals/show.html.erb @@ -5,7 +5,7 @@ <%= turbo_frame_tag "terminal_form" do %> - <%= link_to edit_terminal_path, class: "terminal__link flex align-center gap-half min-width", data: { action: "keydown.meta+j@document->hotkey#click", controller: "hotkey" } do %> + <%= link_to edit_terminal_path, class: "terminal__link flex align-center gap-half min-width", data: { action: "keydown.meta+shift+p@document->hotkey#click", controller: "hotkey" } do %> Fizzy do > Search or type commands… <% end %> diff --git a/app/views/users/_user.html.erb b/app/views/users/_user.html.erb index 950df8ccc..2964451b5 100644 --- a/app/views/users/_user.html.erb +++ b/app/views/users/_user.html.erb @@ -5,11 +5,12 @@ <%= link_to user.name, user, class: "txt-ink btn btn--plain" %> - + <%= form_with model: user, url: user_role_path(user), data: { controller: "form" }, method: :patch do | form | %> <% end %> diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index c0cd4af5d..055baab38 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -48,7 +48,7 @@
    <%= link_to "Cancel and go back", user_path(@user), data: { form_target: "cancel", turbo_frame: "_top" }, hidden: true %> diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb index fa9ca8312..847782db8 100644 --- a/app/views/users/new.html.erb +++ b/app/views/users/new.html.erb @@ -11,7 +11,7 @@

    Fizzy

    - <%= form_with model: @user, url: join_path(@account.join_code), class: "flex flex-column gap" do |form| %> + <%= form_with model: @user, url: join_path(params[:join_code]), class: "flex flex-column gap" do |form| %>
    <% end %>
    diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index f18dc58be..d061814d2 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -31,9 +31,9 @@
    -
    - <%= link_to "Everything assigned to #{@user.name}", cards_path(assignee_ids: [@user.id], indexed_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %> - <%= link_to "Everything added by #{@user.name}", cards_path(creator_ids: [@user.id], indexed_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %> +
    + <%= link_to "See cards assigned to #{ Current.user == @user ? "you" : @user.first_name }", cards_path(assignee_ids: [@user.id], indexed_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %> + <%= link_to "See cards added by #{ Current.user == @user ? "you" : @user.first_name }", cards_path(creator_ids: [@user.id], indexed_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
    @@ -44,10 +44,10 @@ <%= render "users/transfer", user: @user %>
    -
    +
    <%= button_to session_path, method: :delete, class: "btn center txt-medium" do %> <%= icon_tag "logout" %> - Sign out + Sign out <% end %>
    <% end %> diff --git a/app/views/workflows/_workflow.html.erb b/app/views/workflows/_workflow.html.erb index 6ec1e289f..f6972731d 100644 --- a/app/views/workflows/_workflow.html.erb +++ b/app/views/workflows/_workflow.html.erb @@ -8,9 +8,9 @@ <% end %>
    - + -
    +
    <%= turbo_frame_tag dom_id(workflow, :stages) do %> <%= render partial: "workflows/stages/stage", collection: workflow.stages %> diff --git a/app/views/workflows/stages/_color.html.erb b/app/views/workflows/stages/_color.html.erb index 04a9e12a3..b51ae516d 100644 --- a/app/views/workflows/stages/_color.html.erb +++ b/app/views/workflows/stages/_color.html.erb @@ -1,14 +1,21 @@
    - +
    + +
    + <%= form_with model: stage, url: workflow_stage_path(stage.workflow, stage), class: "flex gap-half", data: { controller: "form", action: "change->form#submit" } do |form| %> - <% Colorable::COLORS.each do |color| %> + <% Card::COLORS.each do |color| %> - <% end %> <% end %> - -
    - -
    diff --git a/app/views/workflows/stages/_stage.html.erb b/app/views/workflows/stages/_stage.html.erb index 9d68de0b3..238c2b645 100644 --- a/app/views/workflows/stages/_stage.html.erb +++ b/app/views/workflows/stages/_stage.html.erb @@ -1,9 +1,9 @@ -
    +
    <%= render "workflows/stages/color", stage: stage %> <%= turbo_frame_tag stage do %> - <%= link_to edit_workflow_stage_path(stage.workflow, stage) do %> - <%= stage.name %> + <%= link_to edit_workflow_stage_path(stage.workflow, stage), class: "txt-link overflow-ellipsis" do %> + <%= stage.name %> <% end %> <% end %>
    \ No newline at end of file diff --git a/bin/dev b/bin/dev index 1b7f220b2..e58bc97c3 100755 --- a/bin/dev +++ b/bin/dev @@ -1,5 +1,7 @@ #!/usr/bin/env sh -echo "Starting Fizzy on http://development-tenant.fizzy.localhost:3006" +echo "Access with david@37signals.com / secret123456 on http://37signals.fizzy.localhost:3006" +echo "Access with david@37signals.com / secret123456 on http://honcho.fizzy.localhost:3006" +echo "Access first run on http://first-run.fizzy.localhost:3006" -SOLID_QUEUE_IN_PUMA=true exec ./bin/rails server -p 3006 +exec ./bin/rails server -p 3006 diff --git a/bin/setup b/bin/setup index e50c91ec6..c7223d499 100755 --- a/bin/setup +++ b/bin/setup @@ -1,26 +1,14 @@ -#!/usr/bin/env bash -set -eo pipefail +#!/usr/bin/env ruby -# Use application binstubs -export PATH="./bin:$PATH" +def system!(*args) system(*args, exception: true) end -announce() { - printf "\n--- $@\n" -} +puts "== Installing dependencies ==" +system("gem install bundler --conservative") +system("bundle check") || system!("bundle install") -announce "Installing dependencies" -gem install bundler --conservative -bundle check || bundle install +puts "\n== Preparing database ==" +system! "bin/rails db:prepare" +system! "bin/rails db:reset" if ARGV.include?("--reset") -announce "Preparing database" -if [[ $* == *--reset* ]]; then - rails db:reset -else - rails db:prepare -fi - -announce "Removing old logs and tempfiles" -rails log:clear tmp:clear - -echo -echo "Start developing with bin/dev" +puts "\n== Removing old logs and tempfiles ==" +system! "bin/rails log:clear tmp:clear" diff --git a/config/brakeman.ignore b/config/brakeman.ignore index def63fdfe..2b01354c6 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -46,7 +46,30 @@ 95 ], "note": "" + }, + { + "warning_type": "Remote Code Execution", + "warning_code": 24, + "fingerprint": "b00571393e95a8c7d77b1ed963cfc5cc89333bc82f447cf8783549d817990305", + "check_name": "UnsafeReflection", + "message": "Unsafe reflection method `safe_constantize` called on model attribute", + "file": "app/models/notifier.rb", + "line": 8, + "link": "https://brakemanscanner.org/docs/warning_types/remote_code_execution/", + "code": "\"Notifier::#{Event.eventable.class}EventNotifier\".safe_constantize", + "render_path": null, + "location": { + "type": "method", + "class": "Notifier", + "method": "s(:self).for" + }, + "user_input": "Event.eventable.class", + "confidence": "Medium", + "cwe_id": [ + 470 + ], + "note": "" } ], - "brakeman_version": "7.0.0" + "brakeman_version": "7.0.2" } diff --git a/config/ci.rb b/config/ci.rb index 1a22e3e33..c94990581 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -1,7 +1,7 @@ # Run using bin/ci CI.run do - step "Setup", "bin/setup" + step "Setup", "bin/setup --skip-server" step "Style: Ruby", "bin/rubocop" diff --git a/config/database.yml b/config/database.yml index 17e777cd0..1a0b9537d 100644 --- a/config/database.yml +++ b/config/database.yml @@ -14,11 +14,6 @@ development: <<: *default database: storage/tenants/<%= Rails.env %>/%{tenant}/db/main.sqlite3 tenanted: true - primary_original: - <<: *default - database: storage/storage-production-20250311-1725.sqlite3 - replica: true - readonly: true cable: <<: *default database: storage/development_cable.sqlite3 @@ -45,6 +40,8 @@ production: <<: *default database: storage/tenants/<%= Rails.env %>/%{tenant}/db/main.sqlite3 tenanted: true + extensions: + - ./bin/lib/beamer.so primary_original: <<: *default database: storage/production.sqlite3 diff --git a/config/deploy.beta.yml b/config/deploy.beta.yml index cbf3e9ff4..52249bbcd 100644 --- a/config/deploy.beta.yml +++ b/config/deploy.beta.yml @@ -9,3 +9,25 @@ servers: proxy: hosts: - fizzy.37signals.works # the single tenant is named "fizzy" + + +x-beamer-accessory: &beamer-accessory + image: basecamp/beamer + registry: + username: bcbot + password: + - BASECAMP_REGISTRY_PASSWORD + options: + user: 1000 # Match the UID of the Rails app, for volume permissions + volumes: + - fizzy:/storage + + +accessories: + beamer-primary: + <<: *beamer-accessory + service: beamer-primary + cmd: beamer primary --remove-after=1h --dir=/storage + port: 5000:80 + roles: + - web diff --git a/config/deploy.dhh.yml b/config/deploy.dhh.yml new file mode 100644 index 000000000..05bf874d4 --- /dev/null +++ b/config/deploy.dhh.yml @@ -0,0 +1,12 @@ +servers: + web: + hosts: + - test-1 + jobs: + hosts: + - test-1 +env: + clear: + ARTENANT: test + +proxy: false diff --git a/config/deploy.production.yml b/config/deploy.production.yml index 4359af231..01f5fbfc8 100644 --- a/config/deploy.production.yml +++ b/config/deploy.production.yml @@ -1,17 +1,55 @@ servers: web: hosts: - - once-fizzy-101 + - fizzy-app-101 jobs: hosts: - - once-fizzy-101 + - fizzy-app-101 + web-replica: + hosts: + - fizzy-app-102 + proxy: true proxy: - hosts: - - fizzy.37signals.com # will return a 404 for now - - 37s.fizzy.37signals.com - - dev.fizzy.37signals.com - - qa.fizzy.37signals.com + ssl: false ssh: user: app + + +x-beamer-accessory: &beamer-accessory + image: basecamp/beamer + registry: + username: bcbot + password: + - BASECAMP_REGISTRY_PASSWORD + options: + user: 1000 # Match the UID of the Rails app, for volume permissions + volumes: + - fizzy:/storage + + +accessories: + beamer-primary: + <<: *beamer-accessory + service: beamer-primary + cmd: beamer primary --remove-after=1h --dir=/storage + port: 5000:80 + roles: + - web + + beamer-replica: + <<: *beamer-accessory + service: beamer-replica + cmd: beamer replica --primary=http://fizzy-app-101:5000/ --dir=/storage + roles: + - web-replica + + load-balancer: + image: basecamp/kamal-proxy:lb + roles: + - web + options: + publish: + - 80:80 + - 443:443 diff --git a/config/environments/development.rb b/config/environments/development.rb index ac555fd16..b929be5c4 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -23,10 +23,8 @@ Rails.application.configure do config.action_controller.perform_caching = true config.action_controller.enable_fragment_cache_logging = true - config.cache_store = :solid_cache_store - config.public_file_server.headers = { - "Cache-Control" => "public, max-age=#{2.days.to_i}" - } + config.cache_store = :memory_store + config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{2.days.to_i}" } else config.action_controller.perform_caching = true @@ -62,10 +60,6 @@ Rails.application.configure do # Highlight code that enqueued background job in logs. config.active_job.verbose_enqueue_logs = true - # Use Solid Queue in Development. - config.active_job.queue_adapter = :solid_queue - config.solid_queue.connects_to = { database: { writing: :queue } } - # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true @@ -80,4 +74,7 @@ Rails.application.configure do # Allow all hosts in development config.hosts = nil + + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 3860f659e..3d2359e52 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -11,6 +11,6 @@ # end # These inflection rules are supported but not enabled by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym "RESTful" -# end +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.acronym "SQLite" +end diff --git a/config/initializers/sanitization.rb b/config/initializers/sanitization.rb index 5362f0100..93323bc4a 100644 --- a/config/initializers/sanitization.rb +++ b/config/initializers/sanitization.rb @@ -1,4 +1,4 @@ Rails.application.config.after_initialize do Rails::HTML5::SafeListSanitizer.allowed_tags.merge(%w[ s table tr td th thead tbody details summary video source]) - Rails::HTML5::SafeListSanitizer.allowed_attributes.merge(%w[ data-turbo-frame controls type width ]) + Rails::HTML5::SafeListSanitizer.allowed_attributes.merge(%w[ data-turbo-frame controls type width data-action data-lightbox-target data-lightbox-url-value ]) end diff --git a/config/recurring.yml b/config/recurring.yml index 492e20247..9aff145ac 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -8,3 +8,6 @@ production: remove_abandoned_creations: class: RemoveAbandonedCreationsJob schedule: every hour + sqlite_backups: + class: SQLiteBackupsJob + schedule: every day at 05:00 diff --git a/config/routes.rb b/config/routes.rb index 6eb05dd50..762985595 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,17 +26,17 @@ Rails.application.routes.draw do resources :cards do scope module: :cards do resource :engagement + resource :goldness resource :image resource :pin resource :closure resource :publish resource :reading resource :recover + resource :staging resource :watch - resource :goldness resources :assignments - resources :stagings resources :taggings resources :comments do @@ -46,17 +46,13 @@ Rails.application.routes.draw do end - resources :notifications, only: :index - namespace :notifications do - resource :tray, only: :show - resource :mark_all_read, only: :create - resources :mark_read, only: :create - resource :settings, only: :show - end - resources :notifications do - member do - post :mark_read + scope module: :notifications do + get "tray", to: "trays#show", on: :collection + get "settings", to: "settings#show", on: :collection + + post "readings", to: "readings#create_all", on: :collection, as: :read_all + post "reading", to: "readings#create", on: :member, as: :read end end @@ -107,6 +103,17 @@ Rails.application.routes.draw do route_for :collection_card, comment.card.collection, comment.card, options end + resolve "Mention" do |mention, options| + polymorphic_path(mention.source, options) + end + + resolve "Notification" do |notification, options| + polymorphic_path(notification.notifiable_target, options) + end + + resolve "Event" do |event, options| + polymorphic_path(event.target, options) + end get "up", to: "rails/health#show", as: :rails_health_check get "manifest" => "rails/pwa#manifest", as: :pwa_manifest diff --git a/db/migrate/20250415131630_remove_accounts_name_index.rb b/db/migrate/20250415131630_remove_accounts_name_index.rb new file mode 100644 index 000000000..bb5921619 --- /dev/null +++ b/db/migrate/20250415131630_remove_accounts_name_index.rb @@ -0,0 +1,5 @@ +class RemoveAccountsNameIndex < ActiveRecord::Migration[8.1] + def change + remove_index :accounts, :name + end +end diff --git a/db/migrate/20250415131804_remove_accounts_join_code_index.rb b/db/migrate/20250415131804_remove_accounts_join_code_index.rb new file mode 100644 index 000000000..30a312211 --- /dev/null +++ b/db/migrate/20250415131804_remove_accounts_join_code_index.rb @@ -0,0 +1,5 @@ +class RemoveAccountsJoinCodeIndex < ActiveRecord::Migration[8.1] + def change + remove_index :accounts, :join_code + end +end diff --git a/db/migrate/20250418150259_remove_activity_from_cards.rb b/db/migrate/20250418150259_remove_activity_from_cards.rb new file mode 100644 index 000000000..f8910ea9b --- /dev/null +++ b/db/migrate/20250418150259_remove_activity_from_cards.rb @@ -0,0 +1,8 @@ +class RemoveActivityFromCards < ActiveRecord::Migration[8.1] + def change + remove_index :cards, :activity_score_order + remove_column :cards, :activity_score_order + remove_column :cards, :activity_score_at + remove_column :cards, :activity_score + end +end diff --git a/db/migrate/20250421120008_create_mentions.rb b/db/migrate/20250421120008_create_mentions.rb new file mode 100644 index 000000000..db27f7b2c --- /dev/null +++ b/db/migrate/20250421120008_create_mentions.rb @@ -0,0 +1,11 @@ +class CreateMentions < ActiveRecord::Migration[8.1] + def change + create_table :mentions do |t| + t.references :source, polymorphic: true, null: false, index: true + t.references :mentionee, foreign_key: { to_table: :users }, null: false + t.references :mentioner, foreign_key: { to_table: :users }, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20250422054950_add_source_to_notifications.rb b/db/migrate/20250422054950_add_source_to_notifications.rb new file mode 100644 index 000000000..c64dd68e1 --- /dev/null +++ b/db/migrate/20250422054950_add_source_to_notifications.rb @@ -0,0 +1,11 @@ +class AddSourceToNotifications < ActiveRecord::Migration[8.1] + def change + add_reference :notifications, :source, polymorphic: true, index: true + + execute <<~SQL + update notifications set source_type = 'Event', source_id = event_id; + SQL + + change_column_null :notifications, :source_type, false + end +end diff --git a/db/migrate/20250422062930_remove_unused_columns_from_notifications.rb b/db/migrate/20250422062930_remove_unused_columns_from_notifications.rb new file mode 100644 index 000000000..30f689c88 --- /dev/null +++ b/db/migrate/20250422062930_remove_unused_columns_from_notifications.rb @@ -0,0 +1,6 @@ +class RemoveUnusedColumnsFromNotifications < ActiveRecord::Migration[8.1] + def change + remove_column :notifications, :event_id + remove_column :notifications, :card_id + end +end diff --git a/db/migrate/20250422091602_remove_due_date_from_events.rb b/db/migrate/20250422091602_remove_due_date_from_events.rb new file mode 100644 index 000000000..5a636e3dd --- /dev/null +++ b/db/migrate/20250422091602_remove_due_date_from_events.rb @@ -0,0 +1,5 @@ +class RemoveDueDateFromEvents < ActiveRecord::Migration[8.1] + def change + remove_column :events, :due_date + end +end diff --git a/db/migrate/20250422112857_add_creator_to_notifications.rb b/db/migrate/20250422112857_add_creator_to_notifications.rb new file mode 100644 index 000000000..63635565c --- /dev/null +++ b/db/migrate/20250422112857_add_creator_to_notifications.rb @@ -0,0 +1,18 @@ +class AddCreatorToNotifications < ActiveRecord::Migration[8.1] + def change + add_reference :notifications, :creator, null: true, foreign_key: { to_table: :users } + + execute <<~SQL + UPDATE notifications + SET creator_id = ( + SELECT events.creator_id + FROM events + WHERE events.id = notifications.source_id + AND notifications.source_type = 'Event' + ) + WHERE source_type = 'Event'; + SQL + + change_column_null :notifications, :creator_id, true + end +end diff --git a/db/migrate/20250423114737_remove_notification_resources.rb b/db/migrate/20250423114737_remove_notification_resources.rb new file mode 100644 index 000000000..6c19062f7 --- /dev/null +++ b/db/migrate/20250423114737_remove_notification_resources.rb @@ -0,0 +1,5 @@ +class RemoveNotificationResources < ActiveRecord::Migration[8.1] + def change + remove_reference :notifications, :resource, polymorphic: true, index: true + end +end diff --git a/db/migrate/20250424045407_make_events_eventable.rb b/db/migrate/20250424045407_make_events_eventable.rb new file mode 100644 index 000000000..3094a77ae --- /dev/null +++ b/db/migrate/20250424045407_make_events_eventable.rb @@ -0,0 +1,14 @@ +class MakeEventsEventable < ActiveRecord::Migration[8.1] + def change + add_reference :events, :eventable, polymorphic: true, index: true + + execute <<~SQL + update events set eventable_type = 'Card', eventable_id = card_id; + SQL + + change_column_null :events, :eventable_id, false + change_column_null :events, :eventable_type, false + + remove_reference :events, :card + end +end diff --git a/db/migrate/20250424045639_make_notifications_source_id_not_nullable.rb b/db/migrate/20250424045639_make_notifications_source_id_not_nullable.rb new file mode 100644 index 000000000..512a9e34f --- /dev/null +++ b/db/migrate/20250424045639_make_notifications_source_id_not_nullable.rb @@ -0,0 +1,5 @@ +class MakeNotificationsSourceIdNotNullable < ActiveRecord::Migration[8.1] + def change + change_column_null :notifications, :source_id, false + end +end diff --git a/db/migrate/20250424051059_add_collection_id_to_events.rb b/db/migrate/20250424051059_add_collection_id_to_events.rb new file mode 100644 index 000000000..6270d27c5 --- /dev/null +++ b/db/migrate/20250424051059_add_collection_id_to_events.rb @@ -0,0 +1,17 @@ +class AddCollectionIdToEvents < ActiveRecord::Migration[8.1] + def change + add_reference :events, :collection, foreign_key: true, index: true + + execute <<~SQL + UPDATE events + SET collection_id = ( + SELECT cards.collection_id + FROM cards + WHERE cards.id = events.eventable_id + ) + WHERE eventable_type = 'Card'#{' '} + SQL + + change_column_null :events, :collection_id, false + end +end diff --git a/db/migrate/20250424105223_make_event_summary_optional.rb b/db/migrate/20250424105223_make_event_summary_optional.rb new file mode 100644 index 000000000..f8701dc38 --- /dev/null +++ b/db/migrate/20250424105223_make_event_summary_optional.rb @@ -0,0 +1,5 @@ +class MakeEventSummaryOptional < ActiveRecord::Migration[8.1] + def change + change_column_null :events, :summary_id, true + end +end diff --git a/db/migrate/20250425070025_migrate_existing_events_prefixes.rb b/db/migrate/20250425070025_migrate_existing_events_prefixes.rb new file mode 100644 index 000000000..0b3840be9 --- /dev/null +++ b/db/migrate/20250425070025_migrate_existing_events_prefixes.rb @@ -0,0 +1,20 @@ +class MigrateExistingEventsPrefixes < ActiveRecord::Migration[8.1] + def change + execute <<-SQL + UPDATE events + SET + action = CASE + WHEN action = 'commented' THEN 'comment_created' + ELSE 'card_' || action + END, + eventable_id = CASE + WHEN action = 'commented' THEN json_extract(particulars, '$.comment_id') + ELSE eventable_id + END, + eventable_type = CASE + WHEN action = 'commented' THEN 'Comment' + ELSE eventable_type + END; + SQL + end +end diff --git a/db/migrate/20250425092727_add_auto_close_period_to_collections.rb b/db/migrate/20250425092727_add_auto_close_period_to_collections.rb new file mode 100644 index 000000000..5a96c9a60 --- /dev/null +++ b/db/migrate/20250425092727_add_auto_close_period_to_collections.rb @@ -0,0 +1,11 @@ +class AddAutoClosePeriodToCollections < ActiveRecord::Migration[8.1] + def change + add_column :collections, :auto_close_period, :bigint + + add_index :collections, :auto_close_period + + execute <<~SQL + UPDATE collections SET auto_close_period = #{30.days.to_i} WHERE auto_close_period IS NULL; + SQL + end +end diff --git a/db/migrate/20250429162506_flatten_messages_and_comments.rb b/db/migrate/20250429162506_flatten_messages_and_comments.rb new file mode 100644 index 000000000..090a0201e --- /dev/null +++ b/db/migrate/20250429162506_flatten_messages_and_comments.rb @@ -0,0 +1,21 @@ +class FlattenMessagesAndComments < ActiveRecord::Migration[8.1] + def change + add_reference :comments, :card, null: true, foreign_key: true + + execute <<~SQL + UPDATE comments + SET card_id = ( + SELECT messages.card_id + FROM messages + WHERE messages.messageable_type = 'Comment' + AND messages.messageable_id = comments.id + LIMIT 1 + ) + SQL + + remove_column :events, :summary_id + change_column_null :comments, :card_id, false + drop_table :messages + drop_table :event_summaries + end +end diff --git a/db/schema.rb b/db/schema.rb index 51aa255e4..fd3176781 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_04_12_170620) do +ActiveRecord::Schema[8.1].define(version: 2025_04_29_162506) do create_table "accesses", force: :cascade do |t| t.integer "collection_id", null: false t.datetime "created_at", null: false @@ -27,8 +27,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_12_170620) do t.string "join_code" t.string "name", null: false t.datetime "updated_at", null: false - t.index ["join_code"], name: "index_accounts_on_join_code", unique: true - t.index ["name"], name: "index_accounts_on_name", unique: true end create_table "action_text_markdowns", force: :cascade do |t| @@ -110,9 +108,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_12_170620) do end create_table "cards", force: :cascade do |t| - t.float "activity_score", default: 0.0, null: false - t.datetime "activity_score_at" - t.float "activity_score_order", default: 0.0, null: false t.integer "collection_id", null: false t.datetime "created_at", null: false t.integer "creator_id", null: false @@ -122,7 +117,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_12_170620) do t.text "status", default: "creating", null: false t.string "title" t.datetime "updated_at", null: false - t.index ["activity_score_order"], name: "index_cards_on_activity_score_order" t.index ["collection_id"], name: "index_cards_on_collection_id" t.index ["last_active_at", "status"], name: "index_cards_on_last_active_at_and_status" t.index ["stage_id"], name: "index_cards_on_stage_id" @@ -147,11 +141,13 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_12_170620) do create_table "collections", force: :cascade do |t| t.boolean "all_access", default: false, null: false + t.bigint "auto_close_period" t.datetime "created_at", null: false t.integer "creator_id", null: false t.string "name", null: false t.datetime "updated_at", null: false t.integer "workflow_id" + t.index ["auto_close_period"], name: "index_collections_on_auto_close_period" t.index ["creator_id"], name: "index_collections_on_creator_id" t.index ["workflow_id"], name: "index_collections_on_workflow_id" end @@ -164,9 +160,11 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_12_170620) do end create_table "comments", force: :cascade do |t| + t.integer "card_id", null: false t.datetime "created_at", null: false t.integer "creator_id", null: false t.datetime "updated_at", null: false + t.index ["card_id"], name: "index_comments_on_card_id" end create_table "creators_filters", id: false, force: :cascade do |t| @@ -176,23 +174,19 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_12_170620) do t.index ["filter_id"], name: "index_creators_filters_on_filter_id" end - create_table "event_summaries", force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - create_table "events", force: :cascade do |t| t.string "action", null: false - t.integer "card_id", null: false + t.integer "collection_id", null: false t.datetime "created_at", null: false t.integer "creator_id", null: false - t.date "due_date" + t.integer "eventable_id", null: false + t.string "eventable_type", null: false t.json "particulars", default: {} - t.integer "summary_id", null: false t.datetime "updated_at", null: false - t.index ["card_id"], name: "index_events_on_card_id" + t.index ["action"], name: "index_events_on_summary_id_and_action" + t.index ["collection_id"], name: "index_events_on_collection_id" t.index ["creator_id"], name: "index_events_on_creator_id" - t.index ["summary_id", "action"], name: "index_events_on_summary_id_and_action" + t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable" end create_table "filters", force: :cascade do |t| @@ -218,28 +212,28 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_12_170620) do t.index ["tag_id"], name: "index_filters_tags_on_tag_id" end - create_table "messages", force: :cascade do |t| - t.integer "card_id", null: false + create_table "mentions", force: :cascade do |t| t.datetime "created_at", null: false - t.integer "messageable_id", null: false - t.string "messageable_type", null: false + t.integer "mentionee_id", null: false + t.integer "mentioner_id", null: false + t.integer "source_id", null: false + t.string "source_type", null: false t.datetime "updated_at", null: false - t.index ["card_id"], name: "index_messages_on_card_id" - t.index ["messageable_type", "messageable_id"], name: "index_messages_on_messageable", unique: true + t.index ["mentionee_id"], name: "index_mentions_on_mentionee_id" + t.index ["mentioner_id"], name: "index_mentions_on_mentioner_id" + t.index ["source_type", "source_id"], name: "index_mentions_on_source" end create_table "notifications", force: :cascade do |t| - t.integer "card_id", null: false t.datetime "created_at", null: false - t.integer "event_id", null: false + t.integer "creator_id" t.datetime "read_at" - t.integer "resource_id", null: false - t.string "resource_type", null: false + t.integer "source_id", null: false + t.string "source_type", null: false t.datetime "updated_at", null: false t.integer "user_id", null: false - t.index ["card_id"], name: "index_notifications_on_card_id" - t.index ["event_id"], name: "index_notifications_on_event_id" - t.index ["resource_type", "resource_id"], name: "index_notifications_on_resource" + t.index ["creator_id"], name: "index_notifications_on_creator_id" + t.index ["source_type", "source_id"], name: "index_notifications_on_source" t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } t.index ["user_id"], name: "index_notifications_on_user_id" end @@ -286,6 +280,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_12_170620) do t.datetime "created_at", null: false t.string "title" t.datetime "updated_at", null: false + t.index ["title"], name: "index_tags_on_account_id_and_title", unique: true end create_table "users", force: :cascade do |t| @@ -332,12 +327,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_12_170620) do add_foreign_key "closures", "cards" add_foreign_key "closures", "users" add_foreign_key "collections", "workflows" - add_foreign_key "events", "cards" - add_foreign_key "events", "event_summaries", column: "summary_id" - add_foreign_key "messages", "cards" - add_foreign_key "notifications", "cards" - add_foreign_key "notifications", "events" + add_foreign_key "comments", "cards" + add_foreign_key "events", "collections" + add_foreign_key "mentions", "users", column: "mentionee_id" + add_foreign_key "mentions", "users", column: "mentioner_id" add_foreign_key "notifications", "users" + add_foreign_key "notifications", "users", column: "creator_id" add_foreign_key "pins", "cards" add_foreign_key "pins", "users" add_foreign_key "sessions", "users" diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 951138801..c196953f7 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -1,9 +1,9 @@ --- !ruby/object:ActiveRecord::ConnectionAdapters::SchemaCache columns: accesses: - - &5 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: true - name: id + - &22 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: collection_id cast_type: &1 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer precision: scale: @@ -21,17 +21,7 @@ columns: default_function: collation: comment: - - &24 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: collection_id - cast_type: *1 - sql_type_metadata: *2 - 'null': false - default: - default_function: - collation: - comment: - - &6 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &5 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: created_at cast_type: &3 !ruby/object:ActiveRecord::Type::DateTime @@ -50,19 +40,9 @@ columns: default_function: collation: comment: - - &9 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: updated_at - cast_type: *3 - sql_type_metadata: *4 - 'null': false - default: - default_function: - collation: - comment: - - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: user_id + - &6 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: true + name: id cast_type: *1 sql_type_metadata: *2 'null': false @@ -90,6 +70,26 @@ columns: default_function: collation: comment: + - &9 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: updated_at + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: + default_function: + collation: + comment: + - &28 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: user_id + cast_type: *1 + sql_type_metadata: *2 + 'null': false + default: + default_function: + collation: + comment: accounts: - *5 - *6 @@ -115,7 +115,6 @@ columns: comment: - *9 action_text_markdowns: - - *5 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: content @@ -136,6 +135,7 @@ columns: default_function: collation: comment: + - *5 - *6 - *10 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column @@ -160,7 +160,6 @@ columns: comment: - *9 active_storage_attachments: - - *5 - &16 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: blob_id @@ -181,6 +180,7 @@ columns: default_function: collation: comment: + - *5 - *6 - *10 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column @@ -205,7 +205,6 @@ columns: collation: comment: active_storage_blobs: - - *5 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: byte_size @@ -236,7 +235,7 @@ columns: default_function: collation: comment: - - *6 + - *5 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: filename @@ -247,6 +246,7 @@ columns: default_function: collation: comment: + - *6 - &17 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: key @@ -278,8 +278,8 @@ columns: collation: comment: active_storage_variant_records: - - *5 - *16 + - *6 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: variation_digest @@ -291,7 +291,9 @@ columns: collation: comment: ar_internal_metadata: + - *5 - *17 + - *9 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: value @@ -302,8 +304,6 @@ columns: default_function: collation: comment: - - *6 - - *9 assignees_filters: - &19 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -338,7 +338,6 @@ columns: comment: - *18 assignments: - - *5 - *19 - *20 - &21 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column @@ -351,10 +350,10 @@ columns: default_function: collation: comment: + - *5 - *6 - *9 card_engagements: - - *5 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: card_id @@ -365,56 +364,18 @@ columns: default_function: collation: comment: + - *5 - *6 - *9 card_goldnesses: - - *5 - *21 + - *5 - *6 - *9 cards: + - *22 - *5 - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: activity_score - cast_type: &22 !ruby/object:ActiveModel::Type::Float - precision: - scale: - limit: - sql_type_metadata: &23 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata - sql_type: float - type: :float - limit: - precision: - scale: - 'null': false - default: 0.0 - default_function: - collation: - comment: - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: activity_score_at - cast_type: *3 - sql_type_metadata: *4 - 'null': true - default: - default_function: - collation: - comment: - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: activity_score_order - cast_type: *22 - sql_type_metadata: *23 - 'null': false - default: 0.0 - default_function: - collation: - comment: - - *24 - - *6 - - &25 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &23 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: creator_id cast_type: *1 @@ -427,12 +388,12 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: due_on - cast_type: &26 !ruby/object:ActiveRecord::Type::Date + cast_type: !ruby/object:ActiveRecord::Type::Date precision: scale: limit: timezone: - sql_type_metadata: &27 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: date type: :date limit: @@ -443,6 +404,17 @@ columns: default_function: collation: comment: + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: last_active_at + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: + default_function: + collation: + comment: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: stage_id @@ -463,7 +435,7 @@ columns: default_function: collation: comment: - - &32 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: title cast_type: *7 @@ -474,18 +446,9 @@ columns: collation: comment: - *9 - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: last_active_at - cast_type: *3 - sql_type_metadata: *4 - 'null': false - default: - default_function: - collation: - comment: closure_reasons: - *5 + - *6 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: label @@ -496,12 +459,21 @@ columns: default_function: collation: comment: - - *6 - *9 closures: - - *5 - *21 + - *5 - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: reason + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: - *9 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -513,26 +485,15 @@ columns: default_function: collation: comment: - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: reason - cast_type: *7 - sql_type_metadata: *8 - 'null': false - default: - default_function: - collation: - comment: collections: - - *5 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: all_access - cast_type: &33 !ruby/object:ActiveModel::Type::Boolean + cast_type: &31 !ruby/object:ActiveModel::Type::Boolean precision: scale: limit: - sql_type_metadata: &34 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &32 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: boolean type: :boolean limit: @@ -543,8 +504,19 @@ columns: default_function: collation: comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: auto_close_period + cast_type: *11 + sql_type_metadata: *12 + 'null': true + default: + default_function: + collation: + comment: + - *5 + - *23 - *6 - - *25 - *10 - *9 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column @@ -558,22 +530,18 @@ columns: collation: comment: collections_filters: - - *24 + - *22 - *18 comments: + - *21 - *5 + - *23 - *6 - - *25 - *9 creators_filters: - - *25 + - *23 - *18 - event_summaries: - - *5 - - *6 - - *9 events: - - *5 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: action @@ -584,27 +552,38 @@ columns: default_function: collation: comment: - - *21 - - *6 - - *25 + - *22 + - *5 + - *23 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: - name: due_date - cast_type: *26 - sql_type_metadata: *27 - 'null': true + name: eventable_id + cast_type: *1 + sql_type_metadata: *2 + 'null': false default: default_function: collation: comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: eventable_type + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - *6 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: particulars - cast_type: &28 !ruby/object:ActiveRecord::Type::Json + cast_type: &24 !ruby/object:ActiveRecord::Type::Json precision: scale: limit: - sql_type_metadata: &29 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &25 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: json type: :json limit: @@ -615,31 +594,21 @@ columns: default_function: collation: comment: - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: summary_id - cast_type: *1 - sql_type_metadata: *2 - 'null': false - default: - default_function: - collation: - comment: - *9 filters: - *5 - - *6 - - *25 + - *23 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: fields - cast_type: *28 - sql_type_metadata: *29 + cast_type: *24 + sql_type_metadata: *25 'null': false default: "{}" default_function: collation: comment: + - *6 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: params_digest @@ -665,7 +634,7 @@ columns: comment: filters_tags: - *18 - - &31 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &29 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: tag_id cast_type: *1 @@ -675,13 +644,12 @@ columns: default_function: collation: comment: - messages: + mentions: - *5 - - *21 - *6 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: - name: messageable_id + name: mentionee_id cast_type: *1 sql_type_metadata: *2 'null': false @@ -691,7 +659,27 @@ columns: comment: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: - name: messageable_type + name: mentioner_id + cast_type: *1 + sql_type_metadata: *2 + 'null': false + default: + default_function: + collation: + comment: + - &26 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: source_id + cast_type: *1 + sql_type_metadata: *2 + 'null': false + default: + default_function: + collation: + comment: + - &27 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: source_type cast_type: *7 sql_type_metadata: *8 'null': false @@ -702,18 +690,17 @@ columns: - *9 notifications: - *5 - - *21 - - *6 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: - name: event_id + name: creator_id cast_type: *1 sql_type_metadata: *2 - 'null': false + 'null': true default: default_function: collation: comment: + - *6 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: read_at @@ -724,36 +711,17 @@ columns: default_function: collation: comment: - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: resource_id - cast_type: *1 - sql_type_metadata: *2 - 'null': false - default: - default_function: - collation: - comment: - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: resource_type - cast_type: *7 - sql_type_metadata: *8 - 'null': false - default: - default_function: - collation: - comment: + - *26 + - *27 - *9 - - *30 + - *28 pins: - - *5 - *21 + - *5 - *6 - *9 - - *30 + - *28 reactions: - - *5 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: comment_id @@ -784,6 +752,7 @@ columns: default_function: collation: comment: + - *5 - *6 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -831,31 +800,30 @@ columns: default_function: collation: comment: - - *30 + - *28 taggings: - - *5 - *21 + - *5 - *6 - - *31 + - *29 - *9 tags: - *5 - *6 - - *32 + - *30 - *9 users: - - *5 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: active - cast_type: *33 - sql_type_metadata: *34 + cast_type: *31 + sql_type_metadata: *32 'null': false default: true default_function: collation: comment: - - *6 + - *5 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: email_address @@ -866,6 +834,7 @@ columns: default_function: collation: comment: + - *6 - *10 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -889,22 +858,32 @@ columns: comment: - *9 watches: - - *5 - *21 + - *5 - *6 - *9 - - *30 + - *28 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: watching - cast_type: *33 - sql_type_metadata: *34 + cast_type: *31 + sql_type_metadata: *32 'null': false default: true default_function: collation: comment: workflow_stages: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: color + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: - *5 - *6 - *10 @@ -919,16 +898,6 @@ columns: default_function: collation: comment: - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: color - cast_type: *7 - sql_type_metadata: *8 - 'null': true - default: - default_function: - collation: - comment: workflows: - *5 - *6 @@ -954,12 +923,11 @@ primary_keys: collections_filters: comments: id creators_filters: - event_summaries: id events: id filters: id filters_stages: filters_tags: - messages: id + mentions: id notifications: id pins: id reactions: id @@ -991,12 +959,11 @@ data_sources: collections_filters: true comments: true creators_filters: true - event_summaries: true events: true filters: true filters_stages: true filters_tags: true - messages: true + mentions: true notifications: true pins: true reactions: true @@ -1012,11 +979,10 @@ indexes: accesses: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: accesses - name: index_accesses_on_collection_id_and_user_id - unique: true + name: index_accesses_on_collection_id + unique: false columns: - collection_id - - user_id lengths: {} orders: {} opclasses: {} @@ -1029,10 +995,11 @@ indexes: valid: true - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: accesses - name: index_accesses_on_collection_id - unique: false + name: index_accesses_on_collection_id_and_user_id + unique: true columns: - collection_id + - user_id lengths: {} orders: {} opclasses: {} @@ -1059,39 +1026,7 @@ indexes: nulls_not_distinct: comment: valid: true - accounts: - - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: accounts - name: index_accounts_on_name - unique: true - columns: - - name - lengths: {} - orders: {} - opclasses: {} - where: - type: - using: - include: - nulls_not_distinct: - comment: - valid: true - - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: accounts - name: index_accounts_on_join_code - unique: true - columns: - - join_code - lengths: {} - orders: {} - opclasses: {} - where: - type: - using: - include: - nulls_not_distinct: - comment: - valid: true + accounts: [] action_text_markdowns: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: action_text_markdowns @@ -1111,6 +1046,22 @@ indexes: comment: valid: true active_storage_attachments: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: active_storage_attachments + name: index_active_storage_attachments_on_blob_id + unique: false + columns: + - blob_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: active_storage_attachments name: index_active_storage_attachments_on_slug @@ -1146,22 +1097,6 @@ indexes: nulls_not_distinct: comment: valid: true - - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: active_storage_attachments - name: index_active_storage_attachments_on_blob_id - unique: false - columns: - - blob_id - lengths: {} - orders: {} - opclasses: {} - where: - type: - using: - include: - nulls_not_distinct: - comment: - valid: true active_storage_blobs: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: active_storage_blobs @@ -1199,22 +1134,6 @@ indexes: valid: true ar_internal_metadata: [] assignees_filters: - - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: assignees_filters - name: index_assignees_filters_on_filter_id - unique: false - columns: - - filter_id - lengths: {} - orders: {} - opclasses: {} - where: - type: - using: - include: - nulls_not_distinct: - comment: - valid: true - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: assignees_filters name: index_assignees_filters_on_assignee_id @@ -1231,10 +1150,9 @@ indexes: nulls_not_distinct: comment: valid: true - assigners_filters: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: assigners_filters - name: index_assigners_filters_on_filter_id + table: assignees_filters + name: index_assignees_filters_on_filter_id unique: false columns: - filter_id @@ -1248,6 +1166,7 @@ indexes: nulls_not_distinct: comment: valid: true + assigners_filters: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: assigners_filters name: index_assigners_filters_on_assigner_id @@ -1264,6 +1183,22 @@ indexes: nulls_not_distinct: comment: valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: assigners_filters + name: index_assigners_filters_on_filter_id + unique: false + columns: + - filter_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true assignments: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: assignments @@ -1351,10 +1286,11 @@ indexes: valid: true - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: cards - name: index_cards_on_activity_score_order + name: index_cards_on_last_active_at_and_status unique: false columns: - - activity_score_order + - last_active_at + - status lengths: {} orders: {} opclasses: {} @@ -1381,13 +1317,14 @@ indexes: nulls_not_distinct: comment: valid: true + closure_reasons: [] + closures: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: cards - name: index_cards_on_last_active_at_and_status - unique: false + table: closures + name: index_closures_on_card_id + unique: true columns: - - last_active_at - - status + - card_id lengths: {} orders: {} opclasses: {} @@ -1398,8 +1335,6 @@ indexes: nulls_not_distinct: comment: valid: true - closure_reasons: [] - closures: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: closures name: index_closures_on_card_id_and_created_at @@ -1417,22 +1352,6 @@ indexes: nulls_not_distinct: comment: valid: true - - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: closures - name: index_closures_on_card_id - unique: true - columns: - - card_id - lengths: {} - orders: {} - opclasses: {} - where: - type: - using: - include: - nulls_not_distinct: - comment: - valid: true - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: closures name: index_closures_on_user_id @@ -1450,6 +1369,22 @@ indexes: comment: valid: true collections: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: collections + name: index_collections_on_auto_close_period + unique: false + columns: + - auto_close_period + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: collections name: index_collections_on_creator_id @@ -1515,14 +1450,13 @@ indexes: nulls_not_distinct: comment: valid: true - comments: [] - creators_filters: + comments: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: creators_filters - name: index_creators_filters_on_filter_id + table: comments + name: index_comments_on_card_id unique: false columns: - - filter_id + - card_id lengths: {} orders: {} opclasses: {} @@ -1533,6 +1467,7 @@ indexes: nulls_not_distinct: comment: valid: true + creators_filters: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: creators_filters name: index_creators_filters_on_creator_id @@ -1549,14 +1484,12 @@ indexes: nulls_not_distinct: comment: valid: true - event_summaries: [] - events: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: events - name: index_events_on_card_id + table: creators_filters + name: index_creators_filters_on_filter_id unique: false columns: - - card_id + - filter_id lengths: {} orders: {} opclasses: {} @@ -1567,13 +1500,13 @@ indexes: nulls_not_distinct: comment: valid: true + events: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: events - name: index_events_on_summary_id_and_action + name: index_events_on_collection_id unique: false columns: - - summary_id - - action + - collection_id lengths: {} orders: {} opclasses: {} @@ -1600,6 +1533,39 @@ indexes: nulls_not_distinct: comment: valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: events + name: index_events_on_eventable + unique: false + columns: + - eventable_type + - eventable_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: events + name: index_events_on_summary_id_and_action + unique: false + columns: + - action + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true filters: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: filters @@ -1619,22 +1585,6 @@ indexes: comment: valid: true filters_stages: - - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: filters_stages - name: index_filters_stages_on_stage_id - unique: false - columns: - - stage_id - lengths: {} - orders: {} - opclasses: {} - where: - type: - using: - include: - nulls_not_distinct: - comment: - valid: true - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: filters_stages name: index_filters_stages_on_filter_id @@ -1651,13 +1601,12 @@ indexes: nulls_not_distinct: comment: valid: true - filters_tags: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: filters_tags - name: index_filters_tags_on_tag_id + table: filters_stages + name: index_filters_stages_on_stage_id unique: false columns: - - tag_id + - stage_id lengths: {} orders: {} opclasses: {} @@ -1668,6 +1617,7 @@ indexes: nulls_not_distinct: comment: valid: true + filters_tags: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: filters_tags name: index_filters_tags_on_filter_id @@ -1684,13 +1634,29 @@ indexes: nulls_not_distinct: comment: valid: true - messages: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: messages - name: index_messages_on_card_id + table: filters_tags + name: index_filters_tags_on_tag_id unique: false columns: - - card_id + - tag_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + mentions: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: mentions + name: index_mentions_on_mentionee_id + unique: false + columns: + - mentionee_id lengths: {} orders: {} opclasses: {} @@ -1702,12 +1668,28 @@ indexes: comment: valid: true - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: messages - name: index_messages_on_messageable - unique: true + table: mentions + name: index_mentions_on_mentioner_id + unique: false columns: - - messageable_type - - messageable_id + - mentioner_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: mentions + name: index_mentions_on_source + unique: false + columns: + - source_type + - source_id lengths: {} orders: {} opclasses: {} @@ -1721,10 +1703,27 @@ indexes: notifications: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: notifications - name: index_notifications_on_card_id + name: index_notifications_on_creator_id unique: false columns: - - card_id + - creator_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: notifications + name: index_notifications_on_source + unique: false + columns: + - source_type + - source_id lengths: {} orders: {} opclasses: {} @@ -1771,40 +1770,23 @@ indexes: nulls_not_distinct: comment: valid: true - - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: notifications - name: index_notifications_on_resource - unique: false - columns: - - resource_type - - resource_id - lengths: {} - orders: {} - opclasses: {} - where: - type: - using: - include: - nulls_not_distinct: - comment: - valid: true - - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: notifications - name: index_notifications_on_event_id - unique: false - columns: - - event_id - lengths: {} - orders: {} - opclasses: {} - where: - type: - using: - include: - nulls_not_distinct: - comment: - valid: true pins: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: pins + name: index_pins_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: pins name: index_pins_on_card_id_and_user_id @@ -1822,22 +1804,6 @@ indexes: nulls_not_distinct: comment: valid: true - - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: pins - name: index_pins_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: pins name: index_pins_on_user_id @@ -1857,10 +1823,10 @@ indexes: reactions: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: reactions - name: index_reactions_on_reacter_id + name: index_reactions_on_comment_id unique: false columns: - - reacter_id + - comment_id lengths: {} orders: {} opclasses: {} @@ -1873,10 +1839,10 @@ indexes: valid: true - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: reactions - name: index_reactions_on_comment_id + name: index_reactions_on_reacter_id unique: false columns: - - comment_id + - reacter_id lengths: {} orders: {} opclasses: {} @@ -1939,14 +1905,30 @@ indexes: nulls_not_distinct: comment: valid: true - tags: [] + tags: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: tags + name: index_tags_on_account_id_and_title + unique: true + columns: + - title + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true users: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: users - name: index_users_on_role - unique: false + name: index_users_on_email_address + unique: true columns: - - role + - email_address lengths: {} orders: {} opclasses: {} @@ -1959,10 +1941,10 @@ indexes: valid: true - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: users - name: index_users_on_email_address - unique: true + name: index_users_on_role + unique: false columns: - - email_address + - role lengths: {} orders: {} opclasses: {} @@ -2024,4 +2006,4 @@ indexes: comment: valid: true workflows: [] -version: 20250412170620 +version: 20250429162506 diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 000000000..9af00fe66 --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,39 @@ +raise "Seeding is just for development" unless Rails.env.development? + +# Seed DSL +def seed_account(name) + print " #{name}…" + elapsed = Benchmark.realtime { require_relative "seeds/#{name}" } + puts " #{elapsed.round(2)} sec" +end + +def create_tenant(name) + ApplicationRecord.destroy_tenant name + ApplicationRecord.create_tenant name + ApplicationRecord.current_tenant = name +end + +def create_first_run(name, email_address, password: "secret123456") + FirstRun.create!(name:, email_address:, password:) +end + +def login_as(user) + Current.session = user.sessions.create +end + +def create_user(name, email_address, password: "secret123456") + User.create!(name:, email_address:, password:) +end + +def create_collection(name, creator: Current.user, all_access: true, access_to: []) + Collection.create!(name:, creator:, all_access:).tap { it.accesses.grant_to(access_to) } +end + +def create_card(title, collection:, description: nil, status: :published, creator: Current.user) + collection.cards.create!(title:, description:, creator:, status:) +end + +# Seed accounts +seed_account "37signals" +seed_account "honcho" +seed_account "first-run" diff --git a/db/seeds/37signals.rb b/db/seeds/37signals.rb new file mode 100644 index 000000000..432030bbc --- /dev/null +++ b/db/seeds/37signals.rb @@ -0,0 +1,21 @@ +create_tenant "37signals" + +david = create_first_run "David Heinemeier Hansson", "david@37signals.com" +login_as david + +jz = create_user "Jason Zimdars", "jz@37signals.com" +kevin = create_user "Kevin Mcconnell", "kevin@37signals.com" + +create_collection("Fizzy", access_to: [ jz, kevin ]).tap do |fizzy| + create_card("Prepare sign-up page", description: "We need to do this before the launch.", collection: fizzy) + + create_card("Prepare sign-up page", description: "We need to do this before the launch.", collection: fizzy).tap do |card| + card.toggle_assignment(kevin) + card.engage + end + + create_card("Plain text mentions", description: "We'll support plain text mentions first.", collection: fizzy).tap do |card| + card.toggle_assignment(david) + card.close + end +end diff --git a/db/seeds/first-run.rb b/db/seeds/first-run.rb new file mode 100644 index 000000000..e6df219d9 --- /dev/null +++ b/db/seeds/first-run.rb @@ -0,0 +1 @@ +create_tenant "first-run" diff --git a/db/seeds/honcho.rb b/db/seeds/honcho.rb new file mode 100644 index 000000000..34599c600 --- /dev/null +++ b/db/seeds/honcho.rb @@ -0,0 +1,75 @@ +create_tenant "honcho" + +david = create_first_run "David Heinemeier Hansson", "david@37signals.com" +login_as david + +jason = create_user "Jason Zimdars", "jz@37signals.com" +kevin = create_user "Kevin Mcconnell", "kevin@37signals.com" +sarah = create_user "Sarah Johnson", "sarah@37signals.com" +mike = create_user "Mike Peterson", "mike@37signals.com" + +# Array of authors for random assignment +authors = [ david, jason, kevin, sarah, mike ] + +# Card titles for reuse across collections +card_titles = [ + "Implement authentication", + "Design landing page", + "Set up database", + "Create API endpoints", + "Write unit tests", + "Optimize performance", + "Add user profiles", + "Implement search", + "Create admin panel", + "Set up CI/CD", + "Design logo", + "Create documentation", + "Add payment system", + "Implement notifications", + "Set up analytics", + "Create mobile layout", + "Add social sharing", + "Implement caching", + "Set up monitoring", + "Create error handling" +] + +# Create 10 collections +collections = [ + "Project Launch", + "Frontend Dev", + "Backend Dev", + "Design System", + "Testing Suite", + "Performance", + "User Experience", + "API Development", + "DevOps", + "Documentation" +] + +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| + # Randomly assign to 1-2 authors + card.toggle_assignment(authors.sample) + card.toggle_assignment(authors.sample) if rand > 0.5 + + # Randomly set card state + case rand(3) + when 0 + card.engage + when 1 + card.close + # 2 remains open + end + end + end + end +end diff --git a/lib/rails_ext/turbo_streams_action_helper.rb b/lib/rails_ext/turbo_streams_action_helper.rb deleted file mode 100644 index 001c9706e..000000000 --- a/lib/rails_ext/turbo_streams_action_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -module TurboStreamsActionsHelper - def set_css_variable(target, name:, value:) - tag.turbo_stream target: target, action: "set_css_variable", name:, value: - end -end - -Turbo::Streams::TagBuilder.prepend(TurboStreamsActionsHelper) diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html index 4af2887d3..f78ac9454 100644 --- a/public/406-unsupported-browser.html +++ b/public/406-unsupported-browser.html @@ -12,17 +12,17 @@ } body { - --lch-black: 0% 0 0; - --lch-gray: 75% 0.005 96; - --lch-white: 100% 0 0; + --lch-ink: 0% 0 0; + --lch-ink-lighter: 75% 0.005 96; + --lch-ink-inverted: 100% 0 0; - --color-border: oklch(var(--lch-gray)); - --color-bg: oklch(var(--lch-white)); - --color-text: oklch(var(--lch-black)); + --color-border: oklch(var(--lch-ink-lighter)); + --color-canvas: oklch(var(--lch-ink-inverted)); + --color-text: oklch(var(--lch-ink)); --btn-size: 2.65em; - background-color: var(--color-bg); + background-color: var(--color-canvas); block-size: 100dvh; color: var(--color-text); display: grid; @@ -32,9 +32,9 @@ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; @media (prefers-color-scheme: dark) { - --lch-black: 100% 0 0; - --lch-gray: 44.95% 0 0; - --lch-white: 0% 0 0; + --lch-ink: 100% 0 0; + --lch-ink-lighter: 45% 0 0; + --lch-ink-inverted: 0% 0 0; } } @@ -65,12 +65,12 @@ inline-size: 30dvh; svg { - fill: var(--color-bg); + fill: var(--color-canvas); grid-area: 1/1; max-inline-size: 66%; @media (prefers-color-scheme: dark) { - fill: var(--color-bg); + fill: var(--color-canvas); } } } @@ -91,7 +91,7 @@ .btn { align-items: center; aspect-ratio: 1; - background-color: var(--color-bg); + background-color: var(--color-canvas); block-size: var(--btn-size); border-radius: 50%; border: 1px solid var(--color-border); diff --git a/script/generate_comments_from_events.rb b/script/generate_comments_from_events.rb new file mode 100755 index 000000000..d1b5aecb9 --- /dev/null +++ b/script/generate_comments_from_events.rb @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" + +ApplicationRecord.with_each_tenant do |tenant| + Card.find_each do |card| + card.events.find_each do |event| + Card::Eventable::SystemCommenter.new(card.reload, event).comment + end + end +end diff --git a/test/controllers/cards/assignments_controller_test.rb b/test/controllers/cards/assignments_controller_test.rb index c92f57627..e0629aee4 100644 --- a/test/controllers/cards/assignments_controller_test.rb +++ b/test/controllers/cards/assignments_controller_test.rb @@ -13,12 +13,12 @@ class Cards::AssignmentsControllerTest < ActionDispatch::IntegrationTest test "create" do assert_changes "cards(:logo).assigned_to?(users(:david))", from: false, to: true do post card_assignments_path(cards(:logo)), params: { assignee_id: users(:david).id }, as: :turbo_stream + assert_card_container_rerendered(cards(:logo)) end - assert_response :success assert_changes "cards(:logo).assigned_to?(users(:david))", from: true, to: false do post card_assignments_path(cards(:logo)), params: { assignee_id: users(:david).id }, as: :turbo_stream + assert_card_container_rerendered(cards(:logo)) end - assert_response :success end end diff --git a/test/controllers/cards/closures_controller_test.rb b/test/controllers/cards/closures_controller_test.rb index 1729010a9..1e8f85871 100644 --- a/test/controllers/cards/closures_controller_test.rb +++ b/test/controllers/cards/closures_controller_test.rb @@ -10,11 +10,10 @@ class Cards::ClosuresControllerTest < ActionDispatch::IntegrationTest assert_changes -> { card.reload.closed? }, from: false, to: true do post card_closure_path(card, reason: "Scope too big") + assert_card_container_rerendered(card) end assert_equal "Scope too big", card.closure.reason - - assert_redirected_to collection_card_path(card.collection, card) end test "destroy" do @@ -22,8 +21,7 @@ class Cards::ClosuresControllerTest < ActionDispatch::IntegrationTest assert_changes -> { card.reload.closed? }, from: true, to: false do delete card_closure_path(card) + assert_card_container_rerendered(card) end - - assert_redirected_to collection_card_path(card.collection, card) end end diff --git a/test/controllers/cards/comments_controller_test.rb b/test/controllers/cards/comments_controller_test.rb index ab9c3822a..690cf0656 100644 --- a/test/controllers/cards/comments_controller_test.rb +++ b/test/controllers/cards/comments_controller_test.rb @@ -6,7 +6,7 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest end test "create" do - assert_difference "cards(:logo).messages.comments.count", +1 do + assert_difference -> { cards(:logo).comments.count }, +1 do post card_comments_path(cards(:logo), params: { comment: { body: "Agreed." } }) end diff --git a/test/controllers/cards/engagements_controller_test.rb b/test/controllers/cards/engagements_controller_test.rb index 1e966108c..6b4265b86 100644 --- a/test/controllers/cards/engagements_controller_test.rb +++ b/test/controllers/cards/engagements_controller_test.rb @@ -10,9 +10,8 @@ class Cards::EngagementsControllerTest < ActionDispatch::IntegrationTest assert_changes -> { card.reload.doing? }, from: false, to: true do post card_engagement_path(card) + assert_card_container_rerendered(card) end - - assert_redirected_to collection_card_path(card.collection, card) end test "destroy" do @@ -20,8 +19,12 @@ class Cards::EngagementsControllerTest < ActionDispatch::IntegrationTest assert_changes -> { card.reload.doing? }, from: true, to: false do delete card_engagement_path(card) + assert_card_container_rerendered(card) end - - assert_redirected_to collection_card_path(card.collection, card) end + + private + def assert_card_container_rerendered(card) + assert_turbo_stream action: :replace, target: dom_id(card, :card_container) + end end diff --git a/test/controllers/cards/goldnesses_controller_test.rb b/test/controllers/cards/goldnesses_controller_test.rb index b8cfb9469..447aa2b71 100644 --- a/test/controllers/cards/goldnesses_controller_test.rb +++ b/test/controllers/cards/goldnesses_controller_test.rb @@ -6,22 +6,16 @@ class Cards::GoldnessesControllerTest < ActionDispatch::IntegrationTest end test "create" do - card = cards(:text) - - assert_changes -> { card.reload.golden? }, from: false, to: true do - post card_goldness_path(card) + assert_changes -> { cards(:text).reload.golden? }, from: false, to: true do + post card_goldness_path(cards(:text)), as: :turbo_stream + assert_card_container_rerendered(cards(:text)) end - - assert_redirected_to collection_card_path(card.collection, card) end test "destroy" do - card = cards(:logo) - - assert_changes -> { card.reload.golden? }, from: true, to: false do - delete card_goldness_path(card) + assert_changes -> { cards(:logo).reload.golden? }, from: true, to: false do + delete card_goldness_path(cards(:logo)), as: :turbo_stream + assert_card_container_rerendered(cards(:logo)) end - - assert_redirected_to collection_card_path(card.collection, card) end end diff --git a/test/controllers/cards/pins_controller_test.rb b/test/controllers/cards/pins_controller_test.rb index 39b12a053..3bc569827 100644 --- a/test/controllers/cards/pins_controller_test.rb +++ b/test/controllers/cards/pins_controller_test.rb @@ -8,24 +8,24 @@ class Cards::PinsControllerTest < ActionDispatch::IntegrationTest test "create" do assert_changes -> { cards(:layout).pinned_by?(users(:kevin)) }, from: false, to: true do perform_enqueued_jobs do - assert_turbo_stream_broadcasts([ users(:kevin), :pins ], count: 1) do - post card_pin_path(cards(:layout)) + assert_turbo_stream_broadcasts([ users(:kevin), :pins_tray ], count: 1) do + post card_pin_path(cards(:layout)), as: :turbo_stream end end end - assert_redirected_to card_pin_path(cards(:layout)) + assert_response :success end test "destroy" do assert_changes -> { cards(:shipping).pinned_by?(users(:kevin)) }, from: true, to: false do perform_enqueued_jobs do - assert_turbo_stream_broadcasts([ users(:kevin), :pins ], count: 1) do - delete card_pin_path(cards(:shipping)) + assert_turbo_stream_broadcasts([ users(:kevin), :pins_tray ], count: 1) do + delete card_pin_path(cards(:shipping)), as: :turbo_stream end end end - assert_redirected_to card_pin_path(cards(:shipping)) + assert_response :success end end diff --git a/test/controllers/cards/readings_controller_test.rb b/test/controllers/cards/readings_controller_test.rb index c169517e2..b6fa6d040 100644 --- a/test/controllers/cards/readings_controller_test.rb +++ b/test/controllers/cards/readings_controller_test.rb @@ -5,11 +5,21 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest sign_in_as :kevin end - test "index" do + test "read one notification on card visit" do assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do post card_reading_path(cards(:logo)), as: :turbo_stream end assert_response :success end + + test "read multiple notifications on card visit" do + assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do + post card_reading_path(cards(:logo)), as: :turbo_stream + end + end + + assert_response :success + end end diff --git a/test/controllers/cards/stagings_controller_test.rb b/test/controllers/cards/stagings_controller_test.rb new file mode 100644 index 000000000..5e49876f5 --- /dev/null +++ b/test/controllers/cards/stagings_controller_test.rb @@ -0,0 +1,12 @@ +require "test_helper" + +class Cards::StagingsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "update" do + put card_staging_path(cards(:logo)), params: { stage_id: workflow_stages(:qa_in_progress).id }, as: :turbo_stream + assert_card_container_rerendered(cards(:logo)) + end +end diff --git a/test/controllers/cards/taggings_controller_test.rb b/test/controllers/cards/taggings_controller_test.rb index ad5a8fa91..60f4cf743 100644 --- a/test/controllers/cards/taggings_controller_test.rb +++ b/test/controllers/cards/taggings_controller_test.rb @@ -13,14 +13,14 @@ class Cards::TaggingsControllerTest < ActionDispatch::IntegrationTest test "toggle tag on" do assert_changes "cards(:logo).tagged_with?(tags(:mobile))", from: false, to: true do post card_taggings_path(cards(:logo)), params: { tag_title: tags(:mobile).title }, as: :turbo_stream + assert_card_container_rerendered(cards(:logo)) end - assert_response :success end test "toggle tag off" do assert_changes "cards(:logo).tagged_with?(tags(:web))", from: true, to: false do post card_taggings_path(cards(:logo)), params: { tag_title: tags(:web).title }, as: :turbo_stream + assert_card_container_rerendered(cards(:logo)) end - assert_response :success end end diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index e794e7898..5cb3a6582 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -36,19 +36,17 @@ class CardsControllerTest < ActionDispatch::IntegrationTest patch collection_card_path(collections(:writebook), cards(:logo)), params: { card: { title: "Logo needs to change", - due_on: 1.week.from_now, image: fixture_file_upload("moon.jpg", "image/jpeg"), - draft_comment: "Something more in-depth", + description: "Something more in-depth", tag_ids: [ tags(:mobile).id ] } } - assert_redirected_to collection_card_path(collections(:writebook), cards(:logo)) + assert_response :success card = cards(:logo).reload assert_equal "Logo needs to change", card.title - assert_equal 1.week.from_now.to_date, card.due_on assert_equal "moon.jpg", card.image.filename.to_s assert_equal [ tags(:mobile) ], card.tags - assert_equal "Something more in-depth", card.messages.comments.first.comment.body_plain_text.strip + assert_equal "Something more in-depth", card.description_plain_text.strip end test "users can only see cards in collections they have access to" do diff --git a/test/controllers/collections_controller_test.rb b/test/controllers/collections_controller_test.rb index 20111c4bf..f816332b8 100644 --- a/test/controllers/collections_controller_test.rb +++ b/test/controllers/collections_controller_test.rb @@ -35,7 +35,7 @@ class CollectionsControllerTest < ActionDispatch::IntegrationTest user_ids: users(:david, :jz).pluck(:id) } - assert_redirected_to cards_path(collection_ids: [ collections(:writebook) ]) + assert_redirected_to edit_collection_path(collections(:writebook)) assert_equal "Writebook bugs", collections(:writebook).reload.name assert_equal users(:david, :jz).sort, collections(:writebook).users.sort assert_not collections(:writebook).all_access? @@ -49,7 +49,7 @@ class CollectionsControllerTest < ActionDispatch::IntegrationTest patch collection_path(collection), params: { collection: { name: "Bugs", all_access: true } } - assert_redirected_to cards_path(collection_ids: [ collection ]) + assert_redirected_to edit_collection_path(collection) assert collection.reload.all_access? assert_equal User.all, collection.users end diff --git a/test/controllers/notifications/mark_all_reads_controller_test.rb b/test/controllers/notifications/mark_all_reads_controller_test.rb deleted file mode 100644 index 2fa423b3a..000000000 --- a/test/controllers/notifications/mark_all_reads_controller_test.rb +++ /dev/null @@ -1,17 +0,0 @@ -require "test_helper" - -class Notifications::MarkAllReadsControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in_as :kevin - end - - test "show" do - assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do - assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do - post notifications_mark_all_read_path - end - end - - assert_response :success - end -end diff --git a/test/controllers/notifications/readings_controller_test.rb b/test/controllers/notifications/readings_controller_test.rb new file mode 100644 index 000000000..75a8a4875 --- /dev/null +++ b/test/controllers/notifications/readings_controller_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "create" do + assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + post read_notification_path(notifications(:logo_published_kevin), format: :turbo_stream) + assert_response :success + end + end + + test "create all" do + assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do + post read_all_notifications_path + assert_redirected_to notifications_path + end + end + end +end diff --git a/test/controllers/notifications/trays_controller_test.rb b/test/controllers/notifications/trays_controller_test.rb index d49455ae1..07034fff0 100644 --- a/test/controllers/notifications/trays_controller_test.rb +++ b/test/controllers/notifications/trays_controller_test.rb @@ -6,7 +6,7 @@ class Notifications::TraysControllerTest < ActionDispatch::IntegrationTest end test "show" do - get notifications_tray_path + get tray_notifications_path assert_response :success assert_select "div", text: /Layout is broken/ diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 3b914ea23..fad78128f 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -1,15 +1,65 @@ require "test_helper" class UsersControllerTest < ActionDispatch::IntegrationTest - setup do + test "index" do sign_in_as :kevin + + get users_path + assert_in_body users(:david).name + assert_in_body users(:kevin).name end - test "update" do - assert true + test "new" do + get new_user_path(params: { join_code: "bad" }) + assert_response :forbidden + + get new_user_path(params: { join_code: accounts(:"37s").join_code }) + assert_response :ok + end + + test "create" do + assert_difference -> { User.active.count }, +1 do + post users_path(params: { join_code: accounts(:"37s").join_code }), + params: { user: { name: "Dash", email_address: "dash@example.com", password: "123" } } + assert_redirected_to root_path + end + + follow_redirect! + assert_response :ok + end + + test "show" do + sign_in_as :kevin + + get user_path(users(:david)) + assert_in_body users(:david).name + end + + test "update oneself" do + sign_in_as :kevin + + get edit_user_path(users(:kevin)) + assert_response :ok + + put user_path(users(:kevin)), params: { user: { name: "New Kevin" } } + assert_redirected_to user_path(users(:kevin)) + assert_equal "New Kevin", users(:kevin).reload.name + end + + test "update other as admin" do + sign_in_as :kevin + + get edit_user_path(users(:david)) + assert_response :ok + + put user_path(users(:david)), params: { user: { name: "New David" } } + assert_redirected_to user_path(users(:david)) + assert_equal "New David", users(:david).reload.name end test "destroy" do + sign_in_as :kevin + assert_difference -> { User.active.count }, -1 do delete user_path(users(:david)) end diff --git a/test/controllers/workflows_controller_test.rb b/test/controllers/workflows_controller_test.rb new file mode 100644 index 000000000..8d3d64c69 --- /dev/null +++ b/test/controllers/workflows_controller_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +class WorkflowsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "index" do + get workflows_path + assert_in_body workflows(:on_call).name + end + + test "create" do + get new_workflow_path + assert_response :success + + assert_difference -> { Workflow.count }, +1 do + post workflows_path, params: { workflow: { name: "My new workflow!" } } + assert_redirected_to workflows_path + end + end + + test "show" do + get workflow_path(workflows(:on_call)) + assert_in_body workflows(:on_call).name + end + + test "update" do + get edit_workflow_path(workflows(:on_call)) + assert_response :success + + put workflow_path(workflows(:on_call)), params: { workflow: { name: "Monkeyflow!" } } + follow_redirect! + assert_in_body "Monkeyflow!" + end + + test "destroy" do + assert_difference -> { Workflow.count }, -1 do + delete workflow_path(workflows(:on_call)) + assert_redirected_to workflows_path + end + end +end diff --git a/test/fixtures/cards.yml b/test/fixtures/cards.yml index f49179eb3..2ca8fbf8f 100644 --- a/test/fixtures/cards.yml +++ b/test/fixtures/cards.yml @@ -1,25 +1,26 @@ logo: collection: writebook creator: david + stage: qa_triage title: The logo isn't big enough due_on: <%= 3.days.from_now %> created_at: <%= 1.week.ago %> - activity_score: 7 status: published last_active_at: <%= Time.current %> layout: collection: writebook creator: david + stage: qa_triage title: Layout is broken created_at: <%= 1.week.ago %> - activity_score: 1 status: published last_active_at: <%= Time.current %> text: collection: writebook creator: kevin + stage: qa_triage title: The text is too small created_at: <%= 1.week.ago %> status: published @@ -29,6 +30,7 @@ text: shipping: collection: writebook creator: kevin + stage: qa_triage title: We need to ship the app created_at: <%= 1.week.ago %> status: published diff --git a/test/fixtures/collections.yml b/test/fixtures/collections.yml index 9e1e8d484..b7919cf36 100644 --- a/test/fixtures/collections.yml +++ b/test/fixtures/collections.yml @@ -2,4 +2,5 @@ writebook: name: Writebook creator: david all_access: true + auto_close_period: <%= 30.days.to_i %> workflow: qa diff --git a/test/fixtures/comments.yml b/test/fixtures/comments.yml index 0f17c9760..ede5f327e 100644 --- a/test/fixtures/comments.yml +++ b/test/fixtures/comments.yml @@ -1,10 +1,40 @@ +logo_1: + card: logo + creator: system + created_at: <%= 1.week.ago %> + logo_agreement_jz: + card: logo creator: jz created_at: <%= 2.days.ago %> +logo_3: + card: logo + creator: system + created_at: <%= 1.day.ago %> + logo_agreement_kevin: + card: logo creator: kevin created_at: <%= 2.hours.ago %> +logo_5: + card: logo + creator: system + created_at: <%= 1.hour.ago %> + +layout_1: + card: layout + creator: system + layout_overflowing_david: + card: layout creator: david + +text_1: + card: text + creator: system + +shipping_1: + card: shipping + creator: system diff --git a/test/fixtures/event_summaries.yml b/test/fixtures/event_summaries.yml deleted file mode 100644 index dfe42afcb..000000000 --- a/test/fixtures/event_summaries.yml +++ /dev/null @@ -1,17 +0,0 @@ -logo_initial_activity: - created_at: <%= 1.week.ago %> - -logo_second_activity: - created_at: <%= 1.day.ago %> - -logo_third_activity: - created_at: <%= 1.hour.ago %> - -layout_initial_activity: - created_at: <%= 1.week.ago %> - -text_initial_activity: - created_at: <%= 1.week.ago %> - -shipping_initial_activity: - created_at: <%= 1.week.ago %> diff --git a/test/fixtures/events.yml b/test/fixtures/events.yml index 61746d7fd..be738ef67 100644 --- a/test/fixtures/events.yml +++ b/test/fixtures/events.yml @@ -1,66 +1,65 @@ logo_published: creator: david - card: logo - action: published - summary: logo_initial_activity + collection: writebook + eventable: logo (Card) + action: card_published created_at: <%= 1.week.ago %> logo_assignment_jz: creator: david - card: logo - action: assigned - summary: logo_initial_activity + collection: writebook + eventable: logo (Card) + action: card_assigned particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(:jz) ] }.to_json %> created_at: <%= 1.week.ago + 1.hour %> logo_assignment_km: creator: david - card: logo - action: assigned - summary: logo_second_activity + collection: writebook + eventable: logo (Card) + action: card_assigned particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(:kevin) ] }.to_json %> created_at: <%= 1.day.ago %> layout_published: creator: david - card: layout - action: published - summary: layout_initial_activity + collection: writebook + eventable: layout (Card) + action: card_published created_at: <%= 1.week.ago %> layout_commented: creator: david - card: layout - action: commented - summary: layout_initial_activity - particulars: <%= { comment_id: ActiveRecord::FixtureSet.identify(:layout_overflowing_david) }.to_json %> + collection: writebook + eventable: layout_overflowing_david (Comment) + action: comment_created created_at: <%= 1.week.ago %> layout_assignment_jz: creator: david - card: layout - action: assigned - summary: layout_initial_activity + collection: writebook + eventable: layout (Card) + action: card_assigned particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(:jz) ] }.to_json %> created_at: <%= 1.hour.ago %> text_published: creator: kevin - card: text - action: published - summary: text_initial_activity + collection: writebook + eventable: text (Card) + action: card_published created_at: <%= 1.week.ago %> shipping_published: creator: kevin - card: shipping - action: published - summary: shipping_initial_activity + collection: writebook + eventable: shipping (Card) + action: card_published created_at: <%= 1.week.ago %> shipping_closed: creator: kevin - card: shipping - action: closed - summary: shipping_initial_activity + collection: writebook + eventable: shipping (Card) + action: card_closed created_at: <%= 2.days.ago %> diff --git a/test/fixtures/mentions.yml b/test/fixtures/mentions.yml new file mode 100644 index 000000000..77963954c --- /dev/null +++ b/test/fixtures/mentions.yml @@ -0,0 +1,9 @@ +logo_card_david_mention_by_jz: + source: logo (Card) + mentioner: jz + mentionee: david + +logo_comment_david_mention_by_jz: + source: logo_agreement_jz (Comment) + mentioner: jz + mentionee: david diff --git a/test/fixtures/messages.yml b/test/fixtures/messages.yml deleted file mode 100644 index 443cc85a4..000000000 --- a/test/fixtures/messages.yml +++ /dev/null @@ -1,40 +0,0 @@ -logo_1: - card: logo - messageable: logo_initial_activity (EventSummary) - created_at: <%= 1.week.ago %> - -logo_2: - card: logo - messageable: logo_agreement_jz (Comment) - created_at: <%= 2.days.ago %> - -logo_3: - card: logo - messageable: logo_second_activity (EventSummary) - created_at: <%= 1.day.ago %> - -logo_4: - card: logo - messageable: logo_agreement_kevin (Comment) - created_at: <%= 2.hours.ago %> - -logo_5: - card: logo - messageable: logo_third_activity (EventSummary) - created_at: <%= 1.hour.ago %> - -layout_1: - card: layout - messageable: layout_initial_activity (EventSummary) - -layout_2: - card: layout - messageable: layout_overflowing_david (Comment) - -text_1: - card: text - messageable: text_initial_activity (EventSummary) - -shipping_1: - card: shipping - messageable: shipping_initial_activity (EventSummary) diff --git a/test/fixtures/notifications.yml b/test/fixtures/notifications.yml index d6db9d360..8c88d23d0 100644 --- a/test/fixtures/notifications.yml +++ b/test/fixtures/notifications.yml @@ -1,13 +1,29 @@ logo_published_kevin: user: kevin - event: logo_published - card: logo - resource: logo (Card) + source: logo_published (Event) created_at: <%= 1.week.ago %> + creator: david + +logo_assignment_kevin: + user: kevin + source: logo_assignment_km (Event) + created_at: <%= 1.week.ago %> + creator: david layout_commented_kevin: user: kevin - event: layout_commented - card: layout - resource: layout_overflowing_david (Comment) + source: layout_commented (Event) created_at: <%= 1.week.ago %> + creator: david + +logo_card_david_mention_by_jz: + user: david + source: logo_card_david_mention_by_jz (Mention) + created_at: <%= 1.week.ago %> + creator: david + +logo_comment_david_mention_by_jz: + user: david + source: logo_comment_david_mention_by_jz (Mention) + created_at: <%= 1.week.ago %> + creator: david diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 7bcf86de4..d12c1a4b3 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -17,3 +17,7 @@ kevin: email_address: kevin@37signals.com password_digest: <%= digest %> role: admin + +system: + name: System + role: system diff --git a/test/integration/card_messages.rb b/test/integration/card_messages.rb deleted file mode 100644 index 29eded2f0..000000000 --- a/test/integration/card_messages.rb +++ /dev/null @@ -1,43 +0,0 @@ -require "test_helper" - -class CardMessagesTest < ActionDispatch::IntegrationTest - setup do - sign_in_as :kevin - end - - test "messages system" do - # Create a card - post collection_cards_path(collections(:writebook)) - card = Card.last - assert_equal 1, card.messages.count - assert_predicate card.messages.last, :event_summary? - assert_equal "created", card.messages.last.messageable.events.sole.action - - # Comment on it - post collection_card_comments_path(collections(:writebook), card), params: { comment: { body: "Agreed." } } - assert_equal 2, card.messages.count - assert_predicate card.messages.last, :comment? - assert_equal "Agreed.", card.messages.last.messageable.body - - # Assign it - post collection_card_assignments_path(collections(:writebook), card), params: { assignee_id: users(:kevin).id } - assert_equal 3, card.messages.count - assert_predicate card.messages.last, :event_summary? - assert_equal 1, card.messages.last.event_summary.events.count - assert_equal "assigned", card.messages.last.messageable.events.last.action - - # Stage it - post card_stagings_path(card), params: { stage_id: workflow_stages(:qa_triage).id } - assert_equal 3, card.messages.count - assert_predicate card.messages.last, :event_summary? - assert_equal 2, card.messages.last.event_summary.events.count - assert_equal "staged", card.messages.last.messageable.events.last.action - - # Unstage it - post card_stagings_path(card), params: { stage_id: workflow_stages(:qa_triage).id } - assert_equal 3, card.messages.count - assert_predicate card.messages.last, :event_summary? - assert_equal 3, card.messages.last.event_summary.events.count - assert_equal "unstaged", card.messages.last.messageable.events.last.action - end -end diff --git a/test/models/card/closeable_test.rb b/test/models/card/closeable_test.rb index b8effec74..7f8b4f209 100644 --- a/test/models/card/closeable_test.rb +++ b/test/models/card/closeable_test.rb @@ -1,9 +1,13 @@ require "test_helper" class Card::CloseableTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + end + test "closed scope" do assert_equal [ cards(:shipping) ], Card.closed - assert_not_includes Card.active, cards(:shipping) + assert_not_includes Card.open, cards(:shipping) end test "popping" do @@ -15,11 +19,19 @@ class Card::CloseableTest < ActiveSupport::TestCase assert_equal users(:kevin), cards(:logo).closed_by end - test "auto_close_all_due" do + test "autoclose_at infers the period from the collection" do + freeze_time + + collections(:writebook).update! auto_close_period: 123.days + cards(:logo).update! last_active_at: 2.day.ago + assert_equal (123-2).days.from_now, cards(:logo).auto_close_at + end + + test "auto close all due" do cards(:logo, :shipping).each(&:reconsider) - cards(:logo).update!(last_active_at: 1.day.ago - Card::Closeable::AUTO_CLOSE_AFTER) - cards(:shipping).update!(last_active_at: 1.day.from_now - Card::Closeable::AUTO_CLOSE_AFTER) + cards(:logo).update!(last_active_at: 1.day.ago - collections(:writebook).auto_close_period) + cards(:shipping).update!(last_active_at: 1.day.from_now - collections(:writebook).auto_close_period) assert_difference -> { Card.closed.count }, +1 do Card.auto_close_all_due @@ -28,4 +40,16 @@ class Card::CloseableTest < ActiveSupport::TestCase assert cards(:logo).reload.closed? assert_not cards(:shipping).reload.closed? end + + test "don't auto close those cards where the collection has no auto close period" do + cards(:logo, :shipping).each(&:reconsider) + + collections(:writebook).update auto_close_period: nil + + assert_no_difference -> { Card.closed.count } do + Card.auto_close_all_due + end + + assert_not cards(:logo).reload.closed? + end end diff --git a/test/models/card/colored_test.rb b/test/models/card/colored_test.rb index f4bd1bf36..5eebec25f 100644 --- a/test/models/card/colored_test.rb +++ b/test/models/card/colored_test.rb @@ -2,11 +2,11 @@ require "test_helper" class Card::ColoredTest < ActiveSupport::TestCase test "use default color no stage" do - assert_equal Colorable::DEFAULT_COLOR, cards(:logo).color + assert_equal Card::DEFAULT_COLOR, cards(:logo).color end test "use default color not in doing stage" do - assert_equal Colorable::DEFAULT_COLOR, cards(:text).color + assert_equal Card::DEFAULT_COLOR, cards(:text).color end test "infer color from stage when in doing stage" do diff --git a/test/models/card/commentable_test.rb b/test/models/card/commentable_test.rb index 5af8d6f37..1f82f4ccc 100644 --- a/test/models/card/commentable_test.rb +++ b/test/models/card/commentable_test.rb @@ -6,7 +6,7 @@ class Card::CommentableTest < ActiveSupport::TestCase assert_not cards(:text).watched_by?(users(:kevin)) with_current_user(:kevin) do - cards(:text).capture Comment.new(body: "This sounds interesting!") + cards(:text).comments.create!(body: "This sounds interesting!") end assert cards(:text).watched_by?(users(:kevin)) diff --git a/test/models/card/eventable/system_commenter_test.rb b/test/models/card/eventable/system_commenter_test.rb new file mode 100644 index 000000000..64eb05f0d --- /dev/null +++ b/test/models/card/eventable/system_commenter_test.rb @@ -0,0 +1,76 @@ +require "test_helper" + +class Card::Eventable::SystemCommenterTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + @card = cards(:text) + end + + test "card_assigned" do + assert_system_comment "Assigned to Kevin" do + @card.toggle_assignment users(:kevin) + end + end + + test "card_unassigned" do + @card.toggle_assignment users(:kevin) + @card.comments.destroy_all # To skip deduplication logic + + assert_system_comment "Unassigned from Kevin" do + @card.toggle_assignment users(:kevin) + end + end + + test "card_staged" do + assert_system_comment "David moved this to 'In progress'" do + @card.change_stage_to workflow_stages(:qa_in_progress) + end + end + + test "card_closed" do + assert_system_comment "Closed by David" do + @card.close + end + end + + test "card_title_changed" do + assert_system_comment "David changed title from 'The text is too small' to 'Make text larger'" do + @card.update! title: "Make text larger" + end + end + + test "don't duplicate comments for the same kind of events" do + @card.change_stage_to workflow_stages(:qa_in_progress) + + assert_no_difference -> { @card.comments.count } do + @card.reload.change_stage_to workflow_stages(:qa_on_hold) + assert_match /David moved this to 'On hold'/i, @card.comments.last.body.to_plain_text + end + end + + test "don't duplicate comments for related events" do + @card.toggle_assignment users(:kevin) + + assert_no_difference -> { @card.comments.count } do + @card.reload.toggle_assignment users(:kevin) + assert_match /Unassigned from Kevin/i, @card.comments.last.body.to_plain_text + end + end + + test "don't notify on system comments" do + @card.watch_by(users(:david)) + + assert_no_difference -> { Notification.count } do + @card.toggle_assignment users(:kevin) + end + end + + private + def assert_system_comment(expected_comment) + assert_difference -> { @card.comments.count }, 1 do + yield + assert @card.comments.last.creator.system? + assert_match Regexp.new(expected_comment.strip, Regexp::IGNORECASE), @card.comments.last.body.to_plain_text.strip + end + end +end diff --git a/test/models/card/messages_test.rb b/test/models/card/messages_test.rb index 08ade7f0f..fc8bc3509 100644 --- a/test/models/card/messages_test.rb +++ b/test/models/card/messages_test.rb @@ -4,43 +4,6 @@ class Card::MessagesTest < ActiveSupport::TestCase test "creating a card does not create a message by default" do card = collections(:writebook).cards.create! creator: users(:kevin), title: "New" - assert_empty card.messages - end - - test "creating a card with an initial draft comment" do - card = collections(:writebook).cards.create! creator: users(:kevin), title: "New", - draft_comment: "This is a comment" - - assert_equal 1, card.messages.count - assert_equal "This is a comment", card.draft_comment.strip - end - - test "updating the draft comment" do - card = collections(:writebook).cards.create! creator: users(:kevin), title: "New", - draft_comment: "This is a comment" - - card.update! draft_comment: "This is an updated comment" - - assert_equal 1, card.messages.count - assert_equal "This is an updated comment", card.draft_comment.strip - end - - test "setting the draft comment to be blank removes it" do - card = collections(:writebook).cards.create! creator: users(:kevin), title: "New", - draft_comment: "This is a comment" - - card.update! draft_comment: " " - - assert card.messages.first.nil? - end - - test "omitting the draft comment does not remove it" do - card = collections(:writebook).cards.create! creator: users(:kevin), title: "New", - draft_comment: "This is a comment" - - card.update! title: "Newer" - - assert_equal 1, card.messages.count - assert_equal "This is a comment", card.draft_comment.strip + assert_empty card.comments end end diff --git a/test/models/card/readable_test.rb b/test/models/card/readable_test.rb new file mode 100644 index 000000000..c53d3f44d --- /dev/null +++ b/test/models/card/readable_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class Card::ReadableTest < ActiveSupport::TestCase + test "read clears events notifications" do + assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do + assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do + cards(:logo).read_by(users(:kevin)) + end + end + end + + test "read clear mentions in the description" do + assert_changes -> { notifications(:logo_card_david_mention_by_jz).reload.read? }, from: false, to: true do + cards(:logo).read_by(users(:david)) + end + end + + test "read clear mentions in comments" do + assert_changes -> { notifications(:logo_comment_david_mention_by_jz).reload.read? }, from: false, to: true do + cards(:logo).read_by(users(:david)) + end + end + + test "read clears notifications from the comments" do + assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do + cards(:layout).read_by(users(:kevin)) + end + end +end diff --git a/test/models/card/scorable_test.rb b/test/models/card/scorable_test.rb deleted file mode 100644 index f24785e63..000000000 --- a/test/models/card/scorable_test.rb +++ /dev/null @@ -1,12 +0,0 @@ -require "test_helper" - -class Card::ScorableTest < ActiveSupport::TestCase - test "cards with no activity have a valid activity_score_order" do - card = Card.create! collection: collections(:writebook), creator: users(:kevin) - - card.rescore - - assert card.activity_score.zero? - assert_not card.activity_score_order.infinite? - end -end diff --git a/test/models/card/staged_test.rb b/test/models/card/staged_test.rb new file mode 100644 index 000000000..ce12a75f3 --- /dev/null +++ b/test/models/card/staged_test.rb @@ -0,0 +1,12 @@ +require "test_helper" + +class Card::StagedTest < ActiveSupport::TestCase + setup { Current.session = sessions(:david) } + + test "change stage" do + assert_difference -> { Event.where(action: :card_staged).count }, +1 do + cards(:logo).change_stage_to(workflow_stages(:qa_in_progress)) + assert_equal workflow_stages(:qa_in_progress), cards(:logo).reload.stage + end + end +end diff --git a/test/models/card_test.rb b/test/models/card_test.rb index fd0f26bfa..dac35aa3d 100644 --- a/test/models/card_test.rb +++ b/test/models/card_test.rb @@ -6,11 +6,11 @@ class CardTest < ActiveSupport::TestCase end test "capturing messages" do - assert_difference "cards(:logo).messages.comments.count", +1 do - cards(:logo).capture Comment.new(body: "Agreed.") + assert_difference -> { cards(:logo).comments.count }, +1 do + cards(:logo).comments.create!(body: "Agreed.") end - assert_equal "Agreed.", cards(:logo).messages.comments.last.messageable.body.to_plain_text.chomp + assert_equal "Agreed.", cards(:logo).comments.last.body.to_plain_text.chomp end test "assignment states" do @@ -21,18 +21,18 @@ class CardTest < ActiveSupport::TestCase test "assignment toggling" do assert cards(:logo).assigned_to?(users(:kevin)) - assert_difference({ "cards(:logo).assignees.count" => -1, "Event.count" => +1 }) do + assert_difference({ -> { cards(:logo).assignees.count } => -1, -> { Event.count } => +1 }) do cards(:logo).toggle_assignment users(:kevin) end assert_not cards(:logo).assigned_to?(users(:kevin)) - assert_equal "unassigned", Event.last.action + assert_equal "card_unassigned", Event.last.action assert_equal [ users(:kevin) ], Event.last.assignees assert_difference %w[ cards(:logo).assignees.count Event.count ], +1 do cards(:logo).toggle_assignment users(:kevin) end assert cards(:logo).assigned_to?(users(:kevin)) - assert_equal "assigned", Event.last.action + assert_equal "card_assigned", Event.last.action assert_equal [ users(:kevin) ], Event.last.assignees end @@ -70,11 +70,11 @@ class CardTest < ActiveSupport::TestCase assert_equal [ cards(:shipping) ], Card.closed end - test "active" do - assert_equal cards(:logo, :layout, :text), Card.active + test "open" do + assert_equal cards(:logo, :layout, :text), Card.open end - test "unassigned" do + test "card_unassigned" do assert_equal cards(:shipping, :text), Card.unassigned end @@ -88,8 +88,8 @@ class CardTest < ActiveSupport::TestCase test "in collection" do new_collection = Collection.create! name: "New Collection", creator: users(:david) - assert_equal cards(:logo, :shipping, :layout, :text), Card.in_collection(collections(:writebook)) - assert_empty Card.in_collection(new_collection) + assert_equal cards(:logo, :shipping, :layout, :text), Card.where(collection: collections(:writebook)) + assert_empty Card.where(collection: new_collection) end test "tagged with" do @@ -98,8 +98,8 @@ class CardTest < ActiveSupport::TestCase test "mentioning" do card = collections(:writebook).cards.create! title: "Insufficient haggis", creator: users(:kevin) - cards(:logo).capture Comment.new(body: "I hate haggis") - cards(:text).capture Comment.new(body: "I love haggis") + cards(:logo).comments.create!(body: "I hate haggis") + cards(:text).comments.create!(body: "I love haggis") assert_equal [ card, cards(:logo), cards(:text) ].sort, Card.mentioning("haggis").sort end @@ -120,11 +120,4 @@ class CardTest < ActiveSupport::TestCase assert_includes card.cache_key, ApplicationRecord.current_tenant, "cache key must always include the tenant" end - - test "cache key gracefully handles a nil collection" do - card = cards(:logo) - card.update_column :collection_id, Collection.last.id + 1 - - assert_nothing_raised { card.reload.cache_key } - end end diff --git a/test/models/collection/workflowing_test.rb b/test/models/collection/workflowing_test.rb new file mode 100644 index 000000000..c43cda6ed --- /dev/null +++ b/test/models/collection/workflowing_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class Collection::WorkflowingTest < ActiveSupport::TestCase + test "change all card stages when changing workflow" do + collections(:writebook).update! workflow: workflows(:on_call) + assert_equal [ collections(:writebook).initial_workflow_stage ], collections(:writebook).cards.reload.collect(&:stage).uniq + end + + test "remove stage from cards if workflow is removed" do + collections(:writebook).update! workflow: nil + assert_equal [], collections(:writebook).cards.reload.collect(&:stage).compact + end +end diff --git a/test/models/comment_test.rb b/test/models/comment_test.rb index ae7b505a2..7af13ed36 100644 --- a/test/models/comment_test.rb +++ b/test/models/comment_test.rb @@ -6,8 +6,8 @@ class CommentTest < ActiveSupport::TestCase end test "searchable by body" do - message = cards(:logo).capture Comment.new(body: "I'd prefer something more rustic") + comment = cards(:logo).comments.create!(body: "I'd prefer something more rustic") - assert_includes Comment.search("something rustic"), message.comment + assert_includes Comment.search("something rustic"), comment end end diff --git a/test/models/concerns/mentions_test.rb b/test/models/concerns/mentions_test.rb new file mode 100644 index 000000000..f7454a322 --- /dev/null +++ b/test/models/concerns/mentions_test.rb @@ -0,0 +1,22 @@ +require "test_helper" + +class MentionsTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + end + + test "create mentions when creating messages" do + assert_difference -> { Mention.count }, +1 do + perform_enqueued_jobs only: Mention::CreateJob do + collections(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup, @david?" + end + end + end + + test "mentionees are added as watchers of the card" do + perform_enqueued_jobs only: Mention::CreateJob do + card = collections(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup @kevin?" + assert card.watchers.include?(users(:kevin)) + end + end +end diff --git a/test/models/event_summary_test.rb b/test/models/event_summary_test.rb deleted file mode 100644 index a438c492c..000000000 --- a/test/models/event_summary_test.rb +++ /dev/null @@ -1,9 +0,0 @@ -require "test_helper" - -class EventSummaryTest < ActiveSupport::TestCase - test "body" do - assert_equal "Added by David. Assigned to JZ.", event_summaries(:logo_initial_activity).body - assert_equal "Assigned to Kevin.", event_summaries(:logo_second_activity).body - assert_equal "Added by Kevin.", event_summaries(:text_initial_activity).body - end -end diff --git a/test/models/filter_test.rb b/test/models/filter_test.rb index 9b591bd76..3605c28a1 100644 --- a/test/models/filter_test.rb +++ b/test/models/filter_test.rb @@ -5,11 +5,11 @@ class FilterTest < ActiveSupport::TestCase Current.set session: sessions(:david) do @new_collection = Collection.create! name: "Inaccessible Collection" @new_card = @new_collection.cards.create! - @new_card.update!(stage: workflow_stages(:qa_triage)) + @new_card.update!(stage: workflow_stages(:qa_on_hold)) - cards(:layout).capture Comment.new(body: "I hate haggis") - cards(:logo).capture Comment.new(body: "I love haggis") - cards(:logo).update(stage: workflow_stages(:qa_triage)) + cards(:layout).comments.create!(body: "I hate haggis") + cards(:logo).comments.create!(body: "I love haggis") + cards(:logo).update(stage: workflow_stages(:qa_on_hold)) end assert_not_includes users(:kevin).filters.new.cards, @new_card @@ -17,7 +17,7 @@ class FilterTest < ActiveSupport::TestCase filter = users(:david).filters.new creator_ids: [ users(:david).id ], tag_ids: [ tags(:mobile).id ] assert_equal [ cards(:layout) ], filter.cards - filter = users(:david).filters.new stage_ids: [ workflow_stages(:qa_triage).id ] + filter = users(:david).filters.new stage_ids: [ workflow_stages(:qa_on_hold).id ] assert_equal [ cards(:logo), @new_card ], filter.cards filter = users(:david).filters.new assignment_status: "unassigned", collection_ids: [ @new_collection.id ] @@ -40,6 +40,13 @@ class FilterTest < ActiveSupport::TestCase assert_empty users(:david).filters.new(collection_ids: [ collections(:writebook).id ]).cards end + test "can't see collections that aren't accessible" do + collections(:writebook).update! all_access: false + collections(:writebook).accesses.revoke_from users(:david) + + assert_empty users(:david).filters.new(collection_ids: [ collections(:writebook).id ]).collections + end + test "remembering equivalent filters" do assert_difference "Filter.count", +1 do filter = users(:david).filters.remember(indexed_by: "latest", assignment_status: "unassigned", tag_ids: [ tags(:mobile).id ]) diff --git a/test/models/notifier/event_notifier_test.rb b/test/models/notifier/event_notifier_test.rb new file mode 100644 index 000000000..aafc2fcc1 --- /dev/null +++ b/test/models/notifier/event_notifier_test.rb @@ -0,0 +1,81 @@ +require "test_helper" + +class Notifier::EventNotifierTest < ActiveSupport::TestCase + test "for returns the matching notifier class for the event" do + assert_kind_of Notifier::CardEventNotifier, Notifier.for(events(:logo_published)) + end + + test "generate does not create notifications if the event was system-generated" do + cards(:logo).drafted! + events(:logo_published).update!(creator: User.system) + + assert_no_difference -> { Notification.count } do + Notifier.for(events(:logo_published)).notify + end + end + + test "creates a notification for each watcher, other than the event creator (events)" do + notifications = Notifier.for(events(:layout_commented)).notify + + assert_equal [ users(:kevin) ], notifications.map(&:user) + end + + test "creates a notification for each watcher (mentions)" do + notifications = Notifier.for(events(:layout_commented)).notify + + assert_equal [ users(:kevin) ], notifications.map(&:user) + end + + test "does not create a notification for access-only users" do + collections(:writebook).access_for(users(:kevin)).access_only! + + notifications = Notifier.for(events(:layout_commented)).notify + + assert_equal [ users(:kevin) ], notifications.map(&:user) + end + + test "the published event creates notifications for subscribers as well as watchers" do + notifications = Notifier.for(events(:logo_published)).notify + + assert_equal users(:kevin, :jz).sort, notifications.map(&:user).sort + end + + test "links to the card" do + Notifier.for(events(:logo_published)).notify + + assert_equal cards(:logo), Notification.last.source.eventable + end + + test "assignment events only create a notification for the assignee" do + collections(:writebook).access_for(users(:jz)).watching! + collections(:writebook).access_for(users(:kevin)).everything! + + notifications = Notifier.for(events(:logo_assignment_jz)).notify + + assert_equal [ users(:jz) ], notifications.map(&:user) + end + + test "assignment events do not notify users who are access-only for the collection" do + collections(:writebook).access_for(users(:jz)).access_only! + + notifications = Notifier.for(events(:logo_assignment_jz)).notify + + assert_empty notifications + end + + test "don't create notifications on publish for mentionees" do + users(:kevin).mentioned_by(users(:david), at: cards(:logo)) + + assert_no_difference -> { users(:kevin).notifications.count } do + Notifier.for(events(:logo_published)).notify + end + end + + test "don't create notifications on comment for mentionees" do + users(:david).mentioned_by(users(:kevin), at: cards(:layout)) + + assert_no_difference -> { users(:david).notifications.count } do + Notifier.for(events(:layout_commented)).notify + end + end +end diff --git a/test/models/notifier/mention_notifier_test.rb b/test/models/notifier/mention_notifier_test.rb new file mode 100644 index 000000000..833f55ec8 --- /dev/null +++ b/test/models/notifier/mention_notifier_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class Notifier::EventNotifierTest < ActiveSupport::TestCase + test "for returns the matching notifier class for the mention" do + assert_kind_of Notifier::MentionNotifier, Notifier.for(mentions(:logo_card_david_mention_by_jz)) + end + + test "notify the mentionee" do + users(:kevin).mentioned_by(users(:david), at: cards(:logo)) + + assert_no_difference -> { users(:kevin).notifications.count } do + Notifier.for(mentions(:logo_card_david_mention_by_jz)).notify + end + end + + test "create notifications for mentionee" do + assert_no_difference -> { users(:david).notifications.count } do + Notifier.for(events(:layout_commented)).notify + end + end + + test "don't create notifications for self-mentions" do + assert_no_difference -> { users(:jz).notifications.count } do + Notifier.for(events(:layout_commented)).notify + end + end +end diff --git a/test/models/notifier_test.rb b/test/models/notifier_test.rb deleted file mode 100644 index 1e01e18e8..000000000 --- a/test/models/notifier_test.rb +++ /dev/null @@ -1,59 +0,0 @@ -require "test_helper" - -class NotifierTest < ActiveSupport::TestCase - test "for returns the matching notifier class for the event" do - assert_kind_of Notifier::Published, Notifier.for(events(:logo_published)) - end - - test "generate does not create notifications if the event was system-generated" do - cards(:logo).drafted! - events(:logo_published).update!(creator: User.system) - - assert_no_difference -> { Notification.count } do - Notifier.for(events(:logo_published)).generate - end - end - - test "creates a notification for each watcher, other than the event creator" do - notifications = Notifier.for(events(:layout_commented)).generate - - assert_equal [ users(:kevin) ], notifications.map(&:user) - end - - test "does not create a notification for access-only users" do - collections(:writebook).access_for(users(:kevin)).access_only! - - notifications = Notifier.for(events(:layout_commented)).generate - - assert_equal [ users(:kevin) ], notifications.map(&:user) - end - - test "the published event creates notifications for subscribers as well as watchers" do - notifications = Notifier.for(events(:logo_published)).generate - - assert_equal users(:kevin, :jz).sort, notifications.map(&:user).sort - end - - test "links to the card" do - Notifier.for(events(:logo_published)).generate - - assert_equal cards(:logo), Notification.last.resource - end - - test "assignment events only create a notification for the assignee" do - collections(:writebook).access_for(users(:jz)).watching! - collections(:writebook).access_for(users(:kevin)).everything! - - notifications = Notifier.for(events(:logo_assignment_jz)).generate - - assert_equal [ users(:jz) ], notifications.map(&:user) - end - - test "assignment events do not notify users who are access-only for the collection" do - collections(:writebook).access_for(users(:jz)).access_only! - - notifications = Notifier.for(events(:logo_assignment_jz)).generate - - assert_empty notifications - end -end diff --git a/test/models/user/mentionable_test.rb b/test/models/user/mentionable_test.rb new file mode 100644 index 000000000..9bc441fe5 --- /dev/null +++ b/test/models/user/mentionable_test.rb @@ -0,0 +1,20 @@ +require "test_helper" + +class User::MentionableTest < ActiveSupport::TestCase + test "mentionable handles" do + assert_equal [ "dhh", "david", "davidh" ], User.new(name: "David Heinemeier-Hansson").mentionable_handles + end + + test "mentioned by" do + users(:david).mentions.destroy_all + + assert_difference -> { users(:david).mentions.count }, +1 do + users(:david).mentioned_by users(:jz), at: cards(:logo) + end + + # No dups + assert_no_difference -> { users(:david).mentions.count }, +1 do + users(:david).mentioned_by users(:jz), at: cards(:logo) + end + end +end diff --git a/test/models/user/named_test.rb b/test/models/user/named_test.rb new file mode 100644 index 000000000..8993dbbf8 --- /dev/null +++ b/test/models/user/named_test.rb @@ -0,0 +1,38 @@ +require "test_helper" + +class User::NamedTest < ActiveSupport::TestCase + test "initials" do + assert_initials "M", name: "Michael" + assert_initials "SD", name: "Salvador Dali" + assert_initials "LMM", name: "Lin-Manuel Miranda" + assert_initials "OCD", name: "O'Conor Díez" + assert_initials "ACG", name: "Anne Christine García" + assert_initials "ÁL", name: "Ángela López" + end + + test "first name" do + assert_first_name "Michael", "Michael" + assert_first_name "Salvador", "Salvador Dali" + assert_first_name "Lin-Manuel", "Lin-Manuel Miranda" + assert_first_name "Anne", "Anne Christine García" + end + + test "last name" do + assert_last_name "Dali", "Salvador Dali" + assert_last_name "Miranda", "Lin_Manuel Miranda" + assert_last_name "Christine García", "Anne Christine García" + end + + private + def assert_initials(expected, **attributes) + assert_equal expected, User.new(attributes).initials + end + + def assert_first_name(expected, name) + assert_equal expected, User.new(name: name).first_name + end + + def assert_last_name(expected, name) + assert_equal expected, User.new(name: name).last_name + end +end diff --git a/test/models/workflow_test.rb b/test/models/workflow_test.rb new file mode 100644 index 000000000..23239b3b4 --- /dev/null +++ b/test/models/workflow_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class WorkflowTest < ActiveSupport::TestCase + test "create with default stages" do + workflow = Workflow.create name: "My New Workflow" + assert_equal Workflow::DEFAULT_STAGES.sort, workflow.stages.collect(&:name).sort + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index f7cc1e088..cb34fd812 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -10,6 +10,7 @@ module ActiveSupport # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all - include ChangeTestHelper, SessionTestHelper + include ActiveJob::TestHelper + include CardTestHelper, ChangeTestHelper, SessionTestHelper end end diff --git a/test/test_helpers/card_test_helper.rb b/test/test_helpers/card_test_helper.rb new file mode 100644 index 000000000..6de139c86 --- /dev/null +++ b/test/test_helpers/card_test_helper.rb @@ -0,0 +1,5 @@ +module CardTestHelper + def assert_card_container_rerendered(card) + assert_turbo_stream action: :replace, target: dom_id(card, :card_container) + end +end diff --git a/vendor/javascript/house.min.js b/vendor/javascript/house.min.js index fff156f32..c81e324e5 100644 --- a/vendor/javascript/house.min.js +++ b/vendor/javascript/house.min.js @@ -1 +1 @@ -function t(t){return"string"==typeof t}function e(t,e,n=null,s=!1){return t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:n,cancelable:s}))}class n extends HTMLElement{constructor(){super()}connectedCallback(){this.#t(),this.addEventListener("click",this.#e),this.#n?.addEventListener("change",this.#s)}disconnectedCallback(){this.#n?.removeEventListener("change",this.#s),this.removeEventListener("click",this.#e)}get#n(){return this.querySelector("[data-house-md-toolbar-file-picker]")}#t(){this.#r()&&(this.innerHTML=this.#i())}#e=t=>{const n=t.target.closest("[data-house-md-action]")?.dataset?.houseMdAction;n&&(t.preventDefault(),e(this,"house-md:toolbar-action",{houseMdAction:n}))};#s=t=>{for(const n of t.target.files)e(this,"house-md:toolbar-action",{houseMdAction:"uploadFile",file:n})};#r(){return 0===this.children.length}#i(){return'\n \n \n \n \n \n \n \n '}}customElements.define("house-md-toolbar",n);class s{start=0;end=0;static fromDOMRange(t,e){const{startOffset:n,startContainer:i,endOffset:o,endContainer:l}=t,a=r(e,i,n);if(t.collapsed)return new s(e,a,a);const c=r(e,l,o);return new s(e,a,c)}constructor(t,e,n){this.container=t,this.start=e,this.end=n}toDOMRange(){const t=document.createRange();t.setStart(this.container,1),t.setEnd(this.container,this.container.childNodes.length);const e=document.createTreeWalker(this.container,NodeFilter.SHOW_TEXT);let n=0,s=!1,r=!1;for(;e.nextNode()&&(!s||!r);){const i=e.currentNode.textContent.length;n+i>=this.start&&!s&&(s=!0,t.setStart(e.currentNode,this.start-n)),n+i>=this.end&&!r&&(r=!0,t.setEnd(e.currentNode,this.end-n)),n+=i}return t}isEqual(t){return this.container===t.container&&this.start===t.start&&this.end===t.end}}function r(t,e,n){return e.nodeType===Node.ELEMENT_NODE?function(t,e,n){const s=e.childNodes[n];let r=0;const i=document.createTreeWalker(t);for(;i.nextNode();){if(i.currentNode===s)return r;i.currentNode.nodeType===Node.TEXT_NODE&&(r+=i.currentNode.textContent.length)}return r}(t,e,n):function(t,e,n){const s=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;s.nextNode();){if(s.currentNode===e)return n;n+=s.currentNode.textContent.length}return n}(t,e,n)}class i{#o;#l;#a;constructor(t){this.#o=t,this.#l=new s(this.contentElement,0,0),this.#a=""}current(){return{start:this.start,end:this.end,selection:this.#a}}currentLine(){return this.lineAt(this.end)}previousLine(){const{content:t}=this.#o,e=Math.max(t.lastIndexOf("\n",this.start-1),0);return this.lineAt(e)}lineAt(t){const{content:e}=this.#o,n=Math.max(e.lastIndexOf("\n",t-1)+1,0),s=e.indexOf("\n",t),r=-1===s?e.length:s+1;return{start:n,end:r,content:e.slice(n,r)}}select({start:t,end:e}){this.documentRange=new s(this.contentElement,t,e);const n=this.documentRange.toDOMRange();if(n){const t=window.getSelection();t.removeAllRanges(),t.addRange(n),this.update()}else console.error("Failed to create range",{start:t,end:e})}update=()=>{const t=document.getSelection()?.getRangeAt(0),n=this.#l;t.intersectsNode(this.contentElement)&&(this.#l=s.fromDOMRange(t,this.contentElement),this.#a=this.#o.content.slice(this.start,this.end)),this.#l.isEqual(n)||e(this.element,"house-md:selectionchange",{start:this.start,end:this.end})};get start(){return this.#l.start}get end(){return this.#l.end}get element(){return this.#o.element}get contentElement(){return this.#o.contentElement}get isEmpty(){return this.start===this.end}get isMultiline(){return this.current().selection.includes("\n")}get isBold(){return this.#c("strong")}get isItalic(){return this.#c("em")}get isStrikethrough(){return this.#c("s")}get isLink(){return this.#c(".link")}get isCode(){return this.#c(".code")}get isQuote(){return this.#c(".quote")}get isBulletList(){return this.#c(".ul-li")}get isNumberList(){return this.#c(".ol-li")}#c(t){let e=window.getSelection().getRangeAt(0).endContainer;if(e.nodeType===Node.TEXT_NODE&&(e=e.parentElement),this.contentElement.contains(e))return!!e.closest(t)}}class o{static saveInterval=1e3;constructor(t=500){this.maxSize=t,this.undoStack=[],this.redoStack=[]}undo(t){const e=this.undoStack.pop();return e?(this.redoStack.push(t),e):{}}redo(t){const e=this.redoStack.pop();return e?(this.undoStack.push(t),e):{}}add=function(t){let e;return(...n)=>{e||(t(...n),e=setTimeout((()=>{e=null}),o.saveInterval))}}((t=>{this.undoStack.push(t),this.redoStack=[],this.undoStack.length>this.maxSize&&this.undoStack.shift()}))}function l(t){let e=t;return e=function(t){return t=t.replaceAll(/&/g,"&"),t=t.replaceAll(//g,">"),t=t.replaceAll(/"/g,"""),t=t.replaceAll(/'/g,"'"),t}(e),e=function(t){return t=function(t){return t=t.replaceAll(/(?***$1***"),t=t.replaceAll(/(?___$1___"),t}(t),t=function(t){return t=t.replaceAll(/(?**$1**"),t=t.replaceAll(/(?__$1__"),t}(t),t=function(t){return t=t.replaceAll(/(?*$1*"),t=t.replaceAll(/(?_$1_"),t}(t),t=function(t){return t.replaceAll(/~~(.*)?~~/gm,"~~$1~~")}(t),t=function(t){return t.replaceAll(/==(.*?)==/gm,"==$1==")}(t),t}(e),e=function(t){return t=function(t){return t=t.replaceAll(/^# (.*)$/gm,'# $1'),t=t.replaceAll(/^## (.*)$/gm,'## $1'),t=t.replaceAll(/^### (.*)$/gm,'### $1'),t=t.replaceAll(/^#### (.*)$/gm,'#### $1'),t=t.replaceAll(/^##### (.*)$/gm,'##### $1'),t=t.replaceAll(/^###### (.*)$/gm,'###### $1'),t}(t),t=function(t){return t.replaceAll(/^(\d+\.\s.*)$/gm,'$1')}(t),t=function(t){return t.replaceAll(/^(-\s.*)$/gm,'$1')}(t),t=function(t){return t.replaceAll(/^>(.*)$/gm,'>$1')}(t),t}(e),e=function(t){return function(t){return t.replaceAll(/\n---\n/gm,'\n---\n')}(t)}(e),e=function(t){return t.replaceAll(/(?[$1]($2)')}(e),e=function(t){return t.replaceAll(/!\[(.*?)\]\((.*?)\)/gm,'$&')}(e),e=function(t){return t=t.replaceAll(/^```(.*?)```$/gms,'```$1```'),t=t.replaceAll(/([^`])`([^`]+)`([^`])/gm,'$1`$2`$3'),t}(e),e=function(t){return t.replaceAll(/<!--(.*?)-->/gm,'<!--$1-->')}(e),e}class a{constructor(t,e){this.content=t,this.element=e,this.selection=new i(this),this.history=new o}select(t,e){this.selection.select({start:t,end:e})}insertText(t){this.replaceText(t,this.currentSelection.start,this.currentSelection.end)}replaceText(t,e,n){const s=this.content.slice(0,e),r=`${s}${t}${this.content.slice(n)}`,i={start:s.length+t.length,end:s.length+t.length};this.#h(),this.#d(r,i)}insertParagraph(t,e){this.select(t,e),this.selection.isBulletList?this.#u():this.selection.isNumberList?this.#m():this.replaceText("\n",t,e)}insertLink(t,e,n=""){const{start:s,end:r}=this.currentSelection,i=` ${n}[${t}](${e}) `;this.replaceText(i,s,r),this.selection.select({start:s+i.length,end:s+i.length})}insertFile(t,e,n){n.startsWith("image/")?this.insertImage(t,e):this.insertLink(t,e)}insertImage(t,e){this.insertLink(t,e,"!")}deleteText(t,e){this.replaceText("",Math.max(t,0),e)}deleteCurrentLine(){const{start:t,end:e}=this.currentLine;this.deleteText(t,e)}toggleBold(){this.#p(this.selection.isBold,"**")}toggleItalic(){this.#p(this.selection.isItalic,"_")}toggleStrikethrough(){this.#p(this.selection.isStrikethrough,"~~")}toggleCode(){const[t,e]=this.selection.isMultiline?["```\n","\n```"]:["`","`"];this.#p(this.selection.isCode,t,e)}toggleLink(){this.selection.isLink?this.#g():this.#v()}toggleQuote(){this.selection.isQuote?this.#f("> "):this.#b("> ")}toggleBulletList(){this.selection.isBulletList?this.#f("- "):this.#b("- ")}toggleNumberList(){this.selection.isNumberList?this.#f(/\d+\. /):this.#b(`${this.#L+1}. `)}undo(){const{content:e,start:n,end:s}=this.history.undo(this.#x);t(e)&&this.#d(e,{start:n,end:s})}redo(){const{content:e,start:n,end:s}=this.history.redo(this.#x);t(e)&&this.#d(e,{start:n,end:s})}render(){this.contentElement.innerHTML=l(this.content)+""}get isEmpty(){return!this.content.trim()}get contentElement(){return this.element.contentWrapper}get currentSelection(){return this.selection.current()}get currentLine(){return this.selection.currentLine()}#p(t,e,n=e){t?this.#w(e,n):this.#T(e,n)}#T(t,e=t){const{start:n,end:s,selection:r}=this.currentSelection,i=`${t}${r}${e}`;this.replaceText(i,n,s),this.selection.select({start:n,end:n+r.length+t.length+e.length})}#w(t,e=t){const{start:n,end:s,selection:r}=this.currentSelection;r.startsWith(t)&&r.endsWith(e)&&(this.replaceText("",s-e.length,s),this.replaceText("",n,n+t.length),this.selection.select({start:n,end:s-t.length-e.length}))}#v(){const{start:t,end:e,selection:n}=this.currentSelection,s=this.selection.isEmpty,r=s?"title":n;this.replaceText(`[${r}](url)`,t,e),s?this.selection.select({start:t+1,end:t+r.length+1}):this.selection.select({start:t+n.length+3,end:t+n.length+6})}#g(){const{start:t,end:e,selection:n}=this.currentSelection,s=n.replace(/\[(.*)\]\(.*\)/,"$1");this.replaceText(s,t,e)}#u(){this.#E.match(/^-\s+$/)?this.deleteCurrentLine():(this.replaceText("\n",this.currentSelection.end,this.currentSelection.end),this.replaceText("- ",this.currentSelection.end+1,this.currentSelection.end+1))}#m(){this.#E.match(/^\d+\.\s+$/)?this.deleteCurrentLine():(this.replaceText("\n",this.currentSelection.end,this.currentSelection.end),this.replaceText(`${this.#L+1}. `,this.currentSelection.end+1,this.currentSelection.end+1))}#b(t){const{start:e,end:n}=this.currentSelection,s=this.currentLine;this.replaceText(t,s.start,s.start),this.selection.select({start:e+t.length,end:n+t.length})}#f(t){const{start:e,end:n}=this.currentSelection,s=this.currentLine;if(t instanceof RegExp){const e=s.content.match(t);if(!e)return;t=e[0]}s.content.startsWith(t)&&(this.deleteText(s.start,s.start+t.length),this.selection.select({start:e-t.length,end:n-t.length}))}#h(){this.history.add(this.#x)}#d(t,n){const s=this.content;this.content=t,this.element.value=t,this.selection.select(n),e(this.element,"house-md:change",{previousContent:s,newContent:t})}get#x(){const{start:t,end:e}=this.currentSelection;return{content:this.content,start:t,end:e}}get#E(){return this.currentLine.content}get#L(){const t=this.selection.previousLine().content.match(/^(\d+). /);return t?parseInt(t[1]):0}}class c{#A;constructor(t){this.#A=t}asPlainText(){const t=(new window.DOMParser).parseFromString(this.#A,"text/html");return this.#S(t.body).trim()}#S(t){return t.nodeType===Node.TEXT_NODE?t.nodeValue:Array.from(t.childNodes).map(((e,n)=>{const s=this.#k(e),r=n>0&&this.#k(t.childNodes[n-1]);return(s&&r?"\n\n":"")+this.#S(e)})).join("")}#k(t){return["BR","DIV","H1","H2","H3","H4","H5","H6","LI","P","TR"].includes(t.tagName)}}class h{constructor(t){this.document=t}handleInput(t,e){t.preventDefault();const n=this[`${t.inputType}Handler`];if(n){const r=t.getTargetRanges()[0],{start:i,end:o}=s.fromDOMRange(r,e);n.call(this,t,i,o)}else console.error("Not handling:",t.inputType)}insertTextHandler(t,e,n){this.#C(t,e,n)}insertCompositionTextHandler(t,e,n){this.#C(t,e,n)}insertParagraphHandler(t,e,n){this.document.insertParagraph(e,n)}insertLineBreakHandler(t,e,n){this.document.insertParagraph(e,n)}insertLinkHandler(t,e,n){this.#C(t,e,n)}insertFromYankHandler(t,e,n){this.#C(t,e,n)}insertFromDropHandler(t,e,n){this.#C(t,e,n)}insertFromPasteAsQuotationHandler(t,e,n){const s=t.dataTransfer.getData("text/plain");this.document.replaceText(`> ${s}`,e,n)}insertTransposeHandler(t,e,n){this.#C(t,e,n)}insertReplacementTextHandler(t,e,n){this.#C(t,e,n)}insertFromPasteHandler(t,e,n){this.#C(t,e,n)}deleteContentBackwardHandler(t,e,n){this.document.deleteText(e,n)}deleteByCutHandler(t,e,n){this.document.deleteText(e,n)}deleteWordBackwardHandler(t,e,n){this.document.deleteText(e,n)}deleteWordForwardHandler(t,e,n){this.document.deleteText(e,n)}deleteSoftLineBackwardHandler(t,e,n){this.document.deleteText(e,n)}deleteSoftLineForwardHandler(t,e,n){this.document.deleteText(e,n)}deleteEntireSoftLineHandler(t,e,n){this.document.deleteText(e,n)}deleteHardLineBackwardHandler(t,e,n){this.document.deleteText(e,n)}deleteHardLineForwardHandler(t,e,n){this.document.deleteText(e,n)}deleteByDragHandler(t,e,n){this.document.deleteText(e,n)}deleteContentHandler(t,e,n){this.document.deleteText(e,n)}deleteContentForwardHandler(t,e,n){this.document.deleteText(e,n)}#C(t,e,n){let s;if(t.data)s=t.data;else{const e=t.dataTransfer.getData("text/html");s=e?new c(e).asPlainText():t.dataTransfer.getData("text/plain")}this.document.replaceText(s,e,n)}}class d{constructor(t,e){this.element=t.closest("house-md"),this.file=e}upload(){if(e(this.element,"house-md:before-upload",{file:this.file})){const t=document.createElement("house-md-upload");t.file=this.file,t.uploadsURL=this.#$,this.element.appendChild(t)}}get#$(){return this.element.dataset.uploadsUrl||document.head.querySelector("meta[name=house-uploads-url]")?.content||"/uploads"}}class u{constructor(t){this.document=t}handleAction(t,e){const n=this[t];n?n.call(this,e):console.error("Not handling:",t)}bold(){this.document.toggleBold()}italic(){this.document.toggleItalic()}strikethrough(){this.document.toggleStrikethrough()}quote(){this.document.toggleQuote()}code(){this.document.toggleCode()}link(){this.document.toggleLink()}bulletList(){this.document.toggleBulletList()}numberList(){this.document.toggleNumberList()}undo(){this.document.undo()}redo(){this.document.redo()}uploadFile({file:t}){new d(this.document.element,t).upload()}}class m{constructor(t){this.element=t.closest("house-md")}handleDragOver(t){t.preventDefault()}async handleDrop(t){t.preventDefault();for(const e of t.dataTransfer.items)"file"===e.kind&&new d(this.element,e.getAsFile()).upload()}}const p={"Ctrl+B":"bold","Ctrl+I":"italic","Ctrl+S":"strikethrough","Ctrl+Z":"undo","Ctrl+Shift+Z":"redo"};class g extends HTMLElement{static formAssociated=!0;static observedAttributes=["initialized"];constructor(){super(),this.internals=this.attachInternals(),this.internals.ariaRole="textbox"}attributeChangedCallback(t,e,n){"initialized"===t&&this.isConnected&&null!=e&&e!==n&&requestAnimationFrame((()=>this.#H()))}connectedCallback(){const t=this.querySelector(".house-md-content")?.textContent??this.textContent;this.document=new a(t,this),this.#y(),this.#N(),this.inputHandler=new h(this.document),this.actionHandler=new u(this.document),this.dropAndDropHandler=new m(this),this.autofocus&&this.focus(),this.setAttribute("initialized",""),this.value=t}disconnectedCallback(){this.#_()}validate=t=>{this.required&&this.document.isEmpty?(t.preventDefault(),this.internals.setValidity({valueMissing:!0},"This field is required.",this.element),this.focus()):this.internals.setValidity({})};render(){this.document.render()}focus(){this.contentWrapper.focus()}get required(){return this.hasAttribute("required")}set required(t){t?this.setAttribute("required",""):this.removeAttribute("required")}get autofocus(){return this.hasAttribute("autofocus")}set autofocus(t){t?this.setAttribute("autofocus",""):this.removeAttribute("autofocus")}get value(){return this.document.content}set value(t){this.document.content=t,this.internals.setFormValue(t),this.internals.setValidity({}),this.render()}get form(){return this.internals.form}#y(){this.textContent="",this.toolbar=this.#D(),this.contentWrapper=document.createElement("div"),this.contentWrapper.classList.add("house-md-content"),this.contentWrapper.setAttribute("contenteditable",!0),this.contentWrapper.setAttribute("role","textbox"),this.contentWrapper.setAttribute("aria-multiline",!0),this.append(this.contentWrapper),this.getAttribute("tabindex")?(this.contentWrapper.setAttribute("tabindex",this.getAttribute("tabindex")),this.removeAttribute("tabindex")):this.contentWrapper.setAttribute("tabindex",0)}#D(){if(this.getAttribute("toolbar"))return document.getElementById(this.getAttribute("toolbar"));{const t=document.createElement("house-md-toolbar");return this.prepend(t),t}}#H(){this.disconnectedCallback(),this.getAttribute("toolbar")||this.toolbar.remove(),this.contentWrapper.remove(),this.connectedCallback()}#N(){this.addEventListener("beforeinput",this.#I),this.addEventListener("keydown",this.#B),this.addEventListener("dragover",this.#F),this.addEventListener("drop",this.#R),this.toolbar.addEventListener("house-md:toolbar-action",this.#M),document.addEventListener("selectionchange",this.#z),this.internals.form.addEventListener("submit",this.validate)}#_(){this.removeEventListener("beforeinput",this.#I),this.removeEventListener("keydown",this.#B),this.removeEventListener("dragover",this.#F),this.removeEventListener("drop",this.#R),this.toolbar.removeEventListener("house-md:toolbar-action",this.#M),document.removeEventListener("selectionchange",this.#z),this.internals.form.removeEventListener("submit",this.validate)}#I=t=>{this.inputHandler.handleInput(t,this.contentWrapper)};#B(t){const e=function(t){return p[function(t){const e=[];return(t.metaKey||t.ctrlKey)&&e.push("Ctrl"),t.altKey&&e.push("Alt"),t.shiftKey&&e.push("Shift"),e.push(t.key.toUpperCase()),e.join("+")}(t)]}(t);e&&(t.preventDefault(),this.actionHandler.handleAction(e))}#F=t=>{this.dropAndDropHandler.handleDragOver(t)};#R=t=>{this.dropAndDropHandler.handleDrop(t)};#M=({detail:t})=>{this.actionHandler.handleAction(t.houseMdAction,t)};#z=()=>{this.document.selection.update()}}customElements.define("house-md",g);class v extends HTMLElement{#W;#$;#O;constructor(){super(),this.percentComplete=0,this.status="pending"}connectedCallback(){this.#t(),this.#q(),this.addEventListener("click",this.#e)}disconnectedCallback(){this.removeEventListener("click",this.#e)}async#q(){const t=new FormData;t.append("file",this.file),this.csrfParamName&&t.append(this.csrfParamName,this.csrfToken),this.xhr=new XMLHttpRequest,this.xhr.open("POST",this.uploadsURL,!0),this.xhr.upload.onprogress=this.#P,this.xhr.onload=this.#U,this.xhr.onerror=this.#K,this.xhr.onabort=this.#Q,this.xhr.send(t),this.status="uploading",this.#t()}#t(){this.setAttribute("status",this.status),this.innerHTML=`\n ${this.#V()}\n ${this.#X()}\n ${this.#j()}\n ${this.#Z()}\n `}#V(){return"failed"===this.status?"":""}#X(){return`
    ${this.file.name}
    `}#j(){return`${this.percentComplete}%`}#Z(){return this.#O?`
    ${this.#O}
    `:""}#P=t=>{t.lengthComputable&&(this.percentComplete=t.loaded/t.total*100),this.#t()};#U=async()=>{if(this.xhr.status>=400)this.status="failed",this.#O=this.xhr.responseText||"Upload failed";else{const t=JSON.parse(this.xhr.responseText);await this.document.insertFile(t.fileName,t.fileUrl,t.mimetype),this.status="complete",this.#J()}this.#t()};#K=()=>{this.status="failed",this.#t()};#Q=()=>{this.status="aborted",this.#t()};#e=t=>{t.target.matches("[data-md-action=close]")&&this.remove()};async#J(t=500){await function(t){return new Promise((e=>setTimeout(e,t)))}(t),this.remove()}get document(){return this.closest("house-md").document}get file(){return this.#W}set file(t){this.#W=t}get uploadsURL(){return this.#$}set uploadsURL(t){this.#$=t}get csrfParamName(){return document.head.querySelector("meta[name=csrf-param]")?.content}get csrfToken(){return document.head.querySelector("meta[name=csrf-token]")?.content}}customElements.define("house-md-upload",v);export{a as Document,g as Editor,o as History,i as Selection,n as Toolbar,v as Upload}; +function t(t){return"string"==typeof t}function e(t,e,n=null,s=!1){return t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:n,cancelable:s}))}class n extends HTMLElement{constructor(){super()}connectedCallback(){this.#t(),this.addEventListener("click",this.#e),this.#n?.addEventListener("change",this.#s)}disconnectedCallback(){this.#n?.removeEventListener("change",this.#s),this.removeEventListener("click",this.#e)}get#n(){return this.querySelector("[data-house-md-toolbar-file-picker]")}#t(){this.#i()&&(this.innerHTML=this.#r())}#e=t=>{const n=t.target.closest("[data-house-md-action]")?.dataset?.houseMdAction;n&&(t.preventDefault(),e(this,"house-md:toolbar-action",{houseMdAction:n}))};#s=t=>{for(const n of t.target.files)e(this,"house-md:toolbar-action",{houseMdAction:"uploadFile",file:n})};#i(){return 0===this.children.length}#r(){return'\n \n \n \n \n \n \n \n '}}customElements.define("house-md-toolbar",n);class s{start=0;end=0;static fromDOMRange(t,e){const{startOffset:n,startContainer:r,endOffset:o,endContainer:l}=t,a=i(e,r,n);if(t.collapsed)return new s(e,a,a);const c=i(e,l,o);return new s(e,a,c)}constructor(t,e,n){this.container=t,this.start=e,this.end=n}toDOMRange(){const t=document.createRange();t.setStart(this.container,1),t.setEnd(this.container,this.container.childNodes.length);const e=document.createTreeWalker(this.container,NodeFilter.SHOW_TEXT);let n=0,s=!1,i=!1;for(;e.nextNode()&&(!s||!i);){const r=e.currentNode.textContent.length;n+r>=this.start&&!s&&(s=!0,t.setStart(e.currentNode,this.start-n)),n+r>=this.end&&!i&&(i=!0,t.setEnd(e.currentNode,this.end-n)),n+=r}return t}isEqual(t){return this.container===t.container&&this.start===t.start&&this.end===t.end}}function i(t,e,n){return e.nodeType===Node.ELEMENT_NODE?function(t,e,n){const s=e.childNodes[n];let i=0;const r=document.createTreeWalker(t);for(;r.nextNode();){if(r.currentNode===s)return i;r.currentNode.nodeType===Node.TEXT_NODE&&(i+=r.currentNode.textContent.length)}return i}(t,e,n):function(t,e,n){const s=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;s.nextNode();){if(s.currentNode===e)return n;n+=s.currentNode.textContent.length}return n}(t,e,n)}class r{#o;#l;#a;constructor(t){this.#o=t,this.#l=new s(this.contentElement,0,0),this.#a=""}current(){return{start:this.start,end:this.end,selection:this.#a}}currentLine(){return this.lineAt(this.end)}previousLine(){const{content:t}=this.#o,e=Math.max(t.lastIndexOf("\n",this.start-1),0);return this.lineAt(e)}lineAt(t){const{content:e}=this.#o,n=Math.max(e.lastIndexOf("\n",t-1)+1,0),s=e.indexOf("\n",t),i=-1===s?e.length:s+1;return{start:n,end:i,content:e.slice(n,i)}}select({start:t,end:e}){this.documentRange=new s(this.contentElement,t,e);const n=this.documentRange.toDOMRange();if(n){const t=window.getSelection();t.removeAllRanges(),t.addRange(n),this.update()}else console.error("Failed to create range",{start:t,end:e})}update=()=>{const t=document.getSelection()?.getRangeAt(0),n=this.#l;t.intersectsNode(this.contentElement)&&(this.#l=s.fromDOMRange(t,this.contentElement),this.#a=this.#o.content.slice(this.start,this.end)),this.#l.isEqual(n)||e(this.element,"house-md:selectionchange",{start:this.start,end:this.end})};get start(){return this.#l.start}get end(){return this.#l.end}get element(){return this.#o.element}get contentElement(){return this.#o.contentElement}get isEmpty(){return this.start===this.end}get isMultiline(){return this.current().selection.includes("\n")}get isBold(){return this.#c("strong")}get isItalic(){return this.#c("em")}get isStrikethrough(){return this.#c("s")}get isLink(){return this.#c(".link")}get isCode(){return this.#c(".code")}get isQuote(){return this.#c(".quote")}get isBulletList(){return this.#c(".ul-li")}get isNumberList(){return this.#c(".ol-li")}#c(t){let e=window.getSelection().getRangeAt(0).endContainer;if(e.nodeType===Node.TEXT_NODE&&(e=e.parentElement),this.contentElement.contains(e))return!!e.closest(t)}}class o{static saveInterval=1e3;constructor(t=500){this.maxSize=t,this.undoStack=[],this.redoStack=[]}undo(t){const e=this.undoStack.pop();return e?(this.redoStack.push(t),e):{}}redo(t){const e=this.redoStack.pop();return e?(this.undoStack.push(t),e):{}}add=function(t){let e;return(...n)=>{e||(t(...n),e=setTimeout((()=>{e=null}),o.saveInterval))}}((t=>{this.undoStack.push(t),this.redoStack=[],this.undoStack.length>this.maxSize&&this.undoStack.shift()}))}function l(t){let e=t;return e=function(t){return t=t.replaceAll(/&/g,"&"),t=t.replaceAll(//g,">"),t=t.replaceAll(/"/g,"""),t=t.replaceAll(/'/g,"'"),t}(e),e=function(t){return t=function(t){return t=t.replaceAll(/(?***$1***"),t=t.replaceAll(/(?___$1___"),t}(t),t=function(t){return t=t.replaceAll(/(?**$1**"),t=t.replaceAll(/(?__$1__"),t}(t),t=function(t){return t=t.replaceAll(/(?*$1*"),t=t.replaceAll(/(?_$1_"),t}(t),t=function(t){return t.replaceAll(/~~(.*)?~~/gm,"~~$1~~")}(t),t=function(t){return t.replaceAll(/==(.*?)==/gm,"==$1==")}(t),t}(e),e=function(t){return t=function(t){return t=t.replaceAll(/^# (.*)$/gm,'# $1'),t=t.replaceAll(/^## (.*)$/gm,'## $1'),t=t.replaceAll(/^### (.*)$/gm,'### $1'),t=t.replaceAll(/^#### (.*)$/gm,'#### $1'),t=t.replaceAll(/^##### (.*)$/gm,'##### $1'),t=t.replaceAll(/^###### (.*)$/gm,'###### $1'),t}(t),t=function(t){return t.replaceAll(/^(\d+\.\s.*)$/gm,'$1')}(t),t=function(t){return t.replaceAll(/^(-\s.*)$/gm,'$1')}(t),t=function(t){return t.replaceAll(/^>(.*)$/gm,'>$1')}(t),t}(e),e=function(t){return function(t){return t.replaceAll(/\n---\n/gm,'\n---\n')}(t)}(e),e=function(t){return t.replaceAll(/(?[$1]($2)')}(e),e=function(t){return t.replaceAll(/!\[(.*?)\]\((.*?)\)/gm,'$&')}(e),e=function(t){return t=t.replaceAll(/^```(.*?)```$/gms,'```$1```'),t=t.replaceAll(/([^`])`([^`]+)`([^`])/gm,'$1`$2`$3'),t}(e),e=function(t){return t.replaceAll(/<!--(.*?)-->/gm,'<!--$1-->')}(e),e}class a{constructor(t,e){this.content=t,this.element=e,this.selection=new r(this),this.history=new o}select(t,e){this.selection.select({start:t,end:e})}insertText(t){this.replaceText(t,this.currentSelection.start,this.currentSelection.end)}replaceText(t,e,n){const s=this.content.slice(0,e),i=`${s}${t}${this.content.slice(n)}`,r={start:s.length+t.length,end:s.length+t.length};this.#h(),this.#d(i,r)}insertParagraph(t,e){this.select(t,e),this.selection.isBulletList?this.#u():this.selection.isNumberList?this.#m():this.replaceText("\n",t,e)}insertLink(t,e,n=""){const{start:s,end:i}=this.currentSelection,r=` ${n}[${t}](${e}) `;this.replaceText(r,s,i),this.selection.select({start:s+r.length,end:s+r.length})}insertFile(t,e,n){n.startsWith("image/")?this.insertImage(t,e):this.insertLink(t,e)}insertImage(t,e){this.insertLink(t,e,"!")}deleteText(t,e){this.replaceText("",Math.max(t,0),e)}deleteCurrentLine(){const{start:t,end:e}=this.currentLine;this.deleteText(t,e)}toggleBold(){this.#p(this.selection.isBold,"**")}toggleItalic(){this.#p(this.selection.isItalic,"_")}toggleStrikethrough(){this.#p(this.selection.isStrikethrough,"~~")}toggleCode(){const[t,e]=this.selection.isMultiline?["```\n","\n```"]:["`","`"];this.#p(this.selection.isCode,t,e)}toggleLink(){this.selection.isLink?this.#g():this.#v()}toggleQuote(){this.selection.isQuote?this.#f("> "):this.#b("> ")}toggleBulletList(){this.selection.isBulletList?this.#f("- "):this.#b("- ")}toggleNumberList(){this.selection.isNumberList?this.#f(/\d+\. /):this.#b(`${this.#L+1}. `)}undo(){const{content:e,start:n,end:s}=this.history.undo(this.#x);t(e)&&this.#d(e,{start:n,end:s})}redo(){const{content:e,start:n,end:s}=this.history.redo(this.#x);t(e)&&this.#d(e,{start:n,end:s})}render(){this.contentElement.innerHTML=l(this.content)+"",this.contentElement.classList.toggle("house-md-content-empty",this.isEmpty)}get isEmpty(){return!this.content.trim()}get contentElement(){return this.element.contentWrapper}get currentSelection(){return this.selection.current()}get currentLine(){return this.selection.currentLine()}#p(t,e,n=e){t?this.#w(e,n):this.#E(e,n)}#E(t,e=t){const{start:n,end:s,selection:i}=this.currentSelection,r=`${t}${i}${e}`;this.replaceText(r,n,s),this.selection.select({start:n,end:n+i.length+t.length+e.length})}#w(t,e=t){const{start:n,end:s,selection:i}=this.currentSelection;i.startsWith(t)&&i.endsWith(e)&&(this.replaceText("",s-e.length,s),this.replaceText("",n,n+t.length),this.selection.select({start:n,end:s-t.length-e.length}))}#v(){const{start:t,end:e,selection:n}=this.currentSelection,s=this.selection.isEmpty,i=s?"title":n;this.replaceText(`[${i}](url)`,t,e),s?this.selection.select({start:t+1,end:t+i.length+1}):this.selection.select({start:t+n.length+3,end:t+n.length+6})}#g(){const{start:t,end:e,selection:n}=this.currentSelection,s=n.replace(/\[(.*)\]\(.*\)/,"$1");this.replaceText(s,t,e)}#u(){this.#T.match(/^-\s+$/)?this.deleteCurrentLine():(this.replaceText("\n",this.currentSelection.end,this.currentSelection.end),this.replaceText("- ",this.currentSelection.end+1,this.currentSelection.end+1))}#m(){this.#T.match(/^\d+\.\s+$/)?this.deleteCurrentLine():(this.replaceText("\n",this.currentSelection.end,this.currentSelection.end),this.replaceText(`${this.#L+1}. `,this.currentSelection.end+1,this.currentSelection.end+1))}#b(t){const{start:e,end:n}=this.currentSelection,s=this.currentLine;this.replaceText(t,s.start,s.start),this.selection.select({start:e+t.length,end:n+t.length})}#f(t){const{start:e,end:n}=this.currentSelection,s=this.currentLine;if(t instanceof RegExp){const e=s.content.match(t);if(!e)return;t=e[0]}s.content.startsWith(t)&&(this.deleteText(s.start,s.start+t.length),this.selection.select({start:e-t.length,end:n-t.length}))}#h(){this.history.add(this.#x)}#d(t,n){const s=this.content;this.content=t,this.element.value=t,this.selection.select(n),e(this.element,"house-md:change",{previousContent:s,newContent:t})}get#x(){const{start:t,end:e}=this.currentSelection;return{content:this.content,start:t,end:e}}get#T(){return this.currentLine.content}get#L(){const t=this.selection.previousLine().content.match(/^(\d+). /);return t?parseInt(t[1]):0}}class c{#A;constructor(t){this.#A=t}asPlainText(){const t=(new window.DOMParser).parseFromString(this.#A,"text/html");return this.#S(t.body).trim()}#S(t){return t.nodeType===Node.TEXT_NODE?t.nodeValue:Array.from(t.childNodes).map(((e,n)=>{const s=this.#k(e),i=n>0&&this.#k(t.childNodes[n-1]);return(s&&i?"\n\n":"")+this.#S(e)})).join("")}#k(t){return["BR","DIV","H1","H2","H3","H4","H5","H6","LI","P","TR"].includes(t.tagName)}}class h{constructor(t){this.document=t}handleInput(t,e){t.preventDefault();const n=this[`${t.inputType}Handler`];if(n){const i=t.getTargetRanges()[0],{start:r,end:o}=s.fromDOMRange(i,e);n.call(this,t,r,o)}else console.error("Not handling:",t.inputType)}insertTextHandler(t,e,n){this.#C(t,e,n)}insertCompositionTextHandler(t,e,n){this.#C(t,e,n)}insertParagraphHandler(t,e,n){this.document.insertParagraph(e,n)}insertLineBreakHandler(t,e,n){this.document.insertParagraph(e,n)}insertLinkHandler(t,e,n){this.#C(t,e,n)}insertFromYankHandler(t,e,n){this.#C(t,e,n)}insertFromDropHandler(t,e,n){this.#C(t,e,n)}insertFromPasteAsQuotationHandler(t,e,n){const s=t.dataTransfer.getData("text/plain");this.document.replaceText(`> ${s}`,e,n)}insertTransposeHandler(t,e,n){this.#C(t,e,n)}insertReplacementTextHandler(t,e,n){this.#C(t,e,n)}insertFromPasteHandler(t,e,n){this.#C(t,e,n)}deleteContentBackwardHandler(t,e,n){this.document.deleteText(e,n)}deleteByCutHandler(t,e,n){this.document.deleteText(e,n)}deleteWordBackwardHandler(t,e,n){this.document.deleteText(e,n)}deleteWordForwardHandler(t,e,n){this.document.deleteText(e,n)}deleteSoftLineBackwardHandler(t,e,n){this.document.deleteText(e,n)}deleteSoftLineForwardHandler(t,e,n){this.document.deleteText(e,n)}deleteEntireSoftLineHandler(t,e,n){this.document.deleteText(e,n)}deleteHardLineBackwardHandler(t,e,n){this.document.deleteText(e,n)}deleteHardLineForwardHandler(t,e,n){this.document.deleteText(e,n)}deleteByDragHandler(t,e,n){this.document.deleteText(e,n)}deleteContentHandler(t,e,n){this.document.deleteText(e,n)}deleteContentForwardHandler(t,e,n){this.document.deleteText(e,n)}#C(t,e,n){let s;if(t.data)s=t.data;else{const e=t.dataTransfer.getData("text/html");s=e?new c(e).asPlainText():t.dataTransfer.getData("text/plain")}this.document.replaceText(s,e,n)}}class d{constructor(t,e){this.element=t.closest("house-md"),this.file=e}upload(){if(e(this.element,"house-md:before-upload",{file:this.file})){const t=document.createElement("house-md-upload");t.file=this.file,t.uploadsURL=this.#$,this.element.appendChild(t)}}get#$(){return this.element.dataset.uploadsUrl||document.head.querySelector("meta[name=house-uploads-url]")?.content||"/uploads"}}class u{constructor(t){this.document=t}handleAction(t,e){const n=this[t];n?n.call(this,e):console.error("Not handling:",t)}bold(){this.document.toggleBold()}italic(){this.document.toggleItalic()}strikethrough(){this.document.toggleStrikethrough()}quote(){this.document.toggleQuote()}code(){this.document.toggleCode()}link(){this.document.toggleLink()}bulletList(){this.document.toggleBulletList()}numberList(){this.document.toggleNumberList()}undo(){this.document.undo()}redo(){this.document.redo()}uploadFile({file:t}){new d(this.document.element,t).upload()}}class m{constructor(t){this.element=t.closest("house-md")}handleDragOver(t){t.preventDefault()}async handleDrop(t){t.preventDefault();for(const e of t.dataTransfer.items)"file"===e.kind&&new d(this.element,e.getAsFile()).upload()}}const p={"Ctrl+B":"bold","Ctrl+I":"italic","Ctrl+S":"strikethrough","Ctrl+Z":"undo","Ctrl+Shift+Z":"redo"};class g extends HTMLElement{static formAssociated=!0;static observedAttributes=["initialized"];constructor(){super(),this.internals=this.attachInternals(),this.internals.ariaRole="textbox"}attributeChangedCallback(t,e,n){"initialized"===t&&this.isConnected&&null!=e&&e!==n&&requestAnimationFrame((()=>this.#H()))}connectedCallback(){const t=this.querySelector(".house-md-content")?.textContent??this.textContent;this.document=new a(t,this),this.#y(),this.#N(),this.inputHandler=new h(this.document),this.actionHandler=new u(this.document),this.dropAndDropHandler=new m(this),this.autofocus&&this.focus(),this.setAttribute("initialized",""),this.value=t}disconnectedCallback(){this.#_()}validate=t=>{this.required&&this.document.isEmpty?(t.preventDefault(),this.internals.setValidity({valueMissing:!0},"This field is required.",this.element),this.focus()):this.internals.setValidity({})};render(){this.document.render()}focus(){this.contentWrapper.focus()}get required(){return this.hasAttribute("required")}set required(t){t?this.setAttribute("required",""):this.removeAttribute("required")}get autofocus(){return this.hasAttribute("autofocus")}set autofocus(t){t?this.setAttribute("autofocus",""):this.removeAttribute("autofocus")}get value(){return this.document.content}set value(t){this.document.content=t,this.internals.setFormValue(t),this.internals.setValidity({}),this.render()}get form(){return this.internals.form}#y(){this.textContent="",this.toolbar=this.#D(),this.contentWrapper=document.createElement("div"),this.contentWrapper.classList.add("house-md-content"),this.contentWrapper.setAttribute("contenteditable",!0),this.contentWrapper.setAttribute("role","textbox"),this.contentWrapper.setAttribute("aria-multiline",!0),this.append(this.contentWrapper),this.getAttribute("tabindex")?(this.contentWrapper.setAttribute("tabindex",this.getAttribute("tabindex")),this.removeAttribute("tabindex")):this.contentWrapper.setAttribute("tabindex",0)}#D(){if(this.getAttribute("toolbar"))return document.getElementById(this.getAttribute("toolbar"));{const t=document.createElement("house-md-toolbar");return this.prepend(t),t}}#H(){this.disconnectedCallback(),this.getAttribute("toolbar")||this.toolbar.remove(),this.contentWrapper.remove(),this.connectedCallback()}#N(){this.addEventListener("beforeinput",this.#I),this.addEventListener("keydown",this.#B),this.addEventListener("dragover",this.#F),this.addEventListener("drop",this.#R),this.toolbar.addEventListener("house-md:toolbar-action",this.#M),document.addEventListener("selectionchange",this.#z),this.internals.form.addEventListener("submit",this.validate)}#_(){this.removeEventListener("beforeinput",this.#I),this.removeEventListener("keydown",this.#B),this.removeEventListener("dragover",this.#F),this.removeEventListener("drop",this.#R),this.toolbar.removeEventListener("house-md:toolbar-action",this.#M),document.removeEventListener("selectionchange",this.#z),this.internals.form.removeEventListener("submit",this.validate)}#I=t=>{this.inputHandler.handleInput(t,this.contentWrapper)};#B(t){const e=function(t){return p[function(t){const e=[];return(t.metaKey||t.ctrlKey)&&e.push("Ctrl"),t.altKey&&e.push("Alt"),t.shiftKey&&e.push("Shift"),e.push(t.key.toUpperCase()),e.join("+")}(t)]}(t);e&&(t.preventDefault(),this.actionHandler.handleAction(e))}#F=t=>{this.dropAndDropHandler.handleDragOver(t)};#R=t=>{this.dropAndDropHandler.handleDrop(t)};#M=({detail:t})=>{this.actionHandler.handleAction(t.houseMdAction,t)};#z=()=>{this.document.selection.update()}}customElements.define("house-md",g);class v extends HTMLElement{#W;#$;#O;constructor(){super(),this.percentComplete=0,this.status="pending"}connectedCallback(){this.#t(),this.#q(),this.addEventListener("click",this.#e)}disconnectedCallback(){this.removeEventListener("click",this.#e)}async#q(){const t=new FormData;t.append("file",this.file),this.csrfParamName&&t.append(this.csrfParamName,this.csrfToken),this.xhr=new XMLHttpRequest,this.xhr.open("POST",this.uploadsURL,!0),this.xhr.upload.onprogress=this.#P,this.xhr.onload=this.#U,this.xhr.onerror=this.#K,this.xhr.onabort=this.#Q,this.xhr.send(t),this.status="uploading",this.#t()}#t(){this.setAttribute("status",this.status),this.innerHTML=`\n ${this.#V()}\n ${this.#X()}\n ${this.#j()}\n ${this.#Z()}\n `}#V(){return"failed"===this.status?"":""}#X(){return`
    ${this.file.name}
    `}#j(){return`${this.percentComplete}%`}#Z(){return this.#O?`
    ${this.#O}
    `:""}#P=t=>{t.lengthComputable&&(this.percentComplete=t.loaded/t.total*100),this.#t()};#U=async()=>{if(this.xhr.status>=400)this.status="failed",this.#O=this.xhr.responseText||"Upload failed";else{const t=JSON.parse(this.xhr.responseText);await this.document.insertFile(t.fileName,t.fileUrl,t.mimetype),this.status="complete",this.#J()}this.#t()};#K=()=>{this.status="failed",this.#t()};#Q=()=>{this.status="aborted",this.#t()};#e=t=>{t.target.matches("[data-md-action=close]")&&this.remove()};async#J(t=500){await function(t){return new Promise((e=>setTimeout(e,t)))}(t),this.remove()}get document(){return this.closest("house-md").document}get file(){return this.#W}set file(t){this.#W=t}get uploadsURL(){return this.#$}set uploadsURL(t){this.#$=t}get csrfParamName(){return document.head.querySelector("meta[name=csrf-param]")?.content}get csrfToken(){return document.head.querySelector("meta[name=csrf-token]")?.content}}customElements.define("house-md-upload",v);export{a as Document,g as Editor,o as History,r as Selection,n as Toolbar,v as Upload};